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,
};
}