web / charts
My web apps are a Go backend, hml templates, one plain CSS file, and a small TypeScript bundle with no npm and no package manager. I draw charts with the same stack. A handler computes the geometry in Go, a template writes divs or an SVG, and CSS paints them. There is no charting library and, for most charts, no JavaScript.
The charts still feel live. A hover opens at once, a crosshair lands on the nearest date, a checkbox redraws six plots, and a reader learns a color once and reads it on every page. This article is the design. web / live regions covers a page that redraws itself when data changes.
A ladder of machinery
I pick the lowest rung that answers the question the chart is for.
- Categorical and static: divs and CSS, geometry in Go. A funnel, a bar per category.
- A continuous axis, static: an SVG rendered server-side, geometry in Go. A metric over time.
- Hover detail: a panel placed in Go and shown by CSS. A crosshair that reads every series at one date is this rung too, because the dates are known before the page is sent.
- Filter, drill, or narrow to a range: an ajax round trip that re-renders the plot on the server.
- Interaction that cannot wait for a round trip: panning a range too large to refetch, or a value that follows the cursor between two marks. A script that reads the same JSON the Go builder already emits.
- WebGL, above roughly ten thousand marks on screen.
None of my charts are above rung four.
Geometry in Go
A percentage, a polygon point, and a panel's placement are data. I compute them in a function a test can call, and the template writes the result. A bar chart builder returns a struct:
type Bar struct {
Label string
Class string
Count int
Style hml.SafeCSS
Tip Tip
}
type Bars struct {
HasChart bool
Bars []Bar
Ticks []string
}
The template loops and writes attributes. It runs no arithmetic:
- if data.has_chart
.chart-bars
.chart-axis-y
- for tick in data.ticks
%span.chart-axis-y__tick
= tick
.chart-bars__plot
- for bar in data.bars
.chart-bars__col.chart-tip-host{ tabindex: "0" }
.chart-bars__bar-wrap
.chart-bars__bar{ class: bar.class, style: bar.style }
.chart-axis-x
= bar.label
= render "ui/chart_tip", data: bar.tip
A unit test asserts on the percentages and the tick labels with no browser.
A categorical chart is divs. A chart with a continuous axis is an SVG.
The SVG's viewBox is a fixed coordinate space, such as 0 0 1000 300, and CSS stretches the element to the card's width with
preserveAspectRatio="none". The geometry is ratios rather than
pixels. A filled polygon takes that stretch without distortion. A
stroked line needs vector-effect: non-scaling-stroke, or it draws
thicker where it runs vertically than where it runs across. A glyph
would distort, so axis labels and dates stay in HTML beside the SVG.
Place by time
A point on a time axis sits where its date is in the range rather than where it is in the slice. A year with two observations and a year with none look different, and a gap in reporting draws as a gap:
func TimeXs(ts []time.Time, width float64) []float64 {
xs := make([]float64, len(ts))
if len(ts) == 0 {
return xs
}
if len(ts) == 1 {
xs[0] = width / 2
return xs
}
first := ts[0]
span := ts[len(ts)-1].Sub(first).Seconds()
for i, t := range ts {
if span <= 0 {
xs[i] = width * float64(i) / float64(len(ts)-1)
continue
}
xs[i] = width * t.Sub(first).Seconds() / span
}
return xs
}
A series with one observation is a polyline of that point twice over. A round line cap draws a zero-length line as a dot.
A hover without JavaScript
The browser draws a title attribute half a second late, in an OS
font, never on touch, and never on keyboard focus. So a hover is an
element the handler renders and CSS shows.
The host is the mark's container rather than the mark, because a two-pixel line is a poor target. The host is focusable, so the panel opens on keyboard focus as well as on hover:
.chart-tip-host {
position: relative;
&:is(:hover, :focus-visible, :focus-within) {
z-index: var(--z-index--component);
> .chart-tip,
> .chart-col__line {
opacity: 1;
visibility: visible;
}
}
}
.chart-tip {
opacity: 0;
pointer-events: none;
position: absolute;
top: 100%;
transition: opacity 100ms ease 120ms, visibility 100ms ease 120ms;
visibility: hidden;
width: max-content;
max-width: min(20rem, 80vw);
}
visibility rather than display, so the panel can transition and
leaves the accessibility tree while hidden. The transition opens with
no delay and closes with a short one, so crossing two columns to reach
a third does not flash a panel at each. The mark lightens while its
panel is open, so the two read as one thing. There is no
cursor: pointer on a mark that is not a link.
A panel near the left edge of a plot opens rightward, one near the right edge opens leftward, and one in the middle centers on its mark. This is the part that usually pulls in a positioning library. The panel is a fixed width and the mark's position along the axis is known in the handler, so the handler picks the class:
func tipClassAt(pos float64) string {
switch {
case pos < 1.0/3.0:
return "chart-tip--left"
case pos > 2.0/3.0:
return "chart-tip--right"
default:
return "chart-tip--center"
}
}
By thirds rather than by first-and-last, because a centered panel needs half its width of room on each side.
The panel is one type for every chart: a title, labeled facts one per line, and an optional list of names capped at eight with a count of the rest. The title is the mark's name in full, since the axis under it abbreviates.
The crosshair
A crosshair that reads every series at one date is a hover per date. The plot carries one band per date, absolutely positioned over the SVG, and each band spans halfway to each neighbor:
func TimeColumns(xs []float64, width float64) []Column {
cols := make([]Column, 0, len(xs))
for i, x := range xs {
left, right := 0.0, width
if i > 0 {
left = (xs[i-1] + x) / 2
}
if i < len(xs)-1 {
right = (x + xs[i+1]) / 2
}
bandWidth := right - left
lineAt := 50.0
if bandWidth > 0 {
lineAt = 100 * (x - left) / bandWidth
}
cols = append(cols, Column{
Style: hml.SafeCSS(fmt.Sprintf(
"left: %.2f%%; width: %.2f%%",
100*left/width, 100*bandWidth/width)),
LineStyle: hml.SafeCSS(fmt.Sprintf("left: %.2f%%", lineAt)),
})
}
return cols
}
The band is the hover host. Its panel names every series with a value on that date. The line inside it sits at the date's own position, which is off the band's center when the neighbors are unevenly spaced. Two observations four years apart give two bands half the plot wide.
Every panel is in the HTML already. Two years of weekly points across ten series is a hundred panels of ten lines each. That is more markup than a script would need, and it is still small next to a chart library, and it works with the script off.
Two palettes
I keep two palettes, because two questions have opposite answers.
An ordered category, such as a stage in a pipeline, wants neighbors that look related, so a reader sees distance along the order. That is one ramp, cool at one end and warm at the other, with interpolated stops:
:root {
--color--stage-1: #e3f2fd;
--color--stage-2: #cbe7f8;
--color--stage-3: #a7d1ee;
--color--stage-4: #709ec5;
--color--stage-5: #3b658d;
--color--stage-6: #f6a89e;
--color--stage-7: #ea796f;
--color--stage-8: #cc4844;
}
The ramp is off the brand palette on purpose. Brand colors stand apart from each other, which is what a tag needs and the opposite of what a ramp does.
Several things compared on one plot have no order, so neighbors have to look unrelated. That is a second palette of ten distinct hues, at a lightness that reads as a two-pixel line on white. Ten is the limit. An eleventh hue is one a reader has to look up in the legend every time.
Each class names its color once, as a custom property. One rule spends it as the stroke of a line, the fill of a polygon, and the background of the legend swatch:
.chart-series--1 {
--mark: var(--color--series-1);
}
.chart-series--2 {
--mark: var(--color--series-2);
}
[class*="chart-series--"] {
background-color: var(--mark);
fill: var(--mark);
stroke: var(--mark);
}
The legend swatch wears the same class as the line it names, so a key
cannot drift from its series. The Go side is one lookup per palette,
StageClass(stage) and SeriesClass(i), written out as a switch so
a tool that finds unused CSS can see every selector is live.
On a page of six plots, the handler assigns colors once from a list it has ordered, and each series brings its own class to the builder. The same thing is the same color in all six. The checkbox row that picks what to compare carries a dot per option in that option's line color, so the row is the legend. The dot is round and the legend swatch is square: one is a control and the other is a key.
An axis a reader can add up
The tallest bar stops at a number the axis names rather than at the top of the box, so a bar scales against the axis maximum rather than the largest value in the data.
For small integers, a fixed step table is enough: 2 up to 10, 5 up to
25, 10 up to 50. For anything larger, the step is the 1/2/5 sequence
at the range's own magnitude, the nice idea from d3:
func niceStep(raw float64) float64 {
mag := math.Pow(10, math.Floor(math.Log10(raw)))
switch norm := raw / mag; {
case norm <= 1:
return mag
case norm <= 2:
return 2 * mag
case norm <= 5:
return 5 * mag
default:
return 10 * mag
}
}
The target is about four gaps, because the plots are half a card wide. A count has no half of a thing to label, so its smallest step is 1.
The axis and the hover print numbers through one function, in one format. A hover that says 1,150,000,000 next to an axis labeled 1B asks the reader to do the conversion the chart is for.
One markup, two layouts
A categorical bar chart is columns on a wide screen and rows on a narrow one, label beside bar, from the same markup. The bar's share of the axis is a custom property, set inline by Go:
Style: hml.SafeCSS(fmt.Sprintf("--bar-pct:%.1f%%", pct)),
Columns spend it as a height. Rows spend it as a width:
.chart-bars__bar {
height: var(--bar-pct);
}
@media (max-width: 70em) {
.chart-bars__col {
flex-direction: row;
}
.chart-bars__bar {
height: auto;
width: var(--bar-pct);
}
}
CSS cannot reread an inline height as a width, and a percentage is the
same number either way. So there is no breakpoint in Go, no second
partial, and no scroll container. A column has no min-width, because
the count of columns is data and the card is not always as wide as the
page.
Filtering is a round trip
A row of checkboxes above a comparison page picks what to draw. A change submits the form over ajax, and the response replaces the plots:
%form{ "ajax-get": "/charts", "ajax-submit-on-change": "1" }
- for opt in data.compare_options
%label.chart-compare__option
%input{ type: "checkbox", name: "compare[]", value: opt.value, checked: opt.selected }
%span.chart-compare__dot{ class: opt.dot_class }
%span
= opt.label
The form sits outside the element the response replaces, so the box just ticked survives the swap. The response is the same partial the page rendered on load, from the same builder. The submission is undebounced, because a click on a checkbox is a finished input. There is no copy of the selection in the browser to keep in step with the server.
Why there is no library
Observable Plot sits on about thirty d3 modules, and my apps have no package manager to maintain them with. Go compiled to WebAssembly does not remove the JavaScript, since DOM calls cross the boundary one at a time, so a wasm chart still draws through a canvas shim, and canvas gives up text selection, find-in-page, the CSS tokens, and printing.
Two ideas from Plot are worth reimplementing in Go. One is the nice
axis above. The other is scales as a type, a function from domain to
range. TimeXs is the time scale, and the linear and band scales move
into functions when a third chart needs them.
Fit
This fits a server-rendered application where the handler already
owns the data. A chart is a pure function from rows to a struct, a
template that writes attributes, and a block of shared CSS. The
pieces are a viewBox, :hover and :focus-within, a custom
property, and a form that submits on change. Each does what the
platform says it does.