Skip to content

Commit

Permalink
Add d3.pathRound. (#12)
Browse files Browse the repository at this point in the history
* pathFixed

* expose Path; template literal

* minimize diff

* minimize diff

* Path(digits); pathFixed(digits = 3)

* Update README

* simpler iteration

* pathRound, not pathFixed

* shorter

Co-authored-by: Philippe Rivière <fil@rezo.net>
  • Loading branch information
mbostock and Fil committed Dec 19, 2022
1 parent 6230b3c commit da5c99c
Show file tree
Hide file tree
Showing 4 changed files with 166 additions and 53 deletions.
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ Now code you write once can be used with both Canvas (for performance) and SVG (

## Installing

If you use npm, `npm install d3-path`. You can also download the [latest release on GitHub](https://github.com/d3/d3-path/releases/latest). In modern browsers, you can import d3-path from Skypack:
If you use npm, `npm install d3-path`. You can also download the [latest release on GitHub](https://github.com/d3/d3-path/releases/latest). In modern browsers, you can import d3-path from jsDelivr:

```html
<script type="module">
import {path} from "https://cdn.skypack.dev/d3-path@3";
import {path} from "https://cdn.jsdelivr.net/npm/d3-path@3/+esm";
const p = path();
p.moveTo(1, 2);
Expand Down Expand Up @@ -88,3 +88,7 @@ Creates a new subpath containing just the four points ⟨*x*, *y*⟩, ⟨*x* + *
<a name="path_toString" href="#path_toString">#</a> <i>path</i>.<b>toString</b>()

Returns the string representation of this *path* according to SVG’s [path data specification](http://www.w3.org/TR/SVG/paths.html#PathData).

<a name="pathRound" href="#pathRound">#</a> d3.<b>pathRound</b>(*digits* = 3) · [Source](https://github.com/d3/d3-path/blob/master/src/path.js), [Examples](https://observablehq.com/@d3/d3-path)

Like [d3.path](#path), except limits the digits after the decimal to the specified number of *digits*.
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export {default as path} from "./path.js";
export {Path, path, pathRound} from "./path.js";
126 changes: 76 additions & 50 deletions src/path.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,52 +3,68 @@ const pi = Math.PI,
epsilon = 1e-6,
tauEpsilon = tau - epsilon;

function Path() {
this._x0 = this._y0 = // start of current subpath
this._x1 = this._y1 = null; // end of current subpath
this._ = "";
function append(strings) {
this._ += strings[0];
for (let i = 1, n = strings.length; i < n; ++i) {
this._ += arguments[i] + strings[i];
}
}

function path() {
return new Path;
function appendRound(digits) {
let d = Math.floor(digits);
if (!(d >= 0)) throw new Error(`invalid digits: ${digits}`);
if (d > 15) return append;
const k = 10 ** d;
return function(strings) {
this._ += strings[0];
for (let i = 1, n = strings.length; i < n; ++i) {
this._ += Math.round(arguments[i] * k) / k + strings[i];
}
};
}

Path.prototype = path.prototype = {
constructor: Path,
moveTo: function(x, y) {
this._ += "M" + (this._x0 = this._x1 = +x) + "," + (this._y0 = this._y1 = +y);
},
closePath: function() {
export class Path {
constructor(digits) {
this._x0 = this._y0 = // start of current subpath
this._x1 = this._y1 = null; // end of current subpath
this._ = "";
this._append = digits == null ? append : appendRound(digits);
}
moveTo(x, y) {
this._append`M${this._x0 = this._x1 = +x},${this._y0 = this._y1 = +y}`;
}
closePath() {
if (this._x1 !== null) {
this._x1 = this._x0, this._y1 = this._y0;
this._ += "Z";
this._append`Z`;
}
},
lineTo: function(x, y) {
this._ += "L" + (this._x1 = +x) + "," + (this._y1 = +y);
},
quadraticCurveTo: function(x1, y1, x, y) {
this._ += "Q" + (+x1) + "," + (+y1) + "," + (this._x1 = +x) + "," + (this._y1 = +y);
},
bezierCurveTo: function(x1, y1, x2, y2, x, y) {
this._ += "C" + (+x1) + "," + (+y1) + "," + (+x2) + "," + (+y2) + "," + (this._x1 = +x) + "," + (this._y1 = +y);
},
arcTo: function(x1, y1, x2, y2, r) {
}
lineTo(x, y) {
this._append`L${this._x1 = +x},${this._y1 = +y}`;
}
quadraticCurveTo(x1, y1, x, y) {
this._append`Q${+x1},${+y1},${this._x1 = +x},${this._y1 = +y}`;
}
bezierCurveTo(x1, y1, x2, y2, x, y) {
this._append`C${+x1},${+y1},${+x2},${+y2},${this._x1 = +x},${this._y1 = +y}`;
}
arcTo(x1, y1, x2, y2, r) {
x1 = +x1, y1 = +y1, x2 = +x2, y2 = +y2, r = +r;
var x0 = this._x1,

// Is the radius negative? Error.
if (r < 0) throw new Error(`negative radius: ${r}`);

let x0 = this._x1,
y0 = this._y1,
x21 = x2 - x1,
y21 = y2 - y1,
x01 = x0 - x1,
y01 = y0 - y1,
l01_2 = x01 * x01 + y01 * y01;

// Is the radius negative? Error.
if (r < 0) throw new Error("negative radius: " + r);

// Is this path empty? Move to (x1,y1).
if (this._x1 === null) {
this._ += "M" + (this._x1 = x1) + "," + (this._y1 = y1);
this._append`M${this._x1 = x1},${this._y1 = y1}`;
}

// Or, is (x1,y1) coincident with (x0,y0)? Do nothing.
Expand All @@ -58,12 +74,12 @@ Path.prototype = path.prototype = {
// Equivalently, is (x1,y1) coincident with (x2,y2)?
// Or, is the radius zero? Line to (x1,y1).
else if (!(Math.abs(y01 * x21 - y21 * x01) > epsilon) || !r) {
this._ += "L" + (this._x1 = x1) + "," + (this._y1 = y1);
this._append`L${this._x1 = x1},${this._y1 = y1}`;
}

// Otherwise, draw an arc!
else {
var x20 = x2 - x0,
let x20 = x2 - x0,
y20 = y2 - y0,
l21_2 = x21 * x21 + y21 * y21,
l20_2 = x20 * x20 + y20 * y20,
Expand All @@ -75,32 +91,33 @@ Path.prototype = path.prototype = {

// If the start tangent is not coincident with (x0,y0), line to.
if (Math.abs(t01 - 1) > epsilon) {
this._ += "L" + (x1 + t01 * x01) + "," + (y1 + t01 * y01);
this._append`L${x1 + t01 * x01},${y1 + t01 * y01}`;
}

this._ += "A" + r + "," + r + ",0,0," + (+(y01 * x20 > x01 * y20)) + "," + (this._x1 = x1 + t21 * x21) + "," + (this._y1 = y1 + t21 * y21);
this._append`A${r},${r},0,0,${+(y01 * x20 > x01 * y20)},${this._x1 = x1 + t21 * x21},${this._y1 = y1 + t21 * y21}`;
}
},
arc: function(x, y, r, a0, a1, ccw) {
}
arc(x, y, r, a0, a1, ccw) {
x = +x, y = +y, r = +r, ccw = !!ccw;
var dx = r * Math.cos(a0),

// Is the radius negative? Error.
if (r < 0) throw new Error(`negative radius: ${r}`);

let dx = r * Math.cos(a0),
dy = r * Math.sin(a0),
x0 = x + dx,
y0 = y + dy,
cw = 1 ^ ccw,
da = ccw ? a0 - a1 : a1 - a0;

// Is the radius negative? Error.
if (r < 0) throw new Error("negative radius: " + r);

// Is this path empty? Move to (x0,y0).
if (this._x1 === null) {
this._ += "M" + x0 + "," + y0;
this._append`M${x0},${y0}`;
}

// Or, is (x0,y0) not coincident with the previous point? Line to (x0,y0).
else if (Math.abs(this._x1 - x0) > epsilon || Math.abs(this._y1 - y0) > epsilon) {
this._ += "L" + x0 + "," + y0;
this._append`L${x0},${y0}`;
}

// Is this arc empty? We’re done.
Expand All @@ -111,20 +128,29 @@ Path.prototype = path.prototype = {

// Is this a complete circle? Draw two arcs to complete the circle.
if (da > tauEpsilon) {
this._ += "A" + r + "," + r + ",0,1," + cw + "," + (x - dx) + "," + (y - dy) + "A" + r + "," + r + ",0,1," + cw + "," + (this._x1 = x0) + "," + (this._y1 = y0);
this._append`A${r},${r},0,1,${cw},${x - dx},${y - dy}A${r},${r},0,1,${cw},${this._x1 = x0},${this._y1 = y0}`;
}

// Is this arc non-empty? Draw an arc!
else if (da > epsilon) {
this._ += "A" + r + "," + r + ",0," + (+(da >= pi)) + "," + cw + "," + (this._x1 = x + r * Math.cos(a1)) + "," + (this._y1 = y + r * Math.sin(a1));
this._append`A${r},${r},0,${+(da >= pi)},${cw},${this._x1 = x + r * Math.cos(a1)},${this._y1 = y + r * Math.sin(a1)}`;
}
},
rect: function(x, y, w, h) {
this._ += "M" + (this._x0 = this._x1 = +x) + "," + (this._y0 = this._y1 = +y) + "h" + (+w) + "v" + (+h) + "h" + (-w) + "Z";
},
toString: function() {
}
rect(x, y, w, h) {
this._append`M${this._x0 = this._x1 = +x},${this._y0 = this._y1 = +y}h${w = +w}v${+h}h${-w}Z`;
}
toString() {
return this._;
}
};
}

export default path;
export function path() {
return new Path;
}

// Allow instanceof d3.path
path.prototype = Path.prototype;

export function pathRound(digits = 3) {
return new Path(+digits);
}
83 changes: 83 additions & 0 deletions test/pathRound-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import assert from "assert";
import {path, pathRound} from "../src/index.js";

it("pathRound() defaults to three digits of precision", () => {
const p = pathRound();
p.moveTo(Math.PI, Math.E);
assert.strictEqual(p + "", "M3.142,2.718");
});

it("pathRound(null) is equivalent to pathRound(0)", () => {
const p = pathRound(null);
p.moveTo(Math.PI, Math.E);
assert.strictEqual(p + "", "M3,3");
});

it("pathRound(digits) validates the specified digits", () => {
assert.throws(() => pathRound(NaN), /invalid digits/);
assert.throws(() => pathRound(-1), /invalid digits/);
});

it("pathRound(digits) ignores digits if greater than 15", () => {
const p = pathRound(40);
p.moveTo(Math.PI, Math.E);
assert.strictEqual(p + "", "M3.141592653589793,2.718281828459045");
});

it("pathRound.moveTo(x, y) limits the precision", () => {
const p = pathRound(1);
p.moveTo(123.456, 789.012);
assert.strictEqual(p + "", "M123.5,789");
});

it("pathRound.lineTo(x, y) limits the precision", () => {
const p = pathRound(1);
p.moveTo(0, 0);
p.lineTo(123.456, 789.012);
assert.strictEqual(p + "", "M0,0L123.5,789");
});

it("pathRound.arc(x, y, r, a0, a1, ccw) limits the precision", () => {
const p0 = path(), p = pathRound(1);
p0.arc(10.0001, 10.0001, 123.456, 0, Math.PI+0.0001);
p.arc(10.0001, 10.0001, 123.456, 0, Math.PI+0.0001);
assert.strictEqual(p + "", precision(p0 + "", 1));
p0.arc(10.0001, 10.0001, 123.456, 0, Math.PI-0.0001);
p.arc(10.0001, 10.0001, 123.456, 0, Math.PI-0.0001);
assert.strictEqual(p + "", precision(p0 + "", 1));
p0.arc(10.0001, 10.0001, 123.456, 0, Math.PI / 2, true);
p.arc(10.0001, 10.0001, 123.456, 0, Math.PI / 2, true);
assert.strictEqual(p + "", precision(p0 + "", 1));
});

it("pathRound.arcTo(x1, y1, x2, y2, r) limits the precision", () => {
const p0 = path(), p = pathRound(1);
p0.arcTo(10.0001, 10.0001, 123.456, 456.789, 12345.6789);
p.arcTo(10.0001, 10.0001, 123.456, 456.789, 12345.6789);
assert.strictEqual(p + "", precision(p0 + "", 1));
});

it("pathRound.quadraticCurveTo(x1, y1, x, y) limits the precision", () => {
const p0 = path(), p = pathRound(1);
p0.quadraticCurveTo(10.0001, 10.0001, 123.456, 456.789);
p.quadraticCurveTo(10.0001, 10.0001, 123.456, 456.789);
assert.strictEqual(p + "", precision(p0 + "", 1));
});

it("pathRound.bezierCurveTo(x1, y1, x2, y2, x, y) limits the precision", () => {
const p0 = path(), p = pathRound(1);
p0.bezierCurveTo(10.0001, 10.0001, 123.456, 456.789, 0.007, 0.006);
p.bezierCurveTo(10.0001, 10.0001, 123.456, 456.789, 0.007, 0.006);
assert.strictEqual(p + "", precision(p0 + "", 1));
});

it("pathRound.rect(x, y, w, h) limits the precision", () => {
const p0 = path(), p = pathRound(1);
p0.rect(10.0001, 10.0001, 123.456, 456.789);
p.rect(10.0001, 10.0001, 123.456, 456.789);
assert.strictEqual(p + "", precision(p0 + "", 1));
});

function precision(str, precision) {
return str.replace(/\d+\.\d+/g, s => +parseFloat(s).toFixed(precision));
}

0 comments on commit da5c99c

Please sign in to comment.