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.
gx add chartThe 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.
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>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},
}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();
};// 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
Titlethat 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. |