Functional Weave
Code in TypeScript

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

  • lineChart(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
  • lineChart(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
  • lineChart(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.

export function lineChart(spec: LineChartSpec): LineChart
specLineChartSpec
returnsLineChartevery pixel value rounded to 2 places; draw it with any renderer

The types it declares, generated into your project

export type LineCurve = "linear" | "monotone" | "step";

/** One line. */
export interface LineSeries {
  readonly name: string;
  /** one per x value; null is a gap */
  readonly values: readonly (number | null)[];
}

/** What to draw. */
export interface LineChartSpec {
  readonly width: number;
  readonly height: number;
  /** numeric x values, ascending; give this or dates */
  readonly x: readonly number[] | null;
  /** ISO dates for a time axis, ascending; give this or x */
  readonly dates: readonly string[] | null;
  readonly series: readonly LineSeries[];
  /** straight segments, a smooth monotone curve, or steps */
  readonly curve: LineCurve;
  /** stretch the y axis to include zero */
  readonly yZero: boolean;
}

/** One series, ready to draw. */
export interface SeriesPath {
  readonly name: string;
  /** "#rrggbb", from the Okabe-Ito palette in series order */
  readonly color: string;
  /** SVG path data for stroke, no fill */
  readonly path: string;
}

/** The whole chart; legend[i] and lines[i] are the same series. */
export interface LineChart {
  readonly width: number;
  readonly height: number;
  readonly plot: PlotArea;
  readonly xAxis: Axis;
  readonly yAxis: Axis;
  /** horizontal, one per y tick */
  readonly gridLines: readonly Line[];
  readonly lines: readonly SeriesPath[];
  /** empty for a single series */
  readonly legend: readonly LegendItem[];
}

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

import { lineChart } from "#fune/charts.line-chart@^1";
impl/typescript.ts · 142 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.

import { gridLines } from "./charts_axis_grid_lines.ts";
import { numberAxis } from "./charts_axis_number_axis.ts";
import { timeAxis } from "./charts_axis_time_axis.ts";
import type { Axis } from "./charts_axis_types.ts";
import { extent } from "./charts_extent.ts";  ← from charts.extent ^1.0.0 · built alongside by fune
import { formatTick } from "./charts_format_format_tick.ts";
import { legendRows } from "./charts_layout_legend_rows.ts";
import { plotArea } from "./charts_layout_plot_area.ts";
import type { LegendItem } from "./charts_layout_types.ts";
import type { LineChart, LineChartSpec, SeriesPath } from "./charts_line_chart_types.ts";
import { palette } from "./charts_palette.ts";  ← from charts.palette ^1.0.0 · built alongside by fune
import { linearScale } from "./charts_scale_linear_scale.ts";
import { niceDomain } from "./charts_scale_nice_domain.ts";
import { timeScale } from "./charts_scale_time_scale.ts";
import { linePath } from "./charts_shape_line_path.ts";
import { monotoneCurve } from "./charts_shape_monotone_curve.ts";
import { stepPath } from "./charts_shape_step_path.ts";
import type { Point } from "./charts_shape_types.ts";
import { niceTicks } from "./charts_ticks_nice_ticks.ts";
import { tickStep } from "./charts_ticks_tick_step.ts";
import { roundFloat } from "./math_round_float.ts";  ← from math.round-float ^1.0.0 · built alongside by fune

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

/**
 * A whole line chart as geometry. Every layout decision is a fixed rule
 * (listed in the README), so the same data gives the same chart in every
 * language and every renderer.
 */
export function lineChart(spec: LineChartSpec): LineChart {
  const { width, height, series } = spec;
  if (series.length === 0) throw new RangeError("a line chart needs at least one series");
  if ((spec.x === null) === (spec.dates === null)) {
    throw new RangeError("give exactly one of x and dates");
  }
  const count = spec.x !== null ? spec.x.length : (spec.dates as readonly string[]).length;
  for (const s of series) {
    if (s.values.length !== count) {
      throw new RangeError(`series "${s.name}" has ${s.values.length} values but there are ${count} x values`);
    }
  }

  // Legend first: it decides the top margin.
  let legend: LegendItem[] = [];
  let top = TOP_WITHOUT_LEGEND;
  if (series.length > 1) {
    const rows = legendRows(series.map((s) => s.name), width - 2 * EDGE, CHAR_WIDTH, SWATCH, LEGEND_GAP, LEGEND_ROW);
    legend = rows.map((it) => ({
      ...it,
      x: roundFloat(it.x + EDGE, 2),
      y: roundFloat(it.y + EDGE, 2),
      textX: roundFloat(it.textX + EDGE, 2),
    }));
    top = EDGE + (rows[rows.length - 1].row + 1) * LEGEND_ROW + EDGE;
  }

  // The y domain, niced, and its labels, which decide the left margin.
  const all: (number | null)[] = [];
  for (const s of series) all.push(...s.values);
  const found = extent(all);
  if (found === null) throw new RangeError("a line chart needs at least one value to plot");
  let [lo, hi] = found;
  if (spec.yZero) {
    lo = Math.min(lo, 0);
    hi = Math.max(hi, 0);
  }
  if (lo === hi) {
    lo -= 1;
    hi += 1;
  }
  const yCount = Math.max(2, Math.floor((height - top - BOTTOM) / 50));
  const yDomain = niceDomain([lo, hi], yCount).domain;
  const yStep = tickStep(yDomain[0], yDomain[1], yCount);
  let widest = 0;
  for (const v of niceTicks(yDomain[0], yDomain[1], yCount)) {
    widest = Math.max(widest, [...formatTick(v, yStep)].length);
  }
  const left = TICK_SIZE + LABEL_PADDING + widest * CHAR_WIDTH + EDGE;
  const plot = plotArea(width, height, { top, right: RIGHT, bottom: BOTTOM, left });
  const bottomY = plot.y + plot.height;
  const yRange = [bottomY, plot.y];
  const yAxis = numberAxis(yDomain, yRange, yCount, "left", plot.x, TICK_SIZE);

  // The x axis and each x value's pixel.
  const xRange = [plot.x, plot.x + plot.width];
  const xCount = Math.max(2, Math.floor(plot.width / 80));
  let xAxis: Axis;
  let xs: number[];
  if (spec.x !== null) {
    const xDomain = extent(spec.x);
    if (xDomain === null || xDomain[0] === xDomain[1]) {
      throw new RangeError("a line chart needs at least two different x values");
    }
    xAxis = numberAxis(xDomain, xRange, xCount, "bottom", bottomY, TICK_SIZE);
    xs = spec.x.map((v) => linearScale(xDomain, xRange, v, false));
  } else {
    const dates = spec.dates as readonly string[];
    let first = dates[0];
    let last = dates[0];
    for (const d of dates) {
      if (d < first) first = d;
      if (d > last) last = d;
    }
    if (dates.length === 0 || first === last) {
      throw new RangeError("a line chart needs at least two different x values");
    }
    xAxis = timeAxis([first, last], xRange, xCount, "bottom", bottomY, TICK_SIZE);
    xs = dates.map((d) => timeScale([first, last], xRange, d, false));
  }

  const colors = palette("okabe-ito", series.length, true);
  const lines: SeriesPath[] = series.map((s, i) => {
    const points: Point[] = s.values.map((v, j) => ({
      x: xs[j],
      y: v === null ? null : linearScale(yDomain, yRange, v, false),
    }));
    const path =
      spec.curve === "monotone" ? monotoneCurve(points) : spec.curve === "step" ? stepPath(points, "middle") : linePath(points);
    return { name: s.name, color: colors[i], path };
  });

  return {
    width: roundFloat(width, 2),
    height: roundFloat(height, 2),
    plot,
    xAxis,
    yAxis,
    gridLines: gridLines(yAxis, plot.width),
    lines,
    legend,
  };
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 9 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.line-chart
Download for TypeScript charts.line-chart-1.0.0-typescript.fune · 24,616 bytes sha256 7531de44ed46d07f73ee05ec8f8ecded01339336f9be681feba7d0625207d90a

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

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