├── .gitignore ├── LICENSE ├── README.md ├── bower.json ├── dist └── leaflet.polylineDecorator.js ├── example ├── example.html ├── example.js └── icon_plane.png ├── package.json ├── rollup.config.js ├── screenshot.png ├── src ├── L.PolylineDecorator.js ├── L.Symbol.js └── patternUtils.js └── tests ├── projectPatternOnPointPath.js └── utils.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2013 Benjamin Becquet 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Leaflet PolylineDecorator 2 | 3 | [![CDNJS](https://img.shields.io/cdnjs/v/leaflet-polylinedecorator.svg)](https://cdnjs.com/libraries/leaflet-polylinedecorator) 4 | 5 | A Leaflet plug-in to define and draw patterns on existing Polylines or along coordinate paths. 6 | [Demo](http://bbecquet.github.io/Leaflet.PolylineDecorator/example/example.html). 7 | 8 | ## Compatibility with Leaflet versions 9 | 10 | **The current version of the plugin (on the `master` branch) works only with versions 1.\* of Leaflet**. 11 | 12 | For a version of the plugin compatible with the older 0.7.* Leaflet releases, use the `leaflet-0.7.2` branch. But this branch is not maintained anymore and Leaflet 1.* has been around for a while, so you should definitely update. 13 | 14 | ## npm / bower 15 | 16 | ``` 17 | npm install leaflet-polylinedecorator 18 | ``` 19 | 20 | ``` 21 | bower install leaflet-polylinedecorator 22 | ``` 23 | 24 | ## Features 25 | 26 | * Dashed or dotted lines, arrow heads, markers following line 27 | * Works on Polygons too! (easy, as Polygon extends Polyline) 28 | * Multiple patterns can be applied to the same line 29 | * New behaviors can be obtained by defining new symbols 30 | 31 | ## Screenshot 32 | 33 | ![screenshot](https://raw.github.com/bbecquet/Leaflet.PolylineDecorator/master/screenshot.png "Screenshot showing different applications of the library") 34 | 35 | ## Usage 36 | 37 | To create a decorator and add it to the map: `L.polylineDecorator(latlngs, options).addTo(map);` 38 | 39 | * `latlngs` can be one of the following types: 40 | 41 | * `L.Polyline` 42 | * `L.Polygon` 43 | * an array of `L.LatLng`, or with Leaflet's simplified syntax, an array of 2-cells arrays of coordinates (useful if you just want to draw patterns following coordinates, but not the line itself) 44 | * an array of any of these previous types, to apply the same patterns to multiple lines 45 | 46 | * `options` has a single property `patterns`, which is an array of `Pattern` objects. 47 | 48 | ### `Pattern` definition 49 | 50 | Property | Type | Required | Description 51 | --- | --- | --- | --- 52 | `offset`| *see below* | No | Offset of the first pattern symbol, from the start point of the line. Default: 0. 53 | `endOffset`| *see below* | No | Minimum offset of the last pattern symbol, from the end point of the line. Default: 0. 54 | `repeat`| *see below* | Yes | Repetition interval of the pattern symbols. Defines the distance between each consecutive symbol's anchor point. 55 | `symbol`| Symbol factory | Yes | Instance of a symbol factory class. 56 | 57 | `offset`, `endOffset` and `repeat` can be each defined as a number, in pixels, or in percentage of the line's length, as a string (ex: `'10%'`). 58 | 59 | ### Methods 60 | 61 | Method | Description 62 | --- | --- 63 | `setPaths(latlngs)` | Changes the path(s) the decorator applies to. `latlngs` can be all the types supported by the constructor. Useful for example if you remove polyline from a set, or coordinates change. 64 | `setPatterns( patterns)` | Changes the decorator's pattern definitions, and update the symbols accordingly. 65 | 66 | ## Example 67 | 68 | ```javascript 69 | var polyline = L.polyline([...]).addTo(map); 70 | var decorator = L.polylineDecorator(polyline, { 71 | patterns: [ 72 | // defines a pattern of 10px-wide dashes, repeated every 20px on the line 73 | {offset: 0, repeat: 20, symbol: L.Symbol.dash({pixelSize: 10})} 74 | ] 75 | }).addTo(map); 76 | ``` 77 | 78 | ## Performance note/alternatives 79 | 80 | This plugin creates actual `L.Layer` objects (markers, polyline, etc.) to draw the pattern symbols. This is extra customizable as you can define your own symbols, but it may have an impact on the responsiveness of your map if you have to draw a lot of symbols on many large polylines. 81 | 82 | Here are two light-weight alternatives for simpler cases: 83 | - the [`dashArray` property of `L.Path`](http://leafletjs.com/reference-1.1.0.html#path-dasharray), if you only need to draw simple patterns (dashes, dots, etc.). 84 | - the [`Leaflet.TextPath`](https://github.com/makinacorpus/Leaflet.TextPath) plugin, which is based on SVG. 85 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "leaflet-polylinedecorator", 3 | "main": "dist/leaflet.polylineDecorator.js", 4 | "version": "1.6.0", 5 | "authors": [ 6 | "Benjamin Becquet" 7 | ], 8 | "description": "A plug-in for the JS map library Leaflet, allowing to define patterns (like dashes, arrows, icons, etc.) on Polylines.", 9 | "keywords": [ 10 | "Leaflet", 11 | "Leaflet PolylineDecorator" 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "git@github.com:bbecquet/Leaflet.PolylineDecorator.git" 16 | }, 17 | "license": "MIT", 18 | "ignore": [ 19 | "**/.*", 20 | "node_modules", 21 | "bower_components", 22 | "test", 23 | "tests" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /dist/leaflet.polylineDecorator.js: -------------------------------------------------------------------------------- 1 | (function (global, factory) { 2 | typeof exports === 'object' && typeof module !== 'undefined' ? factory(require('leaflet')) : 3 | typeof define === 'function' && define.amd ? define(['leaflet'], factory) : 4 | (factory(global.L)); 5 | }(this, (function (L$1) { 'use strict'; 6 | 7 | L$1 = L$1 && L$1.hasOwnProperty('default') ? L$1['default'] : L$1; 8 | 9 | // functional re-impl of L.Point.distanceTo, 10 | // with no dependency on Leaflet for easier testing 11 | function pointDistance(ptA, ptB) { 12 | var x = ptB.x - ptA.x; 13 | var y = ptB.y - ptA.y; 14 | return Math.sqrt(x * x + y * y); 15 | } 16 | 17 | var computeSegmentHeading = function computeSegmentHeading(a, b) { 18 | return (Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI + 90 + 360) % 360; 19 | }; 20 | 21 | var asRatioToPathLength = function asRatioToPathLength(_ref, totalPathLength) { 22 | var value = _ref.value, 23 | isInPixels = _ref.isInPixels; 24 | return isInPixels ? value / totalPathLength : value; 25 | }; 26 | 27 | function parseRelativeOrAbsoluteValue(value) { 28 | if (typeof value === 'string' && value.indexOf('%') !== -1) { 29 | return { 30 | value: parseFloat(value) / 100, 31 | isInPixels: false 32 | }; 33 | } 34 | var parsedValue = value ? parseFloat(value) : 0; 35 | return { 36 | value: parsedValue, 37 | isInPixels: parsedValue > 0 38 | }; 39 | } 40 | 41 | var pointsEqual = function pointsEqual(a, b) { 42 | return a.x === b.x && a.y === b.y; 43 | }; 44 | 45 | function pointsToSegments(pts) { 46 | return pts.reduce(function (segments, b, idx, points) { 47 | // this test skips same adjacent points 48 | if (idx > 0 && !pointsEqual(b, points[idx - 1])) { 49 | var a = points[idx - 1]; 50 | var distA = segments.length > 0 ? segments[segments.length - 1].distB : 0; 51 | var distAB = pointDistance(a, b); 52 | segments.push({ 53 | a: a, 54 | b: b, 55 | distA: distA, 56 | distB: distA + distAB, 57 | heading: computeSegmentHeading(a, b) 58 | }); 59 | } 60 | return segments; 61 | }, []); 62 | } 63 | 64 | function projectPatternOnPointPath(pts, pattern) { 65 | // 1. split the path into segment infos 66 | var segments = pointsToSegments(pts); 67 | var nbSegments = segments.length; 68 | if (nbSegments === 0) { 69 | return []; 70 | } 71 | 72 | var totalPathLength = segments[nbSegments - 1].distB; 73 | 74 | var offset = asRatioToPathLength(pattern.offset, totalPathLength); 75 | var endOffset = asRatioToPathLength(pattern.endOffset, totalPathLength); 76 | var repeat = asRatioToPathLength(pattern.repeat, totalPathLength); 77 | 78 | var repeatIntervalPixels = totalPathLength * repeat; 79 | var startOffsetPixels = offset > 0 ? totalPathLength * offset : 0; 80 | var endOffsetPixels = endOffset > 0 ? totalPathLength * endOffset : 0; 81 | 82 | // 2. generate the positions of the pattern as offsets from the path start 83 | var positionOffsets = []; 84 | var positionOffset = startOffsetPixels; 85 | do { 86 | positionOffsets.push(positionOffset); 87 | positionOffset += repeatIntervalPixels; 88 | } while (repeatIntervalPixels > 0 && positionOffset < totalPathLength - endOffsetPixels); 89 | 90 | // 3. projects offsets to segments 91 | var segmentIndex = 0; 92 | var segment = segments[0]; 93 | return positionOffsets.map(function (positionOffset) { 94 | // find the segment matching the offset, 95 | // starting from the previous one as offsets are ordered 96 | while (positionOffset > segment.distB && segmentIndex < nbSegments - 1) { 97 | segmentIndex++; 98 | segment = segments[segmentIndex]; 99 | } 100 | 101 | var segmentRatio = (positionOffset - segment.distA) / (segment.distB - segment.distA); 102 | return { 103 | pt: interpolateBetweenPoints(segment.a, segment.b, segmentRatio), 104 | heading: segment.heading 105 | }; 106 | }); 107 | } 108 | 109 | /** 110 | * Finds the point which lies on the segment defined by points A and B, 111 | * at the given ratio of the distance from A to B, by linear interpolation. 112 | */ 113 | function interpolateBetweenPoints(ptA, ptB, ratio) { 114 | if (ptB.x !== ptA.x) { 115 | return { 116 | x: ptA.x + ratio * (ptB.x - ptA.x), 117 | y: ptA.y + ratio * (ptB.y - ptA.y) 118 | }; 119 | } 120 | // special case where points lie on the same vertical axis 121 | return { 122 | x: ptA.x, 123 | y: ptA.y + (ptB.y - ptA.y) * ratio 124 | }; 125 | } 126 | 127 | (function() { 128 | // save these original methods before they are overwritten 129 | var proto_initIcon = L.Marker.prototype._initIcon; 130 | var proto_setPos = L.Marker.prototype._setPos; 131 | 132 | var oldIE = (L.DomUtil.TRANSFORM === 'msTransform'); 133 | 134 | L.Marker.addInitHook(function () { 135 | var iconOptions = this.options.icon && this.options.icon.options; 136 | var iconAnchor = iconOptions && this.options.icon.options.iconAnchor; 137 | if (iconAnchor) { 138 | iconAnchor = (iconAnchor[0] + 'px ' + iconAnchor[1] + 'px'); 139 | } 140 | this.options.rotationOrigin = this.options.rotationOrigin || iconAnchor || 'center bottom' ; 141 | this.options.rotationAngle = this.options.rotationAngle || 0; 142 | 143 | // Ensure marker keeps rotated during dragging 144 | this.on('drag', function(e) { e.target._applyRotation(); }); 145 | }); 146 | 147 | L.Marker.include({ 148 | _initIcon: function() { 149 | proto_initIcon.call(this); 150 | }, 151 | 152 | _setPos: function (pos) { 153 | proto_setPos.call(this, pos); 154 | this._applyRotation(); 155 | }, 156 | 157 | _applyRotation: function () { 158 | if(this.options.rotationAngle) { 159 | this._icon.style[L.DomUtil.TRANSFORM+'Origin'] = this.options.rotationOrigin; 160 | 161 | if(oldIE) { 162 | // for IE 9, use the 2D rotation 163 | this._icon.style[L.DomUtil.TRANSFORM] = 'rotate(' + this.options.rotationAngle + 'deg)'; 164 | } else { 165 | // for modern browsers, prefer the 3D accelerated version 166 | this._icon.style[L.DomUtil.TRANSFORM] += ' rotateZ(' + this.options.rotationAngle + 'deg)'; 167 | } 168 | } 169 | }, 170 | 171 | setRotationAngle: function(angle) { 172 | this.options.rotationAngle = angle; 173 | this.update(); 174 | return this; 175 | }, 176 | 177 | setRotationOrigin: function(origin) { 178 | this.options.rotationOrigin = origin; 179 | this.update(); 180 | return this; 181 | } 182 | }); 183 | })(); 184 | 185 | L$1.Symbol = L$1.Symbol || {}; 186 | 187 | /** 188 | * A simple dash symbol, drawn as a Polyline. 189 | * Can also be used for dots, if 'pixelSize' option is given the 0 value. 190 | */ 191 | L$1.Symbol.Dash = L$1.Class.extend({ 192 | options: { 193 | pixelSize: 10, 194 | pathOptions: {} 195 | }, 196 | 197 | initialize: function initialize(options) { 198 | L$1.Util.setOptions(this, options); 199 | this.options.pathOptions.clickable = false; 200 | }, 201 | 202 | buildSymbol: function buildSymbol(dirPoint, latLngs, map, index, total) { 203 | var opts = this.options; 204 | var d2r = Math.PI / 180; 205 | 206 | // for a dot, nothing more to compute 207 | if (opts.pixelSize <= 1) { 208 | return L$1.polyline([dirPoint.latLng, dirPoint.latLng], opts.pathOptions); 209 | } 210 | 211 | var midPoint = map.project(dirPoint.latLng); 212 | var angle = -(dirPoint.heading - 90) * d2r; 213 | var a = L$1.point(midPoint.x + opts.pixelSize * Math.cos(angle + Math.PI) / 2, midPoint.y + opts.pixelSize * Math.sin(angle) / 2); 214 | // compute second point by central symmetry to avoid unecessary cos/sin 215 | var b = midPoint.add(midPoint.subtract(a)); 216 | return L$1.polyline([map.unproject(a), map.unproject(b)], opts.pathOptions); 217 | } 218 | }); 219 | 220 | L$1.Symbol.dash = function (options) { 221 | return new L$1.Symbol.Dash(options); 222 | }; 223 | 224 | L$1.Symbol.ArrowHead = L$1.Class.extend({ 225 | options: { 226 | polygon: true, 227 | pixelSize: 10, 228 | headAngle: 60, 229 | pathOptions: { 230 | stroke: false, 231 | weight: 2 232 | } 233 | }, 234 | 235 | initialize: function initialize(options) { 236 | L$1.Util.setOptions(this, options); 237 | this.options.pathOptions.clickable = false; 238 | }, 239 | 240 | buildSymbol: function buildSymbol(dirPoint, latLngs, map, index, total) { 241 | return this.options.polygon ? L$1.polygon(this._buildArrowPath(dirPoint, map), this.options.pathOptions) : L$1.polyline(this._buildArrowPath(dirPoint, map), this.options.pathOptions); 242 | }, 243 | 244 | _buildArrowPath: function _buildArrowPath(dirPoint, map) { 245 | var d2r = Math.PI / 180; 246 | var tipPoint = map.project(dirPoint.latLng); 247 | var direction = -(dirPoint.heading - 90) * d2r; 248 | var radianArrowAngle = this.options.headAngle / 2 * d2r; 249 | 250 | var headAngle1 = direction + radianArrowAngle; 251 | var headAngle2 = direction - radianArrowAngle; 252 | var arrowHead1 = L$1.point(tipPoint.x - this.options.pixelSize * Math.cos(headAngle1), tipPoint.y + this.options.pixelSize * Math.sin(headAngle1)); 253 | var arrowHead2 = L$1.point(tipPoint.x - this.options.pixelSize * Math.cos(headAngle2), tipPoint.y + this.options.pixelSize * Math.sin(headAngle2)); 254 | 255 | return [map.unproject(arrowHead1), dirPoint.latLng, map.unproject(arrowHead2)]; 256 | } 257 | }); 258 | 259 | L$1.Symbol.arrowHead = function (options) { 260 | return new L$1.Symbol.ArrowHead(options); 261 | }; 262 | 263 | L$1.Symbol.Marker = L$1.Class.extend({ 264 | options: { 265 | markerOptions: {}, 266 | rotate: false 267 | }, 268 | 269 | initialize: function initialize(options) { 270 | L$1.Util.setOptions(this, options); 271 | this.options.markerOptions.clickable = false; 272 | this.options.markerOptions.draggable = false; 273 | }, 274 | 275 | buildSymbol: function buildSymbol(directionPoint, latLngs, map, index, total) { 276 | if (this.options.rotate) { 277 | this.options.markerOptions.rotationAngle = directionPoint.heading + (this.options.angleCorrection || 0); 278 | } 279 | return L$1.marker(directionPoint.latLng, this.options.markerOptions); 280 | } 281 | }); 282 | 283 | L$1.Symbol.marker = function (options) { 284 | return new L$1.Symbol.Marker(options); 285 | }; 286 | 287 | var isCoord = function isCoord(c) { 288 | return c instanceof L$1.LatLng || Array.isArray(c) && c.length === 2 && typeof c[0] === 'number'; 289 | }; 290 | 291 | var isCoordArray = function isCoordArray(ll) { 292 | return Array.isArray(ll) && isCoord(ll[0]); 293 | }; 294 | 295 | L$1.PolylineDecorator = L$1.FeatureGroup.extend({ 296 | options: { 297 | patterns: [] 298 | }, 299 | 300 | initialize: function initialize(paths, options) { 301 | L$1.FeatureGroup.prototype.initialize.call(this); 302 | L$1.Util.setOptions(this, options); 303 | this._map = null; 304 | this._paths = this._initPaths(paths); 305 | this._bounds = this._initBounds(); 306 | this._patterns = this._initPatterns(this.options.patterns); 307 | }, 308 | 309 | /** 310 | * Deals with all the different cases. input can be one of these types: 311 | * array of LatLng, array of 2-number arrays, Polyline, Polygon, 312 | * array of one of the previous. 313 | */ 314 | _initPaths: function _initPaths(input, isPolygon) { 315 | var _this = this; 316 | 317 | if (isCoordArray(input)) { 318 | // Leaflet Polygons don't need the first point to be repeated, but we do 319 | var coords = isPolygon ? input.concat([input[0]]) : input; 320 | return [coords]; 321 | } 322 | if (input instanceof L$1.Polyline) { 323 | // we need some recursivity to support multi-poly* 324 | return this._initPaths(input.getLatLngs(), input instanceof L$1.Polygon); 325 | } 326 | if (Array.isArray(input)) { 327 | // flatten everything, we just need coordinate lists to apply patterns 328 | return input.reduce(function (flatArray, p) { 329 | return flatArray.concat(_this._initPaths(p, isPolygon)); 330 | }, []); 331 | } 332 | return []; 333 | }, 334 | 335 | // parse pattern definitions and precompute some values 336 | _initPatterns: function _initPatterns(patternDefs) { 337 | return patternDefs.map(this._parsePatternDef); 338 | }, 339 | 340 | /** 341 | * Changes the patterns used by this decorator 342 | * and redraws the new one. 343 | */ 344 | setPatterns: function setPatterns(patterns) { 345 | this.options.patterns = patterns; 346 | this._patterns = this._initPatterns(this.options.patterns); 347 | this.redraw(); 348 | }, 349 | 350 | /** 351 | * Changes the patterns used by this decorator 352 | * and redraws the new one. 353 | */ 354 | setPaths: function setPaths(paths) { 355 | this._paths = this._initPaths(paths); 356 | this._bounds = this._initBounds(); 357 | this.redraw(); 358 | }, 359 | 360 | /** 361 | * Parse the pattern definition 362 | */ 363 | _parsePatternDef: function _parsePatternDef(patternDef, latLngs) { 364 | return { 365 | symbolFactory: patternDef.symbol, 366 | // Parse offset and repeat values, managing the two cases: 367 | // absolute (in pixels) or relative (in percentage of the polyline length) 368 | offset: parseRelativeOrAbsoluteValue(patternDef.offset), 369 | endOffset: parseRelativeOrAbsoluteValue(patternDef.endOffset), 370 | repeat: parseRelativeOrAbsoluteValue(patternDef.repeat) 371 | }; 372 | }, 373 | 374 | onAdd: function onAdd(map) { 375 | this._map = map; 376 | this._draw(); 377 | this._map.on('moveend', this.redraw, this); 378 | }, 379 | 380 | onRemove: function onRemove(map) { 381 | this._map.off('moveend', this.redraw, this); 382 | this._map = null; 383 | L$1.FeatureGroup.prototype.onRemove.call(this, map); 384 | }, 385 | 386 | /** 387 | * As real pattern bounds depends on map zoom and bounds, 388 | * we just compute the total bounds of all paths decorated by this instance. 389 | */ 390 | _initBounds: function _initBounds() { 391 | var allPathCoords = this._paths.reduce(function (acc, path) { 392 | return acc.concat(path); 393 | }, []); 394 | return L$1.latLngBounds(allPathCoords); 395 | }, 396 | 397 | getBounds: function getBounds() { 398 | return this._bounds; 399 | }, 400 | 401 | /** 402 | * Returns an array of ILayers object 403 | */ 404 | _buildSymbols: function _buildSymbols(latLngs, symbolFactory, directionPoints) { 405 | var _this2 = this; 406 | 407 | return directionPoints.map(function (directionPoint, i) { 408 | return symbolFactory.buildSymbol(directionPoint, latLngs, _this2._map, i, directionPoints.length); 409 | }); 410 | }, 411 | 412 | /** 413 | * Compute pairs of LatLng and heading angle, 414 | * that define positions and directions of the symbols on the path 415 | */ 416 | _getDirectionPoints: function _getDirectionPoints(latLngs, pattern) { 417 | var _this3 = this; 418 | 419 | if (latLngs.length < 2) { 420 | return []; 421 | } 422 | var pathAsPoints = latLngs.map(function (latLng) { 423 | return _this3._map.project(latLng); 424 | }); 425 | return projectPatternOnPointPath(pathAsPoints, pattern).map(function (point) { 426 | return { 427 | latLng: _this3._map.unproject(L$1.point(point.pt)), 428 | heading: point.heading 429 | }; 430 | }); 431 | }, 432 | 433 | redraw: function redraw() { 434 | if (!this._map) { 435 | return; 436 | } 437 | this.clearLayers(); 438 | this._draw(); 439 | }, 440 | 441 | /** 442 | * Returns all symbols for a given pattern as an array of FeatureGroup 443 | */ 444 | _getPatternLayers: function _getPatternLayers(pattern) { 445 | var _this4 = this; 446 | 447 | var mapBounds = this._map.getBounds().pad(0.1); 448 | return this._paths.map(function (path) { 449 | var directionPoints = _this4._getDirectionPoints(path, pattern) 450 | // filter out invisible points 451 | .filter(function (point) { 452 | return mapBounds.contains(point.latLng); 453 | }); 454 | return L$1.featureGroup(_this4._buildSymbols(path, pattern.symbolFactory, directionPoints)); 455 | }); 456 | }, 457 | 458 | /** 459 | * Draw all patterns 460 | */ 461 | _draw: function _draw() { 462 | var _this5 = this; 463 | 464 | this._patterns.map(function (pattern) { 465 | return _this5._getPatternLayers(pattern); 466 | }).forEach(function (layers) { 467 | _this5.addLayer(L$1.featureGroup(layers)); 468 | }); 469 | } 470 | }); 471 | /* 472 | * Allows compact syntax to be used 473 | */ 474 | L$1.polylineDecorator = function (paths, options) { 475 | return new L$1.PolylineDecorator(paths, options); 476 | }; 477 | 478 | }))); 479 | -------------------------------------------------------------------------------- /example/example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Leaflet Polyline Decorator example 8 | 9 | 10 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /example/example.js: -------------------------------------------------------------------------------- 1 |  2 | function init() { 3 | var map = L.map('map', { 4 | center: [52.0, -11.0], 5 | zoom: 5, 6 | layers: [ 7 | L.tileLayer('http://{s}.tile.osm.org/{z}/{x}/{y}.png', { 8 | attribution: '© OpenStreetMap contributors' 9 | }) 10 | ] 11 | }); 12 | 13 | // --- Simple arrow --- 14 | var arrow = L.polyline([[57, -19], [60, -12]], {}).addTo(map); 15 | var arrowHead = L.polylineDecorator(arrow, { 16 | patterns: [ 17 | {offset: '100%', repeat: 0, symbol: L.Symbol.arrowHead({pixelSize: 15, polygon: false, pathOptions: {stroke: true}})} 18 | ] 19 | }).addTo(map); 20 | 21 | // --- Polygon, with an inner ring --- 22 | var polygon = L.polygon([[[54, -6], [55, -7], [56, -2], [55, 1], [53, 0]], [[54, -3], [54, -2], [55, -1], [55, -5]]], {color: "#ff7800", weight: 1}).addTo(map); 23 | var pd = L.polylineDecorator(polygon, { 24 | patterns: [ 25 | {offset: 0, repeat: 10, symbol: L.Symbol.dash({pixelSize: 0})} 26 | ] 27 | }).addTo(map); 28 | 29 | // --- Multi-pattern without Polyline --- 30 | var pathPattern = L.polylineDecorator( 31 | [ [ 49.543519, -12.469833 ], [ 49.808981, -12.895285 ], [ 50.056511, -13.555761 ], [ 50.217431, -14.758789 ], [ 50.476537, -15.226512 ], [ 50.377111, -15.706069 ], [ 50.200275, -16.000263 ], [ 49.860606, -15.414253 ], [ 49.672607, -15.710152 ], [ 49.863344, -16.451037 ], [ 49.774564, -16.875042 ], [ 49.498612, -17.106036 ], [ 49.435619, -17.953064 ], [ 49.041792, -19.118781 ], [ 48.548541, -20.496888 ], [ 47.930749, -22.391501 ], [ 47.547723, -23.781959 ], [ 47.095761, -24.941630 ], [ 46.282478, -25.178463 ], [ 45.409508, -25.601434 ], [ 44.833574, -25.346101 ], [ 44.039720, -24.988345 ] ], 32 | { 33 | patterns: [ 34 | { offset: 12, repeat: 25, symbol: L.Symbol.dash({pixelSize: 10, pathOptions: {color: '#f00', weight: 2}}) }, 35 | { offset: 0, repeat: 25, symbol: L.Symbol.dash({pixelSize: 0}) } 36 | ] 37 | } 38 | ).addTo(map); 39 | 40 | // --- Markers proportionnaly located --- 41 | var markerLine = L.polyline([[58.44773, -28.65234], [52.9354, -23.33496], [53.01478, -14.32617], [58.1707, -10.37109], [59.68993, -0.65918]], {}).addTo(map); 42 | var markerPatterns = L.polylineDecorator(markerLine, { 43 | patterns: [ 44 | { offset: '5%', repeat: '10%', symbol: L.Symbol.marker()} 45 | ] 46 | }).addTo(map); 47 | 48 | // --- Example with a rotated marker --- 49 | var pathPattern = L.polylineDecorator( 50 | [ [ 42.9, -15 ], [ 44.18, -11.4 ], [ 45.77, -8.0 ], [ 47.61, -6.4 ], [ 49.41, -6.1 ], [ 51.01, -7.2 ] ], 51 | { 52 | patterns: [ 53 | { offset: 0, repeat: 10, symbol: L.Symbol.dash({pixelSize: 5, pathOptions: {color: '#000', weight: 1, opacity: 0.2}}) }, 54 | { offset: '16%', repeat: '33%', symbol: L.Symbol.marker({rotate: true, markerOptions: { 55 | icon: L.icon({ 56 | iconUrl: 'icon_plane.png', 57 | iconAnchor: [16, 16] 58 | }) 59 | }})} 60 | ] 61 | } 62 | ).addTo(map); 63 | 64 | // --- Example with an array of Polylines --- 65 | var multiCoords1 = [ 66 | [[47.5468, -0.7910], [48.8068, -0.1318], [49.1242, 1.6699], [49.4966, 3.2958], [51.4266, 2.8564], [51.7542, 2.1093]], 67 | [[48.0193, -2.8125], [46.3165, -2.8564], [44.9336, -1.0107], [44.5278, 1.5820], [44.8714, 3.7353], [45.8287, 5.1855], [48.1953, 5.1416]], 68 | [[45.9205, 0.4394], [46.7699, 0.9228], [47.6061, 2.5488], [47.7540, 3.3837]] 69 | ]; 70 | var plArray = []; 71 | for(var i=0; i 9 | (c instanceof L.LatLng) || 10 | (Array.isArray(c) && c.length === 2 && typeof c[0] === 'number'); 11 | 12 | const isCoordArray = ll => Array.isArray(ll) && isCoord(ll[0]); 13 | 14 | L.PolylineDecorator = L.FeatureGroup.extend({ 15 | options: { 16 | patterns: [] 17 | }, 18 | 19 | initialize: function(paths, options) { 20 | L.FeatureGroup.prototype.initialize.call(this); 21 | L.Util.setOptions(this, options); 22 | this._map = null; 23 | this._paths = this._initPaths(paths); 24 | this._bounds = this._initBounds(); 25 | this._patterns = this._initPatterns(this.options.patterns); 26 | }, 27 | 28 | /** 29 | * Deals with all the different cases. input can be one of these types: 30 | * array of LatLng, array of 2-number arrays, Polyline, Polygon, 31 | * array of one of the previous. 32 | */ 33 | _initPaths: function(input, isPolygon) { 34 | if (isCoordArray(input)) { 35 | // Leaflet Polygons don't need the first point to be repeated, but we do 36 | const coords = isPolygon ? input.concat([input[0]]) : input; 37 | return [coords]; 38 | } 39 | if (input instanceof L.Polyline) { 40 | // we need some recursivity to support multi-poly* 41 | return this._initPaths(input.getLatLngs(), (input instanceof L.Polygon)); 42 | } 43 | if (Array.isArray(input)) { 44 | // flatten everything, we just need coordinate lists to apply patterns 45 | return input.reduce((flatArray, p) => 46 | flatArray.concat(this._initPaths(p, isPolygon)), 47 | []); 48 | } 49 | return []; 50 | }, 51 | 52 | // parse pattern definitions and precompute some values 53 | _initPatterns: function(patternDefs) { 54 | return patternDefs.map(this._parsePatternDef); 55 | }, 56 | 57 | /** 58 | * Changes the patterns used by this decorator 59 | * and redraws the new one. 60 | */ 61 | setPatterns: function(patterns) { 62 | this.options.patterns = patterns; 63 | this._patterns = this._initPatterns(this.options.patterns); 64 | this.redraw(); 65 | }, 66 | 67 | /** 68 | * Changes the patterns used by this decorator 69 | * and redraws the new one. 70 | */ 71 | setPaths: function(paths) { 72 | this._paths = this._initPaths(paths); 73 | this._bounds = this._initBounds(); 74 | this.redraw(); 75 | }, 76 | 77 | /** 78 | * Parse the pattern definition 79 | */ 80 | _parsePatternDef: function(patternDef, latLngs) { 81 | return { 82 | symbolFactory: patternDef.symbol, 83 | // Parse offset and repeat values, managing the two cases: 84 | // absolute (in pixels) or relative (in percentage of the polyline length) 85 | offset: parseRelativeOrAbsoluteValue(patternDef.offset), 86 | endOffset: parseRelativeOrAbsoluteValue(patternDef.endOffset), 87 | repeat: parseRelativeOrAbsoluteValue(patternDef.repeat), 88 | }; 89 | }, 90 | 91 | onAdd: function (map) { 92 | this._map = map; 93 | this._draw(); 94 | this._map.on('moveend', this.redraw, this); 95 | }, 96 | 97 | onRemove: function (map) { 98 | this._map.off('moveend', this.redraw, this); 99 | this._map = null; 100 | L.FeatureGroup.prototype.onRemove.call(this, map); 101 | }, 102 | 103 | /** 104 | * As real pattern bounds depends on map zoom and bounds, 105 | * we just compute the total bounds of all paths decorated by this instance. 106 | */ 107 | _initBounds: function() { 108 | const allPathCoords = this._paths.reduce((acc, path) => acc.concat(path), []); 109 | return L.latLngBounds(allPathCoords); 110 | }, 111 | 112 | getBounds: function() { 113 | return this._bounds; 114 | }, 115 | 116 | /** 117 | * Returns an array of ILayers object 118 | */ 119 | _buildSymbols: function(latLngs, symbolFactory, directionPoints) { 120 | return directionPoints.map((directionPoint, i) => 121 | symbolFactory.buildSymbol(directionPoint, latLngs, this._map, i, directionPoints.length) 122 | ); 123 | }, 124 | 125 | /** 126 | * Compute pairs of LatLng and heading angle, 127 | * that define positions and directions of the symbols on the path 128 | */ 129 | _getDirectionPoints: function(latLngs, pattern) { 130 | if (latLngs.length < 2) { 131 | return []; 132 | } 133 | const pathAsPoints = latLngs.map(latLng => this._map.project(latLng)); 134 | return projectPatternOnPointPath(pathAsPoints, pattern) 135 | .map(point => ({ 136 | latLng: this._map.unproject(L.point(point.pt)), 137 | heading: point.heading, 138 | })); 139 | }, 140 | 141 | redraw: function() { 142 | if (!this._map) { 143 | return; 144 | } 145 | this.clearLayers(); 146 | this._draw(); 147 | }, 148 | 149 | /** 150 | * Returns all symbols for a given pattern as an array of FeatureGroup 151 | */ 152 | _getPatternLayers: function(pattern) { 153 | const mapBounds = this._map.getBounds().pad(0.1); 154 | return this._paths.map(path => { 155 | const directionPoints = this._getDirectionPoints(path, pattern) 156 | // filter out invisible points 157 | .filter(point => mapBounds.contains(point.latLng)); 158 | return L.featureGroup(this._buildSymbols(path, pattern.symbolFactory, directionPoints)); 159 | }); 160 | }, 161 | 162 | /** 163 | * Draw all patterns 164 | */ 165 | _draw: function () { 166 | this._patterns 167 | .map(pattern => this._getPatternLayers(pattern)) 168 | .forEach(layers => { this.addLayer(L.featureGroup(layers)); }); 169 | } 170 | }); 171 | /* 172 | * Allows compact syntax to be used 173 | */ 174 | L.polylineDecorator = function (paths, options) { 175 | return new L.PolylineDecorator(paths, options); 176 | }; 177 | -------------------------------------------------------------------------------- /src/L.Symbol.js: -------------------------------------------------------------------------------- 1 | import L from 'leaflet'; 2 | // enable rotationAngle and rotationOrigin support on L.Marker 3 | import 'leaflet-rotatedmarker'; 4 | 5 | /** 6 | * Defines several classes of symbol factories, 7 | * to be used with L.PolylineDecorator 8 | */ 9 | 10 | L.Symbol = L.Symbol || {}; 11 | 12 | /** 13 | * A simple dash symbol, drawn as a Polyline. 14 | * Can also be used for dots, if 'pixelSize' option is given the 0 value. 15 | */ 16 | L.Symbol.Dash = L.Class.extend({ 17 | options: { 18 | pixelSize: 10, 19 | pathOptions: { } 20 | }, 21 | 22 | initialize: function (options) { 23 | L.Util.setOptions(this, options); 24 | this.options.pathOptions.clickable = false; 25 | }, 26 | 27 | buildSymbol: function(dirPoint, latLngs, map, index, total) { 28 | const opts = this.options; 29 | const d2r = Math.PI / 180; 30 | 31 | // for a dot, nothing more to compute 32 | if(opts.pixelSize <= 1) { 33 | return L.polyline([dirPoint.latLng, dirPoint.latLng], opts.pathOptions); 34 | } 35 | 36 | const midPoint = map.project(dirPoint.latLng); 37 | const angle = (-(dirPoint.heading - 90)) * d2r; 38 | const a = L.point( 39 | midPoint.x + opts.pixelSize * Math.cos(angle + Math.PI) / 2, 40 | midPoint.y + opts.pixelSize * Math.sin(angle) / 2 41 | ); 42 | // compute second point by central symmetry to avoid unecessary cos/sin 43 | const b = midPoint.add(midPoint.subtract(a)); 44 | return L.polyline([map.unproject(a), map.unproject(b)], opts.pathOptions); 45 | } 46 | }); 47 | 48 | L.Symbol.dash = function (options) { 49 | return new L.Symbol.Dash(options); 50 | }; 51 | 52 | L.Symbol.ArrowHead = L.Class.extend({ 53 | options: { 54 | polygon: true, 55 | pixelSize: 10, 56 | headAngle: 60, 57 | pathOptions: { 58 | stroke: false, 59 | weight: 2 60 | } 61 | }, 62 | 63 | initialize: function (options) { 64 | L.Util.setOptions(this, options); 65 | this.options.pathOptions.clickable = false; 66 | }, 67 | 68 | buildSymbol: function(dirPoint, latLngs, map, index, total) { 69 | return this.options.polygon 70 | ? L.polygon(this._buildArrowPath(dirPoint, map), this.options.pathOptions) 71 | : L.polyline(this._buildArrowPath(dirPoint, map), this.options.pathOptions); 72 | }, 73 | 74 | _buildArrowPath: function (dirPoint, map) { 75 | const d2r = Math.PI / 180; 76 | const tipPoint = map.project(dirPoint.latLng); 77 | const direction = (-(dirPoint.heading - 90)) * d2r; 78 | const radianArrowAngle = this.options.headAngle / 2 * d2r; 79 | 80 | const headAngle1 = direction + radianArrowAngle; 81 | const headAngle2 = direction - radianArrowAngle; 82 | const arrowHead1 = L.point( 83 | tipPoint.x - this.options.pixelSize * Math.cos(headAngle1), 84 | tipPoint.y + this.options.pixelSize * Math.sin(headAngle1)); 85 | const arrowHead2 = L.point( 86 | tipPoint.x - this.options.pixelSize * Math.cos(headAngle2), 87 | tipPoint.y + this.options.pixelSize * Math.sin(headAngle2)); 88 | 89 | return [ 90 | map.unproject(arrowHead1), 91 | dirPoint.latLng, 92 | map.unproject(arrowHead2) 93 | ]; 94 | } 95 | }); 96 | 97 | L.Symbol.arrowHead = function (options) { 98 | return new L.Symbol.ArrowHead(options); 99 | }; 100 | 101 | L.Symbol.Marker = L.Class.extend({ 102 | options: { 103 | markerOptions: { }, 104 | rotate: false 105 | }, 106 | 107 | initialize: function (options) { 108 | L.Util.setOptions(this, options); 109 | this.options.markerOptions.clickable = false; 110 | this.options.markerOptions.draggable = false; 111 | }, 112 | 113 | buildSymbol: function(directionPoint, latLngs, map, index, total) { 114 | if(this.options.rotate) { 115 | this.options.markerOptions.rotationAngle = directionPoint.heading + (this.options.angleCorrection || 0); 116 | } 117 | return L.marker(directionPoint.latLng, this.options.markerOptions); 118 | } 119 | }); 120 | 121 | L.Symbol.marker = function (options) { 122 | return new L.Symbol.Marker(options); 123 | }; 124 | -------------------------------------------------------------------------------- /src/patternUtils.js: -------------------------------------------------------------------------------- 1 | // functional re-impl of L.Point.distanceTo, 2 | // with no dependency on Leaflet for easier testing 3 | function pointDistance(ptA, ptB) { 4 | const x = ptB.x - ptA.x; 5 | const y = ptB.y - ptA.y; 6 | return Math.sqrt(x * x + y * y); 7 | } 8 | 9 | const computeSegmentHeading = (a, b) => 10 | ((Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI) + 90 + 360) % 360; 11 | 12 | const asRatioToPathLength = ({ value, isInPixels }, totalPathLength) => 13 | isInPixels ? value / totalPathLength : value; 14 | 15 | function parseRelativeOrAbsoluteValue(value) { 16 | if (typeof value === 'string' && value.indexOf('%') !== -1) { 17 | return { 18 | value: parseFloat(value) / 100, 19 | isInPixels: false, 20 | }; 21 | } 22 | const parsedValue = value ? parseFloat(value) : 0; 23 | return { 24 | value: parsedValue, 25 | isInPixels: parsedValue > 0, 26 | }; 27 | } 28 | 29 | const pointsEqual = (a, b) => a.x === b.x && a.y === b.y; 30 | 31 | function pointsToSegments(pts) { 32 | return pts.reduce((segments, b, idx, points) => { 33 | // this test skips same adjacent points 34 | if (idx > 0 && !pointsEqual(b, points[idx - 1])) { 35 | const a = points[idx - 1]; 36 | const distA = segments.length > 0 ? segments[segments.length - 1].distB : 0; 37 | const distAB = pointDistance(a, b); 38 | segments.push({ 39 | a, 40 | b, 41 | distA, 42 | distB: distA + distAB, 43 | heading: computeSegmentHeading(a, b), 44 | }); 45 | } 46 | return segments; 47 | }, []); 48 | } 49 | 50 | function projectPatternOnPointPath(pts, pattern) { 51 | // 1. split the path into segment infos 52 | const segments = pointsToSegments(pts); 53 | const nbSegments = segments.length; 54 | if (nbSegments === 0) { return []; } 55 | 56 | const totalPathLength = segments[nbSegments - 1].distB; 57 | 58 | const offset = asRatioToPathLength(pattern.offset, totalPathLength); 59 | const endOffset = asRatioToPathLength(pattern.endOffset, totalPathLength); 60 | const repeat = asRatioToPathLength(pattern.repeat, totalPathLength); 61 | 62 | const repeatIntervalPixels = totalPathLength * repeat; 63 | const startOffsetPixels = offset > 0 ? totalPathLength * offset : 0; 64 | const endOffsetPixels = endOffset > 0 ? totalPathLength * endOffset : 0; 65 | 66 | // 2. generate the positions of the pattern as offsets from the path start 67 | const positionOffsets = []; 68 | let positionOffset = startOffsetPixels; 69 | do { 70 | positionOffsets.push(positionOffset); 71 | positionOffset += repeatIntervalPixels; 72 | } while(repeatIntervalPixels > 0 && positionOffset < totalPathLength - endOffsetPixels); 73 | 74 | // 3. projects offsets to segments 75 | let segmentIndex = 0; 76 | let segment = segments[0]; 77 | return positionOffsets.map(positionOffset => { 78 | // find the segment matching the offset, 79 | // starting from the previous one as offsets are ordered 80 | while (positionOffset > segment.distB && segmentIndex < nbSegments - 1) { 81 | segmentIndex++; 82 | segment = segments[segmentIndex]; 83 | } 84 | 85 | const segmentRatio = (positionOffset - segment.distA) / (segment.distB - segment.distA); 86 | return { 87 | pt: interpolateBetweenPoints(segment.a, segment.b, segmentRatio), 88 | heading: segment.heading, 89 | }; 90 | }); 91 | } 92 | 93 | /** 94 | * Finds the point which lies on the segment defined by points A and B, 95 | * at the given ratio of the distance from A to B, by linear interpolation. 96 | */ 97 | function interpolateBetweenPoints(ptA, ptB, ratio) { 98 | if (ptB.x !== ptA.x) { 99 | return { 100 | x: ptA.x + ratio * (ptB.x - ptA.x), 101 | y: ptA.y + ratio * (ptB.y - ptA.y), 102 | }; 103 | } 104 | // special case where points lie on the same vertical axis 105 | return { 106 | x: ptA.x, 107 | y: ptA.y + (ptB.y - ptA.y) * ratio, 108 | }; 109 | } 110 | 111 | export { 112 | projectPatternOnPointPath, 113 | parseRelativeOrAbsoluteValue, 114 | // the following function are exported only for unit testing purpose 115 | computeSegmentHeading, 116 | asRatioToPathLength, 117 | }; 118 | -------------------------------------------------------------------------------- /tests/projectPatternOnPointPath.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { 3 | projectPatternOnPointPath, 4 | } from '../src/patternUtils.js' 5 | 6 | const A = { x: 0, y: 0 }; 7 | const B = { x: 1, y: 0 }; 8 | const C = { x: 1, y: 1 }; 9 | const D = { x: 0, y: 1 }; 10 | 11 | const patternStart = { 12 | offset: { value: 0, isInPixels: true }, 13 | endOffset: { value: 0, isInPixels: true }, 14 | repeat: { value: 0, isInPixels: true }, 15 | } 16 | 17 | const patternEnd = { 18 | offset: { value: 1, isInPixels: false }, 19 | endOffset: { value: 0, isInPixels: true }, 20 | repeat: { value: 0, isInPixels: true }, 21 | } 22 | 23 | const patternQuarter = { 24 | offset: { value: 0.25, isInPixels: false }, 25 | endOffset: { value: 0, isInPixels: true }, 26 | repeat: { value: 0, isInPixels: true }, 27 | } 28 | 29 | const pattern5Repeat = { 30 | offset: { value: 0.1, isInPixels: false }, 31 | endOffset: { value: 0, isInPixels: true }, 32 | repeat: { value: 0.2, isInPixels: false }, 33 | } 34 | 35 | const pattern3Repeat = { 36 | offset: { value: 0.1, isInPixels: false }, 37 | endOffset: { value: 0.4, isInPixels: false }, 38 | repeat: { value: 0.2, isInPixels: false }, 39 | } 40 | 41 | 42 | test('returns an empty array if the line has a zero-length', t => { 43 | t.deepEqual(projectPatternOnPointPath([B, B, B], patternStart), []); 44 | }); 45 | 46 | test('returns an empty array if the line has zero segments', t => { 47 | t.deepEqual(projectPatternOnPointPath([A], patternStart), []); 48 | }); 49 | 50 | test('returns a single position with start point and heading of the first segment for a pattern with offset 0', t => { 51 | const positions = projectPatternOnPointPath([A, B, C], patternStart); 52 | t.deepEqual(positions, [{ 53 | pt: A, 54 | heading: 90, 55 | }]); 56 | }); 57 | 58 | test('returns a single position with end point and heading of the last segment for a pattern with offset 100%', t => { 59 | const positions = projectPatternOnPointPath([A, B, C], patternEnd); 60 | t.deepEqual(positions, [{ 61 | pt: C, 62 | heading: 180, 63 | }]); 64 | }); 65 | 66 | test('computes the position as ratio of the line length', t => { 67 | const positions = projectPatternOnPointPath([A, B], patternQuarter); 68 | t.deepEqual(positions, [{ 69 | pt: { 70 | x: 0.25, 71 | y: 0, 72 | }, 73 | heading: 90, 74 | }]); 75 | }); 76 | 77 | test('returns multiple positions if repeats are specified', t => { 78 | const positions = projectPatternOnPointPath([A, B], pattern5Repeat); 79 | t.is(positions.length, 5); 80 | }); 81 | 82 | test('does not repeat positions beyond the end offset', t => { 83 | const positions = projectPatternOnPointPath([A, B], pattern3Repeat); 84 | t.is(positions.length, 3); 85 | }); 86 | 87 | test('ignores empty segments', t => { 88 | t.deepEqual( 89 | projectPatternOnPointPath([A, A, B, B, B, C, D, D, D], patternStart), 90 | projectPatternOnPointPath([A, B, C, D], patternStart) 91 | ); 92 | t.deepEqual( 93 | projectPatternOnPointPath([A, A, B, B, B, C, D, D, D], patternQuarter), 94 | projectPatternOnPointPath([A, B, C, D], patternQuarter) 95 | ); 96 | t.deepEqual( 97 | projectPatternOnPointPath([A, A, B, B, B, C, D, D, D], pattern3Repeat), 98 | projectPatternOnPointPath([A, B, C, D], pattern3Repeat) 99 | ); 100 | }); 101 | -------------------------------------------------------------------------------- /tests/utils.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { 3 | parseRelativeOrAbsoluteValue, 4 | asRatioToPathLength, 5 | computeSegmentHeading, 6 | } from '../src/patternUtils.js' 7 | 8 | test('parseRelativeOrAbsoluteValue', t => { 9 | t.deepEqual(parseRelativeOrAbsoluteValue(5), { value: 5, isInPixels: true }); 10 | t.deepEqual(parseRelativeOrAbsoluteValue('5'), { value: 5, isInPixels: true }); 11 | t.deepEqual(parseRelativeOrAbsoluteValue('5px'), { value: 5, isInPixels: true }); 12 | t.deepEqual(parseRelativeOrAbsoluteValue('5.123'), { value: 5.123, isInPixels: true }); 13 | t.deepEqual(parseRelativeOrAbsoluteValue('5%'), { value: 0.05, isInPixels: false }); 14 | t.deepEqual(parseRelativeOrAbsoluteValue(0), { value: 0, isInPixels: false }); 15 | }); 16 | 17 | test('asRatioToPathLength returns the value if it is already a ratio', t => { 18 | t.true(asRatioToPathLength({ value: 0, isInPixels: false }, 666) === 0); 19 | t.true(asRatioToPathLength({ value: 1, isInPixels: false }, 666) === 1); 20 | t.true(asRatioToPathLength({ value: 0.5, isInPixels: false }, 666) === 0.5); 21 | }) 22 | 23 | test('asRatioToPathLength computes the ratio if the value is in pixels', t => { 24 | t.true(asRatioToPathLength({ value: 0, isInPixels: true }, 666) === 0); 25 | t.true(asRatioToPathLength({ value: 666, isInPixels: true }, 666) === 1); 26 | t.true(asRatioToPathLength({ value: 333, isInPixels: true }, 666) === 0.5); 27 | }); 28 | 29 | test('computeSegmentHeading returns heading as a south-based, counter-clockwise angle in [0, 360[', t => { 30 | const A = { x: 0, y: 0 }; 31 | const B = { x: 1, y: 0 }; 32 | const C = { x: 1, y: 1 }; 33 | const D = { x: 0, y: 1 }; 34 | 35 | t.true(computeSegmentHeading(D, A) === 0); 36 | t.true(computeSegmentHeading(A, B) === 90); 37 | t.true(computeSegmentHeading(A, C) === 135); 38 | t.true(computeSegmentHeading(A, D) === 180); 39 | t.true(computeSegmentHeading(B, A) === 270); 40 | t.true(computeSegmentHeading(B, D) === 225); 41 | t.true(computeSegmentHeading(C, A) === 315); 42 | }); 43 | --------------------------------------------------------------------------------