├── .eslintignore ├── .eslintrc.cjs ├── .gitignore ├── .npmignore ├── .prettierrc ├── CHANGES.md ├── LICENSE ├── README.md ├── docs ├── d3-interpolate-path.js ├── d3-line-chunked.js ├── example-gallery.js ├── example.css └── index.html ├── example └── d3-line-chunked.js ├── index.js ├── package.json ├── rollup.config.js ├── src ├── .babelrc └── lineChunked.js └── test └── lineChunked-test.cjs /.eslintignore: -------------------------------------------------------------------------------- 1 | rollup.config.js 2 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: false, 5 | node: false, 6 | }, 7 | extends: ['eslint:recommended', 'prettier'], 8 | plugins: ['import'], 9 | parserOptions: { 10 | ecmaVersion: 2020, 11 | sourceType: 'module', 12 | ecmaFeatures: { 13 | jsx: false, 14 | }, 15 | }, 16 | globals: {}, 17 | rules: { 18 | 'no-empty': 'warn', 19 | 'no-inner-declarations': 'off', 20 | }, 21 | ignorePatterns: ['dist', 'node_modules'], 22 | }; 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | build/ 3 | node_modules 4 | npm-debug.log 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | build/*.zip 2 | test/ 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "printWidth": 80, 6 | "arrowParens": "always" 7 | } 8 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 1.4.0 4 | 5 | - Added in support for arbitrarily styling different chunks of the defined data. This can be done through the new `chunk()`, `chunkDefinitions`, and `chunkLineResolver()` properties. See the README for details and the demo for examples. 6 | 7 | 8 | ## 1.3.0 9 | 10 | - Increased gap line default opacity from 0.2 to 0.35 to make it more visible for lower contrast screens (#9) 11 | - Getter/setters now check for arguments.length before deciding to get or set (#10) 12 | - Circles will not disappear and reappear if the X position is the same. (#12) 13 | - Circle animation now handles transitions with delays (#13) 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016, Peter Beshai 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the author nor the names of contributors may be used to 15 | endorse or promote products derived from this software without specific prior 16 | written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # d3-line-chunked 2 | 3 | [![npm version](https://badge.fury.io/js/d3-line-chunked.svg)](https://badge.fury.io/js/d3-line-chunked) 4 | 5 | A d3 plugin that renders a line with potential gaps in the data by styling the gaps differently from the defined areas. It also provides the ability to style arbitrary chunks of the defined data differently. Single points are rendered as circles and transitions are supported. 6 | 7 | Blog: [Showing Missing Data in Line Charts](https://bocoup.com/weblog/showing-missing-data-in-line-charts) 8 | 9 | Demo: http://peterbeshai.com/vis/d3-line-chunked/ 10 | 11 | ![d3-line-chunked-demo](https://cloud.githubusercontent.com/assets/793847/18075172/806683f4-6e40-11e6-96bc-e0250adf0529.gif) 12 | 13 | ## Example Usage 14 | 15 | ```js 16 | var lineChunked = d3.lineChunked() 17 | .x(function (d) { return xScale(d.x); }) 18 | .y(function (d) { return yScale(d.y); }) 19 | .curve(d3.curveLinear) 20 | .defined(function (d) { return d.y != null; }) 21 | .lineStyles({ 22 | stroke: '#0bb', 23 | }); 24 | 25 | d3.select('svg') 26 | .append('g') 27 | .datum(lineData) 28 | .transition() 29 | .duration(1000) 30 | .call(lineChunked); 31 | ``` 32 | 33 | ### Example with multiple lines 34 | 35 | ```js 36 | var lineChunked = d3.lineChunked() 37 | .x(function (d) { return xScale(d.x); }) 38 | .y(function (d) { return yScale(d.y); }) 39 | .defined(function (d) { return d.y != null; }) 40 | .lineStyles({ 41 | stroke: (d, i) => colorScale(i), 42 | }); 43 | 44 | var data = [ 45 | [{ 'x': 0, 'y': 42 }, { 'x': 1, 'y': 76 }, { 'x': 2, 'y': 54 }], 46 | [{ 'x': 0, 'y': 386 }, { 'x': 1 }, { 'x': 2, 'y': 38 }, { 'x': 3, 'y': 192 }], 47 | [{ 'x': 0, 'y': 325 }, { 'x': 1, 'y': 132 }, { 'x': 2 }, { 'x': 3, 'y': 180 }] 48 | ]; 49 | 50 | // bind data 51 | var binding = d3.select('svg').selectAll('g').data(data); 52 | 53 | // append a `` for each line 54 | var entering = binding.enter().append('g'); 55 | 56 | // call lineChunked on enter + update 57 | binding.merge(entering) 58 | .transition() 59 | .call(lineChunked); 60 | 61 | // remove `` when exiting 62 | binding.exit().remove(); 63 | ``` 64 | 65 | ## Development 66 | 67 | Get rollup watching for changes and rebuilding 68 | 69 | ```bash 70 | npm run watch 71 | ``` 72 | 73 | Run a web server in the example directory 74 | 75 | ```bash 76 | cd example 77 | php -S localhost:8000 78 | ``` 79 | 80 | Go to http://localhost:8000 81 | 82 | 83 | ## Installing 84 | 85 | If you use NPM, `npm install d3-line-chunked`. Otherwise, download the [latest release](https://github.com/pbeshai/d3-line-chunked/releases/latest). 86 | 87 | Note that this project relies on the following d3 features and plugins: 88 | - [d3-array](https://github.com/d3/d3-array) 89 | - [d3-selection](https://github.com/d3/d3-selection) 90 | - [d3-shape](https://github.com/d3/d3-shape) 91 | 92 | If you are using transitions, you will also need: 93 | - [d3-interpolate](https://github.com/d3/d3-interpolate) 94 | - [d3-interpolate-path](https://github.com/pbeshai/d3-interpolate-path) (plugin) 95 | 96 | The only thing not included in the default d3 v4 build is the plugin [d3-interpolate-path](https://github.com/pbeshai/d3-interpolate-path). You'll need to get that [separately](https://github.com/pbeshai/d3-interpolate-path#installing). 97 | 98 | ## API Reference 99 | 100 | # d3.**lineChunked**() 101 | 102 | Constructs a new generator for chunked lines with the default settings. 103 | 104 | 105 | # *lineChunked*(*context*) 106 | 107 | Render the chunked line to the given *context*, which may be either a [d3 selection](https://github.com/d3/d3-selection) 108 | of SVG containers (either SVG or G elements) or a corresponding [d3 transition](https://github.com/d3/d3-transition). Reads the data for the line from the `datum` property on the container. 109 | 110 | 111 | # *lineChunked*.**x**([*x*]) 112 | 113 | Define an accessor for getting the `x` value for a data point. See [d3 line.x](https://github.com/d3/d3-shape#line_x) for details. 114 | 115 | 116 | # *lineChunked*.**y**([*y*]) 117 | 118 | Define an accessor for getting the `y` value for a data point. See [d3 line.y](https://github.com/d3/d3-shape#line_y) for details. 119 | 120 | 121 | # *lineChunked*.**curve**([*curve*]) 122 | 123 | Get or set the [d3 curve factory](https://github.com/d3/d3-shape#curves) for the line. See [d3 line.curve](https://github.com/d3/d3-shape#line_curve) for details. 124 | Define an accessor for getting the `curve` value for a data point. See [d3 line.curve](https://github.com/d3/d3-shape#line_curve) for details. 125 | 126 | 127 | # *lineChunked*.**defined**([*defined*]) 128 | 129 | Get or set *defined*, a function that given a data point (`d`) returns `true` if the data is defined for that point and `false` otherwise. This function is important for determining where gaps are in the data when your data includes points without data in them. 130 | 131 | For example, if your data contains attributes `x` and `y`, but no `y` when there is no data available, you might set *defined* as follows: 132 | 133 | ```js 134 | // sample data 135 | var data = [{ x: 1, y: 10 }, { x: 2 }, { x: 3 }, { x: 4, y: 15 }, { x: 5, y: 12 }]; 136 | 137 | // returns true if d has a y value set 138 | function defined(d) { 139 | return d.y != null; 140 | } 141 | ``` 142 | 143 | It is only necessary to define this if your dataset includes entries for points without data. 144 | 145 | The default returns `true` for all points. 146 | 147 | 148 | 149 | # *lineChunked*.**isNext**([*isNext*]) 150 | 151 | Get or set *isNext*, a function to determine if a data point follows the previous. This function enables detecting gaps in the data when there is an unexpected jump. 152 | 153 | For example, if your data contains attributes `x` and `y`, and does not include points with missing data, you might set **isNext** as follows: 154 | 155 | 156 | ```js 157 | // sample data 158 | var data = [{ x: 1, y: 10 }, { x: 4, y: 15 }, { x: 5, y: 12 }]; 159 | 160 | // returns true if current datum is 1 `x` ahead of previous datum 161 | function isNext(previousDatum, currentDatum) { 162 | var expectedDelta = 1; 163 | return (currentDatum.x - previousDatum.x) === expectedDelta; 164 | } 165 | ``` 166 | 167 | It is only necessary to define this if your data doesn't explicitly include gaps in it. 168 | 169 | The default returns `true` for all points. 170 | 171 | 172 | # *lineChunked*.**transitionInitial**([*transitionInitial*]) 173 | 174 | Get or set *transitionInitial*, a boolean flag that indicates whether to perform a transition on initial render or not. If true and the *context* that *lineChunked* is called in is a transition, then the line will animate its y value on initial render. If false, the line will appear rendered immediately with no animation on initial render. This does not affect any subsequent renders and their respective transitions. 175 | 176 | The default value is `true`. 177 | 178 | # *lineChunked*.**extendEnds**([*[xMin, xMax]*]) 179 | 180 | Get or set *extendEnds*, an array `[xMin, xMax]` specifying the minimum and maximum x pixel values 181 | (e.g., `xScale.range()`). If defined, the undefined line will extend to the values provided, 182 | otherwise it will end at the last defined points. 183 | 184 | 185 | # *lineChunked*.**accessData**([*accessData*]) 186 | 187 | Get or set *accessData*, a function that specifies how to map from a dataset entry to the array of line data. This is only useful if your input data doesn't use the standard form of `[point1, point2, point3, ...]`. For example, if you pass in your data as `{ results: [point1, point2, point3, ...] }`, you would want to set accessData to `data => data.results`. For convenience, if your accessData function simply accesses a key of an object, you can pass it in directly: `accessData('results')` is equivalent to `accessData(data => data.results)`. 188 | 189 | The default value is the identity function `data => data`. 190 | 191 | 192 | # *lineChunked*.**lineStyles**([*lineStyles*]) 193 | 194 | Get or set *lineStyles*, an object mapping style keys to style values to be applied to both defined and undefined lines. Uses syntax similar to [d3-selection-multi](https://github.com/d3/d3-selection-multi#selection_styles). 195 | 196 | 197 | 198 | # *lineChunked*.**lineAttrs**([*lineAttrs*]) 199 | 200 | Get or set *lineAttrs*, an object mapping attribute keys to attribute values to be applied to both defined and undefined lines. The passed in *lineAttrs* are merged with the defaults. Uses syntax similar to [d3-selection-multi](https://github.com/d3/d3-selection-multi#selection_attrs). 201 | 202 | The default attrs are: 203 | 204 | ```js 205 | { 206 | fill: 'none', 207 | stroke: '#222', 208 | 'stroke-width': 1.5, 209 | 'stroke-opacity': 1, 210 | } 211 | ``` 212 | 213 | 214 | 215 | # *lineChunked*.**gapStyles**([*gapStyles*]) 216 | 217 | Get or set *gapStyles*, an object mapping style keys to style values to be applied only to undefined lines. It overrides values provided in *lineStyles*. Uses syntax similar to [d3-selection-multi](https://github.com/d3/d3-selection-multi#selection_styles). 218 | 219 | 220 | 221 | # *lineChunked*.**gapAttrs**([*gapAttrs*]) 222 | 223 | Get or set *gapAttrs*, an object mapping attribute keys to attribute values to be applied only to undefined lines. It overrides values provided in *lineAttrs*. The passed in *gapAttrs* are merged with the defaults. Uses syntax similar to [d3-selection-multi](https://github.com/d3/d3-selection-multi#selection_attrs). 224 | 225 | The default attrs are: 226 | 227 | ```js 228 | { 229 | 'stroke-dasharray': '2 2', 230 | 'stroke-opacity': 0.2, 231 | } 232 | ``` 233 | 234 | 235 | # *lineChunked*.**pointStyles**([*pointStyles*]) 236 | 237 | Get or set *pointStyles*, an object mapping style keys to style values to be applied to points. Uses syntax similar to [d3-selection-multi](https://github.com/d3/d3-selection-multi#selection_styles). 238 | 239 | 240 | 241 | # *lineChunked*.**pointAttrs**([*pointAttrs*]) 242 | 243 | Get or set *pointAttrs*, an object mapping attr keys to attr values to be applied to points (circles). Note that if fill is not defined in *pointStyles* or *pointAttrs*, it will be read from the stroke color on the line itself. Uses syntax similar to [d3-selection-multi](https://github.com/d3/d3-selection-multi#selection_attrs). 244 | 245 | 246 | 247 | # *lineChunked*.**chunk**([*chunk*]) 248 | 249 | Get or set *chunk*, a function that given a data point (`d`) returns the name of the chunk it belongs to. This is necessary if you want to have multiple styled chunks of the defined data. There are two reserved chunk names: `"line"` for the default line for defined data, and `"gap"` for undefined data. It is not recommended that you use `"gap"` in this function. The default value maps all data points to `"line"`. 250 | 251 | For example, if you wanted all points with y values less than 10 to be in the `"below-threshold"` chunk, you could do the following: 252 | 253 | ```js 254 | // sample data 255 | var data = [{ x: 1, y: 5 }, { x: 2, y: 8 }, { x: 3, y: 12 }, { x: 4, y: 15 }, { x: 5, y: 6 }]; 256 | 257 | // inspects the y value to determine which chunk to use. 258 | function chunk(d) { 259 | return d.y < 10 ? 'below-threshold' : 'line'; 260 | } 261 | ``` 262 | 263 | 264 | # *lineChunked*.**chunkLineResolver**([*chunkLineResolver*]) 265 | 266 | Get or set *chunkLineResolver*, a function that decides what chunk the line should be rendered in when given two adjacent defined points that may or may not be in the same chunk via `chunk()`. The function takes three parameters: 267 | 268 | * chunkNameLeft (*String*): The name of the chunk for the point on the left 269 | * chunkNameRight (*String*): The name of the chunk for the point on the right 270 | * chunkNames (*String[]*): The ordered list of chunk names from chunkDefinitions 271 | 272 | It returns the name of the chunk that the line segment should be rendered in. By default it uses the order of the keys in the chunkDefinition object.. 273 | 274 | For example, if you wanted all lines between two different chunks to use the styling of the chunk that the left point belongs to, you could define *chunkLineResolver* as follows: 275 | 276 | ```js 277 | // always take the chunk of the item on the left 278 | function chunkLineResolver(chunkNameA, chunkNameB, chunkNames) { 279 | return chunkNameA; 280 | } 281 | ``` 282 | 283 | 284 | # *lineChunked*.**chunkDefinitions**([*chunkDefinitions*]) 285 | 286 | Get or set *chunkDefinitions*, an object mapping chunk names to styling and attribute assignments for each chunk. The format is as follows: 287 | 288 | ``` 289 | { 290 | chunkName1: { 291 | styles: {}, 292 | attrs: {}, 293 | pointStyles: {}, 294 | pointAttrs: {}, 295 | }, 296 | ... 297 | } 298 | ``` 299 | 300 | Note that by using the reserved chunk names `"line"` and `"gap"`, you can accomplish the equivalent of setting `lineStyles`, `lineAttrs`, `gapStyles`, `gapAttrs`, `pointStyles`, and `pointAttrs` individually. Chunks default to reading settings defined for the chunk `"line"` (or by `lineStyles`, `lineAttrs`), so you can place base styles for all chunks there and not have to duplicate them. 301 | 302 | Full multiple chunks example: 303 | 304 | ```js 305 | const lineChunked = d3.lineChunked() 306 | .defined(function (d) { return d[1] !== null; }) 307 | .chunkDefinitions({ 308 | line: { 309 | styles: { 310 | stroke: '#0bb', 311 | }, 312 | }, 313 | gap: { 314 | styles: { 315 | stroke: 'none' 316 | } 317 | }, 318 | 'below-threshold': { 319 | styles: { 320 | 'stroke-dasharray': '2, 2', 321 | 'stroke-opacity': 0.35, 322 | }, 323 | pointStyles: { 324 | 'fill': '#fff', 325 | 'stroke': '#0bb', 326 | } 327 | } 328 | }) 329 | .chunk(function (d) { return d[1] < 2 ? 'below-threshold' : 'line'; }); 330 | ``` 331 | -------------------------------------------------------------------------------- /docs/d3-interpolate-path.js: -------------------------------------------------------------------------------- 1 | (function (global, factory) { 2 | typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('d3-interpolate')) : 3 | typeof define === 'function' && define.amd ? define(['exports', 'd3-interpolate'], factory) : 4 | (factory((global.d3 = global.d3 || {}),global.d3)); 5 | }(this, (function (exports,d3Interpolate) { 'use strict'; 6 | 7 | /** 8 | * List of params for each command type in a path `d` attribute 9 | */ 10 | var typeMap = { 11 | M: ['x', 'y'], 12 | L: ['x', 'y'], 13 | H: ['x'], 14 | V: ['y'], 15 | C: ['x1', 'y1', 'x2', 'y2', 'x', 'y'], 16 | S: ['x2', 'y2', 'x', 'y'], 17 | Q: ['x1', 'y1', 'x', 'y'], 18 | T: ['x', 'y'], 19 | A: ['rx', 'ry', 'xAxisRotation', 'largeArcFlag', 'sweepFlag', 'x', 'y'] 20 | }; 21 | 22 | /** 23 | * Convert to object representation of the command from a string 24 | * 25 | * @param {String} commandString Token string from the `d` attribute (e.g., L0,0) 26 | * @return {Object} An object representing this command. 27 | */ 28 | function commandObject(commandString) { 29 | // convert all spaces to commas 30 | commandString = commandString.trim().replace(/ /g, ','); 31 | 32 | var type = commandString[0]; 33 | var args = commandString.substring(1).split(','); 34 | return typeMap[type.toUpperCase()].reduce(function (obj, param, i) { 35 | // parse X as float since we need it to do distance checks for extending points 36 | obj[param] = param === 'x' ? parseFloat(args[i]) : args[i]; 37 | return obj; 38 | }, { type: type }); 39 | } 40 | 41 | /** 42 | * Converts a command object to a string to be used in a `d` attribute 43 | * @param {Object} command A command object 44 | * @return {String} The string for the `d` attribute 45 | */ 46 | function commandToString(command) { 47 | var type = command.type; 48 | 49 | var params = typeMap[type.toUpperCase()]; 50 | return '' + type + params.map(function (p) { 51 | return command[p]; 52 | }).join(','); 53 | } 54 | 55 | /** 56 | * Converts command A to have the same type as command B. 57 | * 58 | * e.g., L0,5 -> C0,5,0,5,0,5 59 | * 60 | * Uses these rules: 61 | * x1 <- x 62 | * x2 <- x 63 | * y1 <- y 64 | * y2 <- y 65 | * rx <- 0 66 | * ry <- 0 67 | * xAxisRotation <- read from B 68 | * largeArcFlag <- read from B 69 | * sweepflag <- read from B 70 | * 71 | * @param {Object} aCommand Command object from path `d` attribute 72 | * @param {Object} bCommand Command object from path `d` attribute to match against 73 | * @return {Object} aCommand converted to type of bCommand 74 | */ 75 | function convertToSameType(aCommand, bCommand) { 76 | var conversionMap = { 77 | x1: 'x', 78 | y1: 'y', 79 | x2: 'x', 80 | y2: 'y' 81 | }; 82 | 83 | var readFromBKeys = ['xAxisRotation', 'largeArcFlag', 'sweepFlag']; 84 | 85 | // convert (but ignore M types) 86 | if (aCommand.type !== bCommand.type && bCommand.type.toUpperCase() !== 'M') { 87 | (function () { 88 | var aConverted = {}; 89 | Object.keys(bCommand).forEach(function (bKey) { 90 | var bValue = bCommand[bKey]; 91 | // first read from the A command 92 | var aValue = aCommand[bKey]; 93 | 94 | // if it is one of these values, read from B no matter what 95 | if (aValue === undefined) { 96 | if (readFromBKeys.includes(bKey)) { 97 | aValue = bValue; 98 | } else { 99 | // if it wasn't in the A command, see if an equivalent was 100 | if (aValue === undefined && conversionMap[bKey]) { 101 | aValue = aCommand[conversionMap[bKey]]; 102 | } 103 | 104 | // if it doesn't have a converted value, use 0 105 | if (aValue === undefined) { 106 | aValue = 0; 107 | } 108 | } 109 | } 110 | 111 | aConverted[bKey] = aValue; 112 | }); 113 | 114 | // update the type to match B 115 | aConverted.type = bCommand.type; 116 | aCommand = aConverted; 117 | })(); 118 | } 119 | 120 | return aCommand; 121 | } 122 | 123 | /** 124 | * Extends an array of commands to the length of the second array 125 | * inserting points at the spot that is closest by X value. Ensures 126 | * all the points of commandsToExtend are in the extended array and that 127 | * only numPointsToExtend points are added. 128 | * 129 | * @param {Object[]} commandsToExtend The commands array to extend 130 | * @param {Object[]} referenceCommands The commands array to match 131 | * @return {Object[]} The extended commands1 array 132 | */ 133 | function extend(commandsToExtend, referenceCommands, numPointsToExtend) { 134 | // map each command in B to a command in A by counting how many times ideally 135 | // a command in A was in the initial path (see https://github.com/pbeshai/d3-interpolate-path/issues/8) 136 | var initialCommandIndex = void 0; 137 | if (commandsToExtend.length > 1 && commandsToExtend[0].type === 'M') { 138 | initialCommandIndex = 1; 139 | } else { 140 | initialCommandIndex = 0; 141 | } 142 | 143 | var counts = referenceCommands.reduce(function (counts, refCommand, i) { 144 | // skip first M 145 | if (i === 0 && refCommand.type === 'M') { 146 | counts[0] = 1; 147 | return counts; 148 | } 149 | 150 | var minDistance = Math.abs(commandsToExtend[initialCommandIndex].x - refCommand.x); 151 | var minCommand = initialCommandIndex; 152 | 153 | // find the closest point by X position in A 154 | for (var j = initialCommandIndex + 1; j < commandsToExtend.length; j++) { 155 | var distance = Math.abs(commandsToExtend[j].x - refCommand.x); 156 | if (distance < minDistance) { 157 | minDistance = distance; 158 | minCommand = j; 159 | // since we assume sorted by X, once we find a value farther, we can return the min. 160 | } else { 161 | break; 162 | } 163 | } 164 | 165 | counts[minCommand] = (counts[minCommand] || 0) + 1; 166 | return counts; 167 | }, {}); 168 | 169 | // now extend the array adding in at the appropriate place as needed 170 | var extended = []; 171 | var numExtended = 0; 172 | for (var i = 0; i < commandsToExtend.length; i++) { 173 | // add in the initial point for this A command 174 | extended.push(commandsToExtend[i]); 175 | 176 | for (var j = 1; j < counts[i] && numExtended < numPointsToExtend; j++) { 177 | var commandToAdd = Object.assign({}, commandsToExtend[i]); 178 | // don't allow multiple Ms 179 | if (commandToAdd.type === 'M') { 180 | commandToAdd.type = 'L'; 181 | } else { 182 | // try to set control points to x and y 183 | if (commandToAdd.x1 !== undefined) { 184 | commandToAdd.x1 = commandToAdd.x; 185 | commandToAdd.y1 = commandToAdd.y; 186 | } 187 | 188 | if (commandToAdd.x2 !== undefined) { 189 | commandToAdd.x2 = commandToAdd.x; 190 | commandToAdd.y2 = commandToAdd.y; 191 | } 192 | } 193 | extended.push(commandToAdd); 194 | numExtended += 1; 195 | } 196 | } 197 | 198 | return extended; 199 | } 200 | 201 | /** 202 | * Interpolate from A to B by extending A and B during interpolation to have 203 | * the same number of points. This allows for a smooth transition when they 204 | * have a different number of points. 205 | * 206 | * Ignores the `Z` character in paths unless both A and B end with it. 207 | * 208 | * @param {String} a The `d` attribute for a path 209 | * @param {String} b The `d` attribute for a path 210 | */ 211 | function interpolatePath(a, b) { 212 | // remove Z, remove spaces after letters as seen in IE 213 | var aNormalized = a == null ? '' : a.replace(/[Z]/gi, '').replace(/([MLCSTQAHV])\s*/gi, '$1'); 214 | var bNormalized = b == null ? '' : b.replace(/[Z]/gi, '').replace(/([MLCSTQAHV])\s*/gi, '$1'); 215 | var aPoints = aNormalized === '' ? [] : aNormalized.split(/(?=[MLCSTQAHV])/gi); 216 | var bPoints = bNormalized === '' ? [] : bNormalized.split(/(?=[MLCSTQAHV])/gi); 217 | 218 | // if both are empty, interpolation is always the empty string. 219 | if (!aPoints.length && !bPoints.length) { 220 | return function nullInterpolator() { 221 | return ''; 222 | }; 223 | } 224 | 225 | // if A is empty, treat it as if it used to contain just the first point 226 | // of B. This makes it so the line extends out of from that first point. 227 | if (!aPoints.length) { 228 | aPoints.push(bPoints[0]); 229 | 230 | // otherwise if B is empty, treat it as if it contains the first point 231 | // of A. This makes it so the line retracts into the first point. 232 | } else if (!bPoints.length) { 233 | bPoints.push(aPoints[0]); 234 | } 235 | 236 | // convert to command objects so we can match types 237 | var aCommands = aPoints.map(commandObject); 238 | var bCommands = bPoints.map(commandObject); 239 | 240 | // extend to match equal size 241 | var numPointsToExtend = Math.abs(bPoints.length - aPoints.length); 242 | 243 | if (numPointsToExtend !== 0) { 244 | // B has more points than A, so add points to A before interpolating 245 | if (bCommands.length > aCommands.length) { 246 | aCommands = extend(aCommands, bCommands, numPointsToExtend); 247 | 248 | // else if A has more points than B, add more points to B 249 | } else if (bCommands.length < aCommands.length) { 250 | bCommands = extend(bCommands, aCommands, numPointsToExtend); 251 | } 252 | } 253 | 254 | // commands have same length now. 255 | // convert A to the same type of B 256 | aCommands = aCommands.map(function (aCommand, i) { 257 | return convertToSameType(aCommand, bCommands[i]); 258 | }); 259 | 260 | var aProcessed = aCommands.map(commandToString).join(''); 261 | var bProcessed = bCommands.map(commandToString).join(''); 262 | 263 | // if both A and B end with Z add it back in 264 | if ((a == null || a[a.length - 1] === 'Z') && (b == null || b[b.length - 1] === 'Z')) { 265 | aProcessed += 'Z'; 266 | bProcessed += 'Z'; 267 | } 268 | 269 | var stringInterpolator = d3Interpolate.interpolateString(aProcessed, bProcessed); 270 | 271 | return function pathInterpolator(t) { 272 | // at 1 return the final value without the extensions used during interpolation 273 | if (t === 1) { 274 | return b == null ? '' : b; 275 | } 276 | 277 | return stringInterpolator(t); 278 | }; 279 | } 280 | 281 | exports.interpolatePath = interpolatePath; 282 | 283 | Object.defineProperty(exports, '__esModule', { value: true }); 284 | 285 | }))); -------------------------------------------------------------------------------- /docs/d3-line-chunked.js: -------------------------------------------------------------------------------- 1 | (function (global, factory) { 2 | typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('d3-array'), require('d3-selection'), require('d3-shape'), require('d3-interpolate-path')) : 3 | typeof define === 'function' && define.amd ? define(['exports', 'd3-array', 'd3-selection', 'd3-shape', 'd3-interpolate-path'], factory) : 4 | (factory((global.d3 = global.d3 || {}),global.d3,global.d3,global.d3,global.d3)); 5 | }(this, (function (exports,d3Array,d3Selection,d3Shape,d3InterpolatePath) { 'use strict'; 6 | 7 | var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { 8 | return typeof obj; 9 | } : function (obj) { 10 | return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; 11 | }; 12 | 13 | var asyncGenerator = function () { 14 | function AwaitValue(value) { 15 | this.value = value; 16 | } 17 | 18 | function AsyncGenerator(gen) { 19 | var front, back; 20 | 21 | function send(key, arg) { 22 | return new Promise(function (resolve, reject) { 23 | var request = { 24 | key: key, 25 | arg: arg, 26 | resolve: resolve, 27 | reject: reject, 28 | next: null 29 | }; 30 | 31 | if (back) { 32 | back = back.next = request; 33 | } else { 34 | front = back = request; 35 | resume(key, arg); 36 | } 37 | }); 38 | } 39 | 40 | function resume(key, arg) { 41 | try { 42 | var result = gen[key](arg); 43 | var value = result.value; 44 | 45 | if (value instanceof AwaitValue) { 46 | Promise.resolve(value.value).then(function (arg) { 47 | resume("next", arg); 48 | }, function (arg) { 49 | resume("throw", arg); 50 | }); 51 | } else { 52 | settle(result.done ? "return" : "normal", result.value); 53 | } 54 | } catch (err) { 55 | settle("throw", err); 56 | } 57 | } 58 | 59 | function settle(type, value) { 60 | switch (type) { 61 | case "return": 62 | front.resolve({ 63 | value: value, 64 | done: true 65 | }); 66 | break; 67 | 68 | case "throw": 69 | front.reject(value); 70 | break; 71 | 72 | default: 73 | front.resolve({ 74 | value: value, 75 | done: false 76 | }); 77 | break; 78 | } 79 | 80 | front = front.next; 81 | 82 | if (front) { 83 | resume(front.key, front.arg); 84 | } else { 85 | back = null; 86 | } 87 | } 88 | 89 | this._invoke = send; 90 | 91 | if (typeof gen.return !== "function") { 92 | this.return = undefined; 93 | } 94 | } 95 | 96 | if (typeof Symbol === "function" && Symbol.asyncIterator) { 97 | AsyncGenerator.prototype[Symbol.asyncIterator] = function () { 98 | return this; 99 | }; 100 | } 101 | 102 | AsyncGenerator.prototype.next = function (arg) { 103 | return this._invoke("next", arg); 104 | }; 105 | 106 | AsyncGenerator.prototype.throw = function (arg) { 107 | return this._invoke("throw", arg); 108 | }; 109 | 110 | AsyncGenerator.prototype.return = function (arg) { 111 | return this._invoke("return", arg); 112 | }; 113 | 114 | return { 115 | wrap: function (fn) { 116 | return function () { 117 | return new AsyncGenerator(fn.apply(this, arguments)); 118 | }; 119 | }, 120 | await: function (value) { 121 | return new AwaitValue(value); 122 | } 123 | }; 124 | }(); 125 | 126 | var slicedToArray = function () { 127 | function sliceIterator(arr, i) { 128 | var _arr = []; 129 | var _n = true; 130 | var _d = false; 131 | var _e = undefined; 132 | 133 | try { 134 | for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { 135 | _arr.push(_s.value); 136 | 137 | if (i && _arr.length === i) break; 138 | } 139 | } catch (err) { 140 | _d = true; 141 | _e = err; 142 | } finally { 143 | try { 144 | if (!_n && _i["return"]) _i["return"](); 145 | } finally { 146 | if (_d) throw _e; 147 | } 148 | } 149 | 150 | return _arr; 151 | } 152 | 153 | return function (arr, i) { 154 | if (Array.isArray(arr)) { 155 | return arr; 156 | } else if (Symbol.iterator in Object(arr)) { 157 | return sliceIterator(arr, i); 158 | } else { 159 | throw new TypeError("Invalid attempt to destructure non-iterable instance"); 160 | } 161 | }; 162 | }(); 163 | 164 | var toConsumableArray = function (arr) { 165 | if (Array.isArray(arr)) { 166 | for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) arr2[i] = arr[i]; 167 | 168 | return arr2; 169 | } else { 170 | return Array.from(arr); 171 | } 172 | }; 173 | 174 | // only needed if using transitions 175 | 176 | // used to generate IDs for clip paths 177 | var counter = 0; 178 | 179 | /** 180 | * Renders line with potential gaps in the data by styling the gaps differently 181 | * from the defined areas. Single points are rendered as circles. Transitions are 182 | * supported. 183 | */ 184 | function lineChunked () { 185 | var defaultLineAttrs = { 186 | fill: 'none', 187 | stroke: '#222', 188 | 'stroke-width': 1.5, 189 | 'stroke-opacity': 1 190 | }; 191 | var defaultGapAttrs = { 192 | 'stroke-dasharray': '2 2', 193 | 'stroke-opacity': 0.35 194 | }; 195 | var defaultPointAttrs = { 196 | // read fill and r at render time in case the lineAttrs changed 197 | // fill: defaultLineAttrs.stroke, 198 | // r: defaultLineAttrs['stroke-width'], 199 | }; 200 | 201 | var lineChunkName = 'line'; 202 | var gapChunkName = 'gap'; 203 | 204 | /** 205 | * How to access the x attribute of `d` 206 | */ 207 | var x = function x(d) { 208 | return d[0]; 209 | }; 210 | 211 | /** 212 | * How to access the y attribute of `d` 213 | */ 214 | var y = function y(d) { 215 | return d[1]; 216 | }; 217 | 218 | /** 219 | * Function to determine if there is data for a given point. 220 | * @param {Any} d data point 221 | * @return {Boolean} true if the data is defined for the point, false otherwise 222 | */ 223 | var defined = function defined() { 224 | return true; 225 | }; 226 | 227 | /** 228 | * Function to determine if there a point follows the previous. This functions 229 | * enables detecting gaps in the data when there is an unexpected jump. For 230 | * instance, if you have time data for every day and the previous data point 231 | * is for January 5, 2016 and the current data point is for January 12, 2016, 232 | * then there is data missing for January 6-11, so this function would return 233 | * true. 234 | * 235 | * It is only necessary to define this if your data doesn't explicitly include 236 | * gaps in it. 237 | * 238 | * @param {Any} previousDatum The previous data point 239 | * @param {Any} currentDatum The data point under consideration 240 | * @return {Boolean} true If the data is defined for the point, false otherwise 241 | */ 242 | var isNext = function isNext() { 243 | return true; 244 | }; 245 | 246 | /** 247 | * Function to determine which chunk this data is within. 248 | * 249 | * @param {Any} d data point 250 | * @param {Any[]} data the full dataset 251 | * @return {String} The id of the chunk. Defaults to "line" 252 | */ 253 | var chunk = function chunk() { 254 | return lineChunkName; 255 | }; 256 | 257 | /** 258 | * Decides what line the chunk should be in when given two defined points 259 | * in different chunks. Uses the order provided by the keys of chunkDefinition 260 | * if not specified, with `line` and `gap` prepended to the list if not 261 | * in the chunkDefinition object. 262 | * 263 | * @param {String} chunkNameLeft The name of the chunk for the point on the left 264 | * @param {String} chunkNameRight The name of the chunk for the point on the right 265 | * @param {String[]} chunkNames the ordered list of chunk names from chunkDefinitions 266 | * @return {String} The name of the chunk to assign the line segment between the two points to. 267 | */ 268 | var chunkLineResolver = function defaultChunkLineResolver(chunkNameLeft, chunkNameRight, chunkNames) { 269 | var leftIndex = chunkNames.indexOf(chunkNameLeft); 270 | var rightIndex = chunkNames.indexOf(chunkNameRight); 271 | 272 | return leftIndex > rightIndex ? chunkNameLeft : chunkNameRight; 273 | }; 274 | 275 | /** 276 | * Object specifying how to set style and attributes for each chunk. 277 | * Format is an object: 278 | * 279 | * { 280 | * chunkName1: { 281 | * styles: {}, 282 | * attrs: {}, 283 | * pointStyles: {}, 284 | * pointAttrs: {}, 285 | * }, 286 | * ... 287 | * } 288 | */ 289 | var chunkDefinitions = {}; 290 | 291 | /** 292 | * Passed through to d3.line().curve. Default value: d3.curveLinear. 293 | */ 294 | var curve = d3Shape.curveLinear; 295 | 296 | /** 297 | * Object mapping style keys to style values to be applied to both 298 | * defined and undefined lines. Uses syntax similar to d3-selection-multi. 299 | */ 300 | var lineStyles = {}; 301 | 302 | /** 303 | * Object mapping attr keys to attr values to be applied to both 304 | * defined and undefined lines. Uses syntax similar to d3-selection-multi. 305 | */ 306 | var lineAttrs = defaultLineAttrs; 307 | 308 | /** 309 | * Object mapping style keys to style values to be applied only to the 310 | * undefined lines. It overrides values provided in lineStyles. Uses 311 | * syntax similar to d3-selection-multi. 312 | */ 313 | var gapStyles = {}; 314 | 315 | /** 316 | * Object mapping attr keys to attr values to be applied only to the 317 | * undefined lines. It overrides values provided in lineAttrs. Uses 318 | * syntax similar to d3-selection-multi. 319 | */ 320 | var gapAttrs = defaultGapAttrs; 321 | 322 | /** 323 | * Object mapping style keys to style values to be applied to points. 324 | * Uses syntax similar to d3-selection-multi. 325 | */ 326 | var pointStyles = {}; 327 | 328 | /** 329 | * Object mapping attr keys to attr values to be applied to points. 330 | * Note that if fill is not defined in pointStyles or pointAttrs, it 331 | * will be read from the stroke color on the line itself. 332 | * Uses syntax similar to d3-selection-multi. 333 | */ 334 | var pointAttrs = defaultPointAttrs; 335 | 336 | /** 337 | * Flag to set whether to transition on initial render or not. If true, 338 | * the line starts out flat and transitions in its y value. If false, 339 | * it just immediately renders. 340 | */ 341 | var transitionInitial = true; 342 | 343 | /** 344 | * An array `[xMin, xMax]` specifying the minimum and maximum x pixel values 345 | * (e.g., `xScale.range()`). If defined, the undefined line will extend to 346 | * the the values provided, otherwise it will end at the last defined points. 347 | */ 348 | var extendEnds = void 0; 349 | 350 | /** 351 | * Function to determine how to access the line data array from the passed in data 352 | * Defaults to the identity data => data. 353 | * @param {Any} data line dataset 354 | * @return {Array} The array of data points for that given line 355 | */ 356 | var accessData = function accessData(data) { 357 | return data; 358 | }; 359 | 360 | /** 361 | * A flag specifying whether to render in debug mode or not. 362 | */ 363 | var debug = false; 364 | 365 | /** 366 | * Logs warnings if the chunk definitions uses 'style' or 'attr' instead of 367 | * 'styles' or 'attrs' 368 | */ 369 | function validateChunkDefinitions() { 370 | Object.keys(chunkDefinitions).forEach(function (key) { 371 | var def = chunkDefinitions[key]; 372 | if (def.style != null) { 373 | console.warn('Warning: chunkDefinitions expects "styles", but found "style" in ' + key, def); 374 | } 375 | if (def.attr != null) { 376 | console.warn('Warning: chunkDefinitions expects "attrs", but found "attr" in ' + key, def); 377 | } 378 | if (def.pointStyle != null) { 379 | console.warn('Warning: chunkDefinitions expects "pointStyles", but found "pointStyle" in ' + key, def); 380 | } 381 | if (def.pointAttr != null) { 382 | console.warn('Warning: chunkDefinitions expects "pointAttrs", but found "pointAttr" in ' + key, def); 383 | } 384 | }); 385 | } 386 | 387 | /** 388 | * Helper to get the chunk names that are defined. Prepends 389 | * line, gap to the start of the array unless useChunkDefOrder 390 | * is specified. In this case, it only prepends if they are 391 | * not specified in the chunk definitions. 392 | */ 393 | function getChunkNames(useChunkDefOrder) { 394 | var chunkDefNames = Object.keys(chunkDefinitions); 395 | var prependLine = true; 396 | var prependGap = true; 397 | 398 | // if using chunk definition order, only prepend line/gap if they aren't in the 399 | // chunk definition. 400 | if (useChunkDefOrder) { 401 | prependLine = !chunkDefNames.includes(lineChunkName); 402 | prependGap = !chunkDefNames.includes(gapChunkName); 403 | } 404 | 405 | if (prependGap) { 406 | chunkDefNames.unshift(gapChunkName); 407 | } 408 | 409 | if (prependLine) { 410 | chunkDefNames.unshift(lineChunkName); 411 | } 412 | 413 | // remove duplicates and return 414 | return chunkDefNames.filter(function (d, i, a) { 415 | return a.indexOf(d) === i; 416 | }); 417 | } 418 | 419 | /** 420 | * Helper function to compute the contiguous segments of the data 421 | * @param {String} chunkName the chunk name to match. points not matching are removed. 422 | * if undefined, uses 'line'. 423 | * @param {Array} definedSegments An array of segments (subarrays) of the defined line data (output from 424 | * computeDefinedSegments) 425 | * @return {Array} An array of segments (subarrays) of the chunk line data 426 | */ 427 | function computeChunkedSegments(chunkName, definedSegments) { 428 | // helper to split a segment into sub-segments based on the chunk name 429 | function splitSegment(segment, chunkNames) { 430 | var startNewSegment = true; 431 | 432 | // helper for adding to a segment / creating a new one 433 | function addToSegment(segments, d) { 434 | // if we are starting a new segment, start it with this point 435 | if (startNewSegment) { 436 | segments.push([d]); 437 | startNewSegment = false; 438 | 439 | // otherwise add to the last segment 440 | } else { 441 | var lastSegment = segments[segments.length - 1]; 442 | lastSegment.push(d); 443 | } 444 | } 445 | 446 | var segments = segment.reduce(function (segments, d, i) { 447 | var dChunkName = chunk(d); 448 | var dPrev = segment[i - 1]; 449 | var dNext = segment[i + 1]; 450 | 451 | // if it matches name, add to the segment 452 | if (dChunkName === chunkName) { 453 | addToSegment(segments, d); 454 | } else { 455 | // check if this point belongs in the previous chunk: 456 | var added = false; 457 | // doesn't match chunk name, but does it go in the segment? as the end? 458 | if (dPrev) { 459 | var segmentChunkName = chunkLineResolver(chunk(dPrev), dChunkName, chunkNames); 460 | 461 | // if it is supposed to be in this chunk, add it in 462 | if (segmentChunkName === chunkName) { 463 | addToSegment(segments, d); 464 | added = true; 465 | startNewSegment = false; 466 | } 467 | } 468 | 469 | // doesn't belong in previous, so does it belong in next? 470 | if (!added && dNext != null) { 471 | // check if this point belongs in the next chunk 472 | var nextSegmentChunkName = chunkLineResolver(dChunkName, chunk(dNext), chunkNames); 473 | 474 | // if it's supposed to be in the next chunk, create it 475 | if (nextSegmentChunkName === chunkName) { 476 | segments.push([d]); 477 | added = true; 478 | startNewSegment = false; 479 | } else { 480 | startNewSegment = true; 481 | } 482 | 483 | // not previous or next 484 | } else if (!added) { 485 | startNewSegment = true; 486 | } 487 | } 488 | 489 | return segments; 490 | }, []); 491 | 492 | return segments; 493 | } 494 | 495 | var chunkNames = getChunkNames(true); 496 | 497 | var chunkSegments = definedSegments.reduce(function (carry, segment) { 498 | var newSegments = splitSegment(segment, chunkNames); 499 | if (newSegments && newSegments.length) { 500 | return carry.concat(newSegments); 501 | } 502 | 503 | return carry; 504 | }, []); 505 | 506 | return chunkSegments; 507 | } 508 | 509 | /** 510 | * Helper function to compute the contiguous segments of the data 511 | * @param {Array} lineData the line data 512 | * @param {String} chunkName the chunk name to match. points not matching are removed. 513 | * if undefined, uses 'line'. 514 | * @return {Array} An array of segments (subarrays) of the line data 515 | */ 516 | function computeDefinedSegments(lineData) { 517 | var startNewSegment = true; 518 | 519 | // split into segments of continuous data 520 | var segments = lineData.reduce(function (segments, d) { 521 | // skip if this point has no data 522 | if (!defined(d)) { 523 | startNewSegment = true; 524 | return segments; 525 | } 526 | 527 | // if we are starting a new segment, start it with this point 528 | if (startNewSegment) { 529 | segments.push([d]); 530 | startNewSegment = false; 531 | 532 | // otherwise see if we are adding to the last segment 533 | } else { 534 | var lastSegment = segments[segments.length - 1]; 535 | var lastDatum = lastSegment[lastSegment.length - 1]; 536 | // if we expect this point to come next, add it to the segment 537 | if (isNext(lastDatum, d)) { 538 | lastSegment.push(d); 539 | 540 | // otherwise create a new segment 541 | } else { 542 | segments.push([d]); 543 | } 544 | } 545 | 546 | return segments; 547 | }, []); 548 | 549 | return segments; 550 | } 551 | 552 | /** 553 | * Helper function that applies attrs and styles to the specified selection. 554 | * 555 | * @param {Object} selection The d3 selection 556 | * @param {Object} evaluatedDefinition The evaluated styles and attrs obj (part of output from evaluateDefinitions()) 557 | * @param {Boolean} point if true, uses pointAttrs and pointStyles, otherwise attrs and styles (default: false). 558 | * @return {void} 559 | */ 560 | function applyAttrsAndStyles(selection, evaluatedDefinition) { 561 | var point = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; 562 | 563 | var attrsKey = point ? 'pointAttrs' : 'attrs'; 564 | var stylesKey = point ? 'pointStyles' : 'styles'; 565 | 566 | // apply user-provided attrs 567 | Object.keys(evaluatedDefinition[attrsKey]).forEach(function (attr) { 568 | selection.attr(attr, evaluatedDefinition[attrsKey][attr]); 569 | }); 570 | 571 | // apply user-provided styles 572 | Object.keys(evaluatedDefinition[stylesKey]).forEach(function (style) { 573 | selection.style(style, evaluatedDefinition[stylesKey][style]); 574 | }); 575 | } 576 | 577 | /** 578 | * For the selected line, evaluate the definitions objects. This is necessary since 579 | * some of the style/attr values are functions that need to be evaluated per line. 580 | * 581 | * In general, the definitions are added in this order: 582 | * 583 | * 1. definition from lineStyle, lineAttrs, pointStyles, pointAttrs 584 | * 2. if it is the gap line, add in gapStyles, gapAttrs 585 | * 3. definition from chunkDefinitions 586 | * 587 | * Returns an object matching the form of chunkDefinitions: 588 | * { 589 | * line: { styles, attrs, pointStyles, pointAttrs }, 590 | * gap: { styles, attrs } 591 | * chunkName1: { styles, attrs, pointStyles, pointAttrs }, 592 | * ... 593 | * } 594 | */ 595 | function evaluateDefinitions(d, i) { 596 | // helper to evaluate an object of attr or style definitions 597 | function evaluateAttrsOrStyles() { 598 | var input = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; 599 | 600 | return Object.keys(input).reduce(function (output, key) { 601 | var val = input[key]; 602 | 603 | if (typeof val === 'function') { 604 | val = val(d, i); 605 | } 606 | 607 | output[key] = val; 608 | return output; 609 | }, {}); 610 | } 611 | 612 | var evaluated = {}; 613 | 614 | // get the list of chunks to create evaluated definitions for 615 | var chunks = getChunkNames(); 616 | 617 | // for each chunk, evaluate the attrs and styles to use for lines and points 618 | chunks.forEach(function (chunkName) { 619 | var chunkDef = chunkDefinitions[chunkName] || {}; 620 | var evaluatedChunk = { 621 | styles: Object.assign({}, evaluateAttrsOrStyles(lineStyles), evaluateAttrsOrStyles((chunkDefinitions[lineChunkName] || {}).styles), chunkName === gapChunkName ? evaluateAttrsOrStyles(gapStyles) : undefined, evaluateAttrsOrStyles(chunkDef.styles)), 622 | attrs: Object.assign({}, evaluateAttrsOrStyles(lineAttrs), evaluateAttrsOrStyles((chunkDefinitions[lineChunkName] || {}).attrs), chunkName === gapChunkName ? evaluateAttrsOrStyles(gapAttrs) : undefined, evaluateAttrsOrStyles(chunkDef.attrs)) 623 | }; 624 | 625 | // set point attrs. defaults read from this chunk's line settings. 626 | var basePointAttrs = { 627 | fill: evaluatedChunk.attrs.stroke, 628 | r: evaluatedChunk.attrs['stroke-width'] == null ? undefined : parseFloat(evaluatedChunk.attrs['stroke-width']) + 1 629 | }; 630 | 631 | evaluatedChunk.pointAttrs = Object.assign(basePointAttrs, evaluateAttrsOrStyles(pointAttrs), evaluateAttrsOrStyles((chunkDefinitions[lineChunkName] || {}).pointAttrs), evaluateAttrsOrStyles(chunkDef.pointAttrs)); 632 | 633 | // ensure `r` is a number (helps to remove 'px' if provided) 634 | if (evaluatedChunk.pointAttrs.r != null) { 635 | evaluatedChunk.pointAttrs.r = parseFloat(evaluatedChunk.pointAttrs.r); 636 | } 637 | 638 | // set point styles. if no fill attr set, use the line style stroke. otherwise read from the attr. 639 | var basePointStyles = chunkDef.pointAttrs && chunkDef.pointAttrs.fill != null ? {} : { 640 | fill: evaluatedChunk.styles.stroke 641 | }; 642 | 643 | evaluatedChunk.pointStyles = Object.assign(basePointStyles, evaluateAttrsOrStyles(pointStyles), evaluateAttrsOrStyles((chunkDefinitions[lineChunkName] || {}).pointStyles), evaluateAttrsOrStyles(chunkDef.pointStyles)); 644 | 645 | evaluated[chunkName] = evaluatedChunk; 646 | }); 647 | 648 | return evaluated; 649 | } 650 | 651 | /** 652 | * Render the points for when segments have length 1. 653 | */ 654 | function renderCircles(initialRender, transition, context, root, points, evaluatedDefinition, className) { 655 | var primaryClassName = className.split(' ')[0]; 656 | var circles = root.selectAll('.' + primaryClassName).data(points, function (d) { 657 | return d.id; 658 | }); 659 | 660 | // read in properties about the transition if we have one 661 | var transitionDuration = transition ? context.duration() : 0; 662 | var transitionDelay = transition ? context.delay() : 0; 663 | 664 | // EXIT 665 | if (transition) { 666 | circles.exit().transition().delay(transitionDelay).duration(transitionDuration * 0.05).attr('r', 1e-6).remove(); 667 | } else { 668 | circles.exit().remove(); 669 | } 670 | 671 | // ENTER 672 | var circlesEnter = circles.enter().append('circle'); 673 | 674 | // apply user-provided attrs, using attributes from current line if not provided 675 | applyAttrsAndStyles(circlesEnter, evaluatedDefinition, true); 676 | 677 | circlesEnter.classed(className, true).attr('r', 1e-6) // overrides provided `r value for now 678 | .attr('cx', function (d) { 679 | return x(d.data); 680 | }).attr('cy', function (d) { 681 | return y(d.data); 682 | }); 683 | 684 | // handle with transition 685 | if ((!initialRender || initialRender && transitionInitial) && transition) { 686 | var enterDuration = transitionDuration * 0.15; 687 | 688 | // delay sizing up the radius until after the line transition 689 | circlesEnter.transition(context).delay(transitionDelay + (transitionDuration - enterDuration)).duration(enterDuration).attr('r', evaluatedDefinition.pointAttrs.r); 690 | } else { 691 | circlesEnter.attr('r', evaluatedDefinition.pointAttrs.r); 692 | } 693 | 694 | // UPDATE 695 | if (transition) { 696 | circles = circles.transition(context); 697 | } 698 | circles.attr('r', evaluatedDefinition.pointAttrs.r).attr('cx', function (d) { 699 | return x(d.data); 700 | }).attr('cy', function (d) { 701 | return y(d.data); 702 | }); 703 | } 704 | 705 | function renderClipRects(initialRender, transition, context, root, segments, _ref, _ref2, evaluatedDefinition, path, clipPathId) { 706 | var _ref4 = slicedToArray(_ref, 2), 707 | xMin = _ref4[0], 708 | xMax = _ref4[1]; 709 | 710 | var _ref3 = slicedToArray(_ref2, 2), 711 | yMin = _ref3[0], 712 | yMax = _ref3[1]; 713 | 714 | // TODO: issue with assigning IDs to clipPath elements. need to update how we select/create them 715 | // need reference to path element to set stroke-width property 716 | var clipPath = root.select('#' + clipPathId); 717 | var gDebug = root.select('.d3-line-chunked-debug'); 718 | 719 | // set up debug group 720 | if (debug && gDebug.empty()) { 721 | gDebug = root.append('g').classed('d3-line-chunked-debug', true); 722 | } else if (!debug && !gDebug.empty()) { 723 | gDebug.remove(); 724 | } 725 | 726 | var clipPathRects = clipPath.selectAll('rect').data(segments); 727 | var debugRects = void 0; 728 | if (debug) { 729 | debugRects = gDebug.selectAll('rect').data(segments); 730 | } 731 | 732 | // get stroke width to avoid having the clip rects clip the stroke 733 | // See https://github.com/pbeshai/d3-line-chunked/issues/2 734 | var strokeWidth = parseFloat(evaluatedDefinition.styles['stroke-width'] || path.style('stroke-width') // reads from CSS too 735 | || evaluatedDefinition.attrs['stroke-width']); 736 | var strokeWidthClipAdjustment = strokeWidth; 737 | var clipRectY = yMin - strokeWidthClipAdjustment; 738 | var clipRectHeight = yMax + strokeWidthClipAdjustment - (yMin - strokeWidthClipAdjustment); 739 | 740 | // compute the currently visible area pairs of [xStart, xEnd] for each clip rect 741 | // if no clip rects, the whole area is visible. 742 | var visibleArea = void 0; 743 | 744 | if (transition) { 745 | 746 | // compute the start and end x values for a data point based on maximizing visibility 747 | // around the middle of the rect. 748 | var visibleStartEnd = function visibleStartEnd(d, visibleArea) { 749 | // eslint-disable-line no-inner-declarations 750 | var xStart = x(d[0]); 751 | var xEnd = x(d[d.length - 1]); 752 | var xMid = xStart + (xEnd - xStart) / 2; 753 | var visArea = visibleArea.find(function (area) { 754 | return area[0] <= xMid && xMid <= area[1]; 755 | }); 756 | 757 | // set width to overlapping visible area 758 | if (visArea) { 759 | return [Math.max(visArea[0], xStart), Math.min(xEnd, visArea[1])]; 760 | } 761 | 762 | // return xEnd - xStart; 763 | return [xMid, xMid]; 764 | }; 765 | 766 | var exitRect = function exitRect(rect) { 767 | // eslint-disable-line no-inner-declarations 768 | rect.attr('x', function (d) { 769 | return visibleStartEnd(d, nextVisibleArea)[0]; 770 | }).attr('width', function (d) { 771 | var _visibleStartEnd = visibleStartEnd(d, nextVisibleArea), 772 | _visibleStartEnd2 = slicedToArray(_visibleStartEnd, 2), 773 | xStart = _visibleStartEnd2[0], 774 | xEnd = _visibleStartEnd2[1]; 775 | 776 | return xEnd - xStart; 777 | }); 778 | }; 779 | 780 | var enterRect = function enterRect(rect) { 781 | // eslint-disable-line no-inner-declarations 782 | rect.attr('x', function (d) { 783 | return visibleStartEnd(d, visibleArea)[0]; 784 | }).attr('width', function (d) { 785 | var _visibleStartEnd3 = visibleStartEnd(d, visibleArea), 786 | _visibleStartEnd4 = slicedToArray(_visibleStartEnd3, 2), 787 | xStart = _visibleStartEnd4[0], 788 | xEnd = _visibleStartEnd4[1]; 789 | 790 | return xEnd - xStart; 791 | }).attr('y', clipRectY).attr('height', clipRectHeight); 792 | }; 793 | 794 | // select previous rects 795 | var previousRects = clipPath.selectAll('rect').nodes(); 796 | // no previous rects = visible area is everything 797 | if (!previousRects.length) { 798 | visibleArea = [[xMin, xMax]]; 799 | } else { 800 | visibleArea = previousRects.map(function (rect) { 801 | var selectedRect = d3Selection.select(rect); 802 | var xStart = parseFloat(selectedRect.attr('x')); 803 | var xEnd = parseFloat(selectedRect.attr('width')) + xStart; 804 | return [xStart, xEnd]; 805 | }); 806 | } 807 | 808 | // set up the clipping paths 809 | // animate by shrinking width to 0 and setting x to the mid point 810 | var nextVisibleArea = void 0; 811 | if (!segments.length) { 812 | nextVisibleArea = [[0, 0]]; 813 | } else { 814 | nextVisibleArea = segments.map(function (d) { 815 | var xStart = x(d[0]); 816 | var xEnd = x(d[d.length - 1]); 817 | return [xStart, xEnd]; 818 | }); 819 | } 820 | 821 | clipPathRects.exit().transition(context).call(exitRect).remove(); 822 | var clipPathRectsEnter = clipPathRects.enter().append('rect').call(enterRect); 823 | clipPathRects = clipPathRects.merge(clipPathRectsEnter); 824 | clipPathRects = clipPathRects.transition(context); 825 | 826 | // debug rects should match clipPathRects 827 | if (debug) { 828 | debugRects.exit().transition(context).call(exitRect).remove(); 829 | var debugRectsEnter = debugRects.enter().append('rect').style('fill', 'rgba(255, 0, 0, 0.3)').style('stroke', 'rgba(255, 0, 0, 0.6)').call(enterRect); 830 | 831 | debugRects = debugRects.merge(debugRectsEnter); 832 | debugRects = debugRects.transition(context); 833 | } 834 | 835 | // not in transition 836 | } else { 837 | clipPathRects.exit().remove(); 838 | var _clipPathRectsEnter = clipPathRects.enter().append('rect'); 839 | clipPathRects = clipPathRects.merge(_clipPathRectsEnter); 840 | 841 | if (debug) { 842 | debugRects.exit().remove(); 843 | var _debugRectsEnter = debugRects.enter().append('rect').style('fill', 'rgba(255, 0, 0, 0.3)').style('stroke', 'rgba(255, 0, 0, 0.6)'); 844 | debugRects = debugRects.merge(_debugRectsEnter); 845 | } 846 | } 847 | 848 | // after transition, update the clip rect dimensions 849 | function updateRect(rect) { 850 | rect.attr('x', function (d) { 851 | // if at the edge, adjust for stroke width 852 | var val = x(d[0]); 853 | if (val === xMin) { 854 | return val - strokeWidthClipAdjustment; 855 | } 856 | return val; 857 | }).attr('width', function (d) { 858 | // if at the edge, adjust for stroke width to prevent clipping it 859 | var valMin = x(d[0]); 860 | var valMax = x(d[d.length - 1]); 861 | if (valMin === xMin) { 862 | valMin -= strokeWidthClipAdjustment; 863 | } 864 | if (valMax === xMax) { 865 | valMax += strokeWidthClipAdjustment; 866 | } 867 | 868 | return valMax - valMin; 869 | }).attr('y', clipRectY).attr('height', clipRectHeight); 870 | } 871 | 872 | clipPathRects.call(updateRect); 873 | if (debug) { 874 | debugRects.call(updateRect); 875 | } 876 | } 877 | 878 | /** 879 | * Helper function to draw the actual path 880 | */ 881 | function renderPath(initialRender, transition, context, root, lineData, evaluatedDefinition, line, initialLine, className, clipPathId) { 882 | var path = root.select('.' + className.split(' ')[0]); 883 | 884 | // initial render 885 | if (path.empty()) { 886 | path = root.append('path'); 887 | } 888 | var pathSelection = path; 889 | 890 | if (clipPathId) { 891 | path.attr('clip-path', 'url(#' + clipPathId + ')'); 892 | } 893 | 894 | // handle animations for initial render 895 | if (initialRender) { 896 | path.attr('d', initialLine(lineData)); 897 | } 898 | 899 | // apply user defined styles and attributes 900 | applyAttrsAndStyles(path, evaluatedDefinition); 901 | 902 | path.classed(className, true); 903 | 904 | // handle transition 905 | if (transition) { 906 | path = path.transition(context); 907 | } 908 | 909 | if (path.attrTween) { 910 | // use attrTween is available (in transition) 911 | path.attrTween('d', function dTween() { 912 | var previous = d3Selection.select(this).attr('d'); 913 | var current = line(lineData); 914 | return d3InterpolatePath.interpolatePath(previous, current); 915 | }); 916 | } else { 917 | path.attr('d', function () { 918 | return line(lineData); 919 | }); 920 | } 921 | 922 | // can't return path since it might have the transition 923 | return pathSelection; 924 | } 925 | 926 | /** 927 | * Helper to get the line functions to use to draw the lines. Possibly 928 | * updates the line data to be in [x, y] format if extendEnds is true. 929 | * 930 | * @return {Object} { line, initialLine, lineData } 931 | */ 932 | function getLineFunctions(lineData, initialRender, yDomain) { 933 | // eslint-disable-line no-unused-vars 934 | var yMax = yDomain[1]; 935 | 936 | // main line function 937 | var line = d3Shape.line().x(x).y(y).curve(curve); 938 | var initialLine = void 0; 939 | 940 | // if the user specifies to extend ends for the undefined line, add points to the line for them. 941 | if (extendEnds && lineData.length) { 942 | // we have to process the data here since we don't know how to format an input object 943 | // we use the [x, y] format of a data point 944 | var processedLineData = lineData.map(function (d) { 945 | return [x(d), y(d)]; 946 | }); 947 | lineData = [[extendEnds[0], processedLineData[0][1]]].concat(toConsumableArray(processedLineData), [[extendEnds[1], processedLineData[processedLineData.length - 1][1]]]); 948 | 949 | // this line function works on the processed data (default .x and .y read the [x,y] format) 950 | line = d3Shape.line().curve(curve); 951 | } 952 | 953 | // handle animations for initial render 954 | if (initialRender) { 955 | // have the line load in with a flat y value 956 | initialLine = line; 957 | if (transitionInitial) { 958 | initialLine = d3Shape.line().x(x).y(yMax).curve(curve); 959 | 960 | // if the user extends ends, we should use the line that works on that data 961 | if (extendEnds) { 962 | initialLine = d3Shape.line().y(yMax).curve(curve); 963 | } 964 | } 965 | } 966 | 967 | return { 968 | line: line, 969 | initialLine: initialLine || line, 970 | lineData: lineData 971 | }; 972 | } 973 | 974 | function initializeClipPath(chunkName, root) { 975 | if (chunkName === gapChunkName) { 976 | return undefined; 977 | } 978 | 979 | var defs = root.select('defs'); 980 | if (defs.empty()) { 981 | defs = root.append('defs'); 982 | } 983 | 984 | // className = d3-line-chunked-clip-chunkName 985 | var className = 'd3-line-chunked-clip-' + chunkName; 986 | var clipPath = defs.select('.' + className); 987 | 988 | // initial render 989 | if (clipPath.empty()) { 990 | clipPath = defs.append('clipPath').attr('class', className).attr('id', 'd3-line-chunked-clip-' + chunkName + '-' + counter++); 991 | } 992 | 993 | return clipPath.attr('id'); 994 | } 995 | 996 | /** 997 | * Render the lines: circles, paths, clip rects for the given (data, lineIndex) 998 | */ 999 | function renderLines(initialRender, transition, context, root, data, lineIndex) { 1000 | // use the accessor if provided (e.g. if the data is something like 1001 | // `{ results: [[x,y], [[x,y], ...]}`) 1002 | var lineData = accessData(data); 1003 | 1004 | // filter to only defined data to plot the lines 1005 | var filteredLineData = lineData.filter(defined); 1006 | 1007 | // determine the extent of the y values 1008 | var yExtent = d3Array.extent(filteredLineData.map(function (d) { 1009 | return y(d); 1010 | })); 1011 | 1012 | // determine the extent of the x values to handle stroke-width adjustments on 1013 | // clipping rects. Do not use extendEnds here since it can clip the line ending 1014 | // in an unnatural way, it's better to just show the end. 1015 | var xExtent = d3Array.extent(filteredLineData.map(function (d) { 1016 | return x(d); 1017 | })); 1018 | 1019 | // evaluate attrs and styles for the given dataset 1020 | // pass in the raw data and index for computing attrs and styles if they are functinos 1021 | var evaluatedDefinitions = evaluateDefinitions(data, lineIndex); 1022 | 1023 | // update line functions and data depending on animation and render circumstances 1024 | var lineResults = getLineFunctions(filteredLineData, initialRender, yExtent); 1025 | 1026 | // lineData possibly updated if extendEnds is true since we normalize to [x, y] format 1027 | var line = lineResults.line, 1028 | initialLine = lineResults.initialLine, 1029 | modifiedLineData = lineResults.lineData; 1030 | 1031 | // for each chunk type, render a line 1032 | 1033 | var chunkNames = getChunkNames(); 1034 | 1035 | var definedSegments = computeDefinedSegments(lineData); 1036 | 1037 | // for each chunk, draw a line, circles and clip rect 1038 | chunkNames.forEach(function (chunkName) { 1039 | var clipPathId = initializeClipPath(chunkName, root); 1040 | 1041 | var className = 'd3-line-chunked-chunk-' + chunkName; 1042 | if (chunkName === lineChunkName) { 1043 | className = 'd3-line-chunked-defined ' + className; 1044 | } else if (chunkName === gapChunkName) { 1045 | className = 'd3-line-chunked-undefined ' + className; 1046 | } 1047 | 1048 | // get the eval defs for this chunk name 1049 | var evaluatedDefinition = evaluatedDefinitions[chunkName]; 1050 | 1051 | var path = renderPath(initialRender, transition, context, root, modifiedLineData, evaluatedDefinition, line, initialLine, className, clipPathId); 1052 | 1053 | if (chunkName !== gapChunkName) { 1054 | // compute the segments and points for this chunk type 1055 | var segments = computeChunkedSegments(chunkName, definedSegments); 1056 | var points = segments.filter(function (segment) { 1057 | return segment.length === 1; 1058 | }).map(function (segment) { 1059 | return { 1060 | // use random ID so they are treated as entering/exiting each time 1061 | id: x(segment[0]), 1062 | data: segment[0] 1063 | }; 1064 | }); 1065 | 1066 | var circlesClassName = className.split(' ').map(function (name) { 1067 | return name + '-point'; 1068 | }).join(' '); 1069 | renderCircles(initialRender, transition, context, root, points, evaluatedDefinition, circlesClassName); 1070 | 1071 | renderClipRects(initialRender, transition, context, root, segments, xExtent, yExtent, evaluatedDefinition, path, clipPathId); 1072 | } 1073 | }); 1074 | 1075 | // ensure all circles are at the top 1076 | root.selectAll('circle').raise(); 1077 | } 1078 | 1079 | // the main function that is returned 1080 | function lineChunked(context) { 1081 | if (!context) { 1082 | return; 1083 | } 1084 | var selection = context.selection ? context.selection() : context; // handle transition 1085 | 1086 | if (!selection || selection.empty()) { 1087 | return; 1088 | } 1089 | 1090 | var transition = false; 1091 | if (selection !== context) { 1092 | transition = true; 1093 | } 1094 | 1095 | selection.each(function each(data, lineIndex) { 1096 | var root = d3Selection.select(this); 1097 | 1098 | var initialRender = root.select('.d3-line-chunked-defined').empty(); 1099 | renderLines(initialRender, transition, context, root, data, lineIndex); 1100 | }); 1101 | 1102 | // provide warning about wrong attr/defs 1103 | validateChunkDefinitions(); 1104 | } 1105 | 1106 | // ------------------------------------------------ 1107 | // Define getters and setters 1108 | // ------------------------------------------------ 1109 | function getterSetter(_ref5) { 1110 | var get = _ref5.get, 1111 | set = _ref5.set, 1112 | setType = _ref5.setType, 1113 | asConstant = _ref5.asConstant; 1114 | 1115 | return function getSet(newValue) { 1116 | if (arguments.length) { 1117 | // main setter if setType matches newValue type 1118 | if (!setType && newValue != null || setType && (typeof newValue === 'undefined' ? 'undefined' : _typeof(newValue)) === setType) { 1119 | set(newValue); 1120 | 1121 | // setter to constant function if provided 1122 | } else if (asConstant && newValue != null) { 1123 | set(asConstant(newValue)); 1124 | } 1125 | 1126 | return lineChunked; 1127 | } 1128 | 1129 | // otherwise ignore value/no value provided, so use getter 1130 | return get(); 1131 | }; 1132 | } 1133 | 1134 | // define `x([x])` 1135 | lineChunked.x = getterSetter({ 1136 | get: function get() { 1137 | return x; 1138 | }, 1139 | set: function set(newValue) { 1140 | x = newValue; 1141 | }, 1142 | setType: 'function', 1143 | asConstant: function asConstant(newValue) { 1144 | return function () { 1145 | return +newValue; 1146 | }; 1147 | } // d3 v4 uses +, so we do too 1148 | }); 1149 | 1150 | // define `y([y])` 1151 | lineChunked.y = getterSetter({ 1152 | get: function get() { 1153 | return y; 1154 | }, 1155 | set: function set(newValue) { 1156 | y = newValue; 1157 | }, 1158 | setType: 'function', 1159 | asConstant: function asConstant(newValue) { 1160 | return function () { 1161 | return +newValue; 1162 | }; 1163 | } 1164 | }); 1165 | 1166 | // define `defined([defined])` 1167 | lineChunked.defined = getterSetter({ 1168 | get: function get() { 1169 | return defined; 1170 | }, 1171 | set: function set(newValue) { 1172 | defined = newValue; 1173 | }, 1174 | setType: 'function', 1175 | asConstant: function asConstant(newValue) { 1176 | return function () { 1177 | return !!newValue; 1178 | }; 1179 | } 1180 | }); 1181 | 1182 | // define `isNext([isNext])` 1183 | lineChunked.isNext = getterSetter({ 1184 | get: function get() { 1185 | return isNext; 1186 | }, 1187 | set: function set(newValue) { 1188 | isNext = newValue; 1189 | }, 1190 | setType: 'function', 1191 | asConstant: function asConstant(newValue) { 1192 | return function () { 1193 | return !!newValue; 1194 | }; 1195 | } 1196 | }); 1197 | 1198 | // define `chunk([chunk])` 1199 | lineChunked.chunk = getterSetter({ 1200 | get: function get() { 1201 | return chunk; 1202 | }, 1203 | set: function set(newValue) { 1204 | chunk = newValue; 1205 | }, 1206 | setType: 'function', 1207 | asConstant: function asConstant(newValue) { 1208 | return function () { 1209 | return newValue; 1210 | }; 1211 | } 1212 | }); 1213 | 1214 | // define `chunkLineResolver([chunkLineResolver])` 1215 | lineChunked.chunkLineResolver = getterSetter({ 1216 | get: function get() { 1217 | return chunkLineResolver; 1218 | }, 1219 | set: function set(newValue) { 1220 | chunkLineResolver = newValue; 1221 | }, 1222 | setType: 'function' 1223 | }); 1224 | 1225 | // define `chunkDefinitions([chunkDefinitions])` 1226 | lineChunked.chunkDefinitions = getterSetter({ 1227 | get: function get() { 1228 | return chunkDefinitions; 1229 | }, 1230 | set: function set(newValue) { 1231 | chunkDefinitions = newValue; 1232 | }, 1233 | setType: 'object' 1234 | }); 1235 | 1236 | // define `curve([curve])` 1237 | lineChunked.curve = getterSetter({ 1238 | get: function get() { 1239 | return curve; 1240 | }, 1241 | set: function set(newValue) { 1242 | curve = newValue; 1243 | }, 1244 | setType: 'function' 1245 | }); 1246 | 1247 | // define `lineStyles([lineStyles])` 1248 | lineChunked.lineStyles = getterSetter({ 1249 | get: function get() { 1250 | return lineStyles; 1251 | }, 1252 | set: function set(newValue) { 1253 | lineStyles = newValue; 1254 | }, 1255 | setType: 'object' 1256 | }); 1257 | 1258 | // define `gapStyles([gapStyles])` 1259 | lineChunked.gapStyles = getterSetter({ 1260 | get: function get() { 1261 | return gapStyles; 1262 | }, 1263 | set: function set(newValue) { 1264 | gapStyles = newValue; 1265 | }, 1266 | setType: 'object' 1267 | }); 1268 | 1269 | // define `pointStyles([pointStyles])` 1270 | lineChunked.pointStyles = getterSetter({ 1271 | get: function get() { 1272 | return pointStyles; 1273 | }, 1274 | set: function set(newValue) { 1275 | pointStyles = newValue; 1276 | }, 1277 | setType: 'object' 1278 | }); 1279 | 1280 | // define `lineAttrs([lineAttrs])` 1281 | lineChunked.lineAttrs = getterSetter({ 1282 | get: function get() { 1283 | return lineAttrs; 1284 | }, 1285 | set: function set(newValue) { 1286 | lineAttrs = newValue; 1287 | }, 1288 | setType: 'object' 1289 | }); 1290 | 1291 | // define `gapAttrs([gapAttrs])` 1292 | lineChunked.gapAttrs = getterSetter({ 1293 | get: function get() { 1294 | return gapAttrs; 1295 | }, 1296 | set: function set(newValue) { 1297 | gapAttrs = newValue; 1298 | }, 1299 | setType: 'object' 1300 | }); 1301 | 1302 | // define `pointAttrs([pointAttrs])` 1303 | lineChunked.pointAttrs = getterSetter({ 1304 | get: function get() { 1305 | return pointAttrs; 1306 | }, 1307 | set: function set(newValue) { 1308 | pointAttrs = newValue; 1309 | }, 1310 | setType: 'object' 1311 | }); 1312 | 1313 | // define `transitionInitial([transitionInitial])` 1314 | lineChunked.transitionInitial = getterSetter({ 1315 | get: function get() { 1316 | return transitionInitial; 1317 | }, 1318 | set: function set(newValue) { 1319 | transitionInitial = newValue; 1320 | }, 1321 | setType: 'boolean' 1322 | }); 1323 | 1324 | // define `extendEnds([extendEnds])` 1325 | lineChunked.extendEnds = getterSetter({ 1326 | get: function get() { 1327 | return extendEnds; 1328 | }, 1329 | set: function set(newValue) { 1330 | extendEnds = newValue; 1331 | }, 1332 | setType: 'object' // should be an array 1333 | }); 1334 | 1335 | // define `accessData([accessData])` 1336 | lineChunked.accessData = getterSetter({ 1337 | get: function get() { 1338 | return accessData; 1339 | }, 1340 | set: function set(newValue) { 1341 | accessData = newValue; 1342 | }, 1343 | setType: 'function', 1344 | asConstant: function asConstant(newValue) { 1345 | return function (d) { 1346 | return d[newValue]; 1347 | }; 1348 | } 1349 | }); 1350 | 1351 | // define `debug([debug])` 1352 | lineChunked.debug = getterSetter({ 1353 | get: function get() { 1354 | return debug; 1355 | }, 1356 | set: function set(newValue) { 1357 | debug = newValue; 1358 | }, 1359 | setType: 'boolean' 1360 | }); 1361 | 1362 | return lineChunked; 1363 | } 1364 | 1365 | exports.lineChunked = lineChunked; 1366 | 1367 | Object.defineProperty(exports, '__esModule', { value: true }); 1368 | 1369 | }))); -------------------------------------------------------------------------------- /docs/example-gallery.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * File to generate a path for a variety of different conditions 4 | */ 5 | (function (d3) { 6 | var exampleWidth = 300; 7 | var exampleHeight = 100; 8 | 9 | var x = d3.scaleLinear().domain([0, 10]).range([10, exampleWidth - 10]); 10 | var y = d3.scaleLinear().domain([0, 4]).range([exampleHeight - 10, 10]); 11 | 12 | var transitionDuration = 2500; 13 | var transitionDebug = false; 14 | 15 | var examples = [ 16 | { 17 | label: 'Typical', 18 | render: function typicalExample(root) { 19 | var g = root.append('svg') 20 | .attr('width', exampleWidth) 21 | .attr('height', exampleHeight) 22 | .append('g'); 23 | 24 | var chunked = d3.lineChunked() 25 | .x(function (d) { return x(d[0]); }) 26 | .y(function (d) { return y(d[1]); }) 27 | .defined(function (d) { return d[1] !== null; }); 28 | 29 | var data = [[0, 1], [1, 2], [2, null], [3, null], [4, 1], [5, null], [6, 2], [7, 3], [8, null], [9, 1], [10, 1]]; 30 | 31 | g.datum(data).call(chunked); 32 | }, 33 | }, 34 | { 35 | label: 'Typical with curve', 36 | render: function typicalExampleWithCurve(root) { 37 | var g = root.append('svg') 38 | .attr('width', exampleWidth) 39 | .attr('height', exampleHeight) 40 | .append('g'); 41 | 42 | var chunked = d3.lineChunked() 43 | .curve(d3.curveMonotoneX) 44 | .x(function (d) { return x(d[0]); }) 45 | .y(function (d) { return y(d[1]); }) 46 | .defined(function (d) { return d[1] !== null; }); 47 | 48 | var data = [[0, 1], [1, 2], [2, null], [3, null], [4, 1], [5, null], [6, 2], [7, 3], [8, null], [9, 1], [10, 1]]; 49 | 50 | g.datum(data).call(chunked); 51 | }, 52 | }, 53 | { 54 | label: 'Many points, all defined', 55 | render: function manyPointsAllDefined(root) { 56 | var g = root.append('svg') 57 | .attr('width', exampleWidth) 58 | .attr('height', exampleHeight) 59 | .append('g'); 60 | 61 | var chunked = d3.lineChunked() 62 | .x(function (d) { return x(d[0]); }) 63 | .y(function (d) { return y(d[1]); }) 64 | .defined(function (d) { return d[1] !== null; }); 65 | 66 | var data = [[0, 1], [2, 2], [4, 1], [10, 0]]; 67 | 68 | g.datum(data).call(chunked); 69 | }, 70 | }, 71 | { 72 | label: 'Undefined at ends', 73 | render: function undefinedAtEnds(root) { 74 | var g = root.append('svg') 75 | .attr('width', exampleWidth) 76 | .attr('height', exampleHeight) 77 | .append('g'); 78 | 79 | var chunked = d3.lineChunked() 80 | .lineStyles({ 'stroke-width': '10px' }) 81 | .x(function (d) { return x(d[0]); }) 82 | .y(function (d) { return y(d[1]); }) 83 | .defined(function (d) { return d[1] !== null; }); 84 | 85 | var data = [[0, null], [1, null], [2, 1], [3, 3], [4, 2], [5, 2], [6, 0], [7, 1], [8, 1], [9, null], [10, null]]; 86 | 87 | g.datum(data).call(chunked); 88 | }, 89 | }, 90 | { 91 | label: 'Undefined at ends + extendEnds', 92 | render: function undefinedAtEndsExtendEnds(root) { 93 | var g = root.append('svg') 94 | .attr('width', exampleWidth) 95 | .attr('height', exampleHeight) 96 | .append('g'); 97 | 98 | var chunked = d3.lineChunked() 99 | .x(function (d) { return x(d[0]); }) 100 | .y(function (d) { return y(d[1]); }) 101 | .defined(function (d) { return d[1] !== null; }) 102 | .extendEnds(x.range()); 103 | 104 | var data = [[0, null], [1, null], [2, 1], [3, 3], [4, 2], [5, 2], [6, 0], [7, 1], [8, 1], [9, null], [10, null]]; 105 | 106 | g.datum(data).call(chunked); 107 | }, 108 | }, 109 | { 110 | label: 'Lines along top and bottom edges', 111 | render: function topBottomEdges(root) { 112 | var g = root.append('svg') 113 | .attr('width', exampleWidth) 114 | .attr('height', exampleHeight) 115 | .append('g'); 116 | 117 | var chunked = d3.lineChunked() 118 | .x(function (d) { return x(d[0]); }) 119 | .y(function (d) { return y(d[1]); }) 120 | .defined(function (d) { return d[1] !== null; }) 121 | .lineStyles({ 'stroke-width': 4 }); 122 | 123 | var data = [[0, 0], [4, 0], [5, 2], [6, 2], [7, 4], [10, 4]]; 124 | 125 | g.datum(data).call(chunked); 126 | }, 127 | }, 128 | { 129 | label: 'Data length 1', 130 | render: function dataLength1(root) { 131 | var g = root.append('svg') 132 | .attr('width', exampleWidth) 133 | .attr('height', exampleHeight) 134 | .append('g'); 135 | 136 | var chunked = d3.lineChunked() 137 | .x(function (d) { return x(d[0]); }) 138 | .y(function (d) { return y(d[1]); }) 139 | .defined(function (d) { return d[1] !== null; }); 140 | 141 | var data = [[0, 1]]; 142 | 143 | g.datum(data).call(chunked); 144 | }, 145 | }, 146 | { 147 | label: 'Empty Data', 148 | render: function emptyData(root) { 149 | var g = root.append('svg') 150 | .attr('width', exampleWidth) 151 | .attr('height', exampleHeight) 152 | .append('g'); 153 | 154 | var chunked = d3.lineChunked() 155 | .x(function (d) { return x(d[0]); }) 156 | .y(function (d) { return y(d[1]); }) 157 | .defined(function (d) { return d[1] !== null; }); 158 | 159 | var data = []; 160 | 161 | g.datum(data).call(chunked); 162 | }, 163 | }, 164 | { 165 | label: 'One undefined point', 166 | render: function oneDefinedPoint(root) { 167 | var g = root.append('svg') 168 | .attr('width', exampleWidth) 169 | .attr('height', exampleHeight) 170 | .append('g'); 171 | 172 | var chunked = d3.lineChunked() 173 | .x(function (d) { return x(d[0]); }) 174 | .y(function (d) { return y(d[1]); }) 175 | .defined(function (d) { return d[1] !== null; }); 176 | 177 | var data = [[0, null]]; 178 | 179 | g.datum(data).call(chunked); 180 | }, 181 | }, 182 | { 183 | label: 'Many points, all undefined', 184 | render: function manyPointsUndefined(root) { 185 | var g = root.append('svg') 186 | .attr('width', exampleWidth) 187 | .attr('height', exampleHeight) 188 | .append('g'); 189 | 190 | var chunked = d3.lineChunked() 191 | .x(function (d) { return x(d[0]); }) 192 | .y(function (d) { return y(d[1]); }) 193 | .defined(function (d) { return d[1] !== null; }); 194 | 195 | var data = [[0, null], [1, null], [2, null], [3, null]]; 196 | 197 | g.datum(data).call(chunked); 198 | }, 199 | }, 200 | { 201 | label: 'Typical with accessData', 202 | render: function typicalExample(root) { 203 | var g = root.append('svg') 204 | .attr('width', exampleWidth) 205 | .attr('height', exampleHeight) 206 | .append('g'); 207 | 208 | var chunked = d3.lineChunked() 209 | .x(function (d) { return x(d[0]); }) 210 | .y(function (d) { return y(d[1]); }) 211 | .defined(function (d) { return d[1] !== null; }) 212 | .accessData(data => data.results); 213 | 214 | var data = { results: [[0, 1], [1, 2], [2, null], [3, null], [4, 1], [5, null], [6, 2], [7, 3], [8, null], [9, 1], [10, 1]] }; 215 | 216 | g.datum(data).call(chunked); 217 | }, 218 | }, 219 | { 220 | label: 'Different styled chunks', 221 | render: function typicalExample(root) { 222 | var g = root.append('svg') 223 | .attr('width', exampleWidth) 224 | .attr('height', exampleHeight) 225 | .append('g'); 226 | 227 | var chunked = d3.lineChunked() 228 | .x(function (d) { return x(d[0]); }) 229 | .y(function (d) { return y(d[1]); }) 230 | .defined(function (d) { return d[1] !== null; }) 231 | .chunkDefinitions({ 232 | line: { 233 | styles: { 234 | stroke: '#0bb', 235 | }, 236 | }, 237 | gap: { 238 | styles: { 239 | stroke: 'none' 240 | } 241 | }, 242 | chunk1: { 243 | styles: { 244 | 'stroke-dasharray': '2, 2', 245 | 'stroke-opacity': 0.35, 246 | }, 247 | pointStyles: { 248 | 'fill': '#fff', 249 | 'stroke': '#0bb', 250 | } 251 | } 252 | }) 253 | .chunk(function (d) { return d[1] > 1 ? 'chunk1' : 'line'; }); 254 | 255 | var data = [[0, 2], [1, 1], [2, 2], [3, null], [3.5, 1], [4, 0.8], [4.5, null], [5, 1], [6, 2], [7, 1], [7.5, 1], [8, null], [9, 2], [10, null]]; 256 | 257 | g.datum(data).call(chunked); 258 | }, 259 | }, 260 | { 261 | label: 'Different styled chunks 2', 262 | render: function typicalExample(root) { 263 | var g = root.append('svg') 264 | .attr('width', exampleWidth) 265 | .attr('height', exampleHeight) 266 | .append('g'); 267 | 268 | const chunked = d3.lineChunked() 269 | .x(function (d) { return x(d[0]); }) 270 | .y(function (d) { return y(d[1]); }) 271 | .defined(function (d) { return d[1] !== null; }) 272 | .chunkDefinitions({ 273 | line: { 274 | styles: { stroke: 'red' }, 275 | }, 276 | gap: { 277 | styles: { stroke: 'silver' }, 278 | }, 279 | chunk1: { 280 | styles: { stroke: 'blue' }, 281 | }, 282 | }) 283 | .chunk(function (d) { return d[1] > 1 ? 'chunk1' : 'line'; }); 284 | 285 | const data = [[0, 2], [1, 1], [2, 2], [3, null], [4, 1], [5, 2], [6, 1], [7, 1], [8, null], [9, 2], [10, null]]; 286 | 287 | g.datum(data).call(chunked); 288 | }, 289 | }, 290 | { 291 | label: 'Transition: transitionInitial=true', 292 | transition: true, 293 | render: function transitionInitialTrue(root) { 294 | var g = root.append('svg') 295 | .attr('width', exampleWidth) 296 | .attr('height', exampleHeight) 297 | .append('g'); 298 | 299 | var chunked = d3.lineChunked() 300 | .x(function (d) { return x(d[0]); }) 301 | .y(function (d) { return y(d[1]); }) 302 | .defined(function (d) { return d[1] !== null; }) 303 | .debug(transitionDebug) 304 | .transitionInitial(true); 305 | 306 | var data = [[0, 1], [2, 2], [4, 1], [5, null], [6, 2], [7, null], [8, 2], [9, 0]]; 307 | 308 | g.datum(data).transition().duration(transitionDuration).call(chunked); 309 | }, 310 | }, 311 | { 312 | label: 'Transition: full to missing', 313 | transition: true, 314 | render: function fullToMissing(root) { 315 | var g = root.append('svg') 316 | .attr('width', exampleWidth) 317 | .attr('height', exampleHeight) 318 | .append('g'); 319 | 320 | var chunked = d3.lineChunked() 321 | .x(function (d) { return x(d[0]); }) 322 | .y(function (d) { return y(d[1]); }) 323 | .defined(function (d) { return d[1] !== null; }) 324 | .debug(transitionDebug) 325 | .transitionInitial(false); 326 | 327 | var dataStart = [[0, 1], [2, 2], [4, 1], [6, 2], [8, 2], [9, 0]]; 328 | var dataEnd = [[0, 1], [2, 2], [4, 1], [5, null], [6, 2], [7, null], [8, 2], [9, 0]]; 329 | 330 | g.datum(dataStart).call(chunked); 331 | setTimeout(function () { 332 | g.datum(dataEnd).transition().duration(transitionDuration).call(chunked); 333 | }, transitionDuration / 4); 334 | }, 335 | }, 336 | { 337 | label: 'Transition: from point', 338 | transition: true, 339 | render: function fromPoint(root) { 340 | var g = root.append('svg') 341 | .attr('width', exampleWidth) 342 | .attr('height', exampleHeight) 343 | .append('g'); 344 | 345 | var chunked = d3.lineChunked() 346 | .x(function (d) { return x(d[0]); }) 347 | .y(function (d) { return y(d[1]); }) 348 | .defined(function (d) { return d[1] !== null; }) 349 | .debug(transitionDebug) 350 | .transitionInitial(false); 351 | 352 | var dataStart = [[5, 1]]; 353 | var dataEnd = [[0, 1], [2, 2], [4, 1], [5, null], [6, 2], [7, null], [8, 2], [9, 0]]; 354 | 355 | g.datum(dataStart).call(chunked); 356 | setTimeout(function () { 357 | g.datum(dataEnd).transition().duration(transitionDuration).call(chunked); 358 | }, transitionDuration / 4); 359 | }, 360 | }, 361 | { 362 | label: 'Transition: from point + extendEnds', 363 | transition: true, 364 | render: function fromPointExtendEnds(root) { 365 | var g = root.append('svg') 366 | .attr('width', exampleWidth) 367 | .attr('height', exampleHeight) 368 | .append('g'); 369 | 370 | var chunked = d3.lineChunked() 371 | .x(function (d) { return x(d[0]); }) 372 | .y(function (d) { return y(d[1]); }) 373 | .defined(function (d) { return d[1] !== null; }) 374 | .extendEnds(x.range()) 375 | .debug(transitionDebug) 376 | .transitionInitial(false); 377 | 378 | var dataStart = [[5, 1]]; 379 | var dataEnd = [[0, 1], [2, 2], [4, 1], [5, null], [6, 2], [7, null], [8, 2], [9, 0]]; 380 | 381 | g.datum(dataStart).call(chunked); 382 | setTimeout(function () { 383 | g.datum(dataEnd).transition().duration(transitionDuration).call(chunked); 384 | }, transitionDuration / 4); 385 | }, 386 | }, 387 | { 388 | label: 'Transition: gap to line', 389 | transition: true, 390 | render: function gapToLine(root) { 391 | var g = root.append('svg') 392 | .attr('width', exampleWidth) 393 | .attr('height', exampleHeight) 394 | .append('g'); 395 | 396 | var chunked = d3.lineChunked() 397 | .x(function (d) { return x(d[0]); }) 398 | .y(function (d) { return y(d[1]); }) 399 | .defined(function (d) { return d[1] !== null; }) 400 | .debug(transitionDebug) 401 | .transitionInitial(false); 402 | 403 | var dataStart = [[0, 1], [2, 2], [4, 1], [5, null], [8, 2], [9, 0]]; 404 | var dataEnd = [[0, 1], [2, 2], [4, 1], [6, 2], [8, 2], [9, 0]]; 405 | 406 | g.datum(dataStart).call(chunked); 407 | setTimeout(function () { 408 | g.datum(dataEnd).transition().duration(transitionDuration).call(chunked); 409 | }, transitionDuration / 4); 410 | }, 411 | }, 412 | { 413 | label: 'Transition: few -> many segments', 414 | transition: true, 415 | render: function fewToMany(root) { 416 | var g = root.append('svg') 417 | .attr('width', exampleWidth) 418 | .attr('height', exampleHeight) 419 | .append('g'); 420 | 421 | var chunked = d3.lineChunked() 422 | .x(function (d) { return x(d[0]); }) 423 | .y(function (d) { return y(d[1]); }) 424 | .isNext(function (prev, curr) { return curr[0] === prev[0] + 1; }) 425 | .debug(transitionDebug) 426 | .transitionInitial(false); 427 | 428 | var dataStart = [[0, 1], [1, 2], [7, 0], [8, 1], [9, 0], [10, 1]]; 429 | var dataEnd = [[0, 1], [1, 2], [3, 0], [4, 1], [6, 3], [7, 2], [9, 1], [10, 1]]; 430 | 431 | g.datum(dataStart).call(chunked); 432 | setTimeout(function () { 433 | g.datum(dataEnd).transition().duration(transitionDuration).call(chunked); 434 | }, transitionDuration / 4); 435 | }, 436 | }, 437 | { 438 | label: 'Transition: end segment overlap', 439 | transition: true, 440 | render: function endSegmentOverlap(root) { 441 | var g = root.append('svg') 442 | .attr('width', exampleWidth) 443 | .attr('height', exampleHeight) 444 | .append('g'); 445 | 446 | var x = d3 447 | .scaleLinear() 448 | .domain([3, 19]) 449 | .range([10, exampleWidth - 10]); 450 | 451 | var y = d3 452 | .scaleLinear() 453 | .domain([2, 230]) 454 | .range([exampleHeight - 10, 10]); 455 | 456 | var chunked = d3.lineChunked() 457 | .x(function (d) { return x(d.x); }) 458 | .y(function (d) { return y(d.y); }) 459 | .defined(function (d) { return d.y != null; }) 460 | .debug(transitionDebug) 461 | .transitionInitial(false); 462 | 463 | var dataStart = [{"x":3,"y":13,"v":160},{"x":4,"y":230,"v":93},{"x":5,"v":149},{"x":6,"y":4,"v":207},{"x":7,"y":21,"v":96},{"x":8,"y":2,"v":128},{"x":9,"y":6,"v":151},{"x":10,"y":14,"v":224},{"x":11,"v":70},{"x":12,"y":36,"v":104},{"x":13,"y":9,"v":190},{"x":14,"v":202},{"x":15,"y":5,"v":67},{"x":16,"y":5,"v":177},{"x":17,"y":25,"v":79},{"x":18,"y":3,"v":201},{"x":19,"y":34,"v":125}]; 464 | var dataEnd = [{"x":3,"y":63,"v":110},{"x":4,"y":16,"v":133},{"x":5,"y":45,"v":143},{"x":6,"y":3,"v":284},{"x":7,"y":6,"v":150},{"x":8,"y":22,"v":233},{"x":9,"v":207},{"x":10,"y":173,"v":109},{"x":11,"y":110,"v":80},{"x":12,"y":17,"v":133},{"x":13,"y":11,"v":192},{"x":14,"y":2,"v":131},{"x":15,"v":117},{"x":16,"y":149,"v":111},{"x":17,"y":99,"v":149},{"x":18,"y":20,"v":145},{"x":19,"y":10,"v":127}]; 465 | 466 | g.datum(dataStart).call(chunked); 467 | setTimeout(function () { 468 | g.datum(dataEnd).transition().duration(transitionDuration).call(chunked); 469 | }, transitionDuration / 4); 470 | }, 471 | }, 472 | { 473 | label: 'Multiple lines', 474 | transition: true, 475 | render: function endSegmentOverlap(root) { 476 | var g = root.append('svg') 477 | .attr('width', exampleWidth) 478 | .attr('height', exampleHeight) 479 | .append('g'); 480 | 481 | var x = d3 482 | .scaleLinear() 483 | .domain([3, 19]) 484 | .range([10, exampleWidth - 10]); 485 | 486 | var y = d3 487 | .scaleLinear() 488 | .domain([2, 250]) 489 | .range([exampleHeight - 10, 10]); 490 | 491 | var color = d3.scaleOrdinal(d3.schemeCategory10); 492 | 493 | var chunked = d3.lineChunked() 494 | .x(function (d) { return x(d.x); }) 495 | .y(function (d) { return y(d.y); }) 496 | .defined(function (d) { return d.y != null; }) 497 | .debug(transitionDebug) 498 | .lineStyles({ 499 | stroke: (d, i) => color(i), 500 | }) 501 | .transitionInitial(true); 502 | 503 | var dataStart = [[{"x":0,"y":42,"v":93},{"x":1,"y":7,"v":216},{"x":2,"y":5,"v":174},{"x":3,"y":5,"v":105},{"x":4,"y":12,"v":235},{"x":5,"y":108,"v":137},{"x":6,"v":36},{"x":7,"y":146,"v":122},{"x":8,"y":115,"v":223},{"x":9,"v":192},{"x":10,"y":145,"v":114},{"x":11,"y":21,"v":130},{"x":12,"v":64},{"x":13,"v":158},{"x":14,"y":58,"v":226},{"x":15,"y":7,"v":215},{"x":16,"y":44,"v":141},{"x":17,"y":5,"v":126},{"x":18,"y":39,"v":144},{"x":19,"y":28,"v":134}], 504 | [{"x":0,"y":38,"v":166},{"x":1,"y":11,"v":197},{"x":2,"y":38,"v":80},{"x":3,"y":19,"v":222},{"x":4,"v":140},{"x":5,"y":23,"v":100},{"x":6,"y":13,"v":161},{"x":7,"y":47,"v":152},{"x":8,"v":145},{"x":9,"v":143},{"x":10,"y":16,"v":51},{"x":11,"y":17,"v":180},{"x":12,"y":9,"v":148},{"x":13,"v":196},{"x":14,"y":24,"v":207},{"x":15,"y":2,"v":19},{"x":16,"y":4,"v":165},{"x":17,"v":77},{"x":18,"y":123,"v":108},{"x":19,"y":81,"v":234}], 505 | [{"x":0,"y":32,"v":155},{"x":1,"y":13,"v":192},{"x":2,"y":7,"v":157},{"x":3,"y":100,"v":176},{"x":4,"v":106},{"x":5,"y":10,"v":209},{"x":6,"y":26,"v":19},{"x":7,"v":109},{"x":8,"y":7,"v":247},{"x":9,"y":11,"v":172},{"x":10,"y":236,"v":115},{"x":11,"y":1,"v":91},{"x":12,"y":3,"v":180},{"x":13,"y":19,"v":195},{"x":14,"v":46},{"x":15,"y":3,"v":211},{"x":16,"v":183},{"x":17,"v":148},{"x":18,"y":60,"v":181},{"x":19,"y":10,"v":119}]]; 506 | 507 | var dataEnd = [[{"x":0,"y":63,"v":276},{"x":1,"y":30,"v":230},{"x":2,"y":4,"v":139},{"x":3,"y":35,"v":93},{"x":4,"y":1,"v":265},{"x":5,"y":131,"v":206},{"x":6,"y":49,"v":65},{"x":7,"y":10,"v":183},{"x":8,"v":186},{"x":9,"y":36,"v":175},{"x":10,"y":31,"v":28},{"x":11,"y":2,"v":137},{"x":12,"y":15,"v":52},{"x":13,"y":8,"v":200},{"x":14,"y":8,"v":125},{"x":15,"y":79,"v":161},{"x":16,"y":55,"v":241},{"x":17,"y":1,"v":173},{"x":18,"y":6,"v":137},{"x":19,"y":27,"v":120}], 508 | [{"x":0,"y":5,"v":153},{"x":1,"y":36,"v":244},{"x":2,"y":43,"v":57},{"x":3,"y":15,"v":102},{"x":4,"y":281,"v":228},{"x":5,"y":16,"v":174},{"x":6,"y":41,"v":32},{"x":7,"y":45,"v":144},{"x":8,"v":115},{"x":9,"y":27,"v":99},{"x":10,"y":115,"v":190},{"x":11,"v":113},{"x":12,"y":26,"v":154},{"x":13,"y":26,"v":131},{"x":14,"v":211},{"x":15,"v":192},{"x":16,"y":48,"v":103},{"x":17,"y":4,"v":126},{"x":18,"y":6,"v":141},{"x":19,"y":23,"v":187}]]; 509 | 510 | 511 | function updateChart(data) { 512 | var binding = g.selectAll('g').data(data); 513 | var entering = binding.enter().append('g'); 514 | binding.merge(entering) 515 | .transition().duration(transitionDuration / 3) 516 | .call(chunked); 517 | binding.exit().remove(); 518 | } 519 | 520 | updateChart(dataStart); 521 | 522 | setTimeout(function () { 523 | updateChart(dataEnd); 524 | setTimeout(function () { 525 | updateChart(dataStart); 526 | }, transitionDuration / 2); 527 | }, transitionDuration / 2); 528 | 529 | }, 530 | }, 531 | { 532 | label: 'Transition: null to null', 533 | render: function typicalExample(root) { 534 | var g = root.append('svg') 535 | .attr('width', exampleWidth) 536 | .attr('height', exampleHeight) 537 | .append('g'); 538 | 539 | var chunked = d3.lineChunked() 540 | .x(function (d) { return x(d[0]); }) 541 | .y(function (d) { return y(d[1]); }) 542 | .defined(function (d) { return d[1] !== null; }); 543 | 544 | var data = [[0, null]]; 545 | 546 | g.datum(data).call(chunked).transition().call(chunked); 547 | }, 548 | }, 549 | { 550 | label: 'Transition: has point same X location', 551 | transition: true, 552 | render: function fromPoint(root) { 553 | var g = root.append('svg') 554 | .attr('width', exampleWidth) 555 | .attr('height', exampleHeight) 556 | .append('g'); 557 | 558 | var chunked = d3.lineChunked() 559 | .x(function (d) { return x(d[0]); }) 560 | .y(function (d) { return y(d[1]); }) 561 | .defined(function (d) { return d[1] !== null; }) 562 | .debug(transitionDebug) 563 | .transitionInitial(false); 564 | 565 | var dataStart = [[0, 1], [2, 2], [4, 1], [5, null], [6, 2], [7, null], [8, 2], [9, 0]]; 566 | var dataEnd = [[0, 1], [2, 2], [4, 1], [5, null], [6, 0], [7, null], [8, 2], [9, 0]]; 567 | 568 | g.datum(dataStart).call(chunked); 569 | setTimeout(function () { 570 | g.datum(dataEnd).transition().duration(transitionDuration).call(chunked); 571 | }, transitionDuration / 4); 572 | }, 573 | }, 574 | { 575 | label: 'Transition: transition has delay', 576 | transition: true, 577 | render: function fromPoint(root) { 578 | var g = root.append('svg') 579 | .attr('width', exampleWidth) 580 | .attr('height', exampleHeight) 581 | .append('g'); 582 | 583 | var chunked = d3.lineChunked() 584 | .x(function (d) { return x(d[0]); }) 585 | .y(function (d) { return y(d[1]); }) 586 | .defined(function (d) { return d[1] !== null; }) 587 | .isNext(function (prev, curr) { return curr[0] === prev[0] + 1; }) 588 | .debug(transitionDebug) 589 | .transitionInitial(false); 590 | 591 | var dataStart = [[0, 1], [2, 2], [4, 1], [5, null], [6, 2], [7, null], [8, 2], [9, 0]]; 592 | var dataEnd = [[0, 1], [1, 2], [3, 0], [4, 1], [6, 3], [7, 2], [9, 1], [10, 1]]; 593 | 594 | g.datum(dataStart).call(chunked); 595 | setTimeout(function () { 596 | g.datum(dataEnd).transition().delay(2000).duration(transitionDuration).call(chunked); 597 | }, transitionDuration / 4); 598 | }, 599 | }, 600 | 601 | ]; 602 | 603 | 604 | 605 | // render the gallery 606 | var galleryRoot = d3.select('.example-gallery'); 607 | 608 | // append transition timing slider 609 | var transitionSlider = galleryRoot.append('div'); 610 | transitionSlider.append('strong').text('Transition Duration') 611 | var transitionSliderValue = transitionSlider.append('span').text(transitionDuration) 612 | .style('margin-left', '10px'); 613 | transitionSlider.append('input') 614 | .style('display', 'block') 615 | .attr('type', 'range') 616 | .attr('min', 0) 617 | .attr('max', 5000) 618 | .attr('value', transitionDuration) 619 | .on('change', function () { 620 | transitionDuration = parseFloat(this.value); 621 | transitionSliderValue.text(transitionDuration); 622 | }); 623 | var transitionDebugControl = transitionSlider.append('label'); 624 | transitionDebugControl.append('input') 625 | .attr('type', 'checkbox') 626 | .attr('checked', transitionDebug ? true : null) 627 | .on('change', function () { 628 | transitionDebug = this.checked; 629 | }); 630 | transitionDebugControl.append('span') 631 | .text('Debug'); 632 | 633 | 634 | examples.forEach(function (example) { 635 | var div = galleryRoot.append('div').attr('class', 'example'); 636 | 637 | if (example.transition) { 638 | div.append('button') 639 | .style('float', 'right') 640 | .text('Play') 641 | .on('click', function () { 642 | div.select('svg').remove(); 643 | example.render(div); 644 | }); 645 | } 646 | 647 | div.append('h3') 648 | .text(example.label); 649 | 650 | 651 | 652 | example.render(div); 653 | }); 654 | })(window.d3); -------------------------------------------------------------------------------- /docs/example.css: -------------------------------------------------------------------------------- 1 | .main-header { 2 | margin: 0 0 4px; 3 | } 4 | 5 | .main-link a { 6 | color: #888; 7 | } 8 | .main-link { 9 | margin-top: 0; 10 | } 11 | 12 | body { 13 | font: 14px sans-serif; 14 | padding: 15px; 15 | } 16 | 17 | .description { 18 | max-width: 800px; 19 | } 20 | 21 | .example-gallery .example { 22 | display: inline-block; 23 | margin-right: 15px; 24 | margin-bottom: 15px; 25 | border: 1px dotted #ccc; 26 | padding: 10px; 27 | vertical-align: top; 28 | } 29 | 30 | .example h3 { 31 | margin-top: 0; 32 | } -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | d3-line-chunked 5 | 6 | 7 | 8 | 9 |

d3-line-chunked

10 | 11 |
12 |

13 | Code on GitHub 14 |

15 | 16 | 17 | 18 | 19 | 20 | 222 | 223 | -------------------------------------------------------------------------------- /example/d3-line-chunked.js: -------------------------------------------------------------------------------- 1 | (function (global, factory) { 2 | typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('d3-array'), require('d3-selection'), require('d3-shape'), require('d3-interpolate-path')) : 3 | typeof define === 'function' && define.amd ? define(['exports', 'd3-array', 'd3-selection', 'd3-shape', 'd3-interpolate-path'], factory) : 4 | (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.d3 = global.d3 || {}, global.d3, global.d3, global.d3, global.d3)); 5 | }(this, (function (exports, d3Array, d3Selection, d3Shape, d3InterpolatePath) { 'use strict'; 6 | 7 | function _typeof(obj) { 8 | "@babel/helpers - typeof"; 9 | 10 | if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { 11 | _typeof = function (obj) { 12 | return typeof obj; 13 | }; 14 | } else { 15 | _typeof = function (obj) { 16 | return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; 17 | }; 18 | } 19 | 20 | return _typeof(obj); 21 | } 22 | 23 | function _defineProperty(obj, key, value) { 24 | if (key in obj) { 25 | Object.defineProperty(obj, key, { 26 | value: value, 27 | enumerable: true, 28 | configurable: true, 29 | writable: true 30 | }); 31 | } else { 32 | obj[key] = value; 33 | } 34 | 35 | return obj; 36 | } 37 | 38 | function ownKeys(object, enumerableOnly) { 39 | var keys = Object.keys(object); 40 | 41 | if (Object.getOwnPropertySymbols) { 42 | var symbols = Object.getOwnPropertySymbols(object); 43 | if (enumerableOnly) symbols = symbols.filter(function (sym) { 44 | return Object.getOwnPropertyDescriptor(object, sym).enumerable; 45 | }); 46 | keys.push.apply(keys, symbols); 47 | } 48 | 49 | return keys; 50 | } 51 | 52 | function _objectSpread2(target) { 53 | for (var i = 1; i < arguments.length; i++) { 54 | var source = arguments[i] != null ? arguments[i] : {}; 55 | 56 | if (i % 2) { 57 | ownKeys(Object(source), true).forEach(function (key) { 58 | _defineProperty(target, key, source[key]); 59 | }); 60 | } else if (Object.getOwnPropertyDescriptors) { 61 | Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); 62 | } else { 63 | ownKeys(Object(source)).forEach(function (key) { 64 | Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); 65 | }); 66 | } 67 | } 68 | 69 | return target; 70 | } 71 | 72 | function _slicedToArray(arr, i) { 73 | return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); 74 | } 75 | 76 | function _toConsumableArray(arr) { 77 | return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); 78 | } 79 | 80 | function _arrayWithoutHoles(arr) { 81 | if (Array.isArray(arr)) return _arrayLikeToArray(arr); 82 | } 83 | 84 | function _arrayWithHoles(arr) { 85 | if (Array.isArray(arr)) return arr; 86 | } 87 | 88 | function _iterableToArray(iter) { 89 | if (typeof Symbol !== "undefined" && Symbol.iterator in Object(iter)) return Array.from(iter); 90 | } 91 | 92 | function _iterableToArrayLimit(arr, i) { 93 | if (typeof Symbol === "undefined" || !(Symbol.iterator in Object(arr))) return; 94 | var _arr = []; 95 | var _n = true; 96 | var _d = false; 97 | var _e = undefined; 98 | 99 | try { 100 | for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { 101 | _arr.push(_s.value); 102 | 103 | if (i && _arr.length === i) break; 104 | } 105 | } catch (err) { 106 | _d = true; 107 | _e = err; 108 | } finally { 109 | try { 110 | if (!_n && _i["return"] != null) _i["return"](); 111 | } finally { 112 | if (_d) throw _e; 113 | } 114 | } 115 | 116 | return _arr; 117 | } 118 | 119 | function _unsupportedIterableToArray(o, minLen) { 120 | if (!o) return; 121 | if (typeof o === "string") return _arrayLikeToArray(o, minLen); 122 | var n = Object.prototype.toString.call(o).slice(8, -1); 123 | if (n === "Object" && o.constructor) n = o.constructor.name; 124 | if (n === "Map" || n === "Set") return Array.from(o); 125 | if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); 126 | } 127 | 128 | function _arrayLikeToArray(arr, len) { 129 | if (len == null || len > arr.length) len = arr.length; 130 | 131 | for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; 132 | 133 | return arr2; 134 | } 135 | 136 | function _nonIterableSpread() { 137 | throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); 138 | } 139 | 140 | function _nonIterableRest() { 141 | throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); 142 | } 143 | 144 | // used to generate IDs for clip paths 145 | 146 | var counter = 0; 147 | /** 148 | * Renders line with potential gaps in the data by styling the gaps differently 149 | * from the defined areas. Single points are rendered as circles. Transitions are 150 | * supported. 151 | */ 152 | 153 | function render() { 154 | var defaultLineAttrs = { 155 | fill: 'none', 156 | stroke: '#222', 157 | 'stroke-width': 1.5, 158 | 'stroke-opacity': 1 159 | }; 160 | var defaultGapAttrs = { 161 | 'stroke-dasharray': '2 2', 162 | 'stroke-opacity': 0.35 163 | }; 164 | var defaultPointAttrs = {// read fill and r at render time in case the lineAttrs changed 165 | // fill: defaultLineAttrs.stroke, 166 | // r: defaultLineAttrs['stroke-width'], 167 | }; 168 | var lineChunkName = 'line'; 169 | var gapChunkName = 'gap'; 170 | /** 171 | * How to access the x attribute of `d` 172 | */ 173 | 174 | var x = function x(d) { 175 | return d[0]; 176 | }; 177 | /** 178 | * How to access the y attribute of `d` 179 | */ 180 | 181 | 182 | var y = function y(d) { 183 | return d[1]; 184 | }; 185 | /** 186 | * Function to determine if there is data for a given point. 187 | * @param {Any} d data point 188 | * @return {Boolean} true if the data is defined for the point, false otherwise 189 | */ 190 | 191 | 192 | var defined = function defined() { 193 | return true; 194 | }; 195 | /** 196 | * Function to determine if there a point follows the previous. This functions 197 | * enables detecting gaps in the data when there is an unexpected jump. For 198 | * instance, if you have time data for every day and the previous data point 199 | * is for January 5, 2016 and the current data point is for January 12, 2016, 200 | * then there is data missing for January 6-11, so this function would return 201 | * true. 202 | * 203 | * It is only necessary to define this if your data doesn't explicitly include 204 | * gaps in it. 205 | * 206 | * @param {Any} previousDatum The previous data point 207 | * @param {Any} currentDatum The data point under consideration 208 | * @return {Boolean} true If the data is defined for the point, false otherwise 209 | */ 210 | 211 | 212 | var isNext = function isNext() { 213 | return true; 214 | }; 215 | /** 216 | * Function to determine which chunk this data is within. 217 | * 218 | * @param {Any} d data point 219 | * @param {Any[]} data the full dataset 220 | * @return {String} The id of the chunk. Defaults to "line" 221 | */ 222 | 223 | 224 | var chunk = function chunk() { 225 | return lineChunkName; 226 | }; 227 | /** 228 | * Decides what line the chunk should be in when given two defined points 229 | * in different chunks. Uses the order provided by the keys of chunkDefinition 230 | * if not specified, with `line` and `gap` prepended to the list if not 231 | * in the chunkDefinition object. 232 | * 233 | * @param {String} chunkNameLeft The name of the chunk for the point on the left 234 | * @param {String} chunkNameRight The name of the chunk for the point on the right 235 | * @param {String[]} chunkNames the ordered list of chunk names from chunkDefinitions 236 | * @return {String} The name of the chunk to assign the line segment between the two points to. 237 | */ 238 | 239 | 240 | var chunkLineResolver = function defaultChunkLineResolver(chunkNameLeft, chunkNameRight, chunkNames) { 241 | var leftIndex = chunkNames.indexOf(chunkNameLeft); 242 | var rightIndex = chunkNames.indexOf(chunkNameRight); 243 | return leftIndex > rightIndex ? chunkNameLeft : chunkNameRight; 244 | }; 245 | /** 246 | * Object specifying how to set style and attributes for each chunk. 247 | * Format is an object: 248 | * 249 | * { 250 | * chunkName1: { 251 | * styles: {}, 252 | * attrs: {}, 253 | * pointStyles: {}, 254 | * pointAttrs: {}, 255 | * }, 256 | * ... 257 | * } 258 | */ 259 | 260 | 261 | var chunkDefinitions = {}; 262 | /** 263 | * Passed through to d3.line().curve. Default value: d3.curveLinear. 264 | */ 265 | 266 | var curve = d3Shape.curveLinear; 267 | /** 268 | * Object mapping style keys to style values to be applied to both 269 | * defined and undefined lines. Uses syntax similar to d3-selection-multi. 270 | */ 271 | 272 | var lineStyles = {}; 273 | /** 274 | * Object mapping attr keys to attr values to be applied to both 275 | * defined and undefined lines. Uses syntax similar to d3-selection-multi. 276 | */ 277 | 278 | var lineAttrs = defaultLineAttrs; 279 | /** 280 | * Object mapping style keys to style values to be applied only to the 281 | * undefined lines. It overrides values provided in lineStyles. Uses 282 | * syntax similar to d3-selection-multi. 283 | */ 284 | 285 | var gapStyles = {}; 286 | /** 287 | * Object mapping attr keys to attr values to be applied only to the 288 | * undefined lines. It overrides values provided in lineAttrs. Uses 289 | * syntax similar to d3-selection-multi. 290 | */ 291 | 292 | var gapAttrs = defaultGapAttrs; 293 | /** 294 | * Object mapping style keys to style values to be applied to points. 295 | * Uses syntax similar to d3-selection-multi. 296 | */ 297 | 298 | var pointStyles = {}; 299 | /** 300 | * Object mapping attr keys to attr values to be applied to points. 301 | * Note that if fill is not defined in pointStyles or pointAttrs, it 302 | * will be read from the stroke color on the line itself. 303 | * Uses syntax similar to d3-selection-multi. 304 | */ 305 | 306 | var pointAttrs = defaultPointAttrs; 307 | /** 308 | * Flag to set whether to transition on initial render or not. If true, 309 | * the line starts out flat and transitions in its y value. If false, 310 | * it just immediately renders. 311 | */ 312 | 313 | var transitionInitial = true; 314 | /** 315 | * An array `[xMin, xMax]` specifying the minimum and maximum x pixel values 316 | * (e.g., `xScale.range()`). If defined, the undefined line will extend to 317 | * the the values provided, otherwise it will end at the last defined points. 318 | */ 319 | 320 | var extendEnds; 321 | /** 322 | * Function to determine how to access the line data array from the passed in data 323 | * Defaults to the identity data => data. 324 | * @param {Any} data line dataset 325 | * @return {Array} The array of data points for that given line 326 | */ 327 | 328 | var accessData = function accessData(data) { 329 | return data; 330 | }; 331 | /** 332 | * A flag specifying whether to render in debug mode or not. 333 | */ 334 | 335 | 336 | var debug = false; 337 | /** 338 | * Logs warnings if the chunk definitions uses 'style' or 'attr' instead of 339 | * 'styles' or 'attrs' 340 | */ 341 | 342 | function validateChunkDefinitions() { 343 | Object.keys(chunkDefinitions).forEach(function (key) { 344 | var def = chunkDefinitions[key]; 345 | 346 | if (def.style != null) { 347 | console.warn("Warning: chunkDefinitions expects \"styles\", but found \"style\" in ".concat(key), def); 348 | } 349 | 350 | if (def.attr != null) { 351 | console.warn("Warning: chunkDefinitions expects \"attrs\", but found \"attr\" in ".concat(key), def); 352 | } 353 | 354 | if (def.pointStyle != null) { 355 | console.warn("Warning: chunkDefinitions expects \"pointStyles\", but found \"pointStyle\" in ".concat(key), def); 356 | } 357 | 358 | if (def.pointAttr != null) { 359 | console.warn("Warning: chunkDefinitions expects \"pointAttrs\", but found \"pointAttr\" in ".concat(key), def); 360 | } 361 | }); 362 | } 363 | /** 364 | * Helper to get the chunk names that are defined. Prepends 365 | * line, gap to the start of the array unless useChunkDefOrder 366 | * is specified. In this case, it only prepends if they are 367 | * not specified in the chunk definitions. 368 | */ 369 | 370 | 371 | function getChunkNames(useChunkDefOrder) { 372 | var chunkDefNames = Object.keys(chunkDefinitions); 373 | var prependLine = true; 374 | var prependGap = true; // if using chunk definition order, only prepend line/gap if they aren't in the 375 | // chunk definition. 376 | 377 | if (useChunkDefOrder) { 378 | prependLine = !chunkDefNames.includes(lineChunkName); 379 | prependGap = !chunkDefNames.includes(gapChunkName); 380 | } 381 | 382 | if (prependGap) { 383 | chunkDefNames.unshift(gapChunkName); 384 | } 385 | 386 | if (prependLine) { 387 | chunkDefNames.unshift(lineChunkName); 388 | } // remove duplicates and return 389 | 390 | 391 | return chunkDefNames.filter(function (d, i, a) { 392 | return a.indexOf(d) === i; 393 | }); 394 | } 395 | /** 396 | * Helper function to compute the contiguous segments of the data 397 | * @param {String} chunkName the chunk name to match. points not matching are removed. 398 | * if undefined, uses 'line'. 399 | * @param {Array} definedSegments An array of segments (subarrays) of the defined line data (output from 400 | * computeDefinedSegments) 401 | * @return {Array} An array of segments (subarrays) of the chunk line data 402 | */ 403 | 404 | 405 | function computeChunkedSegments(chunkName, definedSegments) { 406 | // helper to split a segment into sub-segments based on the chunk name 407 | function splitSegment(segment, chunkNames) { 408 | var startNewSegment = true; // helper for adding to a segment / creating a new one 409 | 410 | function addToSegment(segments, d) { 411 | // if we are starting a new segment, start it with this point 412 | if (startNewSegment) { 413 | segments.push([d]); 414 | startNewSegment = false; // otherwise add to the last segment 415 | } else { 416 | var lastSegment = segments[segments.length - 1]; 417 | lastSegment.push(d); 418 | } 419 | } 420 | 421 | var segments = segment.reduce(function (segments, d, i) { 422 | var dChunkName = chunk(d); 423 | var dPrev = segment[i - 1]; 424 | var dNext = segment[i + 1]; // if it matches name, add to the segment 425 | 426 | if (dChunkName === chunkName) { 427 | addToSegment(segments, d); 428 | } else { 429 | // check if this point belongs in the previous chunk: 430 | var added = false; // doesn't match chunk name, but does it go in the segment? as the end? 431 | 432 | if (dPrev) { 433 | var segmentChunkName = chunkLineResolver(chunk(dPrev), dChunkName, chunkNames); // if it is supposed to be in this chunk, add it in 434 | 435 | if (segmentChunkName === chunkName) { 436 | addToSegment(segments, d); 437 | added = true; 438 | startNewSegment = false; 439 | } 440 | } // doesn't belong in previous, so does it belong in next? 441 | 442 | 443 | if (!added && dNext != null) { 444 | // check if this point belongs in the next chunk 445 | var nextSegmentChunkName = chunkLineResolver(dChunkName, chunk(dNext), chunkNames); // if it's supposed to be in the next chunk, create it 446 | 447 | if (nextSegmentChunkName === chunkName) { 448 | segments.push([d]); 449 | added = true; 450 | startNewSegment = false; 451 | } else { 452 | startNewSegment = true; 453 | } // not previous or next 454 | 455 | } else if (!added) { 456 | startNewSegment = true; 457 | } 458 | } 459 | 460 | return segments; 461 | }, []); 462 | return segments; 463 | } 464 | 465 | var chunkNames = getChunkNames(true); 466 | var chunkSegments = definedSegments.reduce(function (carry, segment) { 467 | var newSegments = splitSegment(segment, chunkNames); 468 | 469 | if (newSegments && newSegments.length) { 470 | return carry.concat(newSegments); 471 | } 472 | 473 | return carry; 474 | }, []); 475 | return chunkSegments; 476 | } 477 | /** 478 | * Helper function to compute the contiguous segments of the data 479 | * @param {Array} lineData the line data 480 | * @param {String} chunkName the chunk name to match. points not matching are removed. 481 | * if undefined, uses 'line'. 482 | * @return {Array} An array of segments (subarrays) of the line data 483 | */ 484 | 485 | 486 | function computeDefinedSegments(lineData) { 487 | var startNewSegment = true; // split into segments of continuous data 488 | 489 | var segments = lineData.reduce(function (segments, d) { 490 | // skip if this point has no data 491 | if (!defined(d)) { 492 | startNewSegment = true; 493 | return segments; 494 | } // if we are starting a new segment, start it with this point 495 | 496 | 497 | if (startNewSegment) { 498 | segments.push([d]); 499 | startNewSegment = false; // otherwise see if we are adding to the last segment 500 | } else { 501 | var lastSegment = segments[segments.length - 1]; 502 | var lastDatum = lastSegment[lastSegment.length - 1]; // if we expect this point to come next, add it to the segment 503 | 504 | if (isNext(lastDatum, d)) { 505 | lastSegment.push(d); // otherwise create a new segment 506 | } else { 507 | segments.push([d]); 508 | } 509 | } 510 | 511 | return segments; 512 | }, []); 513 | return segments; 514 | } 515 | /** 516 | * Helper function that applies attrs and styles to the specified selection. 517 | * 518 | * @param {Object} selection The d3 selection 519 | * @param {Object} evaluatedDefinition The evaluated styles and attrs obj (part of output from evaluateDefinitions()) 520 | * @param {Boolean} point if true, uses pointAttrs and pointStyles, otherwise attrs and styles (default: false). 521 | * @return {void} 522 | */ 523 | 524 | 525 | function applyAttrsAndStyles(selection, evaluatedDefinition) { 526 | var point = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; 527 | var attrsKey = point ? 'pointAttrs' : 'attrs'; 528 | var stylesKey = point ? 'pointStyles' : 'styles'; // apply user-provided attrs 529 | 530 | Object.keys(evaluatedDefinition[attrsKey]).forEach(function (attr) { 531 | selection.attr(attr, evaluatedDefinition[attrsKey][attr]); 532 | }); // apply user-provided styles 533 | 534 | Object.keys(evaluatedDefinition[stylesKey]).forEach(function (style) { 535 | selection.style(style, evaluatedDefinition[stylesKey][style]); 536 | }); 537 | } 538 | /** 539 | * For the selected line, evaluate the definitions objects. This is necessary since 540 | * some of the style/attr values are functions that need to be evaluated per line. 541 | * 542 | * In general, the definitions are added in this order: 543 | * 544 | * 1. definition from lineStyle, lineAttrs, pointStyles, pointAttrs 545 | * 2. if it is the gap line, add in gapStyles, gapAttrs 546 | * 3. definition from chunkDefinitions 547 | * 548 | * Returns an object matching the form of chunkDefinitions: 549 | * { 550 | * line: { styles, attrs, pointStyles, pointAttrs }, 551 | * gap: { styles, attrs } 552 | * chunkName1: { styles, attrs, pointStyles, pointAttrs }, 553 | * ... 554 | * } 555 | */ 556 | 557 | 558 | function evaluateDefinitions(d, i) { 559 | // helper to evaluate an object of attr or style definitions 560 | function evaluateAttrsOrStyles() { 561 | var input = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; 562 | return Object.keys(input).reduce(function (output, key) { 563 | var val = input[key]; 564 | 565 | if (typeof val === 'function') { 566 | val = val(d, i); 567 | } 568 | 569 | output[key] = val; 570 | return output; 571 | }, {}); 572 | } 573 | 574 | var evaluated = {}; // get the list of chunks to create evaluated definitions for 575 | 576 | var chunks = getChunkNames(); // for each chunk, evaluate the attrs and styles to use for lines and points 577 | 578 | chunks.forEach(function (chunkName) { 579 | var chunkDef = chunkDefinitions[chunkName] || {}; 580 | var evaluatedChunk = { 581 | styles: _objectSpread2(_objectSpread2(_objectSpread2(_objectSpread2({}, evaluateAttrsOrStyles(lineStyles)), evaluateAttrsOrStyles((chunkDefinitions[lineChunkName] || {}).styles)), chunkName === gapChunkName ? evaluateAttrsOrStyles(gapStyles) : undefined), evaluateAttrsOrStyles(chunkDef.styles)), 582 | attrs: _objectSpread2(_objectSpread2(_objectSpread2(_objectSpread2({}, evaluateAttrsOrStyles(lineAttrs)), evaluateAttrsOrStyles((chunkDefinitions[lineChunkName] || {}).attrs)), chunkName === gapChunkName ? evaluateAttrsOrStyles(gapAttrs) : undefined), evaluateAttrsOrStyles(chunkDef.attrs)) 583 | }; // set point attrs. defaults read from this chunk's line settings. 584 | 585 | var basePointAttrs = { 586 | fill: evaluatedChunk.attrs.stroke, 587 | r: evaluatedChunk.attrs['stroke-width'] == null ? undefined : parseFloat(evaluatedChunk.attrs['stroke-width']) + 1 588 | }; 589 | evaluatedChunk.pointAttrs = Object.assign(basePointAttrs, evaluateAttrsOrStyles(pointAttrs), evaluateAttrsOrStyles((chunkDefinitions[lineChunkName] || {}).pointAttrs), evaluateAttrsOrStyles(chunkDef.pointAttrs)); // ensure `r` is a number (helps to remove 'px' if provided) 590 | 591 | if (evaluatedChunk.pointAttrs.r != null) { 592 | evaluatedChunk.pointAttrs.r = parseFloat(evaluatedChunk.pointAttrs.r); 593 | } // set point styles. if no fill attr set, use the line style stroke. otherwise read from the attr. 594 | 595 | 596 | var basePointStyles = chunkDef.pointAttrs && chunkDef.pointAttrs.fill != null ? {} : { 597 | fill: evaluatedChunk.styles.stroke 598 | }; 599 | evaluatedChunk.pointStyles = Object.assign(basePointStyles, evaluateAttrsOrStyles(pointStyles), evaluateAttrsOrStyles((chunkDefinitions[lineChunkName] || {}).pointStyles), evaluateAttrsOrStyles(chunkDef.pointStyles)); 600 | evaluated[chunkName] = evaluatedChunk; 601 | }); 602 | return evaluated; 603 | } 604 | /** 605 | * Render the points for when segments have length 1. 606 | */ 607 | 608 | 609 | function renderCircles(initialRender, transition, context, root, points, evaluatedDefinition, className) { 610 | var primaryClassName = className.split(' ')[0]; 611 | var circles = root.selectAll(".".concat(primaryClassName)).data(points, function (d) { 612 | return d.id; 613 | }); // read in properties about the transition if we have one 614 | 615 | var transitionDuration = transition ? context.duration() : 0; 616 | var transitionDelay = transition ? context.delay() : 0; // EXIT 617 | 618 | if (transition) { 619 | circles.exit().transition().delay(transitionDelay).duration(transitionDuration * 0.05).attr('r', 1e-6).remove(); 620 | } else { 621 | circles.exit().remove(); 622 | } // ENTER 623 | 624 | 625 | var circlesEnter = circles.enter().append('circle'); // apply user-provided attrs, using attributes from current line if not provided 626 | 627 | applyAttrsAndStyles(circlesEnter, evaluatedDefinition, true); 628 | circlesEnter.classed(className, true).attr('r', 1e-6) // overrides provided `r value for now 629 | .attr('cx', function (d) { 630 | return x(d.data); 631 | }).attr('cy', function (d) { 632 | return y(d.data); 633 | }); // handle with transition 634 | 635 | if ((!initialRender || initialRender && transitionInitial) && transition) { 636 | var enterDuration = transitionDuration * 0.15; // delay sizing up the radius until after the line transition 637 | 638 | circlesEnter.transition(context).delay(transitionDelay + (transitionDuration - enterDuration)).duration(enterDuration).attr('r', evaluatedDefinition.pointAttrs.r); 639 | } else { 640 | circlesEnter.attr('r', evaluatedDefinition.pointAttrs.r); 641 | } // UPDATE 642 | 643 | 644 | if (transition) { 645 | circles = circles.transition(context); 646 | } 647 | 648 | circles.attr('r', evaluatedDefinition.pointAttrs.r).attr('cx', function (d) { 649 | return x(d.data); 650 | }).attr('cy', function (d) { 651 | return y(d.data); 652 | }); 653 | } 654 | 655 | function renderClipRects(initialRender, transition, context, root, segments, _ref, _ref2, evaluatedDefinition, path, clipPathId) { 656 | var _ref3 = _slicedToArray(_ref, 2), 657 | xMin = _ref3[0], 658 | xMax = _ref3[1]; 659 | 660 | var _ref4 = _slicedToArray(_ref2, 2), 661 | yMin = _ref4[0], 662 | yMax = _ref4[1]; 663 | 664 | // TODO: issue with assigning IDs to clipPath elements. need to update how we select/create them 665 | // need reference to path element to set stroke-width property 666 | var clipPath = root.select("#".concat(clipPathId)); 667 | var gDebug = root.select('.d3-line-chunked-debug'); // set up debug group 668 | 669 | if (debug && gDebug.empty()) { 670 | gDebug = root.append('g').classed('d3-line-chunked-debug', true); 671 | } else if (!debug && !gDebug.empty()) { 672 | gDebug.remove(); 673 | } 674 | 675 | var clipPathRects = clipPath.selectAll('rect').data(segments); 676 | var debugRects; 677 | 678 | if (debug) { 679 | debugRects = gDebug.selectAll('rect').data(segments); 680 | } // get stroke width to avoid having the clip rects clip the stroke 681 | // See https://github.com/pbeshai/d3-line-chunked/issues/2 682 | 683 | 684 | var strokeWidth = parseFloat(evaluatedDefinition.styles['stroke-width'] || path.style('stroke-width') || // reads from CSS too 685 | evaluatedDefinition.attrs['stroke-width']); 686 | var strokeWidthClipAdjustment = strokeWidth; 687 | var clipRectY = yMin - strokeWidthClipAdjustment; 688 | var clipRectHeight = yMax + strokeWidthClipAdjustment - (yMin - strokeWidthClipAdjustment); // compute the currently visible area pairs of [xStart, xEnd] for each clip rect 689 | // if no clip rects, the whole area is visible. 690 | 691 | var visibleArea; 692 | 693 | if (transition) { 694 | // compute the start and end x values for a data point based on maximizing visibility 695 | // around the middle of the rect. 696 | var visibleStartEnd = function visibleStartEnd(d, visibleArea) { 697 | // eslint-disable-line no-inner-declarations 698 | var xStart = x(d[0]); 699 | var xEnd = x(d[d.length - 1]); 700 | var xMid = xStart + (xEnd - xStart) / 2; 701 | var visArea = visibleArea.find(function (area) { 702 | return area[0] <= xMid && xMid <= area[1]; 703 | }); // set width to overlapping visible area 704 | 705 | if (visArea) { 706 | return [Math.max(visArea[0], xStart), Math.min(xEnd, visArea[1])]; 707 | } // return xEnd - xStart; 708 | 709 | 710 | return [xMid, xMid]; 711 | }; 712 | 713 | var exitRect = function exitRect(rect) { 714 | // eslint-disable-line no-inner-declarations 715 | rect.attr('x', function (d) { 716 | return visibleStartEnd(d, nextVisibleArea)[0]; 717 | }).attr('width', function (d) { 718 | var _visibleStartEnd = visibleStartEnd(d, nextVisibleArea), 719 | _visibleStartEnd2 = _slicedToArray(_visibleStartEnd, 2), 720 | xStart = _visibleStartEnd2[0], 721 | xEnd = _visibleStartEnd2[1]; 722 | 723 | return xEnd - xStart; 724 | }); 725 | }; 726 | 727 | var enterRect = function enterRect(rect) { 728 | // eslint-disable-line no-inner-declarations 729 | rect.attr('x', function (d) { 730 | return visibleStartEnd(d, visibleArea)[0]; 731 | }).attr('width', function (d) { 732 | var _visibleStartEnd3 = visibleStartEnd(d, visibleArea), 733 | _visibleStartEnd4 = _slicedToArray(_visibleStartEnd3, 2), 734 | xStart = _visibleStartEnd4[0], 735 | xEnd = _visibleStartEnd4[1]; 736 | 737 | return xEnd - xStart; 738 | }).attr('y', clipRectY).attr('height', clipRectHeight); 739 | }; 740 | 741 | // select previous rects 742 | var previousRects = clipPath.selectAll('rect').nodes(); // no previous rects = visible area is everything 743 | 744 | if (!previousRects.length) { 745 | visibleArea = [[xMin, xMax]]; 746 | } else { 747 | visibleArea = previousRects.map(function (rect) { 748 | var selectedRect = d3Selection.select(rect); 749 | var xStart = parseFloat(selectedRect.attr('x')); 750 | var xEnd = parseFloat(selectedRect.attr('width')) + xStart; 751 | return [xStart, xEnd]; 752 | }); 753 | } // set up the clipping paths 754 | // animate by shrinking width to 0 and setting x to the mid point 755 | 756 | 757 | var nextVisibleArea; 758 | 759 | if (!segments.length) { 760 | nextVisibleArea = [[0, 0]]; 761 | } else { 762 | nextVisibleArea = segments.map(function (d) { 763 | var xStart = x(d[0]); 764 | var xEnd = x(d[d.length - 1]); 765 | return [xStart, xEnd]; 766 | }); 767 | } 768 | 769 | clipPathRects.exit().transition(context).call(exitRect).remove(); 770 | var clipPathRectsEnter = clipPathRects.enter().append('rect').call(enterRect); 771 | clipPathRects = clipPathRects.merge(clipPathRectsEnter); 772 | clipPathRects = clipPathRects.transition(context); // debug rects should match clipPathRects 773 | 774 | if (debug) { 775 | debugRects.exit().transition(context).call(exitRect).remove(); 776 | var debugRectsEnter = debugRects.enter().append('rect').style('fill', 'rgba(255, 0, 0, 0.3)').style('stroke', 'rgba(255, 0, 0, 0.6)').call(enterRect); 777 | debugRects = debugRects.merge(debugRectsEnter); 778 | debugRects = debugRects.transition(context); 779 | } // not in transition 780 | 781 | } else { 782 | clipPathRects.exit().remove(); 783 | 784 | var _clipPathRectsEnter = clipPathRects.enter().append('rect'); 785 | 786 | clipPathRects = clipPathRects.merge(_clipPathRectsEnter); 787 | 788 | if (debug) { 789 | debugRects.exit().remove(); 790 | 791 | var _debugRectsEnter = debugRects.enter().append('rect').style('fill', 'rgba(255, 0, 0, 0.3)').style('stroke', 'rgba(255, 0, 0, 0.6)'); 792 | 793 | debugRects = debugRects.merge(_debugRectsEnter); 794 | } 795 | } // after transition, update the clip rect dimensions 796 | 797 | 798 | function updateRect(rect) { 799 | rect.attr('x', function (d) { 800 | // if at the edge, adjust for stroke width 801 | var val = x(d[0]); 802 | 803 | if (val === xMin) { 804 | return val - strokeWidthClipAdjustment; 805 | } 806 | 807 | return val; 808 | }).attr('width', function (d) { 809 | // if at the edge, adjust for stroke width to prevent clipping it 810 | var valMin = x(d[0]); 811 | var valMax = x(d[d.length - 1]); 812 | 813 | if (valMin === xMin) { 814 | valMin -= strokeWidthClipAdjustment; 815 | } 816 | 817 | if (valMax === xMax) { 818 | valMax += strokeWidthClipAdjustment; 819 | } 820 | 821 | return valMax - valMin; 822 | }).attr('y', clipRectY).attr('height', clipRectHeight); 823 | } 824 | 825 | clipPathRects.call(updateRect); 826 | 827 | if (debug) { 828 | debugRects.call(updateRect); 829 | } 830 | } 831 | /** 832 | * Helper function to draw the actual path 833 | */ 834 | 835 | 836 | function renderPath(initialRender, transition, context, root, lineData, evaluatedDefinition, line, initialLine, className, clipPathId) { 837 | var path = root.select(".".concat(className.split(' ')[0])); // initial render 838 | 839 | if (path.empty()) { 840 | path = root.append('path'); 841 | } 842 | 843 | var pathSelection = path; 844 | 845 | if (clipPathId) { 846 | path.attr('clip-path', "url(#".concat(clipPathId, ")")); 847 | } // handle animations for initial render 848 | 849 | 850 | if (initialRender) { 851 | path.attr('d', initialLine(lineData)); 852 | } // apply user defined styles and attributes 853 | 854 | 855 | applyAttrsAndStyles(path, evaluatedDefinition); 856 | path.classed(className, true); // handle transition 857 | 858 | if (transition) { 859 | path = path.transition(context); 860 | } 861 | 862 | if (path.attrTween) { 863 | // use attrTween is available (in transition) 864 | path.attrTween('d', function dTween() { 865 | var previous = d3Selection.select(this).attr('d'); 866 | var current = line(lineData); 867 | return d3InterpolatePath.interpolatePath(previous, current); 868 | }); 869 | } else { 870 | path.attr('d', function () { 871 | return line(lineData); 872 | }); 873 | } // can't return path since it might have the transition 874 | 875 | 876 | return pathSelection; 877 | } 878 | /** 879 | * Helper to get the line functions to use to draw the lines. Possibly 880 | * updates the line data to be in [x, y] format if extendEnds is true. 881 | * 882 | * @return {Object} { line, initialLine, lineData } 883 | */ 884 | 885 | 886 | function getLineFunctions(lineData, initialRender, yDomain) { 887 | // eslint-disable-line no-unused-vars 888 | var yMax = yDomain[1]; // main line function 889 | 890 | var line = d3Shape.line().x(x).y(y).curve(curve); 891 | var initialLine; // if the user specifies to extend ends for the undefined line, add points to the line for them. 892 | 893 | if (extendEnds && lineData.length) { 894 | // we have to process the data here since we don't know how to format an input object 895 | // we use the [x, y] format of a data point 896 | var processedLineData = lineData.map(function (d) { 897 | return [x(d), y(d)]; 898 | }); 899 | lineData = [[extendEnds[0], processedLineData[0][1]]].concat(_toConsumableArray(processedLineData), [[extendEnds[1], processedLineData[processedLineData.length - 1][1]]]); // this line function works on the processed data (default .x and .y read the [x,y] format) 900 | 901 | line = d3Shape.line().curve(curve); 902 | } // handle animations for initial render 903 | 904 | 905 | if (initialRender) { 906 | // have the line load in with a flat y value 907 | initialLine = line; 908 | 909 | if (transitionInitial) { 910 | initialLine = d3Shape.line().x(x).y(yMax).curve(curve); // if the user extends ends, we should use the line that works on that data 911 | 912 | if (extendEnds) { 913 | initialLine = d3Shape.line().y(yMax).curve(curve); 914 | } 915 | } 916 | } 917 | 918 | return { 919 | line: line, 920 | initialLine: initialLine || line, 921 | lineData: lineData 922 | }; 923 | } 924 | 925 | function initializeClipPath(chunkName, root) { 926 | if (chunkName === gapChunkName) { 927 | return undefined; 928 | } 929 | 930 | var defs = root.select('defs'); 931 | 932 | if (defs.empty()) { 933 | defs = root.append('defs'); 934 | } // className = d3-line-chunked-clip-chunkName 935 | 936 | 937 | var className = "d3-line-chunked-clip-".concat(chunkName); 938 | var clipPath = defs.select(".".concat(className)); // initial render 939 | 940 | if (clipPath.empty()) { 941 | clipPath = defs.append('clipPath').attr('class', className).attr('id', "d3-line-chunked-clip-".concat(chunkName, "-").concat(counter)); 942 | counter += 1; 943 | } 944 | 945 | return clipPath.attr('id'); 946 | } 947 | /** 948 | * Render the lines: circles, paths, clip rects for the given (data, lineIndex) 949 | */ 950 | 951 | 952 | function renderLines(initialRender, transition, context, root, data, lineIndex) { 953 | // use the accessor if provided (e.g. if the data is something like 954 | // `{ results: [[x,y], [[x,y], ...]}`) 955 | var lineData = accessData(data); // filter to only defined data to plot the lines 956 | 957 | var filteredLineData = lineData.filter(defined); // determine the extent of the y values 958 | 959 | var yExtent = d3Array.extent(filteredLineData.map(function (d) { 960 | return y(d); 961 | })); // determine the extent of the x values to handle stroke-width adjustments on 962 | // clipping rects. Do not use extendEnds here since it can clip the line ending 963 | // in an unnatural way, it's better to just show the end. 964 | 965 | var xExtent = d3Array.extent(filteredLineData.map(function (d) { 966 | return x(d); 967 | })); // evaluate attrs and styles for the given dataset 968 | // pass in the raw data and index for computing attrs and styles if they are functinos 969 | 970 | var evaluatedDefinitions = evaluateDefinitions(data, lineIndex); // update line functions and data depending on animation and render circumstances 971 | 972 | var lineResults = getLineFunctions(filteredLineData, initialRender, yExtent); // lineData possibly updated if extendEnds is true since we normalize to [x, y] format 973 | 974 | var line = lineResults.line, 975 | initialLine = lineResults.initialLine, 976 | modifiedLineData = lineResults.lineData; // for each chunk type, render a line 977 | 978 | var chunkNames = getChunkNames(); 979 | var definedSegments = computeDefinedSegments(lineData); // for each chunk, draw a line, circles and clip rect 980 | 981 | chunkNames.forEach(function (chunkName) { 982 | var clipPathId = initializeClipPath(chunkName, root); 983 | var className = "d3-line-chunked-chunk-".concat(chunkName); 984 | 985 | if (chunkName === lineChunkName) { 986 | className = "d3-line-chunked-defined ".concat(className); 987 | } else if (chunkName === gapChunkName) { 988 | className = "d3-line-chunked-undefined ".concat(className); 989 | } // get the eval defs for this chunk name 990 | 991 | 992 | var evaluatedDefinition = evaluatedDefinitions[chunkName]; 993 | var path = renderPath(initialRender, transition, context, root, modifiedLineData, evaluatedDefinition, line, initialLine, className, clipPathId); 994 | 995 | if (chunkName !== gapChunkName) { 996 | // compute the segments and points for this chunk type 997 | var segments = computeChunkedSegments(chunkName, definedSegments); 998 | var points = segments.filter(function (segment) { 999 | return segment.length === 1; 1000 | }).map(function (segment) { 1001 | return { 1002 | // use random ID so they are treated as entering/exiting each time 1003 | id: x(segment[0]), 1004 | data: segment[0] 1005 | }; 1006 | }); 1007 | var circlesClassName = className.split(' ').map(function (name) { 1008 | return "".concat(name, "-point"); 1009 | }).join(' '); 1010 | renderCircles(initialRender, transition, context, root, points, evaluatedDefinition, circlesClassName); 1011 | renderClipRects(initialRender, transition, context, root, segments, xExtent, yExtent, evaluatedDefinition, path, clipPathId); 1012 | } 1013 | }); // ensure all circles are at the top 1014 | 1015 | root.selectAll('circle').raise(); 1016 | } // the main function that is returned 1017 | 1018 | 1019 | function lineChunked(context) { 1020 | if (!context) { 1021 | return; 1022 | } 1023 | 1024 | var selection = context.selection ? context.selection() : context; // handle transition 1025 | 1026 | if (!selection || selection.empty()) { 1027 | return; 1028 | } 1029 | 1030 | var transition = false; 1031 | 1032 | if (selection !== context) { 1033 | transition = true; 1034 | } 1035 | 1036 | selection.each(function each(data, lineIndex) { 1037 | var root = d3Selection.select(this); 1038 | var initialRender = root.select('.d3-line-chunked-defined').empty(); 1039 | renderLines(initialRender, transition, context, root, data, lineIndex); 1040 | }); // provide warning about wrong attr/defs 1041 | 1042 | validateChunkDefinitions(); 1043 | } // ------------------------------------------------ 1044 | // Define getters and setters 1045 | // ------------------------------------------------ 1046 | 1047 | 1048 | function getterSetter(_ref5) { 1049 | var get = _ref5.get, 1050 | set = _ref5.set, 1051 | setType = _ref5.setType, 1052 | asConstant = _ref5.asConstant; 1053 | return function getSet(newValue) { 1054 | if (arguments.length) { 1055 | // main setter if setType matches newValue type 1056 | // eslint-disable-next-line valid-typeof 1057 | if (!setType && newValue != null || setType && _typeof(newValue) === setType) { 1058 | set(newValue); // setter to constant function if provided 1059 | } else if (asConstant && newValue != null) { 1060 | set(asConstant(newValue)); 1061 | } 1062 | 1063 | return lineChunked; 1064 | } // otherwise ignore value/no value provided, so use getter 1065 | 1066 | 1067 | return get(); 1068 | }; 1069 | } // define `x([x])` 1070 | 1071 | 1072 | lineChunked.x = getterSetter({ 1073 | get: function get() { 1074 | return x; 1075 | }, 1076 | set: function set(newValue) { 1077 | x = newValue; 1078 | }, 1079 | setType: 'function', 1080 | asConstant: function asConstant(newValue) { 1081 | return function () { 1082 | return +newValue; 1083 | }; 1084 | } // d3 v4 uses +, so we do too 1085 | 1086 | }); // define `y([y])` 1087 | 1088 | lineChunked.y = getterSetter({ 1089 | get: function get() { 1090 | return y; 1091 | }, 1092 | set: function set(newValue) { 1093 | y = newValue; 1094 | }, 1095 | setType: 'function', 1096 | asConstant: function asConstant(newValue) { 1097 | return function () { 1098 | return +newValue; 1099 | }; 1100 | } 1101 | }); // define `defined([defined])` 1102 | 1103 | lineChunked.defined = getterSetter({ 1104 | get: function get() { 1105 | return defined; 1106 | }, 1107 | set: function set(newValue) { 1108 | defined = newValue; 1109 | }, 1110 | setType: 'function', 1111 | asConstant: function asConstant(newValue) { 1112 | return function () { 1113 | return !!newValue; 1114 | }; 1115 | } 1116 | }); // define `isNext([isNext])` 1117 | 1118 | lineChunked.isNext = getterSetter({ 1119 | get: function get() { 1120 | return isNext; 1121 | }, 1122 | set: function set(newValue) { 1123 | isNext = newValue; 1124 | }, 1125 | setType: 'function', 1126 | asConstant: function asConstant(newValue) { 1127 | return function () { 1128 | return !!newValue; 1129 | }; 1130 | } 1131 | }); // define `chunk([chunk])` 1132 | 1133 | lineChunked.chunk = getterSetter({ 1134 | get: function get() { 1135 | return chunk; 1136 | }, 1137 | set: function set(newValue) { 1138 | chunk = newValue; 1139 | }, 1140 | setType: 'function', 1141 | asConstant: function asConstant(newValue) { 1142 | return function () { 1143 | return newValue; 1144 | }; 1145 | } 1146 | }); // define `chunkLineResolver([chunkLineResolver])` 1147 | 1148 | lineChunked.chunkLineResolver = getterSetter({ 1149 | get: function get() { 1150 | return chunkLineResolver; 1151 | }, 1152 | set: function set(newValue) { 1153 | chunkLineResolver = newValue; 1154 | }, 1155 | setType: 'function' 1156 | }); // define `chunkDefinitions([chunkDefinitions])` 1157 | 1158 | lineChunked.chunkDefinitions = getterSetter({ 1159 | get: function get() { 1160 | return chunkDefinitions; 1161 | }, 1162 | set: function set(newValue) { 1163 | chunkDefinitions = newValue; 1164 | }, 1165 | setType: 'object' 1166 | }); // define `curve([curve])` 1167 | 1168 | lineChunked.curve = getterSetter({ 1169 | get: function get() { 1170 | return curve; 1171 | }, 1172 | set: function set(newValue) { 1173 | curve = newValue; 1174 | }, 1175 | setType: 'function' 1176 | }); // define `lineStyles([lineStyles])` 1177 | 1178 | lineChunked.lineStyles = getterSetter({ 1179 | get: function get() { 1180 | return lineStyles; 1181 | }, 1182 | set: function set(newValue) { 1183 | lineStyles = newValue; 1184 | }, 1185 | setType: 'object' 1186 | }); // define `gapStyles([gapStyles])` 1187 | 1188 | lineChunked.gapStyles = getterSetter({ 1189 | get: function get() { 1190 | return gapStyles; 1191 | }, 1192 | set: function set(newValue) { 1193 | gapStyles = newValue; 1194 | }, 1195 | setType: 'object' 1196 | }); // define `pointStyles([pointStyles])` 1197 | 1198 | lineChunked.pointStyles = getterSetter({ 1199 | get: function get() { 1200 | return pointStyles; 1201 | }, 1202 | set: function set(newValue) { 1203 | pointStyles = newValue; 1204 | }, 1205 | setType: 'object' 1206 | }); // define `lineAttrs([lineAttrs])` 1207 | 1208 | lineChunked.lineAttrs = getterSetter({ 1209 | get: function get() { 1210 | return lineAttrs; 1211 | }, 1212 | set: function set(newValue) { 1213 | lineAttrs = newValue; 1214 | }, 1215 | setType: 'object' 1216 | }); // define `gapAttrs([gapAttrs])` 1217 | 1218 | lineChunked.gapAttrs = getterSetter({ 1219 | get: function get() { 1220 | return gapAttrs; 1221 | }, 1222 | set: function set(newValue) { 1223 | gapAttrs = newValue; 1224 | }, 1225 | setType: 'object' 1226 | }); // define `pointAttrs([pointAttrs])` 1227 | 1228 | lineChunked.pointAttrs = getterSetter({ 1229 | get: function get() { 1230 | return pointAttrs; 1231 | }, 1232 | set: function set(newValue) { 1233 | pointAttrs = newValue; 1234 | }, 1235 | setType: 'object' 1236 | }); // define `transitionInitial([transitionInitial])` 1237 | 1238 | lineChunked.transitionInitial = getterSetter({ 1239 | get: function get() { 1240 | return transitionInitial; 1241 | }, 1242 | set: function set(newValue) { 1243 | transitionInitial = newValue; 1244 | }, 1245 | setType: 'boolean' 1246 | }); // define `extendEnds([extendEnds])` 1247 | 1248 | lineChunked.extendEnds = getterSetter({ 1249 | get: function get() { 1250 | return extendEnds; 1251 | }, 1252 | set: function set(newValue) { 1253 | extendEnds = newValue; 1254 | }, 1255 | setType: 'object' // should be an array 1256 | 1257 | }); // define `accessData([accessData])` 1258 | 1259 | lineChunked.accessData = getterSetter({ 1260 | get: function get() { 1261 | return accessData; 1262 | }, 1263 | set: function set(newValue) { 1264 | accessData = newValue; 1265 | }, 1266 | setType: 'function', 1267 | asConstant: function asConstant(newValue) { 1268 | return function (d) { 1269 | return d[newValue]; 1270 | }; 1271 | } 1272 | }); // define `debug([debug])` 1273 | 1274 | lineChunked.debug = getterSetter({ 1275 | get: function get() { 1276 | return debug; 1277 | }, 1278 | set: function set(newValue) { 1279 | debug = newValue; 1280 | }, 1281 | setType: 'boolean' 1282 | }); 1283 | return lineChunked; 1284 | } 1285 | 1286 | exports.lineChunked = render; 1287 | 1288 | Object.defineProperty(exports, '__esModule', { value: true }); 1289 | 1290 | }))); 1291 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export { 2 | default as lineChunked, 3 | } from './src/lineChunked.js'; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "d3-line-chunked", 3 | "version": "1.5.1", 4 | "description": "A d3 plugin that renders a line with potential gaps in the data by styling the gaps differently from the defined areas. Single points are rendered as circles. Transitions are supported.", 5 | "author": "Peter Beshai (http://github.com/pbeshai)", 6 | "keywords": [ 7 | "d3", 8 | "d3-module", 9 | "plugin", 10 | "d3-line-chunked", 11 | "line", 12 | "time series", 13 | "chart", 14 | "svg" 15 | ], 16 | "license": "BSD-3-Clause", 17 | "module": "./index.js", 18 | "main": "./index.js", 19 | "exports": { 20 | "umd": "./build/d3-line-chunked.min.js", 21 | "default": "./index.js" 22 | }, 23 | "jsnext:main": "index", 24 | "homepage": "https://github.com/pbeshai/d3-line-chunked", 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/pbeshai/d3-line-chunked.git" 28 | }, 29 | "type": "module", 30 | "scripts": { 31 | "build": "rm -rf build && mkdir build && rollup --config rollup.config.js", 32 | "watch": "rollup --config rollup.config.js --watch", 33 | "lint": "eslint -c .eslintrc.cjs src", 34 | "prettier": "prettier --write \"src/**/*.js\"", 35 | "pretest": "npm run build", 36 | "test": "tape 'test/**/*-test.js'", 37 | "prepublish": "npm run lint && npm run test && uglifyjs build/d3-line-chunked.js -c -m -o build/d3-line-chunked.min.js", 38 | "postpublish": "zip -j build/d3-line-chunked.zip -- LICENSE README.md build/d3-line-chunked.js build/d3-line-chunked.min.js" 39 | }, 40 | "devDependencies": { 41 | "@babel/core": "^7.15.8", 42 | "@babel/plugin-external-helpers": "^7.14.5", 43 | "@babel/preset-env": "^7.15.8", 44 | "@rollup/plugin-babel": "^5.3.0", 45 | "babel-eslint": "^10.1.0", 46 | "eslint": "^7.12.1", 47 | "eslint-config-airbnb-base": "^14.2.0", 48 | "eslint-config-prettier": "^8.3.0", 49 | "eslint-plugin-import": "^2.25.1", 50 | "jsdom": "^18.0.0", 51 | "prettier": "^2.4.1", 52 | "rollup": "^2.32.1", 53 | "tape": "5", 54 | "uglify-js": "3" 55 | }, 56 | "dependencies": { 57 | "d3-array": "2 || 3 ||^1.0.1", 58 | "d3-interpolate": "2 || 3 || ^1.1.1", 59 | "d3-interpolate-path": "2 || ^1.1.0", 60 | "d3-selection": "2 || 3 || ^1.0.2", 61 | "d3-shape": "2 || 3 || ^1.0.3", 62 | "d3-transition": "2 || 3 ||^1.0.2" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel'; 2 | 3 | var globals = { 4 | 'd3-array': 'd3', 5 | 'd3-interpolate': 'd3', 6 | 'd3-interpolate-path': 'd3', 7 | 'd3-shape': 'd3', 8 | 'd3-selection': 'd3', 9 | 'd3-transition': 'd3', 10 | }; 11 | 12 | export default { 13 | input: 'index.js', 14 | plugins: [ 15 | babel({ babelHelpers: 'bundled' }) 16 | ], 17 | external: Object.keys(globals), 18 | output: [ 19 | { extend: true, name: 'd3', format: 'umd', file: 'build/d3-line-chunked.js', globals, }, 20 | { extend: true, name: 'd3', format: 'umd', file: 'example/d3-line-chunked.js', globals, }, 21 | ] 22 | }; 23 | -------------------------------------------------------------------------------- /src/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "esmodules": false 8 | } 9 | } 10 | ] 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/lineChunked.js: -------------------------------------------------------------------------------- 1 | import { extent } from 'd3-array'; 2 | import { select } from 'd3-selection'; 3 | import { curveLinear, line as d3Line } from 'd3-shape'; 4 | import { interpolatePath } from 'd3-interpolate-path'; // only needed if using transitions 5 | 6 | // used to generate IDs for clip paths 7 | let counter = 0; 8 | 9 | /** 10 | * Renders line with potential gaps in the data by styling the gaps differently 11 | * from the defined areas. Single points are rendered as circles. Transitions are 12 | * supported. 13 | */ 14 | export default function render() { 15 | const defaultLineAttrs = { 16 | fill: 'none', 17 | stroke: '#222', 18 | 'stroke-width': 1.5, 19 | 'stroke-opacity': 1, 20 | }; 21 | const defaultGapAttrs = { 22 | 'stroke-dasharray': '2 2', 23 | 'stroke-opacity': 0.35, 24 | }; 25 | const defaultPointAttrs = { 26 | // read fill and r at render time in case the lineAttrs changed 27 | // fill: defaultLineAttrs.stroke, 28 | // r: defaultLineAttrs['stroke-width'], 29 | }; 30 | 31 | const lineChunkName = 'line'; 32 | const gapChunkName = 'gap'; 33 | 34 | /** 35 | * How to access the x attribute of `d` 36 | */ 37 | let x = (d) => d[0]; 38 | 39 | /** 40 | * How to access the y attribute of `d` 41 | */ 42 | let y = (d) => d[1]; 43 | 44 | /** 45 | * Function to determine if there is data for a given point. 46 | * @param {Any} d data point 47 | * @return {Boolean} true if the data is defined for the point, false otherwise 48 | */ 49 | let defined = () => true; 50 | 51 | /** 52 | * Function to determine if there a point follows the previous. This functions 53 | * enables detecting gaps in the data when there is an unexpected jump. For 54 | * instance, if you have time data for every day and the previous data point 55 | * is for January 5, 2016 and the current data point is for January 12, 2016, 56 | * then there is data missing for January 6-11, so this function would return 57 | * true. 58 | * 59 | * It is only necessary to define this if your data doesn't explicitly include 60 | * gaps in it. 61 | * 62 | * @param {Any} previousDatum The previous data point 63 | * @param {Any} currentDatum The data point under consideration 64 | * @return {Boolean} true If the data is defined for the point, false otherwise 65 | */ 66 | let isNext = () => true; 67 | 68 | /** 69 | * Function to determine which chunk this data is within. 70 | * 71 | * @param {Any} d data point 72 | * @param {Any[]} data the full dataset 73 | * @return {String} The id of the chunk. Defaults to "line" 74 | */ 75 | let chunk = () => lineChunkName; 76 | 77 | /** 78 | * Decides what line the chunk should be in when given two defined points 79 | * in different chunks. Uses the order provided by the keys of chunkDefinition 80 | * if not specified, with `line` and `gap` prepended to the list if not 81 | * in the chunkDefinition object. 82 | * 83 | * @param {String} chunkNameLeft The name of the chunk for the point on the left 84 | * @param {String} chunkNameRight The name of the chunk for the point on the right 85 | * @param {String[]} chunkNames the ordered list of chunk names from chunkDefinitions 86 | * @return {String} The name of the chunk to assign the line segment between the two points to. 87 | */ 88 | let chunkLineResolver = function defaultChunkLineResolver( 89 | chunkNameLeft, 90 | chunkNameRight, 91 | chunkNames 92 | ) { 93 | const leftIndex = chunkNames.indexOf(chunkNameLeft); 94 | const rightIndex = chunkNames.indexOf(chunkNameRight); 95 | 96 | return leftIndex > rightIndex ? chunkNameLeft : chunkNameRight; 97 | }; 98 | 99 | /** 100 | * Object specifying how to set style and attributes for each chunk. 101 | * Format is an object: 102 | * 103 | * { 104 | * chunkName1: { 105 | * styles: {}, 106 | * attrs: {}, 107 | * pointStyles: {}, 108 | * pointAttrs: {}, 109 | * }, 110 | * ... 111 | * } 112 | */ 113 | let chunkDefinitions = {}; 114 | 115 | /** 116 | * Passed through to d3.line().curve. Default value: d3.curveLinear. 117 | */ 118 | let curve = curveLinear; 119 | 120 | /** 121 | * Object mapping style keys to style values to be applied to both 122 | * defined and undefined lines. Uses syntax similar to d3-selection-multi. 123 | */ 124 | let lineStyles = {}; 125 | 126 | /** 127 | * Object mapping attr keys to attr values to be applied to both 128 | * defined and undefined lines. Uses syntax similar to d3-selection-multi. 129 | */ 130 | let lineAttrs = defaultLineAttrs; 131 | 132 | /** 133 | * Object mapping style keys to style values to be applied only to the 134 | * undefined lines. It overrides values provided in lineStyles. Uses 135 | * syntax similar to d3-selection-multi. 136 | */ 137 | let gapStyles = {}; 138 | 139 | /** 140 | * Object mapping attr keys to attr values to be applied only to the 141 | * undefined lines. It overrides values provided in lineAttrs. Uses 142 | * syntax similar to d3-selection-multi. 143 | */ 144 | let gapAttrs = defaultGapAttrs; 145 | 146 | /** 147 | * Object mapping style keys to style values to be applied to points. 148 | * Uses syntax similar to d3-selection-multi. 149 | */ 150 | let pointStyles = {}; 151 | 152 | /** 153 | * Object mapping attr keys to attr values to be applied to points. 154 | * Note that if fill is not defined in pointStyles or pointAttrs, it 155 | * will be read from the stroke color on the line itself. 156 | * Uses syntax similar to d3-selection-multi. 157 | */ 158 | let pointAttrs = defaultPointAttrs; 159 | 160 | /** 161 | * Flag to set whether to transition on initial render or not. If true, 162 | * the line starts out flat and transitions in its y value. If false, 163 | * it just immediately renders. 164 | */ 165 | let transitionInitial = true; 166 | 167 | /** 168 | * An array `[xMin, xMax]` specifying the minimum and maximum x pixel values 169 | * (e.g., `xScale.range()`). If defined, the undefined line will extend to 170 | * the the values provided, otherwise it will end at the last defined points. 171 | */ 172 | let extendEnds; 173 | 174 | /** 175 | * Function to determine how to access the line data array from the passed in data 176 | * Defaults to the identity data => data. 177 | * @param {Any} data line dataset 178 | * @return {Array} The array of data points for that given line 179 | */ 180 | let accessData = (data) => data; 181 | 182 | /** 183 | * A flag specifying whether to render in debug mode or not. 184 | */ 185 | let debug = false; 186 | 187 | /** 188 | * Logs warnings if the chunk definitions uses 'style' or 'attr' instead of 189 | * 'styles' or 'attrs' 190 | */ 191 | function validateChunkDefinitions() { 192 | Object.keys(chunkDefinitions).forEach((key) => { 193 | const def = chunkDefinitions[key]; 194 | if (def.style != null) { 195 | console.warn( 196 | `Warning: chunkDefinitions expects "styles", but found "style" in ${key}`, 197 | def 198 | ); 199 | } 200 | if (def.attr != null) { 201 | console.warn( 202 | `Warning: chunkDefinitions expects "attrs", but found "attr" in ${key}`, 203 | def 204 | ); 205 | } 206 | if (def.pointStyle != null) { 207 | console.warn( 208 | `Warning: chunkDefinitions expects "pointStyles", but found "pointStyle" in ${key}`, 209 | def 210 | ); 211 | } 212 | if (def.pointAttr != null) { 213 | console.warn( 214 | `Warning: chunkDefinitions expects "pointAttrs", but found "pointAttr" in ${key}`, 215 | def 216 | ); 217 | } 218 | }); 219 | } 220 | 221 | /** 222 | * Helper to get the chunk names that are defined. Prepends 223 | * line, gap to the start of the array unless useChunkDefOrder 224 | * is specified. In this case, it only prepends if they are 225 | * not specified in the chunk definitions. 226 | */ 227 | function getChunkNames(useChunkDefOrder) { 228 | const chunkDefNames = Object.keys(chunkDefinitions); 229 | let prependLine = true; 230 | let prependGap = true; 231 | 232 | // if using chunk definition order, only prepend line/gap if they aren't in the 233 | // chunk definition. 234 | if (useChunkDefOrder) { 235 | prependLine = !chunkDefNames.includes(lineChunkName); 236 | prependGap = !chunkDefNames.includes(gapChunkName); 237 | } 238 | 239 | if (prependGap) { 240 | chunkDefNames.unshift(gapChunkName); 241 | } 242 | 243 | if (prependLine) { 244 | chunkDefNames.unshift(lineChunkName); 245 | } 246 | 247 | // remove duplicates and return 248 | return chunkDefNames.filter((d, i, a) => a.indexOf(d) === i); 249 | } 250 | 251 | /** 252 | * Helper function to compute the contiguous segments of the data 253 | * @param {String} chunkName the chunk name to match. points not matching are removed. 254 | * if undefined, uses 'line'. 255 | * @param {Array} definedSegments An array of segments (subarrays) of the defined line data (output from 256 | * computeDefinedSegments) 257 | * @return {Array} An array of segments (subarrays) of the chunk line data 258 | */ 259 | function computeChunkedSegments(chunkName, definedSegments) { 260 | // helper to split a segment into sub-segments based on the chunk name 261 | function splitSegment(segment, chunkNames) { 262 | let startNewSegment = true; 263 | 264 | // helper for adding to a segment / creating a new one 265 | function addToSegment(segments, d) { 266 | // if we are starting a new segment, start it with this point 267 | if (startNewSegment) { 268 | segments.push([d]); 269 | startNewSegment = false; 270 | 271 | // otherwise add to the last segment 272 | } else { 273 | const lastSegment = segments[segments.length - 1]; 274 | lastSegment.push(d); 275 | } 276 | } 277 | 278 | const segments = segment.reduce((segments, d, i) => { 279 | const dChunkName = chunk(d); 280 | const dPrev = segment[i - 1]; 281 | const dNext = segment[i + 1]; 282 | 283 | // if it matches name, add to the segment 284 | if (dChunkName === chunkName) { 285 | addToSegment(segments, d); 286 | } else { 287 | // check if this point belongs in the previous chunk: 288 | let added = false; 289 | // doesn't match chunk name, but does it go in the segment? as the end? 290 | if (dPrev) { 291 | const segmentChunkName = chunkLineResolver( 292 | chunk(dPrev), 293 | dChunkName, 294 | chunkNames 295 | ); 296 | 297 | // if it is supposed to be in this chunk, add it in 298 | if (segmentChunkName === chunkName) { 299 | addToSegment(segments, d); 300 | added = true; 301 | startNewSegment = false; 302 | } 303 | } 304 | 305 | // doesn't belong in previous, so does it belong in next? 306 | if (!added && dNext != null) { 307 | // check if this point belongs in the next chunk 308 | const nextSegmentChunkName = chunkLineResolver( 309 | dChunkName, 310 | chunk(dNext), 311 | chunkNames 312 | ); 313 | 314 | // if it's supposed to be in the next chunk, create it 315 | if (nextSegmentChunkName === chunkName) { 316 | segments.push([d]); 317 | added = true; 318 | startNewSegment = false; 319 | } else { 320 | startNewSegment = true; 321 | } 322 | 323 | // not previous or next 324 | } else if (!added) { 325 | startNewSegment = true; 326 | } 327 | } 328 | 329 | return segments; 330 | }, []); 331 | 332 | return segments; 333 | } 334 | 335 | const chunkNames = getChunkNames(true); 336 | 337 | const chunkSegments = definedSegments.reduce((carry, segment) => { 338 | const newSegments = splitSegment(segment, chunkNames); 339 | if (newSegments && newSegments.length) { 340 | return carry.concat(newSegments); 341 | } 342 | 343 | return carry; 344 | }, []); 345 | 346 | return chunkSegments; 347 | } 348 | 349 | /** 350 | * Helper function to compute the contiguous segments of the data 351 | * @param {Array} lineData the line data 352 | * @param {String} chunkName the chunk name to match. points not matching are removed. 353 | * if undefined, uses 'line'. 354 | * @return {Array} An array of segments (subarrays) of the line data 355 | */ 356 | function computeDefinedSegments(lineData) { 357 | let startNewSegment = true; 358 | 359 | // split into segments of continuous data 360 | const segments = lineData.reduce((segments, d) => { 361 | // skip if this point has no data 362 | if (!defined(d)) { 363 | startNewSegment = true; 364 | return segments; 365 | } 366 | 367 | // if we are starting a new segment, start it with this point 368 | if (startNewSegment) { 369 | segments.push([d]); 370 | startNewSegment = false; 371 | 372 | // otherwise see if we are adding to the last segment 373 | } else { 374 | const lastSegment = segments[segments.length - 1]; 375 | const lastDatum = lastSegment[lastSegment.length - 1]; 376 | // if we expect this point to come next, add it to the segment 377 | if (isNext(lastDatum, d)) { 378 | lastSegment.push(d); 379 | 380 | // otherwise create a new segment 381 | } else { 382 | segments.push([d]); 383 | } 384 | } 385 | 386 | return segments; 387 | }, []); 388 | 389 | return segments; 390 | } 391 | 392 | /** 393 | * Helper function that applies attrs and styles to the specified selection. 394 | * 395 | * @param {Object} selection The d3 selection 396 | * @param {Object} evaluatedDefinition The evaluated styles and attrs obj (part of output from evaluateDefinitions()) 397 | * @param {Boolean} point if true, uses pointAttrs and pointStyles, otherwise attrs and styles (default: false). 398 | * @return {void} 399 | */ 400 | function applyAttrsAndStyles(selection, evaluatedDefinition, point = false) { 401 | const attrsKey = point ? 'pointAttrs' : 'attrs'; 402 | const stylesKey = point ? 'pointStyles' : 'styles'; 403 | 404 | // apply user-provided attrs 405 | Object.keys(evaluatedDefinition[attrsKey]).forEach((attr) => { 406 | selection.attr(attr, evaluatedDefinition[attrsKey][attr]); 407 | }); 408 | 409 | // apply user-provided styles 410 | Object.keys(evaluatedDefinition[stylesKey]).forEach((style) => { 411 | selection.style(style, evaluatedDefinition[stylesKey][style]); 412 | }); 413 | } 414 | 415 | /** 416 | * For the selected line, evaluate the definitions objects. This is necessary since 417 | * some of the style/attr values are functions that need to be evaluated per line. 418 | * 419 | * In general, the definitions are added in this order: 420 | * 421 | * 1. definition from lineStyle, lineAttrs, pointStyles, pointAttrs 422 | * 2. if it is the gap line, add in gapStyles, gapAttrs 423 | * 3. definition from chunkDefinitions 424 | * 425 | * Returns an object matching the form of chunkDefinitions: 426 | * { 427 | * line: { styles, attrs, pointStyles, pointAttrs }, 428 | * gap: { styles, attrs } 429 | * chunkName1: { styles, attrs, pointStyles, pointAttrs }, 430 | * ... 431 | * } 432 | */ 433 | function evaluateDefinitions(d, i) { 434 | // helper to evaluate an object of attr or style definitions 435 | function evaluateAttrsOrStyles(input = {}) { 436 | return Object.keys(input).reduce((output, key) => { 437 | let val = input[key]; 438 | 439 | if (typeof val === 'function') { 440 | val = val(d, i); 441 | } 442 | 443 | output[key] = val; 444 | return output; 445 | }, {}); 446 | } 447 | 448 | const evaluated = {}; 449 | 450 | // get the list of chunks to create evaluated definitions for 451 | const chunks = getChunkNames(); 452 | 453 | // for each chunk, evaluate the attrs and styles to use for lines and points 454 | chunks.forEach((chunkName) => { 455 | const chunkDef = chunkDefinitions[chunkName] || {}; 456 | const evaluatedChunk = { 457 | styles: { 458 | ...evaluateAttrsOrStyles(lineStyles), 459 | ...evaluateAttrsOrStyles( 460 | (chunkDefinitions[lineChunkName] || {}).styles 461 | ), 462 | ...(chunkName === gapChunkName 463 | ? evaluateAttrsOrStyles(gapStyles) 464 | : undefined), 465 | ...evaluateAttrsOrStyles(chunkDef.styles), 466 | }, 467 | attrs: { 468 | ...evaluateAttrsOrStyles(lineAttrs), 469 | ...evaluateAttrsOrStyles( 470 | (chunkDefinitions[lineChunkName] || {}).attrs 471 | ), 472 | ...(chunkName === gapChunkName 473 | ? evaluateAttrsOrStyles(gapAttrs) 474 | : undefined), 475 | ...evaluateAttrsOrStyles(chunkDef.attrs), 476 | }, 477 | }; 478 | 479 | // set point attrs. defaults read from this chunk's line settings. 480 | const basePointAttrs = { 481 | fill: evaluatedChunk.attrs.stroke, 482 | r: 483 | evaluatedChunk.attrs['stroke-width'] == null 484 | ? undefined 485 | : parseFloat(evaluatedChunk.attrs['stroke-width']) + 1, 486 | }; 487 | 488 | evaluatedChunk.pointAttrs = Object.assign( 489 | basePointAttrs, 490 | evaluateAttrsOrStyles(pointAttrs), 491 | evaluateAttrsOrStyles( 492 | (chunkDefinitions[lineChunkName] || {}).pointAttrs 493 | ), 494 | evaluateAttrsOrStyles(chunkDef.pointAttrs) 495 | ); 496 | 497 | // ensure `r` is a number (helps to remove 'px' if provided) 498 | if (evaluatedChunk.pointAttrs.r != null) { 499 | evaluatedChunk.pointAttrs.r = parseFloat(evaluatedChunk.pointAttrs.r); 500 | } 501 | 502 | // set point styles. if no fill attr set, use the line style stroke. otherwise read from the attr. 503 | const basePointStyles = 504 | chunkDef.pointAttrs && chunkDef.pointAttrs.fill != null 505 | ? {} 506 | : { 507 | fill: evaluatedChunk.styles.stroke, 508 | }; 509 | 510 | evaluatedChunk.pointStyles = Object.assign( 511 | basePointStyles, 512 | evaluateAttrsOrStyles(pointStyles), 513 | evaluateAttrsOrStyles( 514 | (chunkDefinitions[lineChunkName] || {}).pointStyles 515 | ), 516 | evaluateAttrsOrStyles(chunkDef.pointStyles) 517 | ); 518 | 519 | evaluated[chunkName] = evaluatedChunk; 520 | }); 521 | 522 | return evaluated; 523 | } 524 | 525 | /** 526 | * Render the points for when segments have length 1. 527 | */ 528 | function renderCircles( 529 | initialRender, 530 | transition, 531 | context, 532 | root, 533 | points, 534 | evaluatedDefinition, 535 | className 536 | ) { 537 | const primaryClassName = className.split(' ')[0]; 538 | let circles = root 539 | .selectAll(`.${primaryClassName}`) 540 | .data(points, (d) => d.id); 541 | 542 | // read in properties about the transition if we have one 543 | const transitionDuration = transition ? context.duration() : 0; 544 | const transitionDelay = transition ? context.delay() : 0; 545 | 546 | // EXIT 547 | if (transition) { 548 | circles 549 | .exit() 550 | .transition() 551 | .delay(transitionDelay) 552 | .duration(transitionDuration * 0.05) 553 | .attr('r', 1e-6) 554 | .remove(); 555 | } else { 556 | circles.exit().remove(); 557 | } 558 | 559 | // ENTER 560 | const circlesEnter = circles.enter().append('circle'); 561 | 562 | // apply user-provided attrs, using attributes from current line if not provided 563 | applyAttrsAndStyles(circlesEnter, evaluatedDefinition, true); 564 | 565 | circlesEnter 566 | .classed(className, true) 567 | .attr('r', 1e-6) // overrides provided `r value for now 568 | .attr('cx', (d) => x(d.data)) 569 | .attr('cy', (d) => y(d.data)); 570 | 571 | // handle with transition 572 | if ( 573 | (!initialRender || (initialRender && transitionInitial)) && 574 | transition 575 | ) { 576 | const enterDuration = transitionDuration * 0.15; 577 | 578 | // delay sizing up the radius until after the line transition 579 | circlesEnter 580 | .transition(context) 581 | .delay(transitionDelay + (transitionDuration - enterDuration)) 582 | .duration(enterDuration) 583 | .attr('r', evaluatedDefinition.pointAttrs.r); 584 | } else { 585 | circlesEnter.attr('r', evaluatedDefinition.pointAttrs.r); 586 | } 587 | 588 | // UPDATE 589 | if (transition) { 590 | circles = circles.transition(context); 591 | } 592 | circles 593 | .attr('r', evaluatedDefinition.pointAttrs.r) 594 | .attr('cx', (d) => x(d.data)) 595 | .attr('cy', (d) => y(d.data)); 596 | } 597 | 598 | function renderClipRects( 599 | initialRender, 600 | transition, 601 | context, 602 | root, 603 | segments, 604 | [xMin, xMax], 605 | [yMin, yMax], 606 | evaluatedDefinition, 607 | path, 608 | clipPathId 609 | ) { 610 | // TODO: issue with assigning IDs to clipPath elements. need to update how we select/create them 611 | // need reference to path element to set stroke-width property 612 | const clipPath = root.select(`#${clipPathId}`); 613 | let gDebug = root.select('.d3-line-chunked-debug'); 614 | 615 | // set up debug group 616 | if (debug && gDebug.empty()) { 617 | gDebug = root.append('g').classed('d3-line-chunked-debug', true); 618 | } else if (!debug && !gDebug.empty()) { 619 | gDebug.remove(); 620 | } 621 | 622 | let clipPathRects = clipPath.selectAll('rect').data(segments); 623 | let debugRects; 624 | if (debug) { 625 | debugRects = gDebug.selectAll('rect').data(segments); 626 | } 627 | 628 | // get stroke width to avoid having the clip rects clip the stroke 629 | // See https://github.com/pbeshai/d3-line-chunked/issues/2 630 | const strokeWidth = parseFloat( 631 | evaluatedDefinition.styles['stroke-width'] || 632 | path.style('stroke-width') || // reads from CSS too 633 | evaluatedDefinition.attrs['stroke-width'] 634 | ); 635 | const strokeWidthClipAdjustment = strokeWidth; 636 | const clipRectY = yMin - strokeWidthClipAdjustment; 637 | const clipRectHeight = 638 | yMax + strokeWidthClipAdjustment - (yMin - strokeWidthClipAdjustment); 639 | 640 | // compute the currently visible area pairs of [xStart, xEnd] for each clip rect 641 | // if no clip rects, the whole area is visible. 642 | let visibleArea; 643 | 644 | if (transition) { 645 | // select previous rects 646 | const previousRects = clipPath.selectAll('rect').nodes(); 647 | // no previous rects = visible area is everything 648 | if (!previousRects.length) { 649 | visibleArea = [[xMin, xMax]]; 650 | } else { 651 | visibleArea = previousRects.map((rect) => { 652 | const selectedRect = select(rect); 653 | const xStart = parseFloat(selectedRect.attr('x')); 654 | const xEnd = parseFloat(selectedRect.attr('width')) + xStart; 655 | return [xStart, xEnd]; 656 | }); 657 | } 658 | 659 | // set up the clipping paths 660 | // animate by shrinking width to 0 and setting x to the mid point 661 | let nextVisibleArea; 662 | if (!segments.length) { 663 | nextVisibleArea = [[0, 0]]; 664 | } else { 665 | nextVisibleArea = segments.map((d) => { 666 | const xStart = x(d[0]); 667 | const xEnd = x(d[d.length - 1]); 668 | return [xStart, xEnd]; 669 | }); 670 | } 671 | 672 | // compute the start and end x values for a data point based on maximizing visibility 673 | // around the middle of the rect. 674 | function visibleStartEnd(d, visibleArea) { 675 | // eslint-disable-line no-inner-declarations 676 | const xStart = x(d[0]); 677 | const xEnd = x(d[d.length - 1]); 678 | const xMid = xStart + (xEnd - xStart) / 2; 679 | const visArea = visibleArea.find( 680 | (area) => area[0] <= xMid && xMid <= area[1] 681 | ); 682 | 683 | // set width to overlapping visible area 684 | if (visArea) { 685 | return [Math.max(visArea[0], xStart), Math.min(xEnd, visArea[1])]; 686 | } 687 | 688 | // return xEnd - xStart; 689 | return [xMid, xMid]; 690 | } 691 | 692 | function exitRect(rect) { 693 | // eslint-disable-line no-inner-declarations 694 | rect 695 | .attr('x', (d) => visibleStartEnd(d, nextVisibleArea)[0]) 696 | .attr('width', (d) => { 697 | const [xStart, xEnd] = visibleStartEnd(d, nextVisibleArea); 698 | return xEnd - xStart; 699 | }); 700 | } 701 | 702 | function enterRect(rect) { 703 | // eslint-disable-line no-inner-declarations 704 | rect 705 | .attr('x', (d) => visibleStartEnd(d, visibleArea)[0]) 706 | .attr('width', (d) => { 707 | const [xStart, xEnd] = visibleStartEnd(d, visibleArea); 708 | return xEnd - xStart; 709 | }) 710 | .attr('y', clipRectY) 711 | .attr('height', clipRectHeight); 712 | } 713 | 714 | clipPathRects.exit().transition(context).call(exitRect).remove(); 715 | const clipPathRectsEnter = clipPathRects 716 | .enter() 717 | .append('rect') 718 | .call(enterRect); 719 | clipPathRects = clipPathRects.merge(clipPathRectsEnter); 720 | clipPathRects = clipPathRects.transition(context); 721 | 722 | // debug rects should match clipPathRects 723 | if (debug) { 724 | debugRects.exit().transition(context).call(exitRect).remove(); 725 | const debugRectsEnter = debugRects 726 | .enter() 727 | .append('rect') 728 | .style('fill', 'rgba(255, 0, 0, 0.3)') 729 | .style('stroke', 'rgba(255, 0, 0, 0.6)') 730 | .call(enterRect); 731 | 732 | debugRects = debugRects.merge(debugRectsEnter); 733 | debugRects = debugRects.transition(context); 734 | } 735 | 736 | // not in transition 737 | } else { 738 | clipPathRects.exit().remove(); 739 | const clipPathRectsEnter = clipPathRects.enter().append('rect'); 740 | clipPathRects = clipPathRects.merge(clipPathRectsEnter); 741 | 742 | if (debug) { 743 | debugRects.exit().remove(); 744 | const debugRectsEnter = debugRects 745 | .enter() 746 | .append('rect') 747 | .style('fill', 'rgba(255, 0, 0, 0.3)') 748 | .style('stroke', 'rgba(255, 0, 0, 0.6)'); 749 | debugRects = debugRects.merge(debugRectsEnter); 750 | } 751 | } 752 | 753 | // after transition, update the clip rect dimensions 754 | function updateRect(rect) { 755 | rect 756 | .attr('x', (d) => { 757 | // if at the edge, adjust for stroke width 758 | const val = x(d[0]); 759 | if (val === xMin) { 760 | return val - strokeWidthClipAdjustment; 761 | } 762 | return val; 763 | }) 764 | .attr('width', (d) => { 765 | // if at the edge, adjust for stroke width to prevent clipping it 766 | let valMin = x(d[0]); 767 | let valMax = x(d[d.length - 1]); 768 | if (valMin === xMin) { 769 | valMin -= strokeWidthClipAdjustment; 770 | } 771 | if (valMax === xMax) { 772 | valMax += strokeWidthClipAdjustment; 773 | } 774 | 775 | return valMax - valMin; 776 | }) 777 | .attr('y', clipRectY) 778 | .attr('height', clipRectHeight); 779 | } 780 | 781 | clipPathRects.call(updateRect); 782 | if (debug) { 783 | debugRects.call(updateRect); 784 | } 785 | } 786 | 787 | /** 788 | * Helper function to draw the actual path 789 | */ 790 | function renderPath( 791 | initialRender, 792 | transition, 793 | context, 794 | root, 795 | lineData, 796 | evaluatedDefinition, 797 | line, 798 | initialLine, 799 | className, 800 | clipPathId 801 | ) { 802 | let path = root.select(`.${className.split(' ')[0]}`); 803 | 804 | // initial render 805 | if (path.empty()) { 806 | path = root.append('path'); 807 | } 808 | const pathSelection = path; 809 | 810 | if (clipPathId) { 811 | path.attr('clip-path', `url(#${clipPathId})`); 812 | } 813 | 814 | // handle animations for initial render 815 | if (initialRender) { 816 | path.attr('d', initialLine(lineData)); 817 | } 818 | 819 | // apply user defined styles and attributes 820 | applyAttrsAndStyles(path, evaluatedDefinition); 821 | 822 | path.classed(className, true); 823 | 824 | // handle transition 825 | if (transition) { 826 | path = path.transition(context); 827 | } 828 | 829 | if (path.attrTween) { 830 | // use attrTween is available (in transition) 831 | path.attrTween('d', function dTween() { 832 | const previous = select(this).attr('d'); 833 | const current = line(lineData); 834 | return interpolatePath(previous, current); 835 | }); 836 | } else { 837 | path.attr('d', () => line(lineData)); 838 | } 839 | 840 | // can't return path since it might have the transition 841 | return pathSelection; 842 | } 843 | 844 | /** 845 | * Helper to get the line functions to use to draw the lines. Possibly 846 | * updates the line data to be in [x, y] format if extendEnds is true. 847 | * 848 | * @return {Object} { line, initialLine, lineData } 849 | */ 850 | function getLineFunctions(lineData, initialRender, yDomain) { 851 | // eslint-disable-line no-unused-vars 852 | const yMax = yDomain[1]; 853 | 854 | // main line function 855 | let line = d3Line().x(x).y(y).curve(curve); 856 | let initialLine; 857 | 858 | // if the user specifies to extend ends for the undefined line, add points to the line for them. 859 | if (extendEnds && lineData.length) { 860 | // we have to process the data here since we don't know how to format an input object 861 | // we use the [x, y] format of a data point 862 | const processedLineData = lineData.map((d) => [x(d), y(d)]); 863 | lineData = [ 864 | [extendEnds[0], processedLineData[0][1]], 865 | ...processedLineData, 866 | [extendEnds[1], processedLineData[processedLineData.length - 1][1]], 867 | ]; 868 | 869 | // this line function works on the processed data (default .x and .y read the [x,y] format) 870 | line = d3Line().curve(curve); 871 | } 872 | 873 | // handle animations for initial render 874 | if (initialRender) { 875 | // have the line load in with a flat y value 876 | initialLine = line; 877 | if (transitionInitial) { 878 | initialLine = d3Line().x(x).y(yMax).curve(curve); 879 | 880 | // if the user extends ends, we should use the line that works on that data 881 | if (extendEnds) { 882 | initialLine = d3Line().y(yMax).curve(curve); 883 | } 884 | } 885 | } 886 | 887 | return { 888 | line, 889 | initialLine: initialLine || line, 890 | lineData, 891 | }; 892 | } 893 | 894 | function initializeClipPath(chunkName, root) { 895 | if (chunkName === gapChunkName) { 896 | return undefined; 897 | } 898 | 899 | let defs = root.select('defs'); 900 | if (defs.empty()) { 901 | defs = root.append('defs'); 902 | } 903 | 904 | // className = d3-line-chunked-clip-chunkName 905 | const className = `d3-line-chunked-clip-${chunkName}`; 906 | let clipPath = defs.select(`.${className}`); 907 | 908 | // initial render 909 | if (clipPath.empty()) { 910 | clipPath = defs 911 | .append('clipPath') 912 | .attr('class', className) 913 | .attr('id', `d3-line-chunked-clip-${chunkName}-${counter}`); 914 | counter += 1; 915 | } 916 | 917 | return clipPath.attr('id'); 918 | } 919 | 920 | /** 921 | * Render the lines: circles, paths, clip rects for the given (data, lineIndex) 922 | */ 923 | function renderLines( 924 | initialRender, 925 | transition, 926 | context, 927 | root, 928 | data, 929 | lineIndex 930 | ) { 931 | // use the accessor if provided (e.g. if the data is something like 932 | // `{ results: [[x,y], [[x,y], ...]}`) 933 | const lineData = accessData(data); 934 | 935 | // filter to only defined data to plot the lines 936 | const filteredLineData = lineData.filter(defined); 937 | 938 | // determine the extent of the y values 939 | const yExtent = extent(filteredLineData.map((d) => y(d))); 940 | 941 | // determine the extent of the x values to handle stroke-width adjustments on 942 | // clipping rects. Do not use extendEnds here since it can clip the line ending 943 | // in an unnatural way, it's better to just show the end. 944 | const xExtent = extent(filteredLineData.map((d) => x(d))); 945 | 946 | // evaluate attrs and styles for the given dataset 947 | // pass in the raw data and index for computing attrs and styles if they are functinos 948 | const evaluatedDefinitions = evaluateDefinitions(data, lineIndex); 949 | 950 | // update line functions and data depending on animation and render circumstances 951 | const lineResults = getLineFunctions( 952 | filteredLineData, 953 | initialRender, 954 | yExtent 955 | ); 956 | 957 | // lineData possibly updated if extendEnds is true since we normalize to [x, y] format 958 | const { line, initialLine, lineData: modifiedLineData } = lineResults; 959 | 960 | // for each chunk type, render a line 961 | const chunkNames = getChunkNames(); 962 | 963 | const definedSegments = computeDefinedSegments(lineData); 964 | 965 | // for each chunk, draw a line, circles and clip rect 966 | chunkNames.forEach((chunkName) => { 967 | const clipPathId = initializeClipPath(chunkName, root); 968 | 969 | let className = `d3-line-chunked-chunk-${chunkName}`; 970 | if (chunkName === lineChunkName) { 971 | className = `d3-line-chunked-defined ${className}`; 972 | } else if (chunkName === gapChunkName) { 973 | className = `d3-line-chunked-undefined ${className}`; 974 | } 975 | 976 | // get the eval defs for this chunk name 977 | const evaluatedDefinition = evaluatedDefinitions[chunkName]; 978 | 979 | const path = renderPath( 980 | initialRender, 981 | transition, 982 | context, 983 | root, 984 | modifiedLineData, 985 | evaluatedDefinition, 986 | line, 987 | initialLine, 988 | className, 989 | clipPathId 990 | ); 991 | 992 | if (chunkName !== gapChunkName) { 993 | // compute the segments and points for this chunk type 994 | const segments = computeChunkedSegments(chunkName, definedSegments); 995 | const points = segments 996 | .filter((segment) => segment.length === 1) 997 | .map((segment) => ({ 998 | // use random ID so they are treated as entering/exiting each time 999 | id: x(segment[0]), 1000 | data: segment[0], 1001 | })); 1002 | 1003 | const circlesClassName = className 1004 | .split(' ') 1005 | .map((name) => `${name}-point`) 1006 | .join(' '); 1007 | renderCircles( 1008 | initialRender, 1009 | transition, 1010 | context, 1011 | root, 1012 | points, 1013 | evaluatedDefinition, 1014 | circlesClassName 1015 | ); 1016 | 1017 | renderClipRects( 1018 | initialRender, 1019 | transition, 1020 | context, 1021 | root, 1022 | segments, 1023 | xExtent, 1024 | yExtent, 1025 | evaluatedDefinition, 1026 | path, 1027 | clipPathId 1028 | ); 1029 | } 1030 | }); 1031 | 1032 | // ensure all circles are at the top 1033 | root.selectAll('circle').raise(); 1034 | } 1035 | 1036 | // the main function that is returned 1037 | function lineChunked(context) { 1038 | if (!context) { 1039 | return; 1040 | } 1041 | const selection = context.selection ? context.selection() : context; // handle transition 1042 | 1043 | if (!selection || selection.empty()) { 1044 | return; 1045 | } 1046 | 1047 | let transition = false; 1048 | if (selection !== context) { 1049 | transition = true; 1050 | } 1051 | 1052 | selection.each(function each(data, lineIndex) { 1053 | const root = select(this); 1054 | 1055 | const initialRender = root.select('.d3-line-chunked-defined').empty(); 1056 | renderLines(initialRender, transition, context, root, data, lineIndex); 1057 | }); 1058 | 1059 | // provide warning about wrong attr/defs 1060 | validateChunkDefinitions(); 1061 | } 1062 | 1063 | // ------------------------------------------------ 1064 | // Define getters and setters 1065 | // ------------------------------------------------ 1066 | function getterSetter({ get, set, setType, asConstant }) { 1067 | return function getSet(newValue) { 1068 | if (arguments.length) { 1069 | // main setter if setType matches newValue type 1070 | // eslint-disable-next-line valid-typeof 1071 | if ( 1072 | (!setType && newValue != null) || 1073 | (setType && typeof newValue === setType) 1074 | ) { 1075 | set(newValue); 1076 | 1077 | // setter to constant function if provided 1078 | } else if (asConstant && newValue != null) { 1079 | set(asConstant(newValue)); 1080 | } 1081 | 1082 | return lineChunked; 1083 | } 1084 | 1085 | // otherwise ignore value/no value provided, so use getter 1086 | return get(); 1087 | }; 1088 | } 1089 | 1090 | // define `x([x])` 1091 | lineChunked.x = getterSetter({ 1092 | get: () => x, 1093 | set: (newValue) => { 1094 | x = newValue; 1095 | }, 1096 | setType: 'function', 1097 | asConstant: (newValue) => () => +newValue, // d3 v4 uses +, so we do too 1098 | }); 1099 | 1100 | // define `y([y])` 1101 | lineChunked.y = getterSetter({ 1102 | get: () => y, 1103 | set: (newValue) => { 1104 | y = newValue; 1105 | }, 1106 | setType: 'function', 1107 | asConstant: (newValue) => () => +newValue, 1108 | }); 1109 | 1110 | // define `defined([defined])` 1111 | lineChunked.defined = getterSetter({ 1112 | get: () => defined, 1113 | set: (newValue) => { 1114 | defined = newValue; 1115 | }, 1116 | setType: 'function', 1117 | asConstant: (newValue) => () => !!newValue, 1118 | }); 1119 | 1120 | // define `isNext([isNext])` 1121 | lineChunked.isNext = getterSetter({ 1122 | get: () => isNext, 1123 | set: (newValue) => { 1124 | isNext = newValue; 1125 | }, 1126 | setType: 'function', 1127 | asConstant: (newValue) => () => !!newValue, 1128 | }); 1129 | 1130 | // define `chunk([chunk])` 1131 | lineChunked.chunk = getterSetter({ 1132 | get: () => chunk, 1133 | set: (newValue) => { 1134 | chunk = newValue; 1135 | }, 1136 | setType: 'function', 1137 | asConstant: (newValue) => () => newValue, 1138 | }); 1139 | 1140 | // define `chunkLineResolver([chunkLineResolver])` 1141 | lineChunked.chunkLineResolver = getterSetter({ 1142 | get: () => chunkLineResolver, 1143 | set: (newValue) => { 1144 | chunkLineResolver = newValue; 1145 | }, 1146 | setType: 'function', 1147 | }); 1148 | 1149 | // define `chunkDefinitions([chunkDefinitions])` 1150 | lineChunked.chunkDefinitions = getterSetter({ 1151 | get: () => chunkDefinitions, 1152 | set: (newValue) => { 1153 | chunkDefinitions = newValue; 1154 | }, 1155 | setType: 'object', 1156 | }); 1157 | 1158 | // define `curve([curve])` 1159 | lineChunked.curve = getterSetter({ 1160 | get: () => curve, 1161 | set: (newValue) => { 1162 | curve = newValue; 1163 | }, 1164 | setType: 'function', 1165 | }); 1166 | 1167 | // define `lineStyles([lineStyles])` 1168 | lineChunked.lineStyles = getterSetter({ 1169 | get: () => lineStyles, 1170 | set: (newValue) => { 1171 | lineStyles = newValue; 1172 | }, 1173 | setType: 'object', 1174 | }); 1175 | 1176 | // define `gapStyles([gapStyles])` 1177 | lineChunked.gapStyles = getterSetter({ 1178 | get: () => gapStyles, 1179 | set: (newValue) => { 1180 | gapStyles = newValue; 1181 | }, 1182 | setType: 'object', 1183 | }); 1184 | 1185 | // define `pointStyles([pointStyles])` 1186 | lineChunked.pointStyles = getterSetter({ 1187 | get: () => pointStyles, 1188 | set: (newValue) => { 1189 | pointStyles = newValue; 1190 | }, 1191 | setType: 'object', 1192 | }); 1193 | 1194 | // define `lineAttrs([lineAttrs])` 1195 | lineChunked.lineAttrs = getterSetter({ 1196 | get: () => lineAttrs, 1197 | set: (newValue) => { 1198 | lineAttrs = newValue; 1199 | }, 1200 | setType: 'object', 1201 | }); 1202 | 1203 | // define `gapAttrs([gapAttrs])` 1204 | lineChunked.gapAttrs = getterSetter({ 1205 | get: () => gapAttrs, 1206 | set: (newValue) => { 1207 | gapAttrs = newValue; 1208 | }, 1209 | setType: 'object', 1210 | }); 1211 | 1212 | // define `pointAttrs([pointAttrs])` 1213 | lineChunked.pointAttrs = getterSetter({ 1214 | get: () => pointAttrs, 1215 | set: (newValue) => { 1216 | pointAttrs = newValue; 1217 | }, 1218 | setType: 'object', 1219 | }); 1220 | 1221 | // define `transitionInitial([transitionInitial])` 1222 | lineChunked.transitionInitial = getterSetter({ 1223 | get: () => transitionInitial, 1224 | set: (newValue) => { 1225 | transitionInitial = newValue; 1226 | }, 1227 | setType: 'boolean', 1228 | }); 1229 | 1230 | // define `extendEnds([extendEnds])` 1231 | lineChunked.extendEnds = getterSetter({ 1232 | get: () => extendEnds, 1233 | set: (newValue) => { 1234 | extendEnds = newValue; 1235 | }, 1236 | setType: 'object', // should be an array 1237 | }); 1238 | 1239 | // define `accessData([accessData])` 1240 | lineChunked.accessData = getterSetter({ 1241 | get: () => accessData, 1242 | set: (newValue) => { 1243 | accessData = newValue; 1244 | }, 1245 | setType: 'function', 1246 | asConstant: (newValue) => (d) => d[newValue], 1247 | }); 1248 | 1249 | // define `debug([debug])` 1250 | lineChunked.debug = getterSetter({ 1251 | get: () => debug, 1252 | set: (newValue) => { 1253 | debug = newValue; 1254 | }, 1255 | setType: 'boolean', 1256 | }); 1257 | 1258 | return lineChunked; 1259 | } 1260 | -------------------------------------------------------------------------------- /test/lineChunked-test.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import tape from 'tape'; 3 | import { transition } from 'd3-transition'; 4 | import { select } from 'd3-selection'; 5 | import jsdom from 'jsdom'; 6 | import lineChunked from '../src/lineChunked.js'; 7 | 8 | const definedLineClass = '.d3-line-chunked-defined'; 9 | const undefinedLineClass = '.d3-line-chunked-undefined'; 10 | const definedPointClass = '.d3-line-chunked-defined-point'; 11 | 12 | function getDocument() { 13 | const { JSDOM } = jsdom; 14 | const { document } = (new JSDOM('')).window; 15 | return document; 16 | } 17 | 18 | function lengthOfPath(path) { 19 | if (!path || path.empty()) { 20 | return null; 21 | } 22 | 23 | const d = path.attr('d'); 24 | if (d == null || !d.length) { 25 | return 0; 26 | } 27 | 28 | // only count M and L since we are using curveLinear 29 | return d.split(/(?=[ML])/).length; 30 | } 31 | 32 | function rectDimensions(rect) { 33 | rect = select(rect); 34 | return { 35 | x: rect.attr('x'), 36 | y: rect.attr('y'), 37 | width: rect.attr('width'), 38 | height: rect.attr('height') 39 | }; 40 | } 41 | 42 | // NOTE: stroke-width 0 is used in the tests to prevent accounting for the stroke-width adjustments 43 | // added in https://github.com/pbeshai/d3-line-chunked/issues/2 44 | 45 | tape('lineChunked() getter and setters work', function (t) { 46 | const chunked = lineChunked(); 47 | 48 | t.equal(chunked.x(1).x()(9), 1, 'x makes constant function'); 49 | t.equal(chunked.x(d => 2 * d).x()(2), 4, 'x sets function'); 50 | t.equal(chunked.y(1).y()(9), 1, 'y makes constant function'); 51 | t.equal(chunked.y(d => 2 * d).y()(2), 4, 'y sets function'); 52 | t.equal(chunked.defined(false).defined()(9), false, 'defined makes constant function'); 53 | t.equal(chunked.defined(d => d > 4).defined()(5), true, 'defined sets function'); 54 | t.equal(chunked.isNext(false).isNext()(3), false, 'isNext makes constant function'); 55 | t.equal(chunked.isNext(d => d > 4).isNext()(3), false, 'isNext sets function'); 56 | t.equal(chunked.chunk('my-chunk').chunk()(9), 'my-chunk', 'chunk makes constant function'); 57 | t.equal(chunked.chunk(d => d > 4 ? 'foo' : 'bar').chunk()(5), 'foo', 'chunk sets function'); 58 | t.equal(chunked.chunkLineResolver((a, b) => a).chunkLineResolver()('a', 'b'), 'a', 'chunkLineResolver sets function'); 59 | t.deepEqual(chunked.chunkDefinitions({ chunk1: { styles: { color: 'red' } } }).chunkDefinitions(), { chunk1: { styles: { color: 'red' } } }, 'chunkDefinitions sets object'); 60 | t.equal(chunked.curve(d => 5).curve()(3), 5, 'curve sets function'); 61 | t.deepEqual(chunked.lineStyles({ fill: 'red' }).lineStyles(), { fill: 'red' }, 'lineStyles sets object'); 62 | t.deepEqual(chunked.lineAttrs({ fill: 'red' }).lineAttrs(), { fill: 'red' }, 'lineAttrs sets object'); 63 | t.deepEqual(chunked.gapStyles({ fill: 'red' }).gapStyles(), { fill: 'red' }, 'gapStyles sets object'); 64 | t.deepEqual(chunked.gapAttrs({ fill: 'red' }).gapAttrs(), { fill: 'red' }, 'gapAttrs sets object'); 65 | t.deepEqual(chunked.pointStyles({ fill: 'red' }).pointStyles(), { fill: 'red' }, 'pointStyles sets object'); 66 | t.deepEqual(chunked.pointAttrs({ fill: 'red' }).pointAttrs(), { fill: 'red' }, 'pointAttrs sets object'); 67 | t.equal(chunked.transitionInitial(false).transitionInitial(), false, 'transitionInitial sets boolean'); 68 | t.deepEqual(chunked.extendEnds([5, 20]).extendEnds(), [5, 20], 'extendEnds sets array'); 69 | t.deepEqual(chunked.accessData(d => d.results).accessData()({ results: [5, 20] }), [5, 20], 'accessData sets function'); 70 | t.deepEqual(chunked.accessData('results').accessData()({ results: [5, 20] }), [5, 20], 'accessData sets string'); 71 | 72 | t.end(); 73 | }); 74 | /* 75 | 76 | 77 | 78 | 79 | */ 80 | 81 | tape('lineChunked() with empty data', function (t) { 82 | const document = getDocument(); 83 | const g = select(document.body).append('svg').append('g'); 84 | 85 | const chunked = lineChunked(); 86 | const data = []; 87 | 88 | g.datum(data).call(chunked); 89 | // console.log(g.node().innerHTML); 90 | 91 | t.equal(lengthOfPath(g.select(definedLineClass)), 0); 92 | t.equal(lengthOfPath(g.select(undefinedLineClass)), 0); 93 | t.ok(g.select(definedPointClass).empty()); 94 | t.ok(g.selectAll('clipPath').selectAll('rect').empty()); 95 | 96 | t.end(); 97 | }); 98 | 99 | 100 | tape('lineChunked() with one data point', function (t) { 101 | const document = getDocument(); 102 | const g = select(document.body).append('svg').append('g'); 103 | 104 | const chunked = lineChunked(); 105 | const data = [[0, 1]]; 106 | 107 | g.datum(data).call(chunked); 108 | // console.log(g.node().innerHTML); 109 | 110 | t.equal(lengthOfPath(g.select(definedLineClass)), 1); 111 | t.equal(lengthOfPath(g.select(undefinedLineClass)), 1); 112 | t.equal(g.select(definedPointClass).size(), 1); 113 | t.equal(g.selectAll('clipPath').selectAll('rect').size(), 1); 114 | 115 | t.end(); 116 | }); 117 | 118 | tape('lineChunked() with null transition to null', function (t) { 119 | const document = getDocument(); 120 | const g = select(document.body).append('svg').append('g'); 121 | 122 | const chunked = lineChunked().defined(d => d[1] != null); 123 | const data = [[0, null]]; 124 | 125 | g.datum(data).call(chunked).transition().call(chunked); 126 | // console.log(g.node().innerHTML); 127 | 128 | t.equal(lengthOfPath(g.select(definedLineClass)), 0); 129 | t.equal(lengthOfPath(g.select(undefinedLineClass)), 0); 130 | t.equal(g.select(definedPointClass).size(), 0); 131 | t.equal(g.selectAll('clipPath').selectAll('rect').size(), 0); 132 | 133 | t.end(); 134 | }); 135 | 136 | 137 | tape('lineChunked() with many data points', function (t) { 138 | const document = getDocument(); 139 | const g = select(document.body).append('svg').append('g'); 140 | 141 | const chunked = lineChunked().lineAttrs({ 'stroke-width': 0 }); 142 | const data = [[0, 1], [1, 2], [2, 1]]; 143 | 144 | g.datum(data).call(chunked); 145 | // console.log(g.node().innerHTML); 146 | 147 | t.equal(lengthOfPath(g.select(definedLineClass)), 3); 148 | t.equal(lengthOfPath(g.select(undefinedLineClass)), 3); 149 | t.equal(g.select(definedPointClass).size(), 0); 150 | const rects = g.selectAll('clipPath').selectAll('rect'); 151 | t.equal(rects.size(), 1); 152 | t.deepEqual(rectDimensions(rects.nodes()[0]), { x: '0', width: '2', y: '1', height: '1' }); 153 | 154 | t.end(); 155 | }); 156 | 157 | // this test is important to make sure we don't keep adding in new paths 158 | tape('lineChunked() updates existing path', function (t) { 159 | const document = getDocument(); 160 | const g = select(document.body).append('svg').append('g'); 161 | 162 | const chunked = lineChunked().lineAttrs({ 'stroke-width': 0 }); 163 | const data = [[0, 1], [1, 2], [2, 1]]; 164 | 165 | g.datum(data).call(chunked); 166 | // console.log(g.node().innerHTML); 167 | 168 | t.equal(lengthOfPath(g.select(definedLineClass)), 3); 169 | t.equal(lengthOfPath(g.select(undefinedLineClass)), 3); 170 | t.equal(g.selectAll(definedLineClass).size(), 1); 171 | t.equal(g.selectAll(undefinedLineClass).size(), 1); 172 | 173 | g.datum([[5, 1], [3, 2]]).call(chunked); 174 | 175 | t.equal(lengthOfPath(g.select(definedLineClass)), 2); 176 | t.equal(lengthOfPath(g.select(undefinedLineClass)), 2); 177 | t.equal(g.selectAll(definedLineClass).size(), 1); 178 | t.equal(g.selectAll(undefinedLineClass).size(), 1); 179 | 180 | t.end(); 181 | }); 182 | 183 | tape('lineChunked() with many data points and some undefined', function (t) { 184 | const document = getDocument(); 185 | const g = select(document.body).append('svg').append('g'); 186 | 187 | const chunked = lineChunked() 188 | .lineAttrs({ 'stroke-width': 0 }) 189 | .defined(d => d[1] !== null); 190 | 191 | const data = [[0, 1], [1, 2], [2, null], [3, null], [4, 1], [5, null], [6, 2], [7, 3]]; 192 | 193 | g.datum(data).call(chunked); 194 | // console.log(g.node().innerHTML); 195 | 196 | t.equal(lengthOfPath(g.select(definedLineClass)), 5); 197 | t.equal(lengthOfPath(g.select(undefinedLineClass)), 5); 198 | t.equal(g.select(definedPointClass).size(), 1); 199 | 200 | const rects = g.selectAll('clipPath').selectAll('rect'); 201 | t.equal(rects.size(), 3); 202 | t.deepEqual(rectDimensions(rects.nodes()[0]), { x: '0', width: '1', y: '1', height: '2' }); 203 | t.deepEqual(rectDimensions(rects.nodes()[1]), { x: '4', width: '0', y: '1', height: '2' }); 204 | t.deepEqual(rectDimensions(rects.nodes()[2]), { x: '6', width: '1', y: '1', height: '2' }); 205 | 206 | t.end(); 207 | }); 208 | 209 | 210 | tape('lineChunked() sets attrs and styles', function (t) { 211 | const document = getDocument(); 212 | const g = select(document.body).append('svg').append('g'); 213 | 214 | const chunked = lineChunked() 215 | .lineAttrs({ 216 | 'stroke-width': 4, 217 | stroke: (d, i) => i === 0 ? 'blue' : 'red', 218 | }) 219 | .lineStyles({ 220 | fill: 'purple', 221 | stroke: (d, i) => i === 0 ? 'orange' : 'green', 222 | }) 223 | .gapAttrs({ 224 | 'stroke-width': 2, 225 | stroke: (d, i) => i === 0 ? 'teal' : 'cyan', 226 | }) 227 | .gapStyles({ 228 | stroke: (d, i) => i === 0 ? 'magenta' : 'brown', 229 | }) 230 | .pointAttrs({ 231 | 'r': 20, 232 | }) 233 | .pointStyles({ 234 | fill: 'maroon', 235 | stroke: (d, i) => i === 0 ? 'indigo' : 'violet', 236 | }) 237 | .defined(d => d[1] !== null); 238 | 239 | const data = [[0, 1], [1, 2], [2, null], [3, null], [4, 1], [5, null], [6, 2], [7, 3]]; 240 | 241 | g.datum(data).call(chunked); 242 | // console.log(g.node().innerHTML); 243 | 244 | const line = g.select(definedLineClass); 245 | const gap = g.select(undefinedLineClass); 246 | const point = g.select('circle'); 247 | 248 | t.equal(line.attr('stroke-width'), '4'); 249 | t.equal(line.attr('stroke'), 'blue'); 250 | t.equal(line.style('fill'), 'purple'); 251 | t.equal(line.style('stroke'), 'orange'); 252 | 253 | t.equal(gap.attr('stroke-width'), '2'); 254 | t.equal(gap.attr('stroke'), 'teal'); 255 | t.equal(gap.style('fill'), 'purple'); 256 | t.equal(gap.style('stroke'), 'magenta'); 257 | 258 | t.equal(point.attr('r'), '20'); 259 | t.equal(point.attr('fill'), 'blue'); 260 | t.equal(point.style('fill'), 'maroon'); 261 | t.equal(point.style('stroke'), 'indigo'); 262 | 263 | t.end(); 264 | }); 265 | 266 | tape('lineChunked() sets attrs and styles via chunkDefinitions', function (t) { 267 | const document = getDocument(); 268 | const g = select(document.body).append('svg').append('g'); 269 | 270 | const chunked = lineChunked() 271 | .chunkDefinitions({ 272 | line: { 273 | attrs: { 274 | 'stroke-width': 4, 275 | stroke: (d, i) => i === 0 ? 'blue' : 'red', 276 | }, 277 | styles: { 278 | fill: 'purple', 279 | stroke: (d, i) => i === 0 ? 'orange' : 'green', 280 | }, 281 | pointAttrs: { 282 | 'r': 20, 283 | }, 284 | pointStyles: { 285 | stroke: (d, i) => i === 0 ? 'indigo' : 'violet', 286 | } 287 | }, 288 | gap: { 289 | attrs: { 290 | 'stroke-width': 2, 291 | stroke: (d, i) => i === 0 ? 'teal' : 'cyan', 292 | }, 293 | styles: { 294 | stroke: (d, i) => i === 0 ? 'magenta' : 'brown', 295 | } 296 | }, 297 | chunk1: { 298 | attrs: { 299 | fill: 'orange', 300 | 'stroke-width': 5 301 | }, 302 | styles: { 303 | 'stroke-dasharray': '2, 2' 304 | }, 305 | } 306 | }) 307 | .chunk(d => 'chunk1') 308 | .defined(d => d[1] !== null); 309 | 310 | const data = [[0, 1], [1, 2], [2, null], [3, null], [4, 1], [5, null], [6, 2], [7, 3]]; 311 | 312 | g.datum(data).call(chunked); 313 | // console.log(g.node().innerHTML); 314 | 315 | const line = g.select('.d3-line-chunked-chunk-chunk1'); 316 | const gap = g.select(undefinedLineClass); 317 | const point = g.select('circle'); 318 | 319 | t.equal(line.attr('fill'), 'orange'); 320 | t.equal(line.attr('stroke-width'), '5'); 321 | t.equal(line.attr('stroke'), 'blue'); 322 | t.equal(line.style('fill'), 'purple'); 323 | t.equal(line.style('stroke'), 'orange'); 324 | t.equal(line.style('stroke-dasharray'), '2, 2'); 325 | 326 | t.equal(gap.attr('stroke-width'), '2'); 327 | t.equal(gap.attr('stroke'), 'teal'); 328 | t.equal(gap.style('fill'), 'purple'); 329 | t.equal(gap.style('stroke'), 'magenta'); 330 | 331 | t.equal(point.attr('r'), '20'); 332 | t.equal(point.attr('fill'), 'blue'); 333 | t.equal(point.style('fill'), 'orange'); 334 | t.equal(point.style('stroke'), 'indigo'); 335 | 336 | t.end(); 337 | }); 338 | 339 | 340 | 341 | tape('lineChunked() stroke width clipping adjustments', function (t) { 342 | const document = getDocument(); 343 | const g = select(document.body).append('svg').append('g'); 344 | 345 | const chunked = lineChunked() 346 | .lineAttrs({ 'stroke-width': 2 }) 347 | .defined(d => d[1] !== null); 348 | 349 | const data = [[0, 1], [1, 2], [2, null], [3, null], [4, 1], [5, null], [6, 2], [7, 3]]; 350 | 351 | g.datum(data).call(chunked); 352 | // console.log(g.node().innerHTML); 353 | 354 | t.equal(lengthOfPath(g.select(definedLineClass)), 5); 355 | t.equal(lengthOfPath(g.select(undefinedLineClass)), 5); 356 | t.equal(g.select(definedPointClass).size(), 1); 357 | 358 | const rects = g.selectAll('clipPath').selectAll('rect'); 359 | t.equal(rects.size(), 3); 360 | t.deepEqual(rectDimensions(rects.nodes()[0]), { x: '-2', width: '3', y: '-1', height: '6' }); 361 | t.deepEqual(rectDimensions(rects.nodes()[1]), { x: '4', width: '0', y: '-1', height: '6' }); 362 | t.deepEqual(rectDimensions(rects.nodes()[2]), { x: '6', width: '3', y: '-1', height: '6' }); 363 | 364 | t.end(); 365 | }); 366 | 367 | 368 | tape('lineChunked() when context is a transition', function (t) { 369 | const document = getDocument(); 370 | const g = select(document.body).append('svg').append('g'); 371 | 372 | const chunked = lineChunked() 373 | .lineAttrs({ 'stroke-width': 0 }) 374 | .defined(d => d[1] !== null); 375 | 376 | const data = [[0, 1], [1, 2], [2, null], [3, null], [4, 1], [5, null], [6, 2], [7, 3]]; 377 | 378 | g.datum(data).transition().duration(0).call(chunked); 379 | // console.log(g.node().innerHTML); 380 | 381 | t.equal(lengthOfPath(g.select(definedLineClass)), 5); 382 | t.equal(lengthOfPath(g.select(undefinedLineClass)), 5); 383 | t.equal(g.select(definedPointClass).size(), 1); 384 | 385 | const rects = g.selectAll('clipPath').selectAll('rect'); 386 | t.equal(rects.size(), 3); 387 | t.deepEqual(rectDimensions(rects.nodes()[0]), { x: '0', width: '1', y: '1', height: '2' }); 388 | t.deepEqual(rectDimensions(rects.nodes()[1]), { x: '4', width: '0', y: '1', height: '2' }); 389 | t.deepEqual(rectDimensions(rects.nodes()[2]), { x: '6', width: '1', y: '1', height: '2' }); 390 | 391 | t.end(); 392 | }); 393 | 394 | 395 | tape('lineChunked() - defined and isNext can set gaps in data', function (t) { 396 | const document = getDocument(); 397 | const gDefined = select(document.body).append('svg').append('g'); 398 | 399 | const chunkedDefined = lineChunked() 400 | .lineAttrs({ 'stroke-width': 0 }) 401 | .defined(d => d[1] !== null); 402 | 403 | const dataDefined = [[0, 1], [1, 2], [2, null], [3, null], [4, 1], [5, null], [6, 2], [7, 3]]; 404 | gDefined.datum(dataDefined).call(chunkedDefined); 405 | 406 | const gIsNext = select(document.body).append('svg').append('g'); 407 | 408 | const chunkedIsNext = lineChunked() 409 | .lineAttrs({ 'stroke-width': 0 }) 410 | .isNext((prev, curr) => curr[0] === prev[0] + 1); 411 | 412 | const dataIsNext = [[0, 1], [1, 2], [4, 1], [6, 2], [7, 3]]; 413 | gIsNext.datum(dataIsNext).call(chunkedIsNext); 414 | 415 | // should produce the same clip paths 416 | const rectsDefined = gDefined.selectAll('clipPath').node().innerHTML; 417 | const rectsIsNext = gIsNext.selectAll('clipPath').node().innerHTML; 418 | t.equal(rectsDefined, rectsIsNext); 419 | 420 | t.end(); 421 | }); 422 | 423 | tape('lineChunked() with extendEnds set', function (t) { 424 | const document = getDocument(); 425 | const g = select(document.body).append('svg').append('g'); 426 | 427 | const chunked = lineChunked() 428 | .lineAttrs({ 'stroke-width': 0 }) 429 | .extendEnds([0, 10]) 430 | .defined(d => d[1] !== null); 431 | 432 | const data = [[1, 2], [2, 1], [3, null], [4, 1], [5, null], [6, 2], [7, 3]]; 433 | 434 | g.datum(data).call(chunked); 435 | // console.log(g.node().innerHTML); 436 | 437 | t.equal(lengthOfPath(g.select(definedLineClass)), 7); 438 | t.equal(lengthOfPath(g.select(undefinedLineClass)), 7); 439 | t.equal(g.select(definedPointClass).size(), 1); 440 | 441 | const undefPathPoints = g.select(undefinedLineClass).attr('d').split(/(?=[ML])/); 442 | // should move to edge and line to first point 443 | t.equal(undefPathPoints[0], 'M0,2'); 444 | t.equal(undefPathPoints[1], 'L1,2'); 445 | 446 | // should line to end point 447 | t.equal(undefPathPoints[undefPathPoints.length - 1], 'L10,3'); 448 | 449 | const rects = g.selectAll('clipPath').selectAll('rect'); 450 | t.equal(rects.size(), 3); 451 | t.deepEqual(rectDimensions(rects.nodes()[0]), { x: '1', width: '1', y: '1', height: '2' }); 452 | t.deepEqual(rectDimensions(rects.nodes()[1]), { x: '4', width: '0', y: '1', height: '2' }); 453 | t.deepEqual(rectDimensions(rects.nodes()[2]), { x: '6', width: '1', y: '1', height: '2' }); 454 | 455 | t.end(); 456 | }); 457 | 458 | tape('lineChunked() resolves chunk lines correctly', function (t) { 459 | const document = getDocument(); 460 | const g = select(document.body).append('svg').append('g'); 461 | 462 | const chunked = lineChunked() 463 | .chunkDefinitions({ 464 | line: { 465 | styles: { stroke: 'red', 'stroke-width': 0 }, // use stroke-width 0 to remove strokeWidth adjustments to params 466 | }, 467 | gap: { 468 | styles: { stroke: 'silver' }, 469 | }, 470 | chunk1: { 471 | styles: { stroke: 'blue', 'stroke-width': 0 }, // use stroke-width 0 to remove strokeWidth adjustments to params 472 | }, 473 | }) 474 | .chunk(d => d[1] > 1 ? 'chunk1' : 'line') 475 | .defined(d => d[1] !== null); 476 | 477 | const data = [[0, 2], [1, 1], [2, 2], [3, null], [4, 1], [5, 2], [6, 1], [7, 1], [8, null], [9, 2], [10, null]]; 478 | g.datum(data).call(chunked); 479 | // console.log(g.node().innerHTML); 480 | 481 | const expectedAttrs = { 482 | line: [ 483 | { x: '1', width: '0' }, 484 | { x: '4', width: '0' }, 485 | { x: '6', width: '1' }, 486 | ], 487 | chunk1: [ 488 | { x: '0', width: '2' }, 489 | { x: '4', width: '2' }, 490 | { x: '9', width: '0' }, 491 | ] 492 | }; 493 | const lineRects = g.select('.d3-line-chunked-clip-line').selectAll('rect').nodes(); 494 | const chunk1Rects = g.select('.d3-line-chunked-clip-chunk1').selectAll('rect').nodes(); 495 | 496 | lineRects.forEach((rect, i) => { 497 | t.equal(select(rect).attr('x'), expectedAttrs.line[i].x); 498 | t.equal(select(rect).attr('width'), expectedAttrs.line[i].width); 499 | }); 500 | 501 | chunk1Rects.forEach((rect, i) => { 502 | t.equal(select(rect).attr('x'), expectedAttrs.chunk1[i].x); 503 | t.equal(select(rect).attr('width'), expectedAttrs.chunk1[i].width); 504 | }); 505 | 506 | t.end(); 507 | }); 508 | 509 | tape('lineChunked() puts circles above paths when using multiple chunks', function (t) { 510 | const document = getDocument(); 511 | const g = select(document.body).append('svg').append('g'); 512 | 513 | const chunked = lineChunked() 514 | .chunkDefinitions({ 515 | line: { 516 | styles: { stroke: 'red', 'stroke-width': 0 }, // use stroke-width 0 to remove strokeWidth adjustments to params 517 | }, 518 | gap: { 519 | styles: { stroke: 'silver' }, 520 | }, 521 | chunk1: { 522 | styles: { stroke: 'blue', 'stroke-width': 0 }, // use stroke-width 0 to remove strokeWidth adjustments to params 523 | }, 524 | }) 525 | .chunk(d => d[1] > 1 ? 'chunk1' : 'line') 526 | .defined(d => d[1] !== null); 527 | 528 | const data = [[0, 2], [1, 1], [2, 2], [3, null], [4, 1], [5, 2], [6, 1], [7, 1], [8, null], [9, 2], [10, null]]; 529 | g.datum(data).call(chunked); 530 | // console.log(g.node().innerHTML); 531 | 532 | const children = g.selectAll('*').nodes().map(node => node.nodeName.toLowerCase()); 533 | 534 | let lastPathIndex = -1; 535 | let firstCircleIndex = Infinity; 536 | children.forEach((child, i) => { 537 | if (child === 'path') { 538 | lastPathIndex = i; 539 | } 540 | 541 | if (firstCircleIndex === Infinity && child === 'circle') { 542 | firstCircleIndex = i; 543 | } 544 | }); 545 | 546 | t.equal(lastPathIndex < firstCircleIndex, true, `last path was at ${lastPathIndex}, first circle was at ${firstCircleIndex}`); 547 | 548 | t.end(); 549 | }); 550 | 551 | --------------------------------------------------------------------------------