Skip to content
Gx
GitHub

Chart

A bar, line or area chart.

<chart.Chart id="chart-bar" title="Visitors" categories={[]string{"Jan", "Feb", "Mar", "Apr", "May", "Jun"}} series={[]chart.Series{
  {Name: "Desktop", Values: []float64{186, 305, 237, 73, 209, 214}},
  {Name: "Mobile", Values: []float64{80, 200, 120, 190, 130, 140}},
}} />

Installation

Run the command in the app module.

Terminal
gx add chart

The command writes these files.

File Path in the app
Chart.gx ui/chart/Chart.gx
Chart.fixtures.go ui/chart/Chart.fixtures.go
ChartCanvas.ts ui/chart/ChartCanvas.ts
styles.go ui/chart/styles.go

Copy each file to its path in the app. Change each import of a registry package to the path of that package in the app.

ui/chart/Chart.gx
package chart

props {
  // Id is the id of the root element.
  Id         string
  // Title is the caption of the chart and its accessible name.
  Title      string
  // Kind sets the drawing: Bar, Line or Area.
  Kind       Kind = Bar
  // Categories are the labels of the horizontal axis, in order.
  Categories []string
  // Series are the rows of numbers: one value for each category.
  Series     []Series
  // Class adds classes to the root element.
  Class      string = ""
  // Attrs adds HTML attributes to the root element.
  Attrs      gx.Attrs = nil
}

<figure id={p.Id} data-slot="chart" class={gx.Cx(rootClass, p.Class)} {...p.Attrs}>
  <figcaption class={captionClass}>{p.Title}</figcaption>
  <ChartCanvas table={p.tableID()} label={p.Title} kind={string(p.kind())} categories={p.Categories} series={p.Series} classes={canvasClasses} />
  <table id={p.tableID()} class={tableClass}>
    <thead>
      <tr>
        <th scope="col" class={cellClass}>{p.Title}</th>
        for _, c := range p.Categories {
          <th scope="col" class={cellClass}>{c}</th>
        }
      </tr>
    </thead>
    <tbody>
      for _, s := range p.Series {
        <tr>
          <th scope="row" class={cellClass}>{s.Name}</th>
          for _, v := range s.Values {
            <td class={cellClass}>{formatValue(v)}</td>
          }
        </tr>
      }
    </tbody>
  </table>
</figure>
ui/chart/Chart.fixtures.go
package chart

import "github.com/alternayte/gx"

var months = []string{"Jan", "Feb", "Mar", "Apr", "May", "Jun"}

var visitors = []Series{
	{Name: "Desktop", Values: []float64{186, 305, 237, 73, 209, 214}},
	{Name: "Mobile", Values: []float64{80, 200, 120, 190, 130, 140}},
}

var desktop = []Series{
	{Name: "Desktop", Values: []float64{186, 305, 237, 73, 209, 214}},
}

var ChartFixtures = gx.Fixtures[ChartProps]{
	"Bar":  {Id: "chart-bar", Title: "Visitors", Categories: months, Series: visitors},
	"Line": {Id: "chart-line", Title: "Visitors", Kind: Line, Categories: months, Series: visitors},
	"Area": {Id: "chart-area", Title: "Visitors", Kind: Area, Categories: months, Series: desktop},
}
ui/chart/ChartCanvas.ts
import type { Mount, Props, Update } from "./ChartCanvas.props";

const ns = "http://www.w3.org/2000/svg";

const svg = <K extends keyof SVGElementTagNameMap>(tag: K, attrs: Record<string, string | number>, parent?: Element): SVGElementTagNameMap[K] => {
  const node = document.createElementNS(ns, tag);
  for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, String(value));
  parent?.appendChild(node);
  return node;
};

// niceMax returns a round number at or above the largest value, and the
// step of four ticks below it.
const niceMax = (max: number): { top: number; step: number } => {
  if (max <= 0) return { top: 1, step: 0.25 };
  const rough = max / 4;
  const power = 10 ** Math.floor(Math.log10(rough));
  const step = [1, 2, 2.5, 5, 10].map((m) => m * power).find((s) => s >= rough) ?? 10 * power;
  return { top: step * 4, step };
};

// The size of the drawing in its own units. The SVG scales to the width of
// its box.
const width = 480;
const height = 240;
const pad = { top: 12, right: 12, bottom: 28, left: 44 };

// State is what one mounted chart keeps between a mount and an update.
type State = { props: Props; active: number; draw: () => void };
const states = new WeakMap<HTMLElement, State>();

// The island draws a bar, line or area chart as SVG from the data of the
// props. The server renders the same data as a table; the island takes the
// table out of view and keeps it for a screen reader. With no script the
// page shows the table.
const mount: Mount = (root, props, ctx) => {
  const table = document.getElementById(props.table);
  const c = props.classes;
  const hadClass = table?.className ?? "";
  if (table) table.className = c.hidden;

  root.className = c.root;
  const figure = root.appendChild(document.createElement("div"));
  figure.className = c.figure;
  figure.tabIndex = 0;
  figure.setAttribute("role", "img");
  const drawing = svg("svg", { viewBox: `0 0 ${width} ${height}`, class: c.svg, "aria-hidden": "true" }, figure);
  const tooltip = root.appendChild(document.createElement("div"));
  tooltip.className = c.tooltip;
  tooltip.hidden = true;
  tooltip.setAttribute("role", "status");
  const legend = root.appendChild(document.createElement("ul"));
  legend.className = c.legend;

  const state: State = { props, active: -1, draw: () => {} };
  states.set(root, state);

  const color = (i: number): string => `var(--chart-${(i % 5) + 1})`;
  const plot = { w: width - pad.left - pad.right, h: height - pad.top - pad.bottom };

  state.draw = (): void => {
    const { categories, series, kind } = state.props;
    const numbers = new Intl.NumberFormat(document.documentElement.lang || undefined);
    figure.setAttribute("aria-label", `${state.props.label}. ${series.map((s) => s.name).join(", ")}. ${categories.length} points.`);
    drawing.replaceChildren();
    const largest = Math.max(0, ...series.flatMap((s) => s.values));
    const { top, step } = niceMax(largest);
    const y = (value: number): number => pad.top + plot.h - (value / top) * plot.h;
    const band = categories.length > 0 ? plot.w / categories.length : plot.w;
    const center = (i: number): number => pad.left + band * (i + 0.5);

    for (let tick = 0; tick <= top + step / 2; tick += step) {
      svg("line", { x1: pad.left, x2: width - pad.right, y1: y(tick), y2: y(tick), class: c.grid }, drawing);
      svg("text", { x: pad.left - 6, y: y(tick), "text-anchor": "end", "dominant-baseline": "middle", class: c.axis }, drawing).textContent = numbers.format(tick);
    }
    categories.forEach((name, i) => {
      svg("text", { x: center(i), y: height - 8, "text-anchor": "middle", class: c.axis }, drawing).textContent = name;
    });
    if (state.active >= 0 && state.active < categories.length) {
      svg("rect", { x: pad.left + band * state.active, y: pad.top, width: band, height: plot.h, class: c.highlight }, drawing);
    }

    series.forEach((s, si) => {
      const fill = color(si);
      if (kind === "bar") {
        const inner = band * 0.7;
        const w = inner / series.length;
        s.values.forEach((value, i) => {
          const x = pad.left + band * i + (band - inner) / 2 + w * si;
          const bar = svg("rect", { x: x + 1, y: y(Math.max(value, 0)), width: Math.max(w - 2, 1), height: Math.max(pad.top + plot.h - y(Math.max(value, 0)), 0), rx: 2 }, drawing);
          bar.style.fill = fill;
          bar.setAttribute("data-series", s.name);
          bar.setAttribute("data-point", String(i));
        });
        return;
      }
      const points = s.values.map((value, i) => `${center(i)},${y(Math.max(value, 0))}`);
      if (kind === "area" && points.length > 0) {
        const area = svg("polygon", { points: [`${center(0)},${y(0)}`, ...points, `${center(points.length - 1)},${y(0)}`].join(" ") }, drawing);
        area.style.fill = fill;
        area.style.opacity = "0.25";
      }
      const line = svg("polyline", { points: points.join(" "), fill: "none", "stroke-width": 2, "stroke-linejoin": "round", "stroke-linecap": "round" }, drawing);
      line.style.stroke = fill;
      line.setAttribute("data-series", s.name);
      s.values.forEach((value, i) => {
        const dot = svg("circle", { cx: center(i), cy: y(Math.max(value, 0)), r: i === state.active ? 4 : 2.5 }, drawing);
        dot.style.fill = fill;
        dot.setAttribute("data-series", s.name);
        dot.setAttribute("data-point", String(i));
      });
    });

    legend.replaceChildren();
    series.forEach((s, si) => {
      const item = legend.appendChild(document.createElement("li"));
      item.className = c.legendItem;
      const swatch = item.appendChild(document.createElement("span"));
      swatch.className = c.swatch;
      swatch.style.background = color(si);
      item.appendChild(document.createTextNode(s.name));
    });

    if (state.active >= 0 && state.active < categories.length) {
      tooltip.hidden = false;
      tooltip.replaceChildren();
      tooltip.appendChild(document.createElement("strong")).textContent = categories[state.active];
      for (const s of series) {
        const row = tooltip.appendChild(document.createElement("div"));
        row.textContent = `${s.name}: ${numbers.format(s.values[state.active] ?? 0)}`;
      }
      // The tooltip follows the active point across the width of the box.
      tooltip.style.left = `${(center(state.active) / width) * 100}%`;
    } else {
      tooltip.hidden = true;
    }
  };

  const setActive = (index: number): void => {
    const count = state.props.categories.length;
    const next = index < 0 || count === 0 ? -1 : Math.min(index, count - 1);
    if (next === state.active) return;
    state.active = next;
    state.draw();
  };

  const listen = { signal: ctx.abort };
  figure.addEventListener(
    "pointermove",
    (event) => {
      const box = drawing.getBoundingClientRect();
      const x = ((event.clientX - box.left) / box.width) * width;
      const count = state.props.categories.length;
      if (x < pad.left || x > width - pad.right || count === 0) return setActive(-1);
      setActive(Math.floor(((x - pad.left) / plot.w) * count));
    },
    listen,
  );
  figure.addEventListener("pointerleave", () => setActive(-1), listen);
  figure.addEventListener("blur", () => setActive(-1), listen);
  figure.addEventListener(
    "keydown",
    (event) => {
      const last = state.props.categories.length - 1;
      switch (event.key) {
        case "ArrowRight":
          setActive(Math.min(state.active + 1, last));
          break;
        case "ArrowLeft":
          setActive(state.active <= 0 ? 0 : state.active - 1);
          break;
        case "Home":
          setActive(0);
          break;
        case "End":
          setActive(last);
          break;
        case "Escape":
          setActive(-1);
          break;
        default:
          return;
      }
      event.preventDefault();
    },
    listen,
  );
  state.draw();

  return () => {
    states.delete(root);
    if (table) table.className = hadClass;
    root.replaceChildren();
    root.className = "";
  };
};

export default mount;

// New data from the server draws again. The active point stays.
export const update: Update = (props, root) => {
  const state = states.get(root);
  if (!state) return;
  state.props = props;
  state.draw();
};
ui/chart/styles.go
// Package chart is a bar, line or area chart. The server renders the data
// as a table, and the ChartCanvas island draws it as SVG.
package chart

import "strconv"

// Kind is the drawing of a chart.
type Kind string

// The kinds of chart.Chart.
const (
	Bar  Kind = "bar"
	Line Kind = "line"
	Area Kind = "area"
)

// Series is one row of numbers with a name.
type Series struct {
	// Name is the name of the series in the legend.
	Name string `json:"name"`
	// Values holds one value for each category.
	Values []float64 `json:"values"`
}

// ChartCanvasProps are the props of the island that draws the chart.
type ChartCanvasProps struct {
	// Table is the id of the data table that the island takes out of view.
	Table string `json:"table"`
	// Label is the accessible name of the drawing.
	Label string `json:"label"`
	// Kind is "bar", "line" or "area".
	Kind string `json:"kind"`
	// Categories are the labels of the horizontal axis.
	Categories []string `json:"categories"`
	// Series are the rows of numbers.
	Series []Series `json:"series"`
	// Classes holds the classes of the parts. They are Go constants, so
	// the stylesheet build sees them.
	Classes CanvasClasses `json:"classes"`
}

// CanvasClasses are the classes of the parts that the island draws.
type CanvasClasses struct {
	Root       string `json:"root"`
	Figure     string `json:"figure"`
	SVG        string `json:"svg"`
	Grid       string `json:"grid"`
	Axis       string `json:"axis"`
	Highlight  string `json:"highlight"`
	Tooltip    string `json:"tooltip"`
	Legend     string `json:"legend"`
	LegendItem string `json:"legendItem"`
	Swatch     string `json:"swatch"`
	Hidden     string `json:"hidden"`
}

const rootClass = "flex w-full max-w-lg flex-col gap-2 text-foreground"

const captionClass = "text-sm font-medium"

// tableClass is the classes of the data table with no script.
const tableClass = "w-full border-collapse text-sm"

const cellClass = "border border-border px-2 py-1 text-left font-normal"

var canvasClasses = CanvasClasses{
	Root:       "relative block",
	Figure:     "block rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
	SVG:        "block h-auto w-full",
	Grid:       "stroke-border",
	Axis:       "fill-muted-foreground text-[11px]",
	Highlight:  "fill-muted opacity-60",
	Tooltip:    "pointer-events-none absolute top-2 z-10 -translate-x-1/2 rounded-md border border-border bg-popover px-2 py-1.5 text-xs whitespace-nowrap text-popover-foreground shadow-md",
	Legend:     "mt-2 flex flex-wrap items-center justify-center gap-4 text-xs text-muted-foreground",
	LegendItem: "flex items-center gap-1.5",
	Swatch:     "inline-block size-2.5 rounded-xs",
	// The island keeps the table for a screen reader and takes it out of
	// view.
	Hidden: "sr-only",
}

// kind returns the drawing; a zero value is Bar.
func (p ChartProps) kind() Kind {
	switch p.Kind {
	case Line, Area:
		return p.Kind
	}
	return Bar
}

func (p ChartProps) tableID() string { return p.Id + "-data" }

// formatValue writes one number of the table with no trailing zeros.
func formatValue(v float64) string {
	return strconv.FormatFloat(v, 'f', -1, 64)
}

The theme must define these tokens: --chart-1, --chart-2, --chart-3, --chart-4, --chart-5, --border, --muted, --muted-foreground, --popover, --popover-foreground, --ring.

Usage

<chart.Chart id="visitors" title="Visitors" categories={p.Months} series={p.Visitors} />
<chart.Chart id="revenue" title="Revenue" kind={chart.Line} categories={p.Months} series={p.Revenue} />

The server renders the data as a table. The island draws the same data as SVG and keeps the table for a screen reader. The colours of the series are the theme tokens --chart-1 to --chart-5. A patch with new data draws the chart again and keeps the active point. With no script the page shows the table.

Examples

Bar

<chart.Chart id="chart-bar" title="Visitors" categories={[]string{"Jan", "Feb", "Mar", "Apr", "May", "Jun"}} series={[]chart.Series{
  {Name: "Desktop", Values: []float64{186, 305, 237, 73, 209, 214}},
  {Name: "Mobile", Values: []float64{80, 200, 120, 190, 130, 140}},
}} />

Line

<chart.Chart id="chart-line" title="Visitors" kind={chart.Line} categories={[]string{"Jan", "Feb", "Mar", "Apr", "May", "Jun"}} series={[]chart.Series{
  {Name: "Desktop", Values: []float64{186, 305, 237, 73, 209, 214}},
  {Name: "Mobile", Values: []float64{80, 200, 120, 190, 130, 140}},
}} />

Area

<chart.Chart id="chart-area" title="Visitors" kind={chart.Area} categories={[]string{"Jan", "Feb", "Mar", "Apr", "May", "Jun"}} series={[]chart.Series{
  {Name: "Desktop", Values: []float64{186, 305, 237, 73, 209, 214}},
}} />

API reference

A tag sets a prop by its name with a lower-case first letter: Class is class.

chart.Chart

Prop Type Default Description
Id string Required Id is the id of the root element.
Title string Required Title is the caption of the chart and its accessible name.
Kind Kind Bar Kind sets the drawing: Bar, Line or Area.
Categories []string Required Categories are the labels of the horizontal axis, in order.
Series []Series Required Series are the rows of numbers: one value for each category.
Class string "" Class adds classes to the root element.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.

Do and do not

Do

  • Give the chart a Title that names the data.
  • Give each series one value for each category.
  • Use at most five series. The theme has five chart colours.

Don't

  • Do not use a chart for two or three numbers. Write them as text.
  • Do not use colour as the only difference between two series that a reader must compare. Name them in the text.

Keyboard

Key Action
Tab Moves focus to the chart.
Arrow Right, Arrow Left Shows the values of the next or previous category.
Home, End Shows the values of the first or last category.
Escape Hides the values.