From 7637a2c566ed0580b75efc556843ff7ead3f0f2c Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 18 Dec 2022 09:13:34 -0800 Subject: [PATCH 1/8] path.digits --- src/path/index.js | 18 +++++++-- src/path/string.js | 95 +++++++++++++++++++++++++++------------------- test/asserts.js | 2 +- 3 files changed, 72 insertions(+), 43 deletions(-) diff --git a/src/path/index.js b/src/path/index.js index 45da305..142a889 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,12 @@ export default function(projection, context) { return path; }; - return path.projection(projection).context(context); + path.digits = function(_) { + if (!arguments.length) return digits; + digits = _ == null ? null : +_; + 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..4de7783 100644 --- a/src/path/string.js +++ b/src/path/string.js @@ -1,59 +1,78 @@ -export default function PathString() { - this._string = []; -} +// Simple caching for constant-radius points. +let cacheDigits, cacheTemplate, 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._template = digits == null ? template : templateFixed(digits = +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._ += this._template`M${x},${y}`; this._point = 1; break; } case 1: { - this._string.push("L", x, ",", y); + this._ += this._template`L${x},${y}`; break; } default: { - if (this._circle == null) this._circle = circle(this._radius); - this._string.push("M", x, ",", y, this._circle); + this._ += this._template`M${x},${y}`; + if (this._template !== cacheTemplate || this._radius !== cacheRadius) { + const r = cacheRadius = this._radius; + cacheTemplate = this._template; + cacheCircle = this._template`m0,${r}a${r},${r} 0 1,1 0,${-2 * r}a${r},${r} 0 1,1 0,${2 * r}z`; + } + 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 template(strings) { + let i = 1, string = strings[0]; + for (const j = strings.length; i < j; ++i) { + string += arguments[i] + strings[i]; + } + return string; +} + +function templateFixed(digits) { + if (digits !== cacheDigits) { + (0).toFixed(digits); // validate digits + cacheDigits = digits; + cacheTemplate = function template(strings) { + let i = 1, string = strings[0]; + for (const j = strings.length; i < j; ++i) { + string += +arguments[i].toFixed(digits) + strings[i]; + } + return string; + }; + } + return cacheTemplate; } 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) { From a102edcfd703617e13120c87a4952e4e4a002e88 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 18 Dec 2022 09:23:47 -0800 Subject: [PATCH 2/8] restore direct append --- src/path/string.js | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/src/path/string.js b/src/path/string.js index 4de7783..2027c99 100644 --- a/src/path/string.js +++ b/src/path/string.js @@ -1,9 +1,9 @@ // Simple caching for constant-radius points. -let cacheDigits, cacheTemplate, cacheRadius, cacheCircle; +let cacheDigits, cacheAppend, cacheRadius, cacheCircle; export default class PathString { constructor(digits) { - this._template = digits == null ? template : templateFixed(digits = +digits); + this._append = digits == null ? append : appendFixed(digits = +digits); this._radius = 4.5; this._ = ""; } @@ -27,20 +27,22 @@ export default class PathString { point(x, y) { switch (this._point) { case 0: { - this._ += this._template`M${x},${y}`; + this._append`M${x},${y}`; this._point = 1; break; } case 1: { - this._ += this._template`L${x},${y}`; + this._append`L${x},${y}`; break; } default: { - this._ += this._template`M${x},${y}`; - if (this._template !== cacheTemplate || this._radius !== cacheRadius) { + this._append`M${x},${y}`; + if (this._append !== cacheAppend || this._radius !== cacheRadius) { const r = cacheRadius = this._radius; - cacheTemplate = this._template; - cacheCircle = this._template`m0,${r}a${r},${r} 0 1,1 0,${-2 * r}a${r},${r} 0 1,1 0,${2 * r}z`; + const p = new PathString(this._digits); + p._append`m0,${r}a${r},${r} 0 1,1 0,${-2 * r}a${r},${r} 0 1,1 0,${2 * r}z`; + cacheAppend = this._append; + cacheCircle = p._; } this._ += cacheCircle; break; @@ -54,25 +56,25 @@ export default class PathString { } } -function template(strings) { - let i = 1, string = strings[0]; +function append(strings) { + let i = 1; + this._ += strings[0]; for (const j = strings.length; i < j; ++i) { - string += arguments[i] + strings[i]; + this._ += arguments[i] + strings[i]; } - return string; } -function templateFixed(digits) { +function appendFixed(digits) { if (digits !== cacheDigits) { (0).toFixed(digits); // validate digits cacheDigits = digits; - cacheTemplate = function template(strings) { - let i = 1, string = strings[0]; + cacheAppend = function append(strings) { + let i = 1; + this._ += strings[0]; for (const j = strings.length; i < j; ++i) { - string += +arguments[i].toFixed(digits) + strings[i]; + this._ += +arguments[i].toFixed(digits) + strings[i]; } - return string; }; } - return cacheTemplate; + return cacheAppend; } From e163d04940ff487963b68fd43dfda039980f3865 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 18 Dec 2022 09:24:54 -0800 Subject: [PATCH 3/8] simpler --- src/path/string.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/path/string.js b/src/path/string.js index 2027c99..c656e49 100644 --- a/src/path/string.js +++ b/src/path/string.js @@ -3,7 +3,7 @@ let cacheDigits, cacheAppend, cacheRadius, cacheCircle; export default class PathString { constructor(digits) { - this._append = digits == null ? append : appendFixed(digits = +digits); + this._append = digits == null ? append : appendFixed(+digits); this._radius = 4.5; this._ = ""; } From 59cae0afd8e284193bc1555cf2abba3808342d0b Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 18 Dec 2022 09:32:09 -0800 Subject: [PATCH 4/8] one fewer object --- src/path/string.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/path/string.js b/src/path/string.js index c656e49..9fa6d20 100644 --- a/src/path/string.js +++ b/src/path/string.js @@ -37,12 +37,15 @@ export default class PathString { } default: { this._append`M${x},${y}`; - if (this._append !== cacheAppend || this._radius !== cacheRadius) { - const r = cacheRadius = this._radius; - const p = new PathString(this._digits); - p._append`m0,${r}a${r},${r} 0 1,1 0,${-2 * r}a${r},${r} 0 1,1 0,${2 * r}z`; + 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 = p._; + cacheCircle = this._; + this._ = s; } this._ += cacheCircle; break; From 493e2db3859a8b2a5219a763f02c94303ffe961a Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 18 Dec 2022 09:51:45 -0800 Subject: [PATCH 5/8] test coercion and caching --- src/path/index.js | 2 +- test/path/index-test.js | 64 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/path/index.js b/src/path/index.js index 142a889..cb9a73a 100644 --- a/src/path/index.js +++ b/src/path/index.js @@ -62,7 +62,7 @@ export default function(projection, context) { path.digits = function(_) { if (!arguments.length) return digits; - digits = _ == null ? null : +_; + digits = _ == null ? null : ((0).toFixed(_ = Math.floor(_) || 0), _); if (context === null) contextStream = new PathString(digits); return path; }; diff --git a/test/path/index-test.js b/test/path/index-test.js index ab0e263..5749b03 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); + assert.strictEqual(path.digits(NaN).digits(), 0); +}); + +it("geoPath.digits(digits) throws if digits is not valid", () => { + const path = geoPath(); + 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"); +}); From 8098cc25f26e4e2abfd2b756ad3035b1b9edafe8 Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 18 Dec 2022 21:12:37 -0800 Subject: [PATCH 6/8] round instead of toFixed --- src/path/string.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/path/string.js b/src/path/string.js index 9fa6d20..1edc05e 100644 --- a/src/path/string.js +++ b/src/path/string.js @@ -3,7 +3,7 @@ let cacheDigits, cacheAppend, cacheRadius, cacheCircle; export default class PathString { constructor(digits) { - this._append = digits == null ? append : appendFixed(+digits); + this._append = digits == null ? append : appendRound(digits); this._radius = 4.5; this._ = ""; } @@ -67,15 +67,18 @@ function append(strings) { } } -function appendFixed(digits) { - if (digits !== cacheDigits) { - (0).toFixed(digits); // validate digits - cacheDigits = digits; +function appendRound(digits) { + let 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._ += +arguments[i].toFixed(digits) + strings[i]; + this._ += Math.round(arguments[i] * k) / k + strings[i]; } }; } From e0a7b92e2eb562819339b7f7105ca2f94e57b09b Mon Sep 17 00:00:00 2001 From: Mike Bostock Date: Sun, 18 Dec 2022 22:27:24 -0800 Subject: [PATCH 7/8] fix digits validation --- src/path/index.js | 7 ++++++- src/path/string.js | 2 +- test/path/index-test.js | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/path/index.js b/src/path/index.js index cb9a73a..e8292bc 100644 --- a/src/path/index.js +++ b/src/path/index.js @@ -62,7 +62,12 @@ export default function(projection, context) { path.digits = function(_) { if (!arguments.length) return digits; - digits = _ == null ? null : ((0).toFixed(_ = Math.floor(_) || 0), _); + 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; }; diff --git a/src/path/string.js b/src/path/string.js index 1edc05e..d9efbfc 100644 --- a/src/path/string.js +++ b/src/path/string.js @@ -68,7 +68,7 @@ function append(strings) { } function appendRound(digits) { - let d = Math.floor(digits); + const d = Math.floor(digits); if (!(d >= 0)) throw new RangeError(`invalid digits: ${digits}`); if (d > 15) return append; if (d !== cacheDigits) { diff --git a/test/path/index-test.js b/test/path/index-test.js index 5749b03..5bba4ac 100644 --- a/test/path/index-test.js +++ b/test/path/index-test.js @@ -230,11 +230,11 @@ it("geoPath.digits(digits) floors and coerces digits if not nullish", () => { assert.strictEqual(path.digits("3").digits(), 3); assert.strictEqual(path.digits(" 3").digits(), 3); assert.strictEqual(path.digits("").digits(), 0); - assert.strictEqual(path.digits(NaN).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); }); From 184d7f90b783cbadefab2dc7fcfe841fd8dfc3ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Mon, 19 Dec 2022 10:34:28 +0100 Subject: [PATCH 8/8] document _path_.digits --- README.md | 4 ++++ 1 file changed, 4 insertions(+) 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).