Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add d3.pathRound. #12

Merged
merged 9 commits into from
Dec 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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));
}