charts.shape
SVG path strings for lines, areas, steps, monotone curves, bars, arcs and pie slices, identical in every language.
1.0.1 · 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.
- line_path (points: Point[]) -> string
- area_path (points: AreaPoint[]) -> string
- step_path (points: Point[], position: StepPosition) -> string
- monotone_curve (points: Point[]) -> string
- bar_rects (bars: BarSpec[], orientation: Orientation) -> Rect[]
- arc_path (arc: Arc) -> string
- pie_angles (values: float[], startAngle: float, endAngle: float, padAngle: float) -> PieSlice[]
The types it declares, generated into your project
@dataclass(frozen=True)
class Point:
"""A point of a line in pixels; a null y is a gap."""
x: float
y: Optional[float]
@dataclass(frozen=True)
class AreaPoint:
"""A point of an area in pixels: y1 is the top edge, y0 the baseline; a null y1 is a gap."""
x: float
y0: float
y1: Optional[float]
StepPosition = Literal["before", "middle", "after"]
Orientation = Literal["vertical", "horizontal"]
@dataclass(frozen=True)
class BarSpec:
"""One bar in pixels, before rounding."""
#: where the bar's band starts across the axis (x for vertical bars)
band: float
#: the band's width, at least 0
thickness: float
#: the baseline pixel, usually where the scale puts zero
base: float
#: the value's pixel
value: float
@dataclass(frozen=True)
class Rect:
"""A rectangle ready for <rect>."""
x: float
y: float
width: float
height: float
@dataclass(frozen=True)
class Arc:
"""An annular sector: angles in radians, 0 at 12 o'clock, increasing clockwise."""
cx: float
cy: float
inner_radius: float
outer_radius: float
start_angle: float
end_angle: float
@dataclass(frozen=True)
class PieSlice:
"""The drawn angles of one slice, with half the pad trimmed from each side."""
value: float
start_angle: float
end_angle: float
Once installed, your code imports each one from the group's module.
line_path 9 tests
def line_path(points: Sequence[Point]) -> str
| 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
line_path(points ×3)→ M0,0L10,20L30,15 three pointsline_path(points ×2)→ M1,2.67L3.14,0 coordinates round to 2 places on the stored value, and -0.004 prints as 0, not -0line_path(points ×2)→ M-1.5,2.25L0.13,100 negative and tie-breaking coordinates
from fune.charts.shape import line_path # 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.
from typing import List, Sequence, Tuple
from .charts_shape_types import Point
from .text_format_decimal import format_decimal ← from text.format-decimal ^1.0.0 · built alongside by fune
def line_path(points: Sequence[Point]) -> str:
"""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.
"""
out = ""
for run in point_runs(points):
for i, (x, y) in enumerate(run):
out += ("M" if i == 0 else "L") + svg_pair(x, y)
if len(run) == 1:
out += "Z"
return out
# Shared with the other path functions of this group.
def svg_pair(x: float, y: float) -> str:
""""x,y" with each number rounded to 2 places, trailing zeros dropped."""
return format_decimal(x, 2, True, "") + "," + format_decimal(y, 2, True, "")
def point_runs(points: Sequence[Point]) -> List[List[Tuple[float, float]]]:
"""The unbroken runs of a series: a point with a null y separates them."""
runs: List[List[Tuple[float, float]]] = []
current: List[Tuple[float, float]] = []
for p in points:
if p.y is None:
if current:
runs.append(current)
current = []
else:
# float() so integer inputs take the same binary64 path as the
# other languages.
current.append((float(p.x), float(p.y)))
if current:
runs.append(current)
return runsarea_path 8 tests
def area_path(points: Sequence[AreaPoint]) -> str
| points | AreaPoint[] | top edge y1 and baseline y0 per x; a null y1 breaks the area |
| returns | string |
For example
area_path(points ×3)→ M0,50L10,20L20,40L20,100L10,100L0,100Z an area down to a flat baselinearea_path(points ×2)→ M0,60L5,50L5,70L0,80Z a stacked band with its own baseline per pointarea_path(points ×4)→ M0,5L0,10ZM2,6L3,7L3,10L2,10Z a null y1 splits the area; a lone point becomes a vertical sliver
from fune.charts.shape import area_path # 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.
from typing import List, Sequence
from .charts_shape_line_path import svg_pair ← linePath, another function of this group · built into the same file, even by a slim install
from .charts_shape_types import AreaPoint
def area_path(points: Sequence[AreaPoint]) -> str:
"""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."""
out = ""
runs: List[List[AreaPoint]] = []
run: List[AreaPoint] = []
for p in points:
if p.y1 is None:
if run:
runs.append(run)
run = []
else:
run.append(p)
if run:
runs.append(run)
for run in runs:
for i, p in enumerate(run):
out += ("M" if i == 0 else "L") + svg_pair(float(p.x), float(p.y1))
for p in reversed(run):
out += "L" + svg_pair(float(p.x), float(p.y0))
out += "Z"
return outstep_path 9 tests
def step_path(points: Sequence[Point], position: StepPosition) -> str
| points | Point[] | |
| position | StepPosition | where the vertical step sits: before a point, midway, or after it |
| returns | string |
For example
step_path(points ×3, before)→ M0,0L0,10L10,10L10,5L20,5 step before: vertical first, at the previous xstep_path(points ×3, middle)→ M0,0L5,0L5,10L15,10L15,5L20,5 step middle: the jump halfway between pointsstep_path(points ×3, after)→ M0,0L10,0L10,10L20,10L20,5 step after: horizontal first, jump at the next x
from fune.charts.shape import step_path # 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.
from typing import Sequence
from .charts_shape_line_path import point_runs, svg_pair ← linePath, another function of this group · built into the same file, even by a slim install
from .charts_shape_types import Point, StepPosition
def step_path(points: Sequence[Point], position: StepPosition) -> str:
"""A step line as SVG path data, as d3.curveStepBefore, d3.curveStep and
d3.curveStepAfter draw it: horizontal then vertical moves only."""
if position == "before":
t = 0.0
elif position == "middle":
t = 0.5
elif position == "after":
t = 1.0
else:
raise ValueError("step position must be before, middle or after, received %r" % (position,))
out = ""
for run in point_runs(points):
out += "M" + svg_pair(run[0][0], run[0][1])
for i in range(1, len(run)):
px, py = run[i - 1]
x, y = run[i]
if t <= 0:
out += "L" + svg_pair(px, y) + "L" + svg_pair(x, y)
else:
# d3's own expression, so the midpoint rounds as d3's does.
x1 = px * (1 - t) + x * t
out += "L" + svg_pair(x1, py) + "L" + svg_pair(x1, y)
if 0 < t < 1 and len(run) >= 2:
lx, ly = run[-1]
out += "L" + svg_pair(lx, ly)
if len(run) == 1:
out += "Z"
return outmonotone_curve throws on bad input 10 tests
def monotone_curve(points: Sequence[Point]) -> str
| 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
monotone_curve(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 1monotone_curve(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 flatmonotone_curve(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
from fune.charts.shape import monotone_curve # 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.
from typing import Sequence
from .charts_shape_line_path import point_runs, svg_pair ← linePath, another function of this group · built into the same file, even by a slim install
from .charts_shape_types import Point
def _sign(x: float) -> float:
return -1.0 if x < 0 else 1.0
def _slope3(x0: float, y0: float, x1: float, y1: float, x2: float, y2: float) -> float:
# Fritsch–Carlson: the tangent at the middle point, limited so the curve
# cannot overshoot either neighbouring value. d3's slope3.
h0 = x1 - x0
h1 = x2 - x1
s0 = (y1 - y0) / h0
s1 = (y2 - y1) / h1
p = (s0 * h1 + s1 * h0) / (h0 + h1)
t = (_sign(s0) + _sign(s1)) * min(abs(s0), abs(s1), 0.5 * abs(p))
return t if t != 0 else 0.0
def _slope2(x0: float, y0: float, x1: float, y1: float, t: float) -> float:
return (3 * (y1 - y0) / (x1 - x0) - t) / 2
def _bezier(x0: float, y0: float, x1: float, y1: float, t0: float, t1: float) -> str:
dx = (x1 - x0) / 3
return "C" + svg_pair(x0 + dx, y0 + dx * t0) + "," + svg_pair(x1 - dx, y1 - dx * t1) + "," + svg_pair(x1, y1)
def monotone_curve(points: Sequence[Point]) -> str:
"""A smooth line that stays monotone wherever the data is: d3.curveMonotoneX,
Hermite segments with Fritsch–Carlson tangents, as cubic Béziers."""
out = ""
for run in point_runs(points):
for i in range(1, len(run)):
if not run[i][0] > run[i - 1][0]:
raise ValueError(
"monotone curve needs x strictly increasing, but %r follows %r" % (run[i][0], run[i - 1][0])
)
out += "M" + svg_pair(run[0][0], run[0][1])
n = len(run)
if n == 1:
out += "Z"
continue
if n == 2:
out += "L" + svg_pair(run[1][0], run[1][1])
continue
t0 = 0.0
for i in range(2, n):
ax, ay = run[i - 2]
bx, by = run[i - 1]
cx, cy = run[i]
t1 = _slope3(ax, ay, bx, by, cx, cy)
start = _slope2(ax, ay, bx, by, t1) if i == 2 else t0
out += _bezier(ax, ay, bx, by, start, t1)
t0 = t1
ax, ay = run[n - 2]
bx, by = run[n - 1]
out += _bezier(ax, ay, bx, by, t0, _slope2(ax, ay, bx, by, t0))
return outbar_rects throws on bad input 10 tests
def bar_rects(bars: Sequence[BarSpec], orientation: Orientation) -> List[Rect]
| bars | BarSpec[] | |
| orientation | Orientation | |
| returns | Rect[] | edges rounded to 2 places; widths and heights are differences of rounded edges, never negative |
For example
bar_rects(bars ×1, vertical)→ ×1 a vertical bar up from the baselinebar_rects(bars ×1, vertical)→ ×1 a negative vertical bar hangs below the baseline with a positive heightbar_rects(bars ×1, horizontal)→ ×1 a horizontal bar
from fune.charts.shape import bar_rects # 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.
from typing import List, Sequence
from .charts_shape_types import BarSpec, Orientation, Rect
from .math_round_float import round_float ← from math.round-float ^1.0.0 · built alongside by fune
def bar_rects(bars: Sequence[BarSpec], orientation: Orientation) -> List[Rect]:
"""Rectangles for bars. Edges are rounded to 2 places and sizes are the
differences of the rounded edges, so bars that share an edge share it
exactly, and a negative bar's height is still positive."""
if orientation not in ("vertical", "horizontal"):
raise ValueError("orientation must be vertical or horizontal, received %r" % (orientation,))
out: List[Rect] = []
for bar in bars:
if not bar.thickness >= 0:
raise ValueError("bar thickness must not be negative, received %r" % (bar.thickness,))
band = float(bar.band)
a0 = round_float(band, 2)
a1 = round_float(band + float(bar.thickness), 2)
v0 = round_float(min(float(bar.base), float(bar.value)), 2)
v1 = round_float(max(float(bar.base), float(bar.value)), 2)
across = round_float(a1 - a0, 2)
along = round_float(v1 - v0, 2)
if orientation == "vertical":
out.append(Rect(x=a0, y=v0, width=across, height=along))
else:
out.append(Rect(x=v0, y=a0, width=along, height=across))
return outarc_path throws on bad input 13 tests
def arc_path(arc: Arc) -> str
| arc | Arc | |
| returns | string | a ring segment, a wedge to the centre when innerRadius is 0, or "" for an empty arc |
For example
arc_path(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'clockarc_path(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 donutarc_path(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
from fune.charts.shape import arc_path # 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.
from .charts_shape_line_path import svg_pair ← linePath, another function of this group · built into the same file, even by a slim install
from .charts_shape_types import Arc
from .math_sin_cos import sin_cos ← from math.sin-cos ^1.0.0 · built alongside by fune
PI = 3.141592653589793
TAU = 6.283185307179586
def _arc_point(arc: Arc, r: float, angle: float) -> str:
sc = sin_cos(angle)
return svg_pair(float(arc.cx) + r * sc.sin, float(arc.cy) - r * sc.cos)
def _arc_to(arc: Arc, r: float, large: int, sweep: int, angle: float) -> str:
return "A" + svg_pair(r, r) + ",0," + str(large) + "," + str(sweep) + "," + _arc_point(arc, r, angle)
def arc_path(arc: Arc) -> str:
"""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."""
ri = float(arc.inner_radius)
ro = float(arc.outer_radius)
a0 = float(arc.start_angle)
a1 = float(arc.end_angle)
if not ri >= 0 or not ro >= ri:
raise ValueError(
"arc radii must satisfy 0 <= innerRadius <= outerRadius, received %r and %r" % (arc.inner_radius, arc.outer_radius)
)
span = a1 - a0
if span == 0 or ro == 0:
return ""
sweep = 1 if span > 0 else 0
back = 1 - sweep
direction = 1.0 if span > 0 else -1.0
if 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 so it stays empty.
half = a0 + direction * PI
out = "M" + _arc_point(arc, ro, a0) + _arc_to(arc, ro, 1, sweep, half) + _arc_to(arc, ro, 1, sweep, a0) + "Z"
if ri > 0:
other = a0 - direction * PI
out += "M" + _arc_point(arc, ri, a0) + _arc_to(arc, ri, 1, back, other) + _arc_to(arc, ri, 1, back, a0) + "Z"
return out
large = 1 if abs(span) > PI else 0
out = "M" + _arc_point(arc, ro, a0) + _arc_to(arc, ro, large, sweep, a1)
if ri > 0:
out += "L" + _arc_point(arc, ri, a1) + _arc_to(arc, ri, large, back, a0)
else:
out += "L" + svg_pair(float(arc.cx), float(arc.cy))
return out + "Z"pie_angles throws on bad input 11 tests
def pie_angles(values: Sequence[float], start_angle: float, end_angle: float, pad_angle: float) -> List[PieSlice]
| values | float[] | zero or more each, in the order the slices are drawn |
| start_angle | float | radians, 0 at 12 o'clock, clockwise |
| end_angle | float | usually startAngle + 2 pi; the span is capped at one full turn |
| pad_angle | float | radians of gap between neighbouring slices |
| returns | PieSlice[] |
For example
pie_angles(1, 1, 2, 0, 6.283, 0)→ ×3 a full pie of 1, 1 and 2 in input orderpie_angles(0, 3, 0, 1, 0)→ ×2 a zero value has no widthpie_angles(0, 0, 0, 6.283, 0)→ ×2 all zeros: every slice is empty
from fune.charts.shape import pie_angles # 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.
from typing import List, Sequence
from .charts_shape_types import PieSlice
from .math_round_float import round_float ← from math.round-float ^1.0.0 · built alongside by fune
TAU = 6.283185307179586
def pie_angles(values: Sequence[float], start_angle: float, end_angle: float, pad_angle: float) -> List[PieSlice]:
"""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 go
straight into arc_path and neighbouring slices show a gap of pad_angle."""
if not pad_angle >= 0:
raise ValueError("padAngle must not be negative, received %r" % (pad_angle,))
n = len(values)
total = 0.0
for v in values:
if not v >= 0:
raise ValueError("pie values must be zero or more, received %r" % (v,))
total += float(v)
a0 = float(start_angle)
da = min(TAU, max(-TAU, float(end_angle) - a0))
p = min(abs(da) / n, float(pad_angle)) if n > 0 else 0.0
pa = p * (-1.0 if da < 0 else 1.0)
k = (da - n * pa) / total if total > 0 else 0.0
out: List[PieSlice] = []
for v in values:
v = float(v)
a1 = a0 + (v * k if v > 0 else 0.0) + pa
out.append(PieSlice(value=v, start_angle=round_float(a0 + pa / 2, 6), end_angle=round_float(a1 - pa / 2, 6)))
a0 = a1
return outInstall
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Python 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 Python implementation. Install it without the registry with fune add ./charts.shape-1.0.1-python.fune, or fetch it from a terminal with fune pull charts.shape@1.0.1:python.
The whole function, every language, is one file too: charts.shape-1.0.1.fune, 69,441 bytes, sha256 ef0ef7d0a8ffccb6b5b5eb69ed602f9c715c8a9b4f8fbd002af1ed5e8b19cb2d. 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).
## Notices
Portions follow d3-shape (https://github.com/d3/d3-shape), Copyright 2010-2022 Mike Bostock, under the ISC License; the full notice is in NOTICE.
1.0.1 adds its attribution notices (NOTICE). The code and the tests are unchanged.