Dataviz Style Guide

Table of Contents

The companion to the Graphviz diagram canon: same six semantic color families, same Helvetica in-chart, same restraint — extended to Python (matplotlib, seaborn, plotly), Clojure (oz, hanami, Vega-Lite), and Mermaid. Drop the snippets in and your charts read as siblings of every .dot diagram on the site.

Related: wal.sh Design System.

Scope: bar, line, scatter, area, heatmap, small multiples.

1. First principles

Three rules that govern every chart. These mirror the diagram canon exactly, because a chart is a diagram with data attached. A reader who has learned to read one of our graphs should be able to read all of them.

1.1. Color encodes semantics, not series count

If a series represents success, it is green (#15803d). If it represents error, it is red (#b91c1c). Pick the family from the meaning, then assign it. A bar chart of four random metrics does not get blue/purple/green/amber by default — pick by what each metric means.

1.2. One axis is silent, one axis is loud

Gridlines: #eaeaea, only on the value axis, behind the data. Tick labels and axis title: #666. Data labels: #1a1a1a. The chart should read as one continuous tone with the data emerging from it — not as a grid of competing rectangles.

1.3. The palette is a protocol, not a preference

The same six color families serve Graphviz, matplotlib, Vega-Lite, and Mermaid. Copy the hex values exactly — full six-digit lowercase. Do not substitute a "close enough" color. The coordination cost of keeping all diagrams aligned is paid precisely once, here, and recovered every time a reader moves between pages.

2. The palette

Six families and one accent. The fill is always the 100-weight; the stroke and text are always the 700-weight. The 50-weight is for backgrounds inside the chart (cluster fills, highlighted regions).

2.1. Six semantic families

Family Fill (100) Stroke/Text (700) Cluster BG (50) Semantic role
Blue #dbeafe #1d4ed8 #eff6ff Primary data flow, input, process
Purple #ede9fe #6b21a8 #f5f3ff Abstraction, protocol, API, model
Green #dcfce7 #15803d #f0fdf4 Output, success, storage, revenue
Amber #fef3c7 #b45309 #fffbeb Config, threshold, SLO, target
Yellow #fef9c3 #a16207 #fefce8 Warning, intermediate state, cache
Red #fee2e2 #b91c1c #fef2f2 Error, critical path, failure, alert

The 100-weight fills are pastel backgrounds. The 700-weight values serve triple duty: node border, node text, and series stroke. The 50-weight is for cluster/region backgrounds — lighter than the node fill so the region recedes behind its contents.

2.2. Chart chrome: neutrals

Role Value
Page background #fafafa
Plot area background #ffffff
Axis lines, spines #666666
Gridlines #eaeaea
Tick labels, axis titles #666666
Chart title (above plot) #1a1a1a
Annotation arrows, callouts #555555
De-emphasized / ghost series #cccccc

2.3. Sequential and diverging ramps (continuous data)

When the data is continuous (a heatmap, a gradient encoding), stretch the matching family between its 50-weight and its 700-weight.

  • Sequential blue: #eff6ff#1d4ed8
  • Sequential green: #f0fdf4#15803d
  • Diverging (error vs success): #b91c1c#ffffff#15803d

Do not use jet or rainbow colormaps. Do not use the viridis or plasma defaults unless you are encoding physical quantities where those are the established convention.

3. Typography in charts

Helvetica, three sizes, always. The same rule as Graphviz: fontname"Helvetica"= wins because it renders identically across matplotlib's Agg backend, Cairo, Vega-Lite's SVG output, and Mermaid. Inter is for chrome (titles, captions surrounding the chart); it does not appear inside the plot.

Role Font Size (pt) Color Weight
Chart title (above plot) Inter 14–16 #1a1a1a 700
Subtitle / caption Inter 11–12 #666666 400
Axis title Helvetica 11 #666666 500
Tick labels Helvetica 10 #666666 400
Data labels (on-bar, point) Helvetica 10 #1a1a1a 500
Legend Helvetica 10 #666666 400
Annotation arrows / callouts Helvetica 10 #555555 400

4. Color encoding rules

Pick the family before you write the code. The archetype of the series determines its color.

4.1. Semantic assignment table

If the series represents… Family Examples
The primary thing being measured Blue requests/sec, page views, the metric in the title
A model, forecast, or fitted line Purple regression line, ARIMA forecast, predicted/actual
A positive outcome or success state Green conversion rate, uptime, "passed", revenue
A target, threshold, or configured limit Amber SLO line, budget, target throughput
An intermediate state or warning Yellow queue depth, cache misses, "degraded"
An error, failure, or breach Red error rate, 5xx count, SLO violation, downtime

4.2. Categorical with no inherent meaning

When series are genuinely peer categories (three datacenters in a comparison plot), use the families in this order: Blue, Amber, Green, Purple, Yellow, Red. This is the categorical-range defined in the Clojure palette below and reflected in axes.prop_cycle in the matplotlib style.

4.3. Highlight one series among many

The featured series uses its 700-weight. All other series collapse to #cccccc at 1 px stroke. This is more useful than a legend and less confusing than coloring everything.

5. Python: matplotlib, seaborn, plotly

One rcParams dict, three libraries. Drop this at the top of any notebook or report. matplotlib picks it up directly; seaborn inherits because it sits on top of matplotlib; plotly gets its own template that produces the same chart.

5.1. walsh.mplstyle

Paste into ~/.config/matplotlib/stylelib/walsh.mplstyle or load with plt.style.use('walsh.mplstyle').

# walsh.mplstyle — wal.sh chart style
# Load with: plt.style.use('walsh')

figure.facecolor     : #fafafa
axes.facecolor       : #ffffff
axes.edgecolor       : #666666
axes.labelcolor      : #666666
axes.titlecolor      : #1a1a1a
axes.titleweight     : 700
axes.titlesize       : 14
axes.titlepad        : 12
axes.labelsize       : 11
axes.spines.top      : False
axes.spines.right    : False
axes.grid            : True
axes.grid.axis       : y
axes.axisbelow       : True
grid.color           : #eaeaea
grid.linewidth       : 0.8
grid.linestyle       : -
xtick.color          : #666666
ytick.color          : #666666
xtick.labelsize      : 10
ytick.labelsize      : 10
xtick.direction      : out
ytick.direction      : out
font.family          : Helvetica, Arial, sans-serif
font.size            : 10

# Six semantic families, ordered for categorical use
axes.prop_cycle : cycler('color', [
    '#1d4ed8', '#b45309', '#15803d',
    '#6b21a8', '#a16207', '#b91c1c'])

legend.frameon       : False
legend.fontsize      : 10
lines.linewidth      : 2.0
lines.solid_capstyle : round
patch.edgecolor      : #1d4ed8
patch.facecolor      : #dbeafe

5.2. Named color constants

Pin the semantic names in every script so the meaning is explicit at the call site.

# walsh_colors.py — semantic palette constants
BLUE_FILL   = "#dbeafe"
BLUE_STROKE = "#1d4ed8"

PURPLE_FILL   = "#ede9fe"
PURPLE_STROKE = "#6b21a8"

GREEN_FILL   = "#dcfce7"
GREEN_STROKE = "#15803d"

AMBER_FILL   = "#fef3c7"
AMBER_STROKE = "#b45309"

YELLOW_FILL   = "#fef9c3"
YELLOW_STROKE = "#a16207"

RED_FILL   = "#fee2e2"
RED_STROKE = "#b91c1c"

# Chrome
BG         = "#fafafa"
PLOT_BG    = "#ffffff"
AXIS       = "#666666"
GRID       = "#eaeaea"
TEXT       = "#1a1a1a"
GHOST      = "#cccccc"

# Categorical order (non-semantic, peer categories)
CATEGORICAL = [BLUE_STROKE, AMBER_STROKE, GREEN_STROKE,
               PURPLE_STROKE, YELLOW_STROKE, RED_STROKE]

5.3. seaborn

seaborn inherits matplotlib's rcParams automatically. Set the palette explicitly to override seaborn's defaults.

import seaborn as sns
from walsh_colors import CATEGORICAL

sns.set_theme(style="whitegrid", rc={
    "axes.facecolor": "#ffffff",
    "figure.facecolor": "#fafafa",
    "grid.color": "#eaeaea",
})
sns.set_palette(CATEGORICAL)

5.4. plotly template

import plotly.graph_objects as go
from walsh_colors import *

walsh_template = go.layout.Template(
    layout=go.Layout(
        font=dict(family="Helvetica, Arial, sans-serif",
                  color=AXIS, size=10),
        title=dict(font=dict(color=TEXT, size=14, family="Inter")),
        paper_bgcolor=BG,
        plot_bgcolor=PLOT_BG,
        colorway=CATEGORICAL,
        xaxis=dict(gridcolor=GRID, tickfont=dict(color=AXIS)),
        yaxis=dict(gridcolor=GRID, tickfont=dict(color=AXIS)),
        legend=dict(bgcolor="rgba(0,0,0,0)", font=dict(color=AXIS)),
    )
)

import plotly.io as pio
pio.templates["walsh"] = walsh_template
pio.templates.default = "walsh"

6. Clojure: oz, hanami, Vega-Lite

Vega-Lite is the substrate under oz, hanami, and Saite. A single config map themes them all.

6.1. Palette namespace

(ns walsh.viz)

(def palette
  "Six semantic families — match the diagram canon exactly."
  {:blue   {:fill "#dbeafe" :stroke "#1d4ed8"}
   :purple {:fill "#ede9fe" :stroke "#6b21a8"}
   :green  {:fill "#dcfce7" :stroke "#15803d"}
   :amber  {:fill "#fef3c7" :stroke "#b45309"}
   :yellow {:fill "#fef9c3" :stroke "#a16207"}
   :red    {:fill "#fee2e2" :stroke "#b91c1c"}})

(def categorical-range
  "Ordered for non-semantic categorical use."
  ["#1d4ed8" "#b45309" "#15803d"
   "#6b21a8" "#a16207" "#b91c1c"])

(def config
  "Merge into every Vega-Lite spec under :config."
  {:background "#fafafa"
   :view       {:stroke "transparent"}
   :axis       {:grid true
                :gridColor "#eaeaea"
                :gridWidth 0.8
                :labelColor "#666666"
                :titleColor "#666666"
                :labelFont  "Helvetica"
                :titleFont  "Helvetica"
                :labelFontSize 10
                :titleFontSize 11}
   :title      {:color "#1a1a1a"
                :font "Inter"
                :fontSize 14
                :fontWeight 700}
   :legend     {:labelFont  "Helvetica"
                :titleFont  "Helvetica"
                :labelColor "#666666"
                :titleColor "#666666"}
   :range      {:category categorical-range}})

6.2. Use with oz

(require '[oz.core :as oz])
(require '[walsh.viz :as viz])

(oz/view!
  (merge-with merge
    {:data    {:values data}
     :mark    {:type "line" :color (get-in viz/palette [:blue :stroke])}
     :encoding {:x {:field "date" :type "temporal"}
                :y {:field "value" :type "quantitative"}}}
    {:config viz/config}))

6.3. Use with hanami

(require '[aerial.hanami.common :as hc])
(require '[aerial.hanami.templates :as ht])
(require '[walsh.viz :as viz])

(hc/update-defaults
  :USERDATA {:config viz/config})

7. Mermaid

Mermaid covers what Graphviz cannot easily do in-browser: Gantt charts, sequence diagrams, ER diagrams, pie charts. Prefix every diagram with this init block — it makes Mermaid render in the wal.sh family.

%%{init: {
  "theme": "base",
  "themeVariables": {
    "fontFamily":           "Helvetica, Arial, sans-serif",
    "fontSize":             "13px",
    "background":           "#fafafa",
    "primaryColor":         "#dbeafe",
    "primaryBorderColor":   "#1d4ed8",
    "primaryTextColor":     "#1d4ed8",
    "secondaryColor":       "#ede9fe",
    "secondaryBorderColor": "#6b21a8",
    "secondaryTextColor":   "#6b21a8",
    "tertiaryColor":        "#dcfce7",
    "tertiaryBorderColor":  "#15803d",
    "tertiaryTextColor":    "#15803d",
    "lineColor":            "#888888",
    "textColor":            "#666666",
    "mainBkg":              "#dbeafe",
    "clusterBkg":           "#ffffff"
  }
}}%%

Mermaid is appropriate for: sequence diagrams, Gantt charts, ER diagrams, state diagrams, class diagrams. Use Graphviz for any layout where you need precise edge routing or complex cluster nesting. See diagram-style-guide for the Graphviz archetypes.

8. Examples

Generated with matplotlib and gnuplot using the canonical palette.

8.1. Bar chart

Bar chart example — repositories by language

8.2. Line chart

Line chart example — 30-day latency trend, two services

8.4. Heatmap

Heatmap example — metric correlation matrix

8.5. Gnuplot

Gnuplot example — monthly anomalies with threshold

9. Chart archetypes

Six shapes. Pick the archetype before you write the spec. The archetype decides defaults; the color family decides encoding.

9.1. Bar: categorical comparison

Vertical bars when categories are short labels; horizontal when they are not. Bars are filled with the 700-weight color, no stroke, no rounding. Use one color per bar only when each bar means something different; otherwise all bars in blue 700.

Conventions:

  • Sort descending by value unless the category order is meaningful
  • Y-axis always starts at 0
  • No legend when all bars share one color — use the title instead
  • Max 12 bars before switching to a table or a top-N + "other" slice
Fig 08.1 — error rate by service (blue, ordered descending)
  2.0% |
  1.5% |  ■
  1.0% |  ■  ■
  0.5% |  ■  ■  ■  ■  ■
   0%  +--+--+--+--+--+--
       auth orders search media profile geo

9.2. Line: series over time

2 px stroke, round joins. If you have multiple series, only the primary one is blue; the rest follow the categorical order. Threshold lines are amber dashed; alert moments are red callouts (small filled triangle on the time axis).

This archetype is the site's most common chart. The advisory daily counts charts (advisory-daily-counts.png, atcscc-cache.png) use this archetype: blue primary series, amber dashed threshold at +2 SD, red dot markers on exceedances.

Conventions:

  • Show the threshold in the subtitle rather than a legend entry
  • If the series has gaps (missing data), show them as dashed segments or explicit gap markers rather than connecting across the void
  • Annotate significant events with a vertical rule in #cccccc and a short label in Helvetica 10 above the axis

9.3. Scatter: relationship between two quantities

Points: 60 px² square markers (Vega-Lite default size 60), no stroke. Use opacity: 0.6 when overplotting is likely. A fit line, if present, is purple at 1.5 px, no markers.

9.4. Stacked area: composition over time

Use only when the stack genuinely sums to a meaningful total. Each layer uses its semantic family's 100-weight fill with a 1 px 700-weight stroke between layers. Limit to 4 layers; beyond that switch to small multiples.

9.5. Heatmap: two categorical axes, one quantity

Sequential ramp from the family's 50-weight to its 700-weight (e.g., blue: #eff6ff#1d4ed8). Cells are square, 1 px gap, no border. Annotate the cell value in Helvetica 10; color flips to white when the cell is darker than #888888.

9.6. Small multiples: same chart, many slices

Grid of identical charts, one slice each. Share axes (resolve: {scale: {y: "shared"}} in Vega-Lite). Each panel's series uses the same color — the slice is the variable, not the encoding. Label panels with their slice value above the chart area, Helvetica 12, #666666.

10. Tool selection: when to use what

The right tool depends on the output format, the update frequency, and the complexity of the layout.

Scenario Tool Format Notes
System architecture, pipelines, DAGs Graphviz SVG/PNG Canonical. Edge routing, cluster nesting
Time series, bar, scatter in reports matplotlib SVG/PNG Use walsh.mplstyle; gnuplot for org-babel
Interactive dashboards or org HTML export Vega-Lite SVG Via oz or hanami in Clojure; Altair in Python
Sequence, Gantt, ER, state diagrams Mermaid SVG Use the init block from Section 7
One-off static charts in org documents gnuplot PNG/SVG Already supported in org-babel, low deps
Animated or highly custom layouts D3 / SVG SVG Last resort — only if no archetype fits

10.1. gnuplot in org-babel

gnuplot is available on the site's publishing host and renders cleanly inside org-mode without additional dependencies. Use it for charts that live inside a single .org file.

set terminal pngcairo size 800,450 enhanced font "Helvetica,10" \
    background "#fafafa"
set output 'chart.png'

set border lc rgb "#666666"
set grid ytics lc rgb "#eaeaea" lw 0.8
set xtics textcolor rgb "#666666"
set ytics textcolor rgb "#666666"
set xlabel "Date" textcolor rgb "#666666"
set ylabel "Count" textcolor rgb "#666666"
set title "Request rate" textcolor rgb "#1a1a1a" font "Helvetica,14"

# Primary series in blue 700
set style line 1 lc rgb "#1d4ed8" lw 2 pt 7 ps 0.8
# Threshold in amber dashed
set style line 2 lc rgb "#b45309" lw 1.5 dt 2
# Exceedance dots in red
set style line 3 lc rgb "#b91c1c" pt 7 ps 1.2

plot data using 1:2 with linespoints ls 1 title "", \
     threshold with lines ls 2 title ""

10.2. Graphviz vs gnuplot vs Vega-Lite decision tree

  • Is this a graph (nodes + edges)? Use Graphviz.
  • Is this a time series, bar, scatter, or distribution plot? Use gnuplot for org-mode documents, Vega-Lite for interactive or web output, matplotlib for Python notebooks.
  • Is this a sequence diagram, Gantt, or ER diagram? Use Mermaid.
  • Does it need interactivity or animation? Use Vega-Lite or D3/SVG.

11. Export and embedding

Ship SVG when you can; PNG at 2x when you cannot.

Tool Format Setting
matplotlib SVG fig.savefig('chart.svg', bbox_inches'tight', facecolor='#fafafa')=
matplotlib PNG dpi=144 (already in style); use dpi=200 for print
Vega-Lite SVG oz: (oz/export! spec "chart.svg")
Vega-Lite PNG via vl-convert: vl-convert vl2png chart.json chart.png --scale 2
Plotly SVG/PNG fig.write_image('chart.svg', width=800, height=450, scale=2)
Mermaid SVG mmdc -i diagram.mmd -o diagram.svg -t base -c theme.json
gnuplot PNG set terminal pngcairo size 800,450 font "Helvetica,10"

11.1. Embedding in org-mode

Always include #+CAPTION, #+NAME, and #+ATTR_HTML with a descriptive :alt text. The alt text describes what the chart shows, not what type of chart it is.

#+CAPTION: Request rate vs SLO target, last 6 days
#+NAME: fig:requests-slo
#+ATTR_HTML: :alt Line chart showing blue request series staying well below the amber SLO threshold of 200/s :width 700
file:chart.svg

For Graphviz diagrams built with org-babel, use :file diagram.png in the src block header and add the same caption/name/attr-html block immediately after the closing #+end_src.

12. What to avoid

These mistakes signal that the chart was not designed; it was generated and pasted.

  • Default library palettes. matplotlib's tab10, seaborn's deep, plotly's Plotly, Vega's tableau10. They make the chart look like a tutorial.
  • Rainbow or jet colormaps. Use the sequential blue ramp or the diverging red/blue ramp.
  • 3D bars, 3D pie, or any 3D anything. The third dimension adds no information and destroys the ability to read off accurate values.
  • Pie charts beyond 4 slices. Use a horizontal bar chart instead. Past 4 slices no one can compare the angles.
  • Gradient fills on bars. Bars are flat 700-weight. The gradient implies a continuous quantity that does not exist.
  • Drop shadows on data marks. This is a decorative element with no semantic content.
  • Truncated Y-axes on bar charts. Bar charts must start at 0. On line charts a truncated axis is fine and often preferable.
  • More than 6 series on one plot. Switch to small multiples.
  • Legends on a chart with one series. Use the series color in the title or subtitle instead.
  • Color as the only encoding of a categorical distinction. Always pair with shape (scatter), pattern (bar fill), or direct labels.
  • Custom hex values that "almost match" the canon. Copy the values from this page exactly — rounding to #1d50d8 or #15803e breaks the consistency contract.

Last Updated: 2026-05-22 09:22:01

build: 2026-05-22 12:59 | sha: a5e9f12