charts.shape
SVG path strings for lines, areas, steps, monotone curves, bars, arcs and pie slices, identical in every language.
1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra
Pinned by 70 tests, run in TypeScript, Python and Rust.linePath 9 · areaPath 8 · stepPath 9 · monotoneCurve 10 · barRects 10 · arcPath 13 · pieAngles 11
What it does
SVG path data for the marks of a chart: lines, filled areas, step lines, smooth monotone curves, bar rectangles, arcs and pie angles. Inputs are already in pixels (put the data through `charts.scale` first); the outputs are strings for `<path d="...">` and numbers for `<rect>`, the same in TypeScript, Python and Rust, so a server-rendered chart and a browser-rendered one match to the character.
This is a group: install only what you draw, e.g. `require charts.shape ^1.0.0 only=linePath`. Every path function uses `linePath`'s helpers, so `linePath` always comes along.
The functions
A group: 7 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.
- linePath (points: Point[]) -> string
- areaPath (points: AreaPoint[]) -> string
- stepPath (points: Point[], position: StepPosition) -> string
- monotoneCurve (points: Point[]) -> string
- barRects (bars: BarSpec[], orientation: Orientation) -> Rect[]
- arcPath (arc: Arc) -> string
- pieAngles (values: float[], startAngle: float, endAngle: float, padAngle: float) -> PieSlice[]
The types it declares, generated into your project
/** A point of a line in pixels; a null y is a gap. */
export interface Point {
readonly x: number;
readonly y: number | null;
}
/** A point of an area in pixels: y1 is the top edge, y0 the baseline; a null y1 is a gap. */
export interface AreaPoint {
readonly x: number;
readonly y0: number;
readonly y1: number | null;
}
export type StepPosition = "before" | "middle" | "after";
export type Orientation = "vertical" | "horizontal";
/** One bar in pixels, before rounding. */
export interface BarSpec {
/** where the bar's band starts across the axis (x for vertical bars) */
readonly band: number;
/** the band's width, at least 0 */
readonly thickness: number;
/** the baseline pixel, usually where the scale puts zero */
readonly base: number;
/** the value's pixel */
readonly value: number;
}
/** A rectangle ready for <rect>. */
export interface Rect {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
/** An annular sector: angles in radians, 0 at 12 o'clock, increasing clockwise. */
export interface Arc {
readonly cx: number;
readonly cy: number;
readonly innerRadius: number;
readonly outerRadius: number;
readonly startAngle: number;
readonly endAngle: number;
}
/** The drawn angles of one slice, with half the pad trimmed from each side. */
export interface PieSlice {
readonly value: number;
readonly startAngle: number;
readonly endAngle: number;
}
Once installed, your code imports each one from the group's module.
linePath 9 tests
export function linePath(points: readonly Point[]): string
| points | Point[] | in pixels, in drawing order; a null y breaks the line |
| returns | string | "M10,20L30,40", numbers rounded to 2 places; "" when no point has a y |
For example
linePath(points ×3)→ M0,0L10,20L30,15 three pointslinePath(points ×2)→ M1,2.67L3.14,0 coordinates round to 2 places on the stored value, and -0.004 prints as 0, not -0linePath(points ×2)→ M-1.5,2.25L0.13,100 negative and tie-breaking coordinates
import { linePath } from "#fune/charts.shape@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { formatDecimal } from "./text_format_decimal.ts"; ← from text.format-decimal ^1.0.0 · built alongside by fune
import type { Point } from "./charts_shape_types.ts";
/**
* A polyline as SVG path data, in d3.line's format ("M10,20L30,40").
* A null y ends the current subpath; a run of one point is "Mx,yZ", as d3
* closes it so a round line cap still shows a dot.
*/
export function linePath(points: readonly Point[]): string {
let out = "";
for (const run of pointRuns(points)) {
run.forEach(([x, y], i) => {
out += (i === 0 ? "M" : "L") + svgPair(x, y);
});
if (run.length === 1) out += "Z";
}
return out;
}
// Shared with the other path functions of this group.
/** "x,y" with each number rounded to 2 places, trailing zeros dropped. */
export function svgPair(x: number, y: number): string {
return formatDecimal(x, 2, true, "") + "," + formatDecimal(y, 2, true, "");
}
/** The unbroken runs of a series: a point with a null y separates them. */
export function pointRuns(points: readonly Point[]): [number, number][][] {
const runs: [number, number][][] = [];
let current: [number, number][] = [];
for (const p of points) {
if (p.y === null || p.y === undefined) {
if (current.length > 0) runs.push(current);
current = [];
} else {
current.push([p.x, p.y]);
}
}
if (current.length > 0) runs.push(current);
return runs;
}areaPath 8 tests
export function areaPath(points: readonly AreaPoint[]): string
| points | AreaPoint[] | top edge y1 and baseline y0 per x; a null y1 breaks the area |
| returns | string |
For example
areaPath(points ×3)→ M0,50L10,20L20,40L20,100L10,100L0,100Z an area down to a flat baselineareaPath(points ×2)→ M0,60L5,50L5,70L0,80Z a stacked band with its own baseline per pointareaPath(points ×4)→ M0,5L0,10ZM2,6L3,7L3,10L2,10Z a null y1 splits the area; a lone point becomes a vertical sliver
import { areaPath } from "#fune/charts.shape@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { svgPair } from "./charts_shape_line_path.ts"; ← linePath, another function of this group · built into the same file, even by a slim install
import type { AreaPoint } from "./charts_shape_types.ts";
/**
* A filled area as SVG path data, in d3.area's format: along the top edge
* (y1) left to right, back along the baseline (y0) right to left, closed.
* A null y1 ends one area and starts the next.
*/
export function areaPath(points: readonly AreaPoint[]): string {
let out = "";
let run: AreaPoint[] = [];
const flush = () => {
if (run.length === 0) return;
run.forEach((p, i) => {
out += (i === 0 ? "M" : "L") + svgPair(p.x, p.y1 as number);
});
for (let i = run.length - 1; i >= 0; i--) out += "L" + svgPair(run[i].x, run[i].y0);
out += "Z";
run = [];
};
for (const p of points) {
if (p.y1 === null || p.y1 === undefined) flush();
else run.push(p);
}
flush();
return out;
}stepPath 9 tests
export function stepPath(points: readonly Point[], position: StepPosition): string
| points | Point[] | |
| position | StepPosition | where the vertical step sits: before a point, midway, or after it |
| returns | string |
For example
stepPath(points ×3, before)→ M0,0L0,10L10,10L10,5L20,5 step before: vertical first, at the previous xstepPath(points ×3, middle)→ M0,0L5,0L5,10L15,10L15,5L20,5 step middle: the jump halfway between pointsstepPath(points ×3, after)→ M0,0L10,0L10,10L20,10L20,5 step after: horizontal first, jump at the next x
import { stepPath } from "#fune/charts.shape@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { pointRuns, svgPair } from "./charts_shape_line_path.ts"; ← linePath, another function of this group · built into the same file, even by a slim install
import type { Point, StepPosition } from "./charts_shape_types.ts";
/**
* A step line as SVG path data, as d3.curveStepBefore, d3.curveStep and
* d3.curveStepAfter draw it: horizontal then vertical moves only.
*/
export function stepPath(points: readonly Point[], position: StepPosition): string {
let t: number;
if (position === "before") t = 0;
else if (position === "middle") t = 0.5;
else if (position === "after") t = 1;
else throw new RangeError(`step position must be before, middle or after, received ${position}`);
let out = "";
for (const run of pointRuns(points)) {
out += "M" + svgPair(run[0][0], run[0][1]);
for (let i = 1; i < run.length; i++) {
const [px, py] = run[i - 1];
const [x, y] = run[i];
if (t <= 0) {
out += "L" + svgPair(px, y) + "L" + svgPair(x, y);
} else {
// d3's own expression, so the midpoint rounds as d3's does.
const x1 = px * (1 - t) + x * t;
out += "L" + svgPair(x1, py) + "L" + svgPair(x1, y);
}
}
if (t > 0 && t < 1 && run.length >= 2) {
const [lx, ly] = run[run.length - 1];
out += "L" + svgPair(lx, ly);
}
if (run.length === 1) out += "Z";
}
return out;
}monotoneCurve throws on bad input 10 tests
export function monotoneCurve(points: readonly Point[]): string
| points | Point[] | x must strictly increase within each unbroken run |
| returns | string | cubic Bézier segments that never overshoot the data (Fritsch–Carlson, d3.curveMonotoneX) |
For example
monotoneCurve(points ×3)→ M0,0C0.33,0.5,0.67,1,1,1C1.33,1,1.67,0.5,2,0 a peak: the tangent at the top is flat, so the curve does not rise above 1monotoneCurve(points ×3)→ M0,0C0.33,0,0.67,0,1,0C1.33,0,1.67,5,2,10 flat then rising: a Catmull-Rom curve would dip below 0 here; this one stays flatmonotoneCurve(points ×4)→ M0,0C0.33,0.75,0.67,1.5,1,2C1.33,2.5,1.67,2.33,2,3C2.33,3.67,2.67,4.83,3,6 four rising points, tangents limited by Fritsch-Carlson
import { monotoneCurve } from "#fune/charts.shape@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { pointRuns, svgPair } from "./charts_shape_line_path.ts"; ← linePath, another function of this group · built into the same file, even by a slim install
import type { Point } from "./charts_shape_types.ts";
function sign(x: number): number {
return x < 0 ? -1 : 1;
}
// Fritsch–Carlson: the tangent at the middle of three points, limited so the
// curve cannot overshoot either neighbouring value. Written as d3's slope3.
function slope3(x0: number, y0: number, x1: number, y1: number, x2: number, y2: number): number {
const h0 = x1 - x0;
const h1 = x2 - x1;
const s0 = (y1 - y0) / h0;
const s1 = (y2 - y1) / h1;
const p = (s0 * h1 + s1 * h0) / (h0 + h1);
return (sign(s0) + sign(s1)) * Math.min(Math.abs(s0), Math.abs(s1), 0.5 * Math.abs(p)) || 0;
}
// The tangent at an end, from the one-sided slope and the known tangent (d3's slope2).
function slope2(x0: number, y0: number, x1: number, y1: number, t: number): number {
return (3 * (y1 - y0) / (x1 - x0) - t) / 2;
}
function bezier(x0: number, y0: number, x1: number, y1: number, t0: number, t1: number): string {
const dx = (x1 - x0) / 3;
return "C" + svgPair(x0 + dx, y0 + dx * t0) + "," + svgPair(x1 - dx, y1 - dx * t1) + "," + svgPair(x1, y1);
}
/**
* A smooth line through the points that stays monotone wherever the data is,
* so it never invents a peak or dip between samples: d3.curveMonotoneX,
* Hermite segments with Fritsch–Carlson tangents, as cubic Béziers.
*/
export function monotoneCurve(points: readonly Point[]): string {
let out = "";
for (const run of pointRuns(points)) {
for (let i = 1; i < run.length; i++) {
if (!(run[i][0] > run[i - 1][0])) {
throw new RangeError(`monotone curve needs x strictly increasing, but ${run[i][0]} follows ${run[i - 1][0]}`);
}
}
out += "M" + svgPair(run[0][0], run[0][1]);
const n = run.length;
if (n === 1) {
out += "Z";
continue;
}
if (n === 2) {
out += "L" + svgPair(run[1][0], run[1][1]);
continue;
}
// Tangent at each interior point, then the ends from their neighbours.
let t0 = 0;
for (let i = 2; i < n; i++) {
const [ax, ay] = run[i - 2];
const [bx, by] = run[i - 1];
const [cx, cy] = run[i];
const t1 = slope3(ax, ay, bx, by, cx, cy);
const start = i === 2 ? slope2(ax, ay, bx, by, t1) : t0;
out += bezier(ax, ay, bx, by, start, t1);
t0 = t1;
}
const [ax, ay] = run[n - 2];
const [bx, by] = run[n - 1];
out += bezier(ax, ay, bx, by, t0, slope2(ax, ay, bx, by, t0));
}
return out;
}barRects throws on bad input 10 tests
export function barRects(bars: readonly BarSpec[], orientation: Orientation): readonly Rect[]
| bars | BarSpec[] | |
| orientation | Orientation | |
| returns | Rect[] | edges rounded to 2 places; widths and heights are differences of rounded edges, never negative |
For example
barRects(bars ×1, vertical)→ ×1 a vertical bar up from the baselinebarRects(bars ×1, vertical)→ ×1 a negative vertical bar hangs below the baseline with a positive heightbarRects(bars ×1, horizontal)→ ×1 a horizontal bar
import { barRects } from "#fune/charts.shape@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { roundFloat } from "./math_round_float.ts"; ← from math.round-float ^1.0.0 · built alongside by fune
import type { BarSpec, Orientation, Rect } from "./charts_shape_types.ts";
/**
* Rectangles for bars. The edges are rounded to 2 places and the sizes are the
* differences of the rounded edges, so bars that share an edge share it
* exactly, and the height of a negative bar is still positive.
*/
export function barRects(bars: readonly BarSpec[], orientation: Orientation): readonly Rect[] {
if (orientation !== "vertical" && orientation !== "horizontal") {
throw new RangeError(`orientation must be vertical or horizontal, received ${orientation}`);
}
return bars.map((bar) => {
if (!(bar.thickness >= 0)) throw new RangeError(`bar thickness must not be negative, received ${bar.thickness}`);
const a0 = roundFloat(bar.band, 2);
const a1 = roundFloat(bar.band + bar.thickness, 2);
const v0 = roundFloat(Math.min(bar.base, bar.value), 2);
const v1 = roundFloat(Math.max(bar.base, bar.value), 2);
const across = roundFloat(a1 - a0, 2);
const along = roundFloat(v1 - v0, 2);
return orientation === "vertical"
? { x: a0, y: v0, width: across, height: along }
: { x: v0, y: a0, width: along, height: across };
});
}arcPath throws on bad input 13 tests
export function arcPath(arc: Arc): string
| arc | Arc | |
| returns | string | a ring segment, a wedge to the centre when innerRadius is 0, or "" for an empty arc |
For example
arcPath(cx 50, cy 50, inner radius 0, outer radius 50, start angle 0, end angle 1.571)→ M50,0A50,50,0,0,1,100,50L50,50Z a quarter wedge from 12 o'clock to 3 o'clockarcPath(cx 50, cy 50, inner radius 25, outer radius 50, start angle 0, end angle 1.571)→ M50,0A50,50,0,0,1,100,50L75,50A25,25,0,0,0,50,25Z a quarter of a donutarcPath(cx 50, cy 50, inner radius 0, outer radius 50, start angle 0, end angle 4.712)→ M50,0A50,50,0,1,1,0,50L50,50Z three quarters sets the large-arc flag
import { arcPath } from "#fune/charts.shape@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { sinCos } from "./math_sin_cos.ts"; ← from math.sin-cos ^1.0.0 · built alongside by fune
import { svgPair } from "./charts_shape_line_path.ts"; ← linePath, another function of this group · built into the same file, even by a slim install
import type { Arc } from "./charts_shape_types.ts";
const PI = 3.141592653589793;
const TAU = 6.283185307179586;
function arcPoint(arc: Arc, r: number, angle: number): string {
const sc = sinCos(angle);
return svgPair(arc.cx + r * sc.sin, arc.cy - r * sc.cos);
}
function arcTo(arc: Arc, r: number, large: number, sweep: number, angle: number): string {
const radius = svgPair(r, r);
return "A" + radius + ",0," + large + "," + sweep + "," + arcPoint(arc, r, angle);
}
/**
* An annular sector as SVG path data. Angles are radians from 12 o'clock,
* clockwise, as in d3.arc. See the README for the exact path format.
*/
export function arcPath(arc: Arc): string {
const { innerRadius: ri, outerRadius: ro, startAngle: a0, endAngle: a1 } = arc;
if (!(ri >= 0) || !(ro >= ri)) {
throw new RangeError(`arc radii must satisfy 0 <= innerRadius <= outerRadius, received ${ri} and ${ro}`);
}
const span = a1 - a0;
if (span === 0 || ro === 0) return "";
const sweep = span > 0 ? 1 : 0;
const back = 1 - sweep;
const dir = span > 0 ? 1 : -1;
if (Math.abs(span) >= TAU) {
// One SVG arc cannot start and end at the same point, so a full turn is
// two half turns; the hole is drawn the other way round so it stays empty.
const half = a0 + dir * PI;
let out = "M" + arcPoint(arc, ro, a0) + arcTo(arc, ro, 1, sweep, half) + arcTo(arc, ro, 1, sweep, a0) + "Z";
if (ri > 0) {
const back = a0 - dir * PI;
out += "M" + arcPoint(arc, ri, a0) + arcTo(arc, ri, 1, 1 - sweep, back) + arcTo(arc, ri, 1, 1 - sweep, a0) + "Z";
}
return out;
}
const large = Math.abs(span) > PI ? 1 : 0;
let out = "M" + arcPoint(arc, ro, a0) + arcTo(arc, ro, large, sweep, a1);
if (ri > 0) out += "L" + arcPoint(arc, ri, a1) + arcTo(arc, ri, large, back, a0);
else out += "L" + svgPair(arc.cx, arc.cy);
return out + "Z";
}pieAngles throws on bad input 11 tests
export function pieAngles(values: readonly number[], startAngle: number, endAngle: number, padAngle: number): readonly PieSlice[]
| values | float[] | zero or more each, in the order the slices are drawn |
| startAngle | float | radians, 0 at 12 o'clock, clockwise |
| endAngle | float | usually startAngle + 2 pi; the span is capped at one full turn |
| padAngle | float | radians of gap between neighbouring slices |
| returns | PieSlice[] |
For example
pieAngles(1, 1, 2, 0, 6.283, 0)→ ×3 a full pie of 1, 1 and 2 in input orderpieAngles(0, 3, 0, 1, 0)→ ×2 a zero value has no widthpieAngles(0, 0, 0, 6.283, 0)→ ×2 all zeros: every slice is empty
import { pieAngles } from "#fune/charts.shape@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { roundFloat } from "./math_round_float.ts"; ← from math.round-float ^1.0.0 · built alongside by fune
import type { PieSlice } from "./charts_shape_types.ts";
const TAU = 6.283185307179586;
/**
* Start and end angles of pie slices in input order: d3.pie with sort(null),
* then half the pad trimmed from each side, so the angles can go straight
* into arcPath and neighbouring slices show a gap of padAngle.
*/
export function pieAngles(values: readonly number[], startAngle: number, endAngle: number, padAngle: number): readonly PieSlice[] {
if (!(padAngle >= 0)) throw new RangeError(`padAngle must not be negative, received ${padAngle}`);
const n = values.length;
let sum = 0;
for (const v of values) {
if (!(v >= 0)) throw new RangeError(`pie values must be zero or more, received ${v}`);
sum += v;
}
let a0 = startAngle;
const da = Math.min(TAU, Math.max(-TAU, endAngle - a0));
const p = n > 0 ? Math.min(Math.abs(da) / n, padAngle) : 0;
const pa = p * (da < 0 ? -1 : 1);
const k = sum > 0 ? (da - n * pa) / sum : 0;
const out: PieSlice[] = [];
for (const v of values) {
const a1 = a0 + (v > 0 ? v * k : 0) + pa;
out.push({ value: v, startAngle: roundFloat(a0 + pa / 2, 6), endAngle: roundFloat(a1 - pa / 2, 6) });
a0 = a1;
}
return out;
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add charts.shape
That builds the whole group. To build only what you call, and whatever it uses inside the group:
fune add charts.shape --only linePath
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./charts.shape-1.0.0-typescript.fune, or fetch it from a terminal with fune pull charts.shape@1.0.0:typescript.
The whole function, every language, is one file too: charts.shape-1.0.0.fune, 68,319 bytes, sha256 15b509f1fa14c931ee25641ba2cc66f912ffd9bb3383191ac51509f7ebe38d79. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before charts.shape.linePath
// fune: before charts.shape.areaPath
// fune: before charts.shape.stepPath
// fune: before charts.shape.monotoneCurve
// fune: before charts.shape.barRects
// fune: before charts.shape.arcPath
// fune: before charts.shape.pieAngles
after — your function gets the result and the arguments, and returns the final result.
// fune: after charts.shape.linePath
// fune: after charts.shape.areaPath
// fune: after charts.shape.stepPath
// fune: after charts.shape.monotoneCurve
// fune: after charts.shape.barRects
// fune: after charts.shape.arcPath
// fune: after charts.shape.pieAngles
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace math.round-float in charts.shape
// fune: replace math.sin-cos in charts.shape
// fune: replace text.format-decimal in charts.shape
step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show charts.shape --steps.
// fune: step charts.shape.<fn> after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
linePath 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| three points | points ×3 | → | M0,0L10,20L30,15 |
| coordinates round to 2 places on the stored value, and -0.004 prints as 0, not -0 | points ×2 | → | M1,2.67L3.14,0 |
| negative and tie-breaking coordinates | points ×2 | → | M-1.5,2.25L0.13,100 |
| a null y breaks the line into two subpaths | points ×5 | → | M0,0L1,1M3,3L4,4 |
| an isolated point is closed so a round cap shows it | points ×6 | → | M0,0ZM2,2ZM4,4L5,5 |
| leading and trailing gaps are dropped | points ×4 | → | M1,1L2,2 |
| a single point | points ×1 | → | M1234.5,0.1Z |
| no points | → | ||
| only gaps | points ×2 | → |
areaPath 8 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| an area down to a flat baseline | points ×3 | → | M0,50L10,20L20,40L20,100L10,100L0,100Z |
| a stacked band with its own baseline per point | points ×2 | → | M0,60L5,50L5,70L0,80Z |
| a null y1 splits the area; a lone point becomes a vertical sliver | points ×4 | → | M0,5L0,10ZM2,6L3,7L3,10L2,10Z |
| one point | points ×1 | → | M3,4L3,10Z |
| rounding on the stored values: 0.005 up, 1.005 down, 2.675 down | points ×2 | → | M0.01,2.67L1,1L1,1L0.01,1Z |
| no points | → | ||
| only gaps | points ×1 | → | |
| an area above and below a mid baseline | points ×2 | → | M0,20L10,80L10,50L0,50Z |
stepPath 9 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| step before: vertical first, at the previous x | points ×3, before | → | M0,0L0,10L10,10L10,5L20,5 |
| step middle: the jump halfway between points | points ×3, middle | → | M0,0L5,0L5,10L15,10L15,5L20,5 |
| step after: horizontal first, jump at the next x | points ×3, after | → | M0,0L10,0L10,10L20,10L20,5 |
| two points, step before | points ×2, before | → | M0,0L0,8L4,8 |
| a single point is closed | points ×1, middle | → | M3,4Z |
| a gap splits the steps | points ×5, middle | → | M0,0L1,0L1,2L2,2M4,1L6,1L6,3L8,3 |
| the midpoint of 0 and 1.01 is 0.505000000000000004, which rounds up | points ×2, middle | → | M0,0L0.51,0L0.51,1L1.01,1 |
| no points | , after | → | |
| an unknown position is an error | points ×3, centre | → | error: step position must be before, middle or after |
monotoneCurve 10 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a peak: the tangent at the top is flat, so the curve does not rise above 1 | points ×3 | → | M0,0C0.33,0.5,0.67,1,1,1C1.33,1,1.67,0.5,2,0 |
| flat then rising: a Catmull-Rom curve would dip below 0 here; this one stays flat | points ×3 | → | M0,0C0.33,0,0.67,0,1,0C1.33,0,1.67,5,2,10 |
| four rising points, tangents limited by Fritsch-Carlson | points ×4 | → | M0,0C0.33,0.75,0.67,1.5,1,2C1.33,2.5,1.67,2.33,2,3C2.33,3.67,2.67,4.83,3,6 |
| uneven spacing | points ×3 | → | M0,0C0.67,2,1.33,4,2,4C2.33,4,2.67,4,3,4 |
| two points are a straight line | points ×2 | → | M0,0L4,4 |
| one point is closed | points ×1 | → | M5,5Z |
| a gap starts a new curve | points ×6 | → | M0,0L1,1M3,0C3.33,0.5,3.67,1,4,1C4.33,1,4.67,0.5,5,0 |
| no points | → | ||
| repeated x is an error | points ×2 | → | error: monotone curve needs x strictly increasing |
| decreasing x is an error | points ×3 | → | error: monotone curve needs x strictly increasing |
barRects 10 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a vertical bar up from the baseline | bars ×1, vertical | → | ×1 |
| a negative vertical bar hangs below the baseline with a positive height | bars ×1, vertical | → | ×1 |
| a horizontal bar | bars ×1, horizontal | → | ×1 |
| a negative horizontal bar | bars ×1, horizontal | → | ×1 |
| neighbouring bars share a rounded edge (rounding each width alone leaves a gap) | bars ×2, vertical | → | ×2 |
| a zero value is a zero-height bar | bars ×1, vertical | → | ×1 |
| zero thickness | bars ×1, vertical | → | ×1 |
| no bars | , vertical | → | |
| a negative thickness is an error | bars ×1, vertical | → | error: bar thickness must not be negative |
| an unknown orientation is an error | bars ×1, diagonal | → | error: orientation must be vertical or horizontal |
arcPath 13 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a quarter wedge from 12 o'clock to 3 o'clock | cx 50, cy 50, inner radius 0, outer radius 50, start angle 0, end angle 1.571 | → | M50,0A50,50,0,0,1,100,50L50,50Z |
| a quarter of a donut | cx 50, cy 50, inner radius 25, outer radius 50, start angle 0, end angle 1.571 | → | M50,0A50,50,0,0,1,100,50L75,50A25,25,0,0,0,50,25Z |
| three quarters sets the large-arc flag | cx 50, cy 50, inner radius 0, outer radius 50, start angle 0, end angle 4.712 | → | M50,0A50,50,0,1,1,0,50L50,50Z |
| exactly a half turn is not a large arc | cx 0, cy 0, inner radius 0, outer radius 100, start angle 0, end angle 3.142 | → | M0,-100A100,100,0,0,1,0,100L0,0Z |
| anticlockwise clears the sweep flag | cx 50, cy 50, inner radius 0, outer radius 50, start angle 0, end angle -1.571 | → | M50,0A50,50,0,0,0,0,50L50,50Z |
| 30 degrees: the end point rounds to 50,-86.6 | cx 0, cy 0, inner radius 0, outer radius 100, start angle 0, end angle 0.524 | → | M0,-100A100,100,0,0,1,50,-86.6L0,0Z |
| a full circle is two half arcs (one arc back to its start draws nothing) | cx 50, cy 50, inner radius 0, outer radius 50, start angle 0, end angle 6.283 | → | M50,0A50,50,0,1,1,50,100A50,50,0,1,1,50,0Z |
| more than a full turn is a full circle | cx 50, cy 50, inner radius 0, outer radius 50, start angle 0, end angle 10 | → | M50,0A50,50,0,1,1,50,100A50,50,0,1,1,50,0Z |
| a full ring draws its hole the other way round | cx 50, cy 50, inner radius 20, outer radius 50, start angle 0, end angle 6.283 | → | M50,0A50,50,0,1,1,50,100A50,50,0,1,1,50,0ZM50,30A20,20,0,1,0,50,70A20,20,0,1,0,50,30Z |
| no angle is no path | cx 50, cy 50, inner radius 0, outer radius 50, start angle 1, end angle 1 | → |
Show the other 3 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| no radius is no path | cx 50, cy 50, inner radius 0, outer radius 0, start angle 0, end angle 1 | → | |
| an inner radius beyond the outer is an error | cx 0, cy 0, inner radius 60, outer radius 50, start angle 0, end angle 1 | → | error: arc radii must satisfy 0 <= innerRadius <= outerRadius |
| a negative inner radius is an error | cx 0, cy 0, inner radius -1, outer radius 50, start angle 0, end angle 1 | → | error: arc radii must satisfy 0 <= innerRadius <= outerRadius |
pieAngles 11 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a full pie of 1, 1 and 2 in input order | 1, 1, 2, 0, 6.283, 0 | → | ×3 |
| a zero value has no width | 0, 3, 0, 1, 0 | → | ×2 |
| all zeros: every slice is empty | 0, 0, 0, 6.283, 0 | → | ×2 |
| padding: half the pad is trimmed from each side of each slice | 1, 1, 0, 2, 0.2 | → | ×2 |
| a pad wider than the pie allows is capped at an equal share | 1, 1, 1, 1, 0, 1, 1 | → | ×4 |
| an end before the start runs anticlockwise | 1, 1, 0, -2, 0 | → | ×2 |
| the span is capped at one full turn | 1, 0, 10, 0 | → | ×1 |
| a half pie from 9 o'clock to 3 o'clock | 2, 1, 1, -1.571, 1.571, 0 | → | ×3 |
| no values | , 0, 6.283, 0 | → | |
| a negative value is an error | 1, -1, 0, 6.283, 0 | → | error: pie values must be zero or more |
Show the other 1 test
| Case | Arguments | Expected | |
|---|---|---|---|
| a negative pad is an error | 1, 0, 6.283, -0.1 | → | error: padAngle must not be negative |
More from the author
## Numbers in paths
Every coordinate is rounded to 2 decimal places by `math.round-float` (half away from zero on the stored double) and printed by `text.format-decimal` with trailing zeros dropped: `10`, `10.5`, `0.13`, never `10.00`, `1e-7` or `-0`. A hundredth of a pixel is invisible, and fixed text is what makes the three languages agree. The path grammar follows d3-shape exactly: commands with no spaces (`M10,20L30,40`), and `C` with its three points separated by commas.
## Lines, areas and steps
- `linePath` is d3.line with `curveLinear`. A point whose `y` is null is a gap: the line stops and starts again with a new `M`. A run of a single point becomes `Mx,yZ`, which d3 emits so that a round line cap still draws a dot. - `areaPath` is d3.area: along the top edge (`y1`) left to right, back along the baseline (`y0`) right to left, then `Z`. `y0` is per point, so stacked areas pass their lower band edge. A null `y1` is a gap. - `stepPath` is d3.curveStepBefore (`before`), d3.curveStep (`middle`) and d3.curveStepAfter (`after`), including d3's extra final `L` for `middle`. The midpoint is computed as d3 does, `x0 x (1 - t) + x1 x t`.
## Monotone curves
`monotoneCurve` is d3.curveMonotoneX: cubic Hermite segments, written as Béziers, whose tangents are chosen by the Fritsch–Carlson rule so the curve is monotone wherever the data is. It never invents a bump between two equal values or dips below a flat start before a rise, which a Catmull-Rom or cardinal spline does. The interior tangent is d3's `(sign(s0) + sign(s1)) x min(|s0|, |s1|, |p| / 2)` with `p` the weighted mean slope; the end tangents come from the one-sided slope. Two points are a straight `L`; one is `Mx,yZ`. Within an unbroken run x must strictly increase: d3 silently produces a broken path otherwise, and here it is an error.
## Bars
`barRects` turns bars given in pixels (`band` start and `thickness` across the axis, `base` and `value` pixels along it) into rectangles. Edges are rounded, and widths and heights are the differences of the rounded edges, so two bars that share an edge still share it exactly after rounding (rounding each width separately leaves hairline gaps). A bar below its baseline gets a positive height and its `y` at the top edge, as `<rect>` needs. `vertical` bars stand on the x axis; `horizontal` bars grow along x.
## Arcs
`arcPath` takes angles in radians from 12 o'clock, increasing clockwise (as d3.arc), so a point at angle `a` and radius `r` is `(cx + r sin a, cy - r cos a)`. The sine and cosine come from `math.sin-cos`, not the platform, so arc ends round the same way everywhere. The format:
- Ring segment: `M` outer start, `A ro,ro,0,large,sweep,` outer end, `L` inner end, `A ri,ri,0,large,1-sweep,` inner start, `Z`. - `innerRadius` 0 is a wedge: after the outer arc, `L cx,cy Z`. - `large` is 1 when the span is more than half a turn (exactly half is 0); `sweep` is 1 when the arc runs clockwise (end after start), 0 otherwise. - A span of a full turn or more is a full circle. One SVG arc cannot end where it starts (it draws nothing), so it is two half arcs, `M start A ... opposite A ... start Z`; a ring adds its hole as a second subpath drawn the other way round, so the nonzero fill rule leaves it empty. - Zero span or zero outer radius is `""`. Radii must satisfy 0 <= innerRadius <= outerRadius. d3's corner radius and padRadius are not supported.
## Pie angles
`pieAngles` is d3.pie with `sort(null)`: slices in input order, each value's share of the span from `startAngle` to `endAngle` (capped at one full turn; an end before the start runs anticlockwise), with `padAngle` between slices (capped at an equal share each). One difference: d3 returns each slice's angles including its pad and leaves the arc generator to trim it; here half the pad is already trimmed from each side, so the angles go straight into `arcPath`. Angles are rounded to 6 decimal places; the running total is not, so the last slice still ends at the end angle. Zero values are empty slices; negative values and a negative pad are errors (d3 quietly treats negatives as zero).
Sources: d3-shape (github.com/d3/d3-shape: line, area, curveStep, curveMonotoneX, pie); F. N. Fritsch and R. E. Carlson, "Monotone Piecewise Cubic Interpolation", SIAM Journal on Numerical Analysis 17(2), 1980, pp. 238-246; W3C, Scalable Vector Graphics (SVG) 2, "Paths" chapter (path data grammar and elliptical arc flags, section 9.3.8, and the out-of-range parameters note on arcs whose end point equals their start).