diff --git a/README.md b/README.md index a571838..57af7c2 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,10 @@ Returns the projected planar bounding box (typically in pixels) for the specifie Returns the projected planar centroid (typically in pixels) for the specified GeoJSON *object*. This is handy for, say, labeling state or county boundaries, or displaying a symbol map. For example, a [noncontiguous cartogram](https://bl.ocks.org/mbostock/4055908) might scale each state around its centroid. This method observes any clipping performed by the [projection](#path_projection); see [*projection*.clipAngle](#projection_clipAngle) and [*projection*.clipExtent](#projection_clipExtent). This is the planar equivalent of [d3.geoCentroid](#geoCentroid). +# path.digits([digits]) [<>](https://github.com/d3/d3-geo/blob/main/src/path/index.js "Source") + +If *digits* is specified (as a non-negative number), sets the number of fractional digits for coordinates generated in SVG path strings. If *projection* is not specified, returns the current number of digits, which defaults to 3. + # path.measure(object) [<>](https://github.com/d3/d3-geo/blob/main/src/path/measure.js "Source") Returns the projected planar length (typically in pixels) for the specified GeoJSON *object*. Point and MultiPoint geometries have zero length. For Polygon and MultiPolygon geometries, this method computes the summed length of all rings. This method observes any clipping performed by the [projection](#path_projection); see [*projection*.clipAngle](#projection_clipAngle) and [*projection*.clipExtent](#projection_clipExtent). This is the planar equivalent of [d3.geoLength](#geoLength). diff --git a/src/path/index.js b/src/path/index.js index 45da305..e8292bc 100644 --- a/src/path/index.js +++ b/src/path/index.js @@ -8,7 +8,8 @@ import pathMeasure from "./measure.js"; import PathString from "./string.js"; export default function(projection, context) { - var pointRadius = 4.5, + let digits = 3, + pointRadius = 4.5, projectionStream, contextStream; @@ -41,12 +42,14 @@ export default function(projection, context) { }; path.projection = function(_) { - return arguments.length ? (projectionStream = _ == null ? (projection = null, identity) : (projection = _).stream, path) : projection; + if (!arguments.length) return projection; + projectionStream = _ == null ? (projection = null, identity) : (projection = _).stream; + return path; }; path.context = function(_) { if (!arguments.length) return context; - contextStream = _ == null ? (context = null, new PathString) : new PathContext(context = _); + contextStream = _ == null ? (context = null, new PathString(digits)) : new PathContext(context = _); if (typeof pointRadius !== "function") contextStream.pointRadius(pointRadius); return path; }; @@ -57,5 +60,17 @@ export default function(projection, context) { return path; }; - return path.projection(projection).context(context); + path.digits = function(_) { + if (!arguments.length) return digits; + if (_ == null) digits = null; + else { + const d = Math.floor(_); + if (!(d >= 0)) throw new RangeError(`invalid digits: ${_}`); + digits = d; + } + if (context === null) contextStream = new PathString(digits); + return path; + }; + + return path.projection(projection).digits(digits).context(context); } diff --git a/src/path/string.js b/src/path/string.js index 02e57b0..d9efbfc 100644 --- a/src/path/string.js +++ b/src/path/string.js @@ -1,59 +1,86 @@ -export default function PathString() { - this._string = []; -} +// Simple caching for constant-radius points. +let cacheDigits, cacheAppend, cacheRadius, cacheCircle; -PathString.prototype = { - _radius: 4.5, - _circle: circle(4.5), - pointRadius: function(_) { - if ((_ = +_) !== this._radius) this._radius = _, this._circle = null; +export default class PathString { + constructor(digits) { + this._append = digits == null ? append : appendRound(digits); + this._radius = 4.5; + this._ = ""; + } + pointRadius(_) { + this._radius = +_; return this; - }, - polygonStart: function() { + } + polygonStart() { this._line = 0; - }, - polygonEnd: function() { + } + polygonEnd() { this._line = NaN; - }, - lineStart: function() { + } + lineStart() { this._point = 0; - }, - lineEnd: function() { - if (this._line === 0) this._string.push("Z"); + } + lineEnd() { + if (this._line === 0) this._ += "Z"; this._point = NaN; - }, - point: function(x, y) { + } + point(x, y) { switch (this._point) { case 0: { - this._string.push("M", x, ",", y); + this._append`M${x},${y}`; this._point = 1; break; } case 1: { - this._string.push("L", x, ",", y); + this._append`L${x},${y}`; break; } default: { - if (this._circle == null) this._circle = circle(this._radius); - this._string.push("M", x, ",", y, this._circle); + this._append`M${x},${y}`; + if (this._radius !== cacheRadius || this._append !== cacheAppend) { + const r = this._radius; + const s = this._; + this._ = ""; // stash the old string so we can cache the circle path fragment + this._append`m0,${r}a${r},${r} 0 1,1 0,${-2 * r}a${r},${r} 0 1,1 0,${2 * r}z`; + cacheRadius = r; + cacheAppend = this._append; + cacheCircle = this._; + this._ = s; + } + this._ += cacheCircle; break; } } - }, - result: function() { - if (this._string.length) { - var result = this._string.join(""); - this._string = []; - return result; - } else { - return null; - } } -}; + result() { + const result = this._; + this._ = ""; + return result.length ? result : null; + } +} -function circle(radius) { - return "m0," + radius - + "a" + radius + "," + radius + " 0 1,1 0," + -2 * radius - + "a" + radius + "," + radius + " 0 1,1 0," + 2 * radius - + "z"; +function append(strings) { + let i = 1; + this._ += strings[0]; + for (const j = strings.length; i < j; ++i) { + this._ += arguments[i] + strings[i]; + } +} + +function appendRound(digits) { + const d = Math.floor(digits); + if (!(d >= 0)) throw new RangeError(`invalid digits: ${digits}`); + if (d > 15) return append; + if (d !== cacheDigits) { + const k = 10 ** d; + cacheDigits = d; + cacheAppend = function append(strings) { + let i = 1; + this._ += strings[0]; + for (const j = strings.length; i < j; ++i) { + this._ += Math.round(arguments[i] * k) / k + strings[i]; + } + }; + } + return cacheAppend; } diff --git a/test/asserts.js b/test/asserts.js index 1720a72..b24d767 100644 --- a/test/asserts.js +++ b/test/asserts.js @@ -11,7 +11,7 @@ function normalizePath(path) { } function formatNumber(s) { - return Math.abs((s = +s) - Math.round(s)) < 1e-6 ? Math.round(s) : s.toFixed(6); + return Math.abs((s = +s) - Math.round(s)) < 1e-6 ? Math.round(s) : s.toFixed(3); } export function assertInDelta(actual, expected, delta = 1e-6) { diff --git a/test/path/index-test.js b/test/path/index-test.js index ab0e263..5bba4ac 100644 --- a/test/path/index-test.js +++ b/test/path/index-test.js @@ -203,3 +203,67 @@ it("geoPath(LineString) then geoPath(Point) does not treat the point as part of {type: "arc", x: 165, y: 160, r: 4.5} ]); }); + +it("geoPath.digits() defaults to three", () => { + const path = geoPath(); + assert.strictEqual(path.digits(), 3); +}); + +it("geoPath.digits(digits) returns the current path after setting the digits option", () => { + const path = geoPath(); + assert.strictEqual(path.digits(4), path); + assert.strictEqual(path.digits(), 4); + assert.strictEqual(path.digits(0).digits(), 0); + assert.strictEqual(geoPath().digits(), 3); // doesn’t affect default +}); + +it("geoPath.digits(nullish) sets digits to null", () => { + const path = geoPath(); + assert.strictEqual(path.digits(null).digits(), null); + assert.strictEqual(path.digits(undefined).digits(), null); +}); + +it("geoPath.digits(digits) floors and coerces digits if not nullish", () => { + const path = geoPath(); + assert.strictEqual(path.digits(3.5).digits(), 3); + assert.strictEqual(path.digits(3.9).digits(), 3); + assert.strictEqual(path.digits("3").digits(), 3); + assert.strictEqual(path.digits(" 3").digits(), 3); + assert.strictEqual(path.digits("").digits(), 0); +}); + +it("geoPath.digits(digits) throws if digits is not valid", () => { + const path = geoPath(); + assert.throws(() => path.digits(NaN).digits(), RangeError); + assert.throws(() => path.digits(-1).digits(), RangeError); + assert.throws(() => path.digits(-0.1).digits(), RangeError); +}); + +it("path(object) respects the specified digits", () => { + const line = {type: "LineString", coordinates: [[Math.PI, Math.E], [Math.E, Math.PI]]}; + assert.strictEqual(geoPath().digits(0)(line), "M3,3L3,3"); + assert.strictEqual(geoPath().digits(1)(line), "M3.1,2.7L2.7,3.1"); + assert.strictEqual(geoPath().digits(2)(line), "M3.14,2.72L2.72,3.14"); + assert.strictEqual(geoPath().digits(3)(line), "M3.142,2.718L2.718,3.142"); + assert.strictEqual(geoPath().digits(4)(line), "M3.1416,2.7183L2.7183,3.1416"); + assert.strictEqual(geoPath().digits(5)(line), "M3.14159,2.71828L2.71828,3.14159"); + assert.strictEqual(geoPath().digits(6)(line), "M3.141593,2.718282L2.718282,3.141593"); + assert.strictEqual(geoPath().digits(40)(line), "M3.141592653589793,2.718281828459045L2.718281828459045,3.141592653589793"); + assert.strictEqual(geoPath().digits(null)(line), "M3.141592653589793,2.718281828459045L2.718281828459045,3.141592653589793"); +}); + +it("path(object) handles variable-radius points with different digits", () => { + const p1 = geoPath().digits(1); + const p2 = geoPath().digits(2); + const point = {type: "Point", coordinates: [Math.PI, Math.E]}; + assert.strictEqual(p1.pointRadius(1)(point), "M3.1,2.7m0,1a1,1 0 1,1 0,-2a1,1 0 1,1 0,2z"); + assert.strictEqual(p1(point), "M3.1,2.7m0,1a1,1 0 1,1 0,-2a1,1 0 1,1 0,2z"); + assert.strictEqual(p1.pointRadius(2)(point), "M3.1,2.7m0,2a2,2 0 1,1 0,-4a2,2 0 1,1 0,4z"); + assert.strictEqual(p1(point), "M3.1,2.7m0,2a2,2 0 1,1 0,-4a2,2 0 1,1 0,4z"); + assert.strictEqual(p2.pointRadius(1)(point), "M3.14,2.72m0,1a1,1 0 1,1 0,-2a1,1 0 1,1 0,2z"); + assert.strictEqual(p2(point), "M3.14,2.72m0,1a1,1 0 1,1 0,-2a1,1 0 1,1 0,2z"); + assert.strictEqual(p1(point), "M3.1,2.7m0,2a2,2 0 1,1 0,-4a2,2 0 1,1 0,4z"); + assert.strictEqual(p2.pointRadius(2)(point), "M3.14,2.72m0,2a2,2 0 1,1 0,-4a2,2 0 1,1 0,4z"); + assert.strictEqual(p2(point), "M3.14,2.72m0,2a2,2 0 1,1 0,-4a2,2 0 1,1 0,4z"); + assert.strictEqual(p1(point), "M3.1,2.7m0,2a2,2 0 1,1 0,-4a2,2 0 1,1 0,4z"); +});