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.

  1. Categorical and static: divs and CSS, geometry in Go. A funnel, a bar per category.
  2. A continuous axis, static: an SVG rendered server-side, geometry in Go. A metric over time.
  3. 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.
  4. Filter, drill, or narrow to a range: an ajax round trip that re-renders the plot on the server.
  5. 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.
  6. 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.

← All articles