Functional Weave
Code in Python

charts.line-chart

Complete line chart geometry as data: plot area, number or date axes, gridlines, series paths, colours, legend.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 11 tests, run in TypeScript, Python and Rust.

What it does

A complete line chart as data: where the plot sits, both axes with their ticks and labels, horizontal gridlines, one SVG path per series with its colour, and a legend. Nothing is drawn; any renderer (SVG, canvas, a PDF library, a native view) turns it into pixels, and because every number comes from the same rules the chart is identical whether it was built in TypeScript, Python or Rust.

It is assembled from the rest of the charts suite: `charts.extent`, `charts.scale`, `charts.ticks`, `charts.format`, `charts.axis`, `charts.shape`, `charts.layout` and `charts.palette`.

For example

  • line_chart(width 300, height 200, x 0, 1, 2, 3, 4, dates —, series ×1, curve linear, y zero true) → width 300, height 200, plot …, x axis …, y axis …, grid lines ×4, lines ×1, legend one series over numbers: y stretched to zero and niced to 0-60, no legend
  • line_chart(width 400, height 250, x —, dates 2026-01-01, 2026-02-01, 2026-03-01, 2026-04-01, series ×2, curve linear, y zero false) → width 400, height 250, plot …, x axis …, y axis …, grid lines ×4, lines ×2, legend ×2 two series over dates: monthly ticks, a legend row, and a gap that breaks line A
  • line_chart(width 200, height 150, x 0, 1, dates —, series ×1, curve step, y zero false) → width 200, height 150, plot …, x axis …, y axis …, grid lines ×3, lines ×1, legend a step curve: the step sits midway, and labels get one place for a 0.5 step

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

def line_chart(spec: LineChartSpec) -> LineChart
specLineChartSpec
returnsLineChartevery pixel value rounded to 2 places; draw it with any renderer

The types it declares, generated into your project

LineCurve = Literal["linear", "monotone", "step"]

@dataclass(frozen=True)
class LineSeries:
    """One line."""

    name: str
    #: one per x value; null is a gap
    values: List[Optional[float]]

@dataclass(frozen=True)
class LineChartSpec:
    """What to draw."""

    width: float
    height: float
    #: numeric x values, ascending; give this or dates
    x: Optional[List[float]]
    #: ISO dates for a time axis, ascending; give this or x
    dates: Optional[List[str]]
    series: List[LineSeries]
    #: straight segments, a smooth monotone curve, or steps
    curve: LineCurve
    #: stretch the y axis to include zero
    y_zero: bool

@dataclass(frozen=True)
class SeriesPath:
    """One series, ready to draw."""

    name: str
    #: "#rrggbb", from the Okabe-Ito palette in series order
    color: str
    #: SVG path data for stroke, no fill
    path: str

@dataclass(frozen=True)
class LineChart:
    """The whole chart; legend[i] and lines[i] are the same series."""

    width: float
    height: float
    plot: PlotArea
    x_axis: Axis
    y_axis: Axis
    #: horizontal, one per y tick
    grid_lines: List[Line]
    lines: List[SeriesPath]
    #: empty for a single series
    legend: List[LegendItem]

Your code names it in one line, in the file that uses it

from fune.charts.line_chart import line_chart  # charts.line-chart@^1
impl/python.py · 129 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_axis_grid_lines import grid_lines
from .charts_axis_number_axis import number_axis
from .charts_axis_time_axis import time_axis
from .charts_extent import extent  ← from charts.extent ^1.0.0 · built alongside by fune
from .charts_format_format_tick import format_tick
from .charts_layout_legend_rows import legend_rows
from .charts_layout_plot_area import plot_area
from .charts_layout_types import LegendItem, Margins
from .charts_line_chart_types import LineChart, LineChartSpec, SeriesPath
from .charts_palette import palette  ← from charts.palette ^1.0.0 · built alongside by fune
from .charts_scale_linear_scale import linear_scale
from .charts_scale_nice_domain import nice_domain
from .charts_scale_time_scale import time_scale
from .charts_shape_line_path import line_path
from .charts_shape_monotone_curve import monotone_curve
from .charts_shape_step_path import step_path
from .charts_shape_types import Point
from .charts_ticks_nice_ticks import nice_ticks
from .charts_ticks_tick_step import tick_step
from .math_round_float import round_float  ← from math.round-float ^1.0.0 · built alongside by fune

# Fixed layout constants; the README explains each.
CHAR_WIDTH = 7
SWATCH = 10
LEGEND_GAP = 16
LEGEND_ROW = 18
EDGE = 8
TICK_SIZE = 6
LABEL_PADDING = 3
TOP_WITHOUT_LEGEND = 12
RIGHT = 20
BOTTOM = 30


def line_chart(spec: LineChartSpec) -> LineChart:
    """A whole line chart as geometry, by the fixed rules in the README."""
    width, height, series = spec.width, spec.height, spec.series
    if len(series) == 0:
        raise ValueError("a line chart needs at least one series")
    if (spec.x is None) == (spec.dates is None):
        raise ValueError("give exactly one of x and dates")
    count = len(spec.x) if spec.x is not None else len(spec.dates)
    for s in series:
        if len(s.values) != count:
            raise ValueError(
                'series "%s" has %d values but there are %d x values' % (s.name, len(s.values), count)
            )

    legend = []
    top = TOP_WITHOUT_LEGEND
    if len(series) > 1:
        rows = legend_rows([s.name for s in series], width - 2 * EDGE, CHAR_WIDTH, SWATCH, LEGEND_GAP, LEGEND_ROW)
        legend = [
            LegendItem(
                label=it.label,
                row=it.row,
                x=round_float(it.x + EDGE, 2),
                y=round_float(it.y + EDGE, 2),
                width=it.width,
                text_x=round_float(it.text_x + EDGE, 2),
            )
            for it in rows
        ]
        top = EDGE + (rows[-1].row + 1) * LEGEND_ROW + EDGE

    values = [v for s in series for v in s.values]
    found = extent(values)
    if found is None:
        raise ValueError("a line chart needs at least one value to plot")
    lo, hi = found
    if spec.y_zero:
        lo = min(lo, 0.0)
        hi = max(hi, 0.0)
    if lo == hi:
        lo -= 1
        hi += 1
    y_count = max(2, int((height - top - BOTTOM) // 50))
    y_domain = nice_domain([lo, hi], y_count).domain
    y_step = tick_step(y_domain[0], y_domain[1], y_count)
    widest = 0
    for v in nice_ticks(y_domain[0], y_domain[1], y_count):
        widest = max(widest, len(format_tick(v, y_step)))
    left = TICK_SIZE + LABEL_PADDING + widest * CHAR_WIDTH + EDGE
    plot = plot_area(width, height, Margins(top=top, right=RIGHT, bottom=BOTTOM, left=left))
    bottom_y = plot.y + plot.height
    y_range = [bottom_y, plot.y]
    y_axis = number_axis(y_domain, y_range, y_count, "left", plot.x, TICK_SIZE)

    x_range = [plot.x, plot.x + plot.width]
    x_count = max(2, int(plot.width // 80))
    if spec.x is not None:
        x_domain = extent(spec.x)
        if x_domain is None or x_domain[0] == x_domain[1]:
            raise ValueError("a line chart needs at least two different x values")
        x_axis = number_axis(x_domain, x_range, x_count, "bottom", bottom_y, TICK_SIZE)
        xs = [linear_scale(x_domain, x_range, v, False) for v in spec.x]
    else:
        dates = spec.dates
        if len(dates) == 0 or min(dates) == max(dates):
            raise ValueError("a line chart needs at least two different x values")
        first, last = min(dates), max(dates)
        x_axis = time_axis([first, last], x_range, x_count, "bottom", bottom_y, TICK_SIZE)
        xs = [time_scale([first, last], x_range, d, False) for d in dates]

    colors = palette("okabe-ito", len(series), True)
    lines = []
    for i, s in enumerate(series):
        points = [
            Point(x=xs[j], y=None if v is None else linear_scale(y_domain, y_range, v, False))
            for j, v in enumerate(s.values)
        ]
        if spec.curve == "monotone":
            path = monotone_curve(points)
        elif spec.curve == "step":
            path = step_path(points, "middle")
        else:
            path = line_path(points)
        lines.append(SeriesPath(name=s.name, color=colors[i], path=path))

    return LineChart(
        width=round_float(width, 2),
        height=round_float(height, 2),
        plot=plot,
        x_axis=x_axis,
        y_axis=y_axis,
        grid_lines=grid_lines(y_axis, plot.width),
        lines=lines,
        legend=legend,
    )

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 9 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.line-chart
Download for Python charts.line-chart-1.0.0-python.fune · 23,998 bytes sha256 b399eec3bc4cd1f33373ca116340354b08b1fae121e21704c30dfa8127a38dbe

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./charts.line-chart-1.0.0-python.fune, or fetch it from a terminal with fune pull charts.line-chart@1.0.0:python.

The whole function, every language, is one file too: charts.line-chart-1.0.0.fune, 38,535 bytes, sha256 2fb332d4d4f2ac73f5de7a6d1bd32c4b4a05d6cd5b0577ba0dea2ee6dc209855. 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.line-chart

after — your function gets the result and the arguments, and returns the final result.

# fune: after charts.line-chart

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 charts.axis in charts.line-chart
# fune: replace charts.extent in charts.line-chart
# fune: replace charts.format in charts.line-chart
# fune: replace charts.layout in charts.line-chart
# fune: replace charts.palette in charts.line-chart
# fune: replace charts.scale in charts.line-chart
# fune: replace charts.shape in charts.line-chart
# fune: replace charts.ticks in charts.line-chart
# fune: replace math.round-float in charts.line-chart

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show charts.line-chart --steps.

# fune: step charts.line-chart 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.

CaseArgumentsExpected
one series over numbers: y stretched to zero and niced to 0-60, no legend width 300, height 200, x 0, 1, 2, 3, 4, dates —, series ×1, curve linear, y zero true → width 300, height 200, plot …, x axis …, y axis …, grid lines ×4, lines ×1, legend
two series over dates: monthly ticks, a legend row, and a gap that breaks line A width 400, height 250, x —, dates 2026-01-01, 2026-02-01, 2026-03-01, 2026-04-01, series ×2, curve linear, y zero false → width 400, height 250, plot …, x axis …, y axis …, grid lines ×4, lines ×2, legend ×2
a step curve: the step sits midway, and labels get one place for a 0.5 step width 200, height 150, x 0, 1, dates —, series ×1, curve step, y zero false → width 200, height 150, plot …, x axis …, y axis …, grid lines ×3, lines ×1, legend
a monotone curve flattens into a plateau without overshooting it width 200, height 150, x 0, 0.5, 1, dates —, series ×1, curve monotone, y zero false → width 200, height 150, plot …, x axis …, y axis …, grid lines ×3, lines ×1, legend
no series is an error width 300, height 200, x 0, 1, dates —, series , curve linear, y zero false → error: a line chart needs at least one series
x and dates together is an error width 300, height 200, x 0, 1, dates 2026-01-01, 2026-01-02, series ×1, curve linear, y zero false → error: give exactly one of x and dates
neither x nor dates is an error width 300, height 200, x —, dates —, series ×1, curve linear, y zero false → error: give exactly one of x and dates
a series of the wrong length is an error width 300, height 200, x 0, 1, 2, dates —, series ×1, curve linear, y zero false → error: series "A" has 2 values but there are 3 x values
every value a gap is an error width 300, height 200, x 0, 1, dates —, series ×1, curve linear, y zero false → error: a line chart needs at least one value to plot
one x value cannot make an axis width 300, height 200, x 5, 5, dates —, series ×1, curve linear, y zero false → error: a line chart needs at least two different x values
Show the other 1 test
CaseArgumentsExpected
a chart too small for its margins is an error width 40, height 40, x 0, 1, dates —, series ×1, curve linear, y zero false → error: margins leave no room to plot

More from the author

## The x axis

Give numbers in `x` or ISO dates in `dates`, never both. Numbers get a linear axis across their exact extent, with nice ticks inside it; dates get a time axis with ticks on day, week, month, quarter or year boundaries, whichever gives about one tick per 80 pixels. Every series has one value per x, and `null` is a gap that breaks the line. The x values need at least two different values; a monotone curve also needs them strictly ascending.

## The y axis

The extent of every series together, stretched to include zero when `yZero` is set, then widened to nice numbers by `charts.scale` niceDomain with about one tick per 50 pixels (at least 2). A flat series is given a domain one unit either side so it still has a height.

## The fixed layout rules

- Text is estimated at 7 pixels per character (12px sans-serif); no font is measured, so every language gets the same layout. - A legend is shown only for more than one series: swatches 10 pixels, items 16 pixels apart, rows 18 pixels high, wrapped within the chart width less 8 pixels each side, starting 8 pixels from the top-left corner. - Margins: top 12, or the legend's height plus 8 above and below it; right 20; bottom 30 (tick 6, padding 3, a line of text); left fits the widest y label: 6 + 3 + 7 per character + 8. - Ticks are 6 pixels, as d3's default. Gridlines run from the y axis across the whole plot width. - Colours are the Okabe-Ito palette in series order (`charts.palette`), repeating after eight series. `legend[i]` and `lines[i]` are the same series. - Curves: `linear` straight segments; `monotone` Fritsch-Carlson, which never overshoots the data (use it for smooth lines, never a spline that invents peaks); `step` with the step midway between points.

All pixel values are rounded to 2 decimal places, half away from zero.

A chart with no series, no values, a series of the wrong length, or a size too small for its margins is an error rather than an empty picture.

Files

PathBytes
README.md2,583
impl/python.py4,992
impl/rust.rs8,451
impl/typescript.ts5,553
vectors.json10,133