diff --git a/README.md b/README.md index b7ba40f..7574138 100644 --- a/README.md +++ b/README.md @@ -38,16 +38,35 @@ With a GeoJSON containing lines, it becomes: ``` +You can also apply attributes only to parts of the text, e.g. to create multi colored labels: + +```javascript +layer.setText([ + { fill: 'red', text: 'Red' }, + ' ' + { style: 'fill: blue', text: 'Blue' } +]) +``` + +### `text` parameter +The `text` parameter of `setText()` can either be: +* A string: use this string as label +* An object: use value of key 'text' as content (which can be either string, object or array), other key/value pairs as attributes for a `tspan` SVG node. +* An array: The label consists of several parts, where each part can either be a string, an object or an array. + ### Options -* `repeat` Specifies if the text should be repeated along the polyline (Default: `false`) +* `repeat` Specifies if the text should be repeated along the polyline (Default: `false`). Specify `repeat` as float to set the distance between each repetition in pixels (will be approximated by spaces). * `center` Centers the text according to the polyline's bounding box (Default: `false`) * `below` Show text below the path (Default: false) * `offset` Set an offset to position text relative to the polyline (Default: 0) * `orientation` Rotate text. (Default: 0) - {orientation: angle} - rotate to a specified angle (e.g. {orientation: 15}) - - {orientation: flip} - filps the text 180deg correction for upside down text placement on west -> east lines + - {orientation: flip} - flips the text 180deg correction for upside down text placement on west -> east lines - {orientation: perpendicular} - places text at right angles to the line. + - {orientation: auto} - flips the text on (part of) ways running west to east, so that they are readable upside down. +* `allowCrop` If the line is too short to display the whole text, crop the text. If false, don't show the text at all. (Default: true). +* `turnedText` When orientation=auto is used, use this text for east -> west lines. * `attributes` Object containing the attributes applied to the `text` tag. Check valid attributes [here](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/text#Attributes) (Default: `{}`) diff --git a/index.html b/index.html index ad8145e..690d487 100644 --- a/index.html +++ b/index.html @@ -186,9 +186,56 @@ ] }; + var rainbowGeom = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + -4.04296875, + 17.476432197195518 + ], + [ + -2.8125, + 21.12549763660628 + ], + [ + 0.3515625, + 24.287026865376436 + ], + [ + 5.09765625, + 25.64152637306577 + ], + [ + 11.25, + 25.64152637306577 + ], + [ + 15.908203125, + 23.805449612314625 + ], + [ + 18.45703125, + 21.289374355860424 + ], + [ + 19.86328125, + 17.644022027872726 + ] + ] + } + } + ] + }; + L.geoJson(flightsWE, { onEachFeature: function (feature, layer) { - layer.setText(feature.properties.flight, {offset: -5}); + layer.setText(feature.properties.flight, {offset: -5, repeat: 20, center: true}); }, style: { weight: 3, @@ -199,7 +246,7 @@ L.geoJson(flightsEW, { onEachFeature: function (feature, layer) { - layer.setText(feature.properties.flight, {offset: -5, orientation: 'flip'}); + layer.setText(feature.properties.flight, {offset: -5, orientation: 'flip', allowCrop: false}); }, style: { weight: 3, @@ -208,6 +255,183 @@ } }).addTo(map); + L.geoJson(rainbowGeom, { + onEachFeature: function (feature, layer) { + layer.setText( + [ + { fill: '#ff0000', text: 'R' }, + { fill: '#ff7f00', text: 'a' }, + { fill: '#ffff00', text: 'i' }, + { fill: '#00ff00', text: 'n' }, + { fill: '#008f8f', text: 'b' }, + { fill: '#0000ff', text: 'o' }, + { fill: '#8b00ff', text: 'w' }, + ], + { + offset: -5, + repeat: 20, + center: true, + attributes: { 'font-size': '16pt', 'font-weight': 'bold', 'stroke-width': 0.5, 'stroke': '#000000' } + } + ); + }, + style: { + weight: 2, + color: 'black' + } + }).addTo(map); + + var road = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 33.3984375, + 29.53522956294847 + ], + [ + 26.894531249999996, + 28.613459424004414 + ], + [ + 24.960937499999996, + 27.994401411046148 + ], + [ + 23.5546875, + 25.3241665257384 + ], + [ + 23.291015625, + 22.836945920943855 + ], + [ + 23.642578125, + 20.3034175184893 + ], + [ + 25.13671875, + 18.729501999072138 + ], + [ + 28.4765625, + 18.145851771694467 + ], + [ + 33.75, + 17.811456088564483 + ], + [ + 40.95703125, + 16.636191878397664 + ], + [ + 41.748046875, + 14.774882506516272 + ], + [ + 41.572265625, + 11.695272733029402 + ], + [ + 36.73828124999999, + 10.574222078332806 + ], + [ + 31.113281249999996, + 9.362352822055605 + ], + [ + 24.521484375, + 8.494104537551882 + ], + [ + 22.67578125, + 11.609193407938953 + ], + [ + 19.599609375, + 13.496472765758952 + ], + [ + 15.8203125, + 11.26461221250444 + ], + [ + 15.908203125, + 5.7908968128719565 + ], + [ + 18.720703125, + 1.7575368113083254 + ], + [ + 22.8515625, + -0.5273363048115043 + ], + [ + 27.421875, + -0.17578097424708533 + ], + [ + 30.05859375, + 4.039617826768437 + ], + [ + 31.201171875, + 6.664607562172573 + ], + [ + 36.650390625, + 7.536764322084078 + ], + [ + 39.7265625, + 4.653079918274051 + ] + ] + } + } + ] + }; + + // casing + L.geoJson(road, { + style: { + weight: 13, + color: 'black' + } + }).addTo(map); + // road + label + L.geoJson(road, { + onEachFeature: function (feature, layer) { + layer.setText( + [ 'Road', { text: ' ⯈', fill: 'red' } ], + { + repeat: 20, + center: true, + offset: 0, + orientation: 'auto', + attributes: { + 'font-size': '8pt', + 'font-weight': 'bold', + 'dominant-baseline': 'middle', + }, + turnedText: [ { text: '⯇ ', fill: 'blue' }, 'Road' ], + } + ); + }, + style: { + weight: 10, + color: 'white' + } + }).addTo(map); + var pos1 = [40.418075, -3.704643], pos2 = [40.413119, -3.702369]; var line = L.polyline([pos1, pos2]); diff --git a/leaflet.textpath.js b/leaflet.textpath.js index 0f22be2..ff79d7a 100755 --- a/leaflet.textpath.js +++ b/leaflet.textpath.js @@ -11,6 +11,7 @@ var __onAdd = L.Polyline.prototype.onAdd, __updatePath = L.Polyline.prototype._updatePath, __bringToFront = L.Polyline.prototype.bringToFront; +var _getLengthCache = {} var PolylineTextPath = { @@ -44,6 +45,28 @@ var PolylineTextPath = { } }, + _getLength: function (text, options) { + var cacheId = JSON.stringify(text) + '|' + JSON.stringify(options.attributes) + + if (cacheId in _getLengthCache) { + return _getLengthCache[cacheId] + } + + var svg = this._map._renderer._container; + + var pattern = L.SVG.create('text'); + for (var attr in options.attributes) + pattern.setAttribute(attr, options.attributes[attr]); + this._applyText(pattern, text); + svg.appendChild(pattern); + var length = pattern.getComputedTextLength(); + svg.removeChild(pattern); + + _getLengthCache[cacheId] = length + + return length; + }, + setText: function (text, options) { this._text = text; this._textOptions = options; @@ -59,6 +82,7 @@ var PolylineTextPath = { fillColor: 'black', attributes: {}, below: false, + allowCrop: true }; options = L.Util.extend(defaults, options); @@ -73,39 +97,136 @@ var PolylineTextPath = { return this; } - text = text.replace(/ /g, '\u00A0'); // Non breakable spaces var id = 'pathdef-' + L.Util.stamp(this); var svg = this._map._renderer._container; this._path.setAttribute('id', id); - if (options.repeat) { + var textLength = null; + var pathLength = null; + var finalText = []; + var dx = 0 + + if (!options.allowCrop) { + if (textLength === null) { + textLength = this._getLength(text, options); + } + if (pathLength === null) { + pathLength = this._path.getTotalLength(); + } + + if (textLength > pathLength) { + return this; + } + } + + if (options.repeat === false) { + /* Center text according to the path's bounding box */ + if (options.center) { + if (textLength === null) { + textLength = this._getLength(text, options); + } + + /* Set the position for the left side of the textNode */ + dx = Math.max(0, (pathLength / 2) - (textLength / 2)); + } + + if (options.orientation === 'auto') { + var poiBegin = this._path.getPointAtLength(dx) + var poiEnd = this._path.getPointAtLength(dx + textLength) + var leftToRight = poiEnd.x >= poiBegin.x + + if (leftToRight) { + finalText.push(text); + } else { + finalText.push({ text: turnText(text), rotate: 180 }); + } + } else if (options.orientation === 'flip') { + finalText.push({ text: turnText(text), rotate: 180 }); + } else { + finalText = [ text ]; + } + } else { /* Compute single pattern length */ - var pattern = L.SVG.create('text'); - for (var attr in options.attributes) - pattern.setAttribute(attr, options.attributes[attr]); - pattern.appendChild(document.createTextNode(text)); - svg.appendChild(pattern); - var alength = pattern.getComputedTextLength(); - svg.removeChild(pattern); + if (textLength === null) { + textLength = this._getLength(text, options); + } + + if (options.orientation === 'auto' || options.orientation === 'flip') { + var textTurned = turnText(options.turnedText || text) + var textLengthTurned = this._getLength(options.turnedText || text, options) + } + + if (pathLength === null) { + pathLength = this._path.getTotalLength(); + } + + /* Compute length of a space */ + var slength = this._getLength('\u00A0', options); /* Create string as long as path */ - text = new Array(Math.ceil(this._path.getTotalLength() / alength)).join(text); + var repeatDistance = parseFloat(options.repeat) || 0 + var pos = 0 + var spacingBalance = 0 + var repeatCount = Math.floor((pathLength + repeatDistance) / (textLength + repeatDistance)) || 1; + var finalText = [] + + /* Calculate the position for the left side of the textNode */ + if (options.center) { + dx = Math.max(0, (pathLength - textLength * repeatCount - repeatDistance * (repeatCount - 1)) / 2); + } + + var i = 0; + do { + var spacesCount = 0 + if (i > 0) { + spacesCount = Math.round((repeatDistance + spacingBalance) / slength); + spacingBalance = repeatDistance - (spacesCount * slength); + pos += spacesCount * slength + finalText.push('\u00A0'.repeat(spacesCount)); + } + + if (options.orientation === 'auto') { + var poiBegin = this._path.getPointAtLength(pos) + var poiEnd = this._path.getPointAtLength(pos + textLength) + var leftToRight = poiEnd.x >= poiBegin.x + + if (leftToRight) { + finalText.push(text); + pos += textLength + } else { + finalText.push({ text: textTurned, rotate: 180 }); + pos += textLengthTurned + } + } else if (options.orientation === 'flip') { + finalText.push({ text: textTurned, rotate: 180 }); + pos += textLengthTurned + } else { + finalText.push(text); + pos += textLength + } + + i++ + } while (pos + repeatDistance + textLength < pathLength); } /* Put it along the path using textPath */ var textNode = L.SVG.create('text'), textPath = L.SVG.create('textPath'); - var dy = options.offset || this._path.getAttribute('stroke-width'); + var dy = 'offset' in options ? options.offset : this._path.getAttribute('stroke-width'); textPath.setAttributeNS("http://www.w3.org/1999/xlink", "xlink:href", '#'+id); textNode.setAttribute('dy', dy); for (var attr in options.attributes) textNode.setAttribute(attr, options.attributes[attr]); - textPath.appendChild(document.createTextNode(text)); + this._applyText(textPath, finalText); textNode.appendChild(textPath); this._textNode = textNode; + if (dx !== 0) { + textNode.setAttribute('dx', dx); + } + if (options.below) { svg.insertBefore(textNode, svg.firstChild); } @@ -113,24 +234,17 @@ var PolylineTextPath = { svg.appendChild(textNode); } - /* Center text according to the path's bounding box */ - if (options.center) { - var textLength = textNode.getComputedTextLength(); - var pathLength = this._path.getTotalLength(); - /* Set the position for the left side of the textNode */ - textNode.setAttribute('dx', ((pathLength / 2) - (textLength / 2))); - } - /* Change label rotation (if required) */ if (options.orientation) { var rotateAngle = 0; switch (options.orientation) { - case 'flip': - rotateAngle = 180; - break; case 'perpendicular': rotateAngle = 90; break; + case 'auto': + case 'flip': + rotateAngle = 0; + break; default: rotateAngle = options.orientation; } @@ -156,6 +270,28 @@ var PolylineTextPath = { } return this; + }, + + _applyText: function(parentNode, text) { + if (Array.isArray(text)) { + text.forEach(function (part) { + this._applyText(parentNode, part); + }.bind(this)); + } else if (typeof text === 'object' && text !== null) { + var tspan = L.SVG.create('tspan'); + parentNode.appendChild(tspan); + + for (var attr in text) { + if (attr === 'text') { + this._applyText(tspan, text[attr]); + } else { + tspan.setAttribute(attr, text[attr]); + } + } + } else { + text = text.replace(/ /g, '\u00A0'); // Non breakable spaces + parentNode.appendChild(document.createTextNode(text)); + } } }; @@ -172,6 +308,23 @@ L.LayerGroup.include({ } }); - +function turnText (text) { + if (Array.isArray(text)) { + return text + .slice().reverse() + .map(function (part) { + return turnText(part) + }) + } else if (typeof text === 'object' && text !== null) { + var ret = {} + for (var attr in text) { + ret[attr] = text[attr] + } + ret.text = turnText(ret.text) + return ret + } else { + return text.split('').reverse().join('') + } +} })(); diff --git a/package.json b/package.json index f24b1ea..6eb8905 100644 --- a/package.json +++ b/package.json @@ -21,5 +21,8 @@ }, "peerDependencies": { "leaflet": "^1.3.1" + }, + "dependencies": { + "innersvg-polyfill": "0.0.2" } }