Functional Weave
Code in Rust

charts.bar-chart@1.0.0

impl/typescript.ts

5,497 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 { bandAxis } from "./charts_axis_band_axis.ts";
import { gridLines } from "./charts_axis_grid_lines.ts";
import { numberAxis } from "./charts_axis_number_axis.ts";
import type { BarChart, BarChartSpec, ChartBar } from "./charts_bar_chart_types.ts";
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 { palette } from "./charts_palette.ts";  ← from charts.palette ^1.0.0 · built alongside by fune
import { bandScale } from "./charts_scale_band_scale.ts";
import { linearScale } from "./charts_scale_linear_scale.ts";
import { niceDomain } from "./charts_scale_nice_domain.ts";
import { barRects } from "./charts_shape_bar_rects.ts";
import type { BarSpec } from "./charts_shape_types.ts";
import { stack } from "./charts_stack.ts";  ← from charts.stack ^1.0.0 · built alongside by fune
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;
const CATEGORY_PADDING_INNER = 0.2;
const CATEGORY_PADDING_OUTER = 0.1;
const SERIES_PADDING = 0.05;

/**
 * A whole bar chart as geometry, grouped or stacked. 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 barChart(spec: BarChartSpec): BarChart {
  const { width, height, categories, series } = spec;
  if (spec.mode !== "grouped" && spec.mode !== "stacked") {
    throw new RangeError(`mode must be grouped or stacked, received ${spec.mode}`);
  }
  if (categories.length === 0) throw new RangeError("a bar chart needs at least one category");
  if (series.length === 0) throw new RangeError("a bar chart needs at least one series");
  for (const s of series) {
    if (s.values.length !== categories.length) {
      throw new RangeError(`series "${s.name}" has ${s.values.length} values but there are ${categories.length} categories`);
    }
  }
  const names = series.map((s) => s.name);

  let legend: LegendItem[] = [];
  let top = TOP_WITHOUT_LEGEND;
  if (series.length > 1) {
    const rows = legendRows(names, 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;
  }

  // Bars grow from zero, so zero is always in the y domain. Stacked bars
  // stack positives up and negatives down (a diverging stack).
  const stacked = spec.mode === "stacked" ? stack(series.map((s) => s.values), "diverging") : null;
  let lo = 0;
  let hi = 0;
  series.forEach((s, i) => {
    s.values.forEach((v, j) => {
      const ends = stacked === null ? [v] : [stacked[i][j].y0, stacked[i][j].y1];
      for (const e of ends) {
        if (e < lo) lo = e;
        if (e > hi) hi = e;
      }
    });
  });
  if (lo === hi) 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 y = (v: number) => linearScale(yDomain, yRange, v, false);
  const yAxis = numberAxis(yDomain, yRange, yCount, "left", plot.x, TICK_SIZE);
  const xRange = [plot.x, plot.x + plot.width];
  const xAxis = bandAxis(categories, xRange, CATEGORY_PADDING_INNER, CATEGORY_PADDING_OUTER, "bottom", bottomY, TICK_SIZE);

  const colors = palette("okabe-ito", series.length, true);
  const specs: BarSpec[] = [];
  const meta: { series: string; category: string; value: number; color: string }[] = [];
  series.forEach((s, i) => {
    categories.forEach((c, j) => {
      const outer = bandScale(categories, xRange, c, CATEGORY_PADDING_INNER, CATEGORY_PADDING_OUTER, 0.5);
      if (stacked !== null) {
        specs.push({ band: outer.start, thickness: outer.width, base: y(stacked[i][j].y0), value: y(stacked[i][j].y1) });
      } else if (series.length === 1) {
        specs.push({ band: outer.start, thickness: outer.width, base: y(0), value: y(s.values[j]) });
      } else {
        const inner = bandScale(names, [outer.start, outer.start + outer.width], s.name, SERIES_PADDING, 0, 0.5);
        specs.push({ band: inner.start, thickness: inner.width, base: y(0), value: y(s.values[j]) });
      }
      meta.push({ series: s.name, category: c, value: s.values[j], color: colors[i] });
    });
  });
  const rects = barRects(specs, "vertical");
  const bars: ChartBar[] = meta.map((m, k) => ({ ...m, rect: rects[k] }));

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