Functional Weave
Code in Python

charts.line-chart@1.0.0

impl/typescript.ts

5,553 bytes · the TypeScript implementation · view 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,
  };
}