├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── loop.gif ├── package.json └── src ├── avoidOverlap.js ├── circlePath.js ├── geoToCircle.js ├── index.js ├── interpolatePath.js ├── interpolatePaths.js ├── polygonToCircle.js └── radiusScale.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | npm-debug.log -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 TWO-N, Inc. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cirque 2 | 3 | Utilities for negotiating between circles and paths in SVG. See [demo](http://projects.two-n.com/cirque/). 4 | 5 | ![Cirque](loop.gif) 6 | 7 | `npm install --save cirque` (or `yarn add cirque`) 8 | 9 | 10 | ## Functions 11 | 12 | # __interpolatePath__(_a_, _b_) 13 | 14 | Interpolates between two SVG path description strings. 15 | 16 | Expects a _circle path_ (which gets sampled) and a polygonal _chain path_ (which gets oversampled when necessary), although two circles work just by ordinary interpolation, and two chains should work in many cases as well. 17 | 18 | # __interpolatePaths__(_a_, _b_) 19 | 20 | Individually interpolates (using [interpolatePath](#interpolatePath)) between corresponding items in two parallel arrays of SVG path strings. 21 | 22 | # __circlePath__(_circle_) 23 | 24 | Converts the passed _circle object_ to an SVG path string (i.e. a _circle path_ consisting of two arc commands). 25 | 26 | # __geoToCircle__(_geometry_, [_path_], [_radius_], [_object_]) 27 | 28 | Converts the geometry object to a _circle object_ sharing its centroid. 29 | 30 | _Circle objects_ take the form: `{ x, y, r }` 31 | 32 | - _geometry_: any GeoJSON geometry or feature (required) 33 | - _path_: [geographic path generator](https://github.com/d3/d3-geo#geoPath). Defaults to bare `d3.geoPath()`, which assumes pre-projected geometry 34 | - _radius_: circle radius. Defaults to deriving radius from projected area 35 | - _object_: mutates passed existing object rather than creating a new one 36 | 37 | # __polygonToCircle__(_polygon_) 38 | 39 | Converts a polygon to a _circle object_ sharing its centroid. 40 | 41 | - _polygon_: an array of polygon vertices (as two-element arrays) (required) 42 | - _radius_: circle radius. Defaults to computing radius from polygon area 43 | - _object_: mutates passed existing object rather than creating a new one 44 | 45 | # __avoidOverlap__(_objects_, [_margin_]) 46 | 47 | Pass an array of _circle objects_ to separate colliding circles so that no overlaps remain. Mutates objects in place. Margin (minimum gap, or maximum overlap if negative) defaults to 0. (Uses `d3.forceCollide`.) 48 | 49 | # __radiusScale__(_area_, _value_) 50 | 51 | Receives total area and total value as arguments, and returns a D3 scale in which the area of a circle with the given radius corresponds to a linearly-scaled value. 52 | 53 | 54 | ## Examples 55 | ```js 56 | let render // Given a function that renders SVG paths 57 | let path // Given a geo path generator 58 | ``` 59 | 60 | ### Example: geometry 61 | ```js 62 | import { geoToCircle, circlePath, interpolatePath } from 'cirque' 63 | 64 | let geometry // Given a GeoJSON Polygon or MultiPolygon geometry 65 | 66 | const interpolator = interpolatePath( 67 | path(geometry), 68 | circlePath( geoToCircle(geometry, path) ) 69 | ) 70 | 71 | d3.transition().tween('shape', () => t => { render( interpolator(t) ) }) 72 | ``` 73 | 74 | ### Example: features 75 | 76 | ```js 77 | import * as cirque from 'cirque' 78 | 79 | let features // Given an array of GeoJSON Polygon or MultiPolygon features 80 | 81 | const scale = cirque.radiusScale( path.area(mergedFeatures), 7.5e9 ) 82 | const circles = features.map(feature => 83 | circle.geoToCircle(feature, path, scale(feature.properties['population'])) 84 | ) 85 | 86 | const separatedCircles = cirque.avoidOverlap(circles) 87 | const circlePaths = separatedCircles.map(cirque.circlePath) 88 | const interpolator = cirque.interpolatePaths(features.map(path), circlePaths) 89 | 90 | d3.transition().tween('shapes', () => t => { render( interpolator(t) ) }) 91 | ``` 92 | 93 | 94 | ## Approach 95 | 96 | - Aligns circle and path by tracing a circle using the path's commands 97 | - Splits path commands when necessary to maintain a balance between a) mapping path commands uniformly, and b) aligning per distance unit 98 | - Avoids [more sophisticated](http://spencermortensen.com/articles/bezier-circle/) circle approximation methods in favor of high-rate sampling 99 | 100 | 101 | ## Limitations 102 | 103 | The _chain path_ is a SVG path description of a polygonal chain (i.e. polyline, composite Bézier, etc.) containing any SVG path commands except arcs, support for which is planned. 104 | 105 | The _circle path_ is a SVG path description containing an M command followed by at least one A command (but typically two). `circlePath` is a utility for generating simple, compatible _circle paths_. Some more flexiblility in the format may come in the future, including (optionally) adhering to winding order. 106 | 107 | 108 | ## Rationale 109 | 110 | Just as a lack of color is physically considered _black_ (though artistically often considered _white_), a lack of shape can in a certain sense be called a _circle_ (or a n-sphere generally): no discrete segmentation, and no starting point any better than another. 111 | 112 | This shapelessness is desirable for comparing values in a controlled way (say, in a bubble map) to minimize distortion and distraction. 113 | 114 | The tools in this package amount to a method for going between precise forms such as geographic areas, and corresponding value-sized bubbles, while maintaining constancy. 115 | 116 | 117 | ## Shape morphing alternatives 118 | 119 | - [SVG.js path morphing plugin](https://github.com/svgdotjs/svg.pathmorphing.js): simple and well done. Has some of the same approach and associated limitations 120 | - [SVG Morpheus](http://alexk111.github.io/SVG-Morpheus/): works well and supports arbitrary SVG 👍 121 | - [MorphSVG](https://greensock.com/morphSVG): proprietary but seems quite customizable and the demos are awesome 122 | 123 | 124 | ## Discussion and contribution 125 | 126 | Open an [issue](https://github.com/two-n/cirque/issues/new) or [pull request](https://github.com/two-n/cirque/compare) with either high-level use cases or practical support tickets, or contact us on [twitter](https://twitter.com/2nfo). We intend to keep this package focused on its stated mission, but advice, critique, and experiences are very welcome. 127 | -------------------------------------------------------------------------------- /loop.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/two-n/cirque/82d5c3a6cfeb2a05b7b6f5cec3599f7483bda037/loop.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cirque", 3 | "version": "0.2.0", 4 | "description": "Utilities for negotiating between circles and paths in SVG", 5 | "main": "./dist/index.js", 6 | "scripts": { 7 | "test": "mocha --compilers js:babel-register", 8 | "build": "babel src -d dist", 9 | "watch": "babel -w src -d dist", 10 | "prepublish": "npm run build" 11 | }, 12 | "license": "MIT", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/two-n/cirque.git" 16 | }, 17 | "homepage": "https://github.com/two-n/cirque", 18 | "bugs": "https://github.com/two-n/cirque/issues", 19 | "devDependencies": { 20 | "babel-cli": "^6.10.1", 21 | "babel-plugin-transform-object-assign": "^6.22.0", 22 | "babel-plugin-transform-object-rest-spread": "^6.8.0", 23 | "babel-preset-es2015": "^6.9.0", 24 | "babel-register": "^6.9.0", 25 | "chai": "^3.5.0", 26 | "mocha": "^2.5.3" 27 | }, 28 | "babel": { 29 | "presets": [ 30 | "es2015" 31 | ], 32 | "plugins": [ 33 | "transform-object-assign", 34 | "transform-object-rest-spread" 35 | ] 36 | }, 37 | "dependencies": { 38 | "bezier-js": "^2.2.2", 39 | "d3-array": "^1.1.1", 40 | "d3-force": "^1.0.6", 41 | "d3-geo": "^1.6.3", 42 | "d3-interpolate": "^1.1.4", 43 | "d3-polygon": "^1.0.3", 44 | "d3-scale": "^1.0.5" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/avoidOverlap.js: -------------------------------------------------------------------------------- 1 | import { forceSimulation, forceCollide, forceX, forceY } from 'd3-force' 2 | 3 | // Returns same array with circles' x and y mutated to avoid collisions 4 | export default (circles, margin = 0) => { 5 | circles.forEach(d => { d.x0 = d.x; d.y0 = d.y }) 6 | const force = forceSimulation(circles).stop() 7 | .force('x', forceX(d => d.x0)) 8 | .force('y', forceY(d => d.y0)) 9 | .force('collide', forceCollide(d => d.r + margin)) 10 | for (let i = 0; i < 100; ++i) force.tick() 11 | circles.forEach(d => { delete d.x0; delete d.y0 }) 12 | return circles 13 | } 14 | -------------------------------------------------------------------------------- /src/circlePath.js: -------------------------------------------------------------------------------- 1 | export default ({ x, y, r, clockwise=false }) => 2 | `M${x},${y - r}A${r},${r},0,0,${Math.round(clockwise)},${x},${y + r}A${r},${r},0,0,${Math.round(clockwise)},${x},${y - r}` 3 | -------------------------------------------------------------------------------- /src/geoToCircle.js: -------------------------------------------------------------------------------- 1 | import { geoPath } from 'd3-geo' 2 | 3 | export default (geometry, path, r, object = {}) => { 4 | path = path != null ? path : geoPath() 5 | const [x, y] = path.centroid(geometry) 6 | r = r != null ? r : Math.sqrt(path.area(geometry) / Math.PI) 7 | return Object.assign(object, { x, y, r, clockwise: true }) 8 | } 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import radiusScale from './radiusScale' 2 | import geoToCircle from './geoToCircle' 3 | import polygonToCircle from './polygonToCircle' 4 | import avoidOverlap from './avoidOverlap' 5 | import circlePath from './circlePath' 6 | import interpolatePath from './interpolatePath' 7 | import interpolatePaths from './interpolatePaths' 8 | 9 | export { 10 | radiusScale, 11 | geoToCircle, 12 | polygonToCircle, 13 | avoidOverlap, 14 | circlePath, 15 | interpolatePath, 16 | interpolatePaths, 17 | } 18 | -------------------------------------------------------------------------------- /src/interpolatePath.js: -------------------------------------------------------------------------------- 1 | import { range, pairs, sum, mean, scan, descending, merge } from 'd3-array' 2 | import { interpolate } from 'd3-interpolate' 3 | import Bezier from 'bezier-js' 4 | 5 | 6 | const MISALIGNMENT_TOLERANCE = 0.15 7 | const MIN_CIRCLE_SAMPLE_DISTANCE = 1 8 | 9 | 10 | // Vector arithmetic, cycling a if shorter than b 11 | const add = (a, b) => b.map((b_i, i) => b_i + a[i % a.length]) 12 | const subtract = (a, b) => b.map((b_i, i) => b_i - a[i % a.length]) // a from b 13 | 14 | const distance = (pt1, pt2=pt1) => 15 | Math.sqrt(Math.pow(pt1[0] - pt2[0], 2) + Math.pow(pt1[1] - pt2[1], 2)) 16 | const circumference = ({ r }) => 2 * Math.PI * r 17 | const midpoint = (pt1, pt2) => [(pt1[0] + pt2[0]) / 2, (pt1[1] + pt2[1]) / 2] 18 | const computeIntervals = points => pairs(points).map(pair => distance(...pair)) 19 | 20 | 21 | const angleTowards = (pt1, pt2) => 22 | Math.atan2(pt2[1] - pt1[1], pt2[0] - pt1[0]) + Math.PI/2 23 | 24 | const project = (c, angle) => 25 | [c.x + c.r * Math.sin(angle), c.y + c.r * -Math.cos(angle)] 26 | 27 | const sampleCircle = (c, numSamples, offset = 0) => 28 | range(0, numSamples).map(i => 29 | project(c, (c.clockwise ? 1 : -1) * i/numSamples * 2*Math.PI + offset) 30 | ) 31 | 32 | 33 | const N = '[-\\d\\.e]+', // number 34 | MN = `(${N})`, // matched number 35 | S = '(?:\s+|,)', // separator 36 | OS = `${S}?`, // optional separator 37 | MOVE = [`M\\s*`,MN,S,MN].join(''), 38 | ARC = [`A\\s*`,MN,S,N,S,N,S,N,S,MN,S,N,S,N].join(''), 39 | CIRCLE = [`^\\s*`,MOVE,OS,ARC,OS,ARC,OS,'$'].join(''), 40 | circlePathPattern = new RegExp(CIRCLE, 'i'), 41 | isCircle = RegExp.prototype.test.bind(circlePathPattern) 42 | 43 | const processCircle = d => { 44 | const [x, top, r, sweep] = d.match(circlePathPattern).slice(1).map(Number), 45 | circle = { x, y: top + r, r, clockwise: !!+sweep } 46 | return { 47 | circle, 48 | numPoints: Math.ceil(circumference(circle) / MIN_CIRCLE_SAMPLE_DISTANCE), 49 | } 50 | } 51 | 52 | 53 | // Return an array of path commands in the form { type, coords } 54 | // - Use only M,L,Q,C,A — convert the rest (including relative commands) to equivalents 55 | const pathToCommands = d => { 56 | const commandIndices = [] 57 | for (let i = 0; i < d.length; i++) { 58 | if (/[MLHVCSQTAZ]/i.test(d[i])) commandIndices.push(i) 59 | } 60 | 61 | let prevPoint = [0, 0], movePoint 62 | return commandIndices.map((index, i) => { 63 | const type = d[index], 64 | absolute = /[A-Z]/.test(type), 65 | slice = d.slice(index + 1, commandIndices[i + 1]), 66 | coords = slice.split(/\s+|,/).filter(Boolean).map(Number) 67 | 68 | if (/Z/i.test(type)) return { type: 'L', coords: prevPoint = movePoint } 69 | 70 | if (/[HV]/i.test(type)) { 71 | const vertical = /V/i.test(type) 72 | return { 73 | type: 'L', 74 | coords: prevPoint = [ 75 | !vertical ? coords[0] + (absolute ? 0 : prevPoint[0]) : prevPoint[0], 76 | vertical ? coords[0] + (absolute ? 0 : prevPoint[1]) : prevPoint[1], 77 | ], 78 | } 79 | } 80 | 81 | if (/[TS]/i.test(type)) { 82 | const { type: prevType, coords: prevCoords } = commands[i - 1] 83 | const controlPoint = /T,Q|S,C/i.test([ type, prevType ]) 84 | ? add(prevPoint, subtract(prevCoords.slice(-4, -2), prevPoint)) 85 | : prevPoint 86 | 87 | const newCoords = absolute ? coords : add(prevPoint, coords) 88 | prevPoint = newCoords.slice(-2) 89 | return { 90 | type: /T/.test(type) ? 'Q' : 'C', 91 | coords: [...controlPoint, newCoords], 92 | } 93 | } 94 | 95 | if (/A/i.test(type)) { 96 | const point = coords.slice(-2) 97 | return { 98 | type: 'A', 99 | coords: [ 100 | ...coords.slice(0, -2), 101 | ...(prevPoint = (absolute ? point : add(prevPoint, point))), 102 | ], 103 | } 104 | } 105 | 106 | const newCoords = absolute ? coords : add(prevPoint, coords) 107 | prevPoint = newCoords.slice(-2) 108 | if (/M/i.test(type)) { movePoint = prevPoint } 109 | return { type: type.toUpperCase(), coords: newCoords } 110 | }) 111 | } 112 | 113 | 114 | // Return processed path segments (each beginning with a move command) 115 | const processPath = d => { 116 | const commands = pathToCommands(d) 117 | 118 | const shapes = [] 119 | let currShape 120 | commands.forEach(command => { 121 | if (command.type === 'M') shapes.push(currShape = []) 122 | currShape.push(command) 123 | }) 124 | 125 | return shapes.map(commands => { 126 | const points = commands.map(({ coords, point }) => coords.slice(-2)) 127 | const centroid = [mean(points.map(d => d[0])), mean(points.map(d => d[1]))] 128 | 129 | return { 130 | commands, 131 | numPoints: commands.length, 132 | length: sum([0, ...computeIntervals(points)]), 133 | offset: angleTowards(centroid, midpoint(points[0], points[points.length - 1])) 134 | } 135 | }).sort((a, b) => descending(a.length, b.length)) 136 | } 137 | 138 | 139 | const shapeIterator = (d, numPoints) => { 140 | if (d.circle != null) { 141 | const points = sampleCircle(d.circle, numPoints - 1, d.offset) 142 | points.push(points[0]) 143 | return { 144 | commands: points.map(coords => ({ coords })), // Type not yet known 145 | length: distance(points[0], points[1]) * (numPoints - 1), 146 | progress: 0, 147 | } 148 | } 149 | return { commands: [...d.commands], length: d.length, progress: 0 } 150 | } 151 | 152 | 153 | // Returns a copy of the original command with size zero 154 | // const dummyCommand = (original, prevPoint) => { 155 | // const { type } = original 156 | // if (/Z/i.test(type)) return { type } 157 | 158 | // const coords = /[A-Z]/.test(type) ? prevPoint : [0, 0] 159 | // if (/M|L|T/i.test(type)) return { type, coords } 160 | // if (/H/i.test(type)) return { type, coords: coords.slice(0, 1) } 161 | // if (/V/i.test(type)) return { type, coords: coords.slice(1, 2) } 162 | // if (/Q|S/i.test(type)) return { type, coords: [...coords, ...coords] } 163 | // if (/C/i.test(type)) return { type, coords: [...coords, ...coords, ...coords] } 164 | // if (/A/i.test(type)) return { type, coords: [0, 0, ...original.coords.slice(2, 5), ...coords] } 165 | // } 166 | 167 | 168 | const splitCommand = ({ type, coords }, numPieces, prevPoint) => { 169 | if (/[ML]/.test(type)) { 170 | const piece = [0, 1].map(i => (coords[i] - prevPoint[i]) / numPieces) 171 | return range(1, numPieces + 1).map(i => ({ 172 | type, 173 | coords: [prevPoint[0] + i * piece[0], prevPoint[1] + i * piece[1]], 174 | })) 175 | } 176 | if (/[CQ]/.test(type)) { 177 | return range(0, numPieces - 1).reduce((pieces, i) => { 178 | const split = pieces.pop().split( (1 / numPieces) / (1 - (i / numPieces)) ) 179 | pieces.push(split.left, split.right) 180 | return pieces 181 | }, [ new Bezier(...prevPoint, ...coords) ]) 182 | .map(({ points }) => ( 183 | { type, coords: merge(points.slice(1).map(d => [d.x, d.y])) } 184 | )) 185 | } 186 | if (/A/.test(type)) { 187 | console.error('Path interpolation arc segments not yet supported') 188 | } 189 | } 190 | 191 | 192 | const resplitCommand = (d, i) => { 193 | if (d.prevSplitCommandPiece != d.commands[i - 1]) { 194 | d.completeSplitCommand = d.commands[i - 1] 195 | d.numSplitPieces = 1 196 | } 197 | d.numSplitPieces += 1 198 | const commands = splitCommand( 199 | d.completeSplitCommand, 200 | d.numSplitPieces, 201 | d.commands[i - d.numSplitPieces].coords.slice(-2) 202 | ) 203 | d.prevSplitCommandPiece = commands[commands.length - 1] 204 | 205 | d.commands.splice(i - commands.length + 1, commands.length - 1, ...commands) 206 | d.progress -= lastInterval(commands) / d.length 207 | } 208 | 209 | 210 | // Generates shortest command of given type 211 | const generateCommand = (type, coords, prevPoint) => { 212 | if (/[MLT]/.test(type)) return { type, coords } 213 | if (/Q/.test(type)) { 214 | const controlPoint = midpoint(prevPoint, coords) 215 | return { type, coords: [...controlPoint, ...coords] } 216 | } 217 | if (/C/.test(type)) { 218 | const controlPoint = midpoint(prevPoint, coords) 219 | return { type, coords: [...controlPoint, ...controlPoint, ...coords] } 220 | } 221 | if (/A/.test(type)) { 222 | const r = MIN_CIRCLE_SAMPLE_DISTANCE * 100 223 | // TODO need to copy the two flags. Interpolated flags are invalid 224 | console.error('Path interpolation arc segments not yet supported') 225 | return { type, coords: [r, r, 0, 0, 0, ...coords] } 226 | } 227 | } 228 | 229 | 230 | const lastInterval = commands => 231 | distance(...commands.slice(-2).map(({ coords }) => coords.slice(-2))) 232 | 233 | const nextProgress = (d, i) => 234 | d.progress + lastInterval(d.commands.slice(Math.max(0, i - 1), i + 1)) / d.length 235 | 236 | const inFront = (A, B, i) => { 237 | if (A.commands[i] == null) return A 238 | if (B.commands[i] == null) return B 239 | const nextProgressA = nextProgress(A, i) 240 | const nextProgressB = nextProgress(B, i) 241 | if (Math.abs(nextProgressA - nextProgressB) < 1e-6) return null 242 | return nextProgressA >= nextProgressB ? A : B 243 | } 244 | 245 | // Returns overloaded value: 246 | // - which side has missing type, or 247 | // - if neither, then whether types are alike 248 | const typeMismatch = (A, B, i) => { 249 | const aType = A.commands[i] && A.commands[i].type, 250 | bType = B.commands[i] && B.commands[i].type 251 | return aType == null ? A : bType == null ? B : aType !== bType; 252 | } 253 | 254 | 255 | // const alignCommand = (A, B, i) => { 256 | // contents of loop 257 | // } 258 | 259 | const alignShapes = (a, b) => { 260 | const numPoints = Math.max(a.numPoints, b.numPoints) 261 | const A = shapeIterator(a, numPoints), B = shapeIterator(b, numPoints) 262 | 263 | // Iterate in parallel and align command sequence 264 | for (let i = 0; i < Math.max(A.commands.length, B.commands.length); i++) { 265 | 266 | const front = inFront(A, B, i), back = A === front ? B : A 267 | 268 | // Is the next command aligned? 269 | const mismatch = typeMismatch(A, B, i) 270 | switch (mismatch) { 271 | case true: 272 | // Is progress getting too uneven? 273 | if (Math.abs(nextProgress(A, i) - nextProgress(B, i)) > MISALIGNMENT_TOLERANCE) { 274 | // TODO have frontrunner pause by inserting zero-size command? 275 | // Or better yet: have ahead split the last command, if possible 276 | } 277 | break; 278 | 279 | case false: 280 | // TODO if last command type === behind's current command type, 281 | // then frontrunner could split the last command 282 | // else insert a zero-size command (dummy)? 283 | // (same as above) 284 | break; 285 | 286 | case A: // A is missing type 287 | case B: // B is missing type 288 | if (!front || mismatch === front || /M/.test(front.commands[i].type) || /M/.test(front.commands[i - 1].type)) { // Is the side with missing type ahead? 289 | // assign it the other's command type 290 | const other = mismatch === A ? B : A 291 | if (mismatch.commands[i] != null) { 292 | mismatch.commands[i] = generateCommand( 293 | other.commands[i].type, 294 | mismatch.commands[i].coords.slice(-2), 295 | i > 0 && mismatch.commands[i - 1].coords.slice(-2) 296 | ) 297 | } 298 | else { 299 | resplitCommand(mismatch, i) 300 | } 301 | } 302 | else { 303 | // // Assign wildcard an 'L' and insert a zero-size 'L' on the other side 304 | // const command = dummyCommand({ type: 'L' }, front.points[i - 1]) 305 | // front.commands.splice(i, 0, command) 306 | // front.points.splice(i, 0, front.points[i - 1]) 307 | // back.commands[i] = generateCommand('L', back.points[i], back.points[i - 1]) 308 | 309 | resplitCommand(front, i) 310 | 311 | back.commands[i] = generateCommand( 312 | front.commands[i - 1].type, 313 | back.commands[i].coords.slice(-2), 314 | i > 0 && back.commands[i - 1].coords.slice(-2) 315 | ) 316 | } 317 | break; 318 | } 319 | A.progress = nextProgress(A, i) 320 | B.progress = nextProgress(B, i) 321 | } 322 | return [A.commands, B.commands] 323 | } 324 | 325 | 326 | const commandsToPath = commands => commands.map(c => `${c.type}${c.coords || ''}`).join('') 327 | 328 | export default (a, b) => { 329 | if (a === b) return () => b 330 | 331 | let interpolatePath 332 | const shapeIsCircle = [a, b].map(isCircle) 333 | if (shapeIsCircle[0] && shapeIsCircle[1]) { 334 | // If both are circle paths, skip sampling and use ordinary interpolation 335 | interpolatePath = interpolate(a, b) 336 | } 337 | else { 338 | const shapeSegments = [a, b].map((d, i) => (shapeIsCircle[i] ? processCircle : processPath)(d)) 339 | 340 | const numSegments = Math.min(...shapeSegments.map((segments, i) => { 341 | return shapeIsCircle[i] ? Infinity : segments.length 342 | })) 343 | const alignedSegments = shapeSegments.map((d, i) => { 344 | return shapeIsCircle[i] 345 | ? [ d, ...range(1, numSegments).map(() => ({ circle: { ...d.circle, r: 0 }, numPoints: 1 })) ] 346 | : d.slice(0, numSegments) 347 | }) 348 | 349 | const pathA = [], pathB = [] 350 | alignedSegments[0].forEach((segmentA, i) => { 351 | const segmentB = alignedSegments[1][i] 352 | 353 | segmentA.offset == null && (segmentA.offset = segmentB.offset) 354 | segmentB.offset == null && (segmentB.offset = segmentA.offset) 355 | 356 | const alignedShapes = 357 | shapeIsCircle[0] 358 | ? alignShapes(segmentA, segmentB) 359 | : alignShapes(segmentB, segmentA).reverse() // Putting circle first seems to work better 360 | pathA.push(commandsToPath(alignedShapes[0])) 361 | pathB.push(commandsToPath(alignedShapes[1])) 362 | }) 363 | 364 | interpolatePath = interpolate(pathA.join(''), pathB.join('')) 365 | } 366 | return t => t === 0 ? a : t === 1 ? b : interpolatePath(t) 367 | } 368 | -------------------------------------------------------------------------------- /src/interpolatePaths.js: -------------------------------------------------------------------------------- 1 | import interpolatePath from './interpolatePath' 2 | 3 | export default (a, b) => { 4 | const interpolators = b.map( (b_i, i) => interpolatePath(a[i], b_i) ) 5 | return t => b.map( (b_i, i) => interpolators[i](t) ) 6 | } 7 | -------------------------------------------------------------------------------- /src/polygonToCircle.js: -------------------------------------------------------------------------------- 1 | import { polygonCentroid, polygonArea } from 'd3-polygon' 2 | 3 | export default (polygon, r, object = {}) => { 4 | const [x, y] = polygonCentroid(polygon) 5 | r = r != null ? r : Math.sqrt(polygonArea(polygon) / Math.PI) 6 | return Object.assign(object, { x, y, r }) 7 | } 8 | -------------------------------------------------------------------------------- /src/radiusScale.js: -------------------------------------------------------------------------------- 1 | import { scaleSqrt } from 'd3-scale' 2 | 3 | export default (totalArea, totalValue) => 4 | scaleSqrt() 5 | .domain([0, totalValue]) 6 | .range([0, Math.sqrt(totalArea / Math.PI)]) 7 | --------------------------------------------------------------------------------