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.

How much machinery

I pick the least machinery 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 belongs here 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 need more than the fourth option.

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.

Position by date

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.

Hover panels 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 needs 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.

Crosshair bands

A crosshair that reads every series at one date is a hover per date. The plot has 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 lists 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, but it is still smaller than a chart library and it works with JavaScript 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 does not use the brand palette. Brand colors stand apart from each other. A tag needs that separation, but a ramp needs the opposite.

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 sets its color once, as a custom property. One rule applies 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 uses the same class as its line, 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 shows a dot per option in that option's line color, so the row is also the legend. The dot is round and the legend swatch is square: one is a control and the other is a key.

Axis steps and labels

The tallest bar stops at a number the axis labels 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 for 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.

A reader who sees a tall stack in one quarter wants the rows behind it. So in the stacked column plot, each segment is an <a> in place of a <div>, with the same height style. Its URL is the page's current URL plus a period parameter, such as period=2026Q2, and the filter box that the segment stands for. The link also carries ajax-get, so a click swaps the page body without a full load.

The handler narrows the table, the count, and the CSV export to that period. It counts the chart's rows before the period narrows them, so the chart still draws every period and the reader still sees the column they picked. The picked column keeps its color. The other columns fade but stay links. A line over the table names the period and links back to all periods. A key that the window does not draw narrows nothing.

A second click on the picked column clears the pick. The handler builds one cleared URL with no period and with the chart's filter groups empty, and gives it to each segment of the picked column. The search and the other filters stay. With no clear link, a second click loaded the same page again.

The pick lives in the URL, so it survives a reload, and a reader can send it to a teammate. No JavaScript holds a selection state.

Filtering with 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 is no escape: a wasm chart draws through a canvas shim, and canvas loses text selection, find-in-page, the CSS tokens, and printing.

Two ideas from Plot are worth writing in Go. One is the nice axis above. The other is a scale as a function from domain to range, which TimeXs already is.

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. It needs five platform features: a viewBox, :hover, :focus-within, a custom property, and a form that submits on change.

← All articles