Functional Weave
Code in Python

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.

  1. line_path (points: Point[]) -> string
  2. area_path (points: AreaPoint[]) -> string
  3. step_path (points: Point[], position: StepPosition) -> string
  4. monotone_curve (points: Point[]) -> string
  5. bar_rects (bars: BarSpec[], orientation: Orientation) -> Rect[]
  6. arc_path (arc: Arc) -> string
  7. 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
pointsPoint[]in pixels, in drawing order; a null y breaks the line
returnsstring"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 points
  • line_path(points ×2) → M1,2.67L3.14,0 coordinates round to 2 places on the stored value, and -0.004 prints as 0, not -0
  • line_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
impl/python/line_path.py · 45 lines · open · raw

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 runs

area_path 8 tests

def area_path(points: Sequence[AreaPoint]) -> str
pointsAreaPoint[]top edge y1 and baseline y0 per x; a null y1 breaks the area
returnsstring

For example

  • area_path(points ×3) → M0,50L10,20L20,40L20,100L10,100L0,100Z an area down to a flat baseline
  • area_path(points ×2) → M0,60L5,50L5,70L0,80Z a stacked band with its own baseline per point
  • area_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
impl/python/area_path.py · 29 lines · open · raw

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 out

step_path 9 tests

def step_path(points: Sequence[Point], position: StepPosition) -> str
pointsPoint[]
positionStepPositionwhere the vertical step sits: before a point, midway, or after it
returnsstring

For example

  • step_path(points ×3, before) → M0,0L0,10L10,10L10,5L20,5 step before: vertical first, at the previous x
  • step_path(points ×3, middle) → M0,0L5,0L5,10L15,10L15,5L20,5 step middle: the jump halfway between points
  • step_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
impl/python/step_path.py · 35 lines · open · raw

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 out

monotone_curve throws on bad input 10 tests

def monotone_curve(points: Sequence[Point]) -> str
pointsPoint[]x must strictly increase within each unbroken run
returnsstringcubic 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 1
  • monotone_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 flat
  • monotone_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
impl/python/monotone_curve.py · 62 lines · open · raw

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 out

bar_rects throws on bad input 10 tests

def bar_rects(bars: Sequence[BarSpec], orientation: Orientation) -> List[Rect]
barsBarSpec[]
orientationOrientation
returnsRect[]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 baseline
  • bar_rects(bars ×1, vertical) → ×1 a negative vertical bar hangs below the baseline with a positive height
  • bar_rects(bars ×1, horizontal) → ×1 a horizontal bar
from fune.charts.shape import bar_rects  # charts.shape@^1
impl/python/bar_rects.py · 28 lines · open · raw

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 out

arc_path throws on bad input 13 tests

def arc_path(arc: Arc) -> str
arcArc
returnsstringa 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'clock
  • arc_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 donut
  • arc_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
impl/python/arc_path.py · 50 lines · open · raw

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]
valuesfloat[]zero or more each, in the order the slices are drawn
start_anglefloatradians, 0 at 12 o'clock, clockwise
end_anglefloatusually startAngle + 2 pi; the span is capped at one full turn
pad_anglefloatradians of gap between neighbouring slices
returnsPieSlice[]

For example

  • pie_angles(1, 1, 2, 0, 6.283, 0) → ×3 a full pie of 1, 1 and 2 in input order
  • pie_angles(0, 3, 0, 1, 0) → ×2 a zero value has no width
  • pie_angles(0, 0, 0, 6.283, 0) → ×2 all zeros: every slice is empty
from fune.charts.shape import pie_angles  # charts.shape@^1
impl/python/pie_angles.py · 32 lines · open · raw

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 out

Install

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
Download for Python charts.shape-1.0.1-python.fune · 43,299 bytes sha256 2376f8882fd4a00dfbf121d9bfd3dd26bc22f4e70d3b8b21143938fc0b26f56f

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

CaseArgumentsExpected
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

CaseArgumentsExpected
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

CaseArgumentsExpected
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

CaseArgumentsExpected
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

CaseArgumentsExpected
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

CaseArgumentsExpected
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
CaseArgumentsExpected
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

CaseArgumentsExpected
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
CaseArgumentsExpected
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.

Files

PathBytes
NOTICE836
README.md5,399
impl/python/arc_path.py2,022
impl/python/area_path.py921
impl/python/bar_rects.py1,310
impl/python/line_path.py1,474
impl/python/monotone_curve.py2,207
impl/python/pie_angles.py1,288
impl/python/step_path.py1,275
impl/rust/arc_path.rs2,703
impl/rust/area_path.rs1,522
impl/rust/bar_rects.rs2,019
impl/rust/line_path.rs1,866
impl/rust/monotone_curve.rs2,572
impl/rust/pie_angles.rs1,995
impl/rust/step_path.rs1,592
impl/typescript/arc_path.ts1,969
impl/typescript/area_path.ts850
impl/typescript/bar_rects.ts1,225
impl/typescript/line_path.ts1,350
impl/typescript/monotone_curve.ts2,502
impl/typescript/pie_angles.ts1,249
impl/typescript/step_path.ts1,284
vectors.json13,740