Visualize.Theme (Visualize v0.5.1)

Copy Markdown View Source

The colours, font and sizes a chart draws with (spec/08 §7).

A theme is a struct of named slots: :series_1 … :series_n for the categorical colours, :axis, :grid, :text, :background and :surface — the plane the data is laid on, between the background and the grid — the five roles :good, :warning, :bad, :info and :neutral, and :font_family, :font_size, :label_size and :title_size. Nothing in the library carries a colour literal; every axis, mark and component reads its values through resolve/3, in the mode its render target needs:

  • :literal - the stored value, which a canvas needs
  • :css - a custom-property reference with the literal as its fallback, var(--vis-axis, #666666), which the SVG output carries so a stylesheet can override it from any ancestor (D-55)

stylesheet/1 writes a .vis-theme-<name> rule declaring every slot, so a consumer switches every chart under an element of that class without touching Elixir.

Meaning: ramps and roles

A series colour promises distinct, never a meaning. A theme also carries four ramps whose meaning the library fixes (spec/08 §7.5, D-135) — :status (good → caution → bad), :thermal (cold → neutral → hot), :diverging (below → at → above a midpoint) and :sequential (less → more) — each a list of anchor colours interpolated in CIE Lab. {:ramp, name, t} is the colour at t in 0..1, and the status roles are fixed points on the status ramp: :good is {:ramp, :status, 0}, :warning is 0.5 and :bad is 1. Every ramp and role is checked against its contract — hue order, lightness direction, a neutral midpoint — when a theme is built by new/1 or merge/2, and a theme that breaks one raises ArgumentError naming the ramp and the term broken.

Examples

iex> Visualize.Theme.resolve(Visualize.Theme.default(), :axis, :css)
"var(--vis-axis, #666666)"
iex> Visualize.Theme.resolve(Visualize.Theme.dark(), {:series, 11}, :literal)
"#7fb3e6"
iex> Visualize.Theme.new(font_family: "Inter, sans-serif").font_family
"Inter, sans-serif"
iex> Visualize.Theme.resolve(Visualize.Theme.default(), :warning, :literal)
"#bd7407"
iex> Visualize.Theme.resolve(Visualize.Theme.default(), {:ramp, :status, 0.5}, :css)
"var(--vis-warning, #bd7407)"

Summary

Types

How a slot renders: a CSS reference with fallback, or the stored value.

One of the four ramps whose meaning the library fixes (spec/08 §7.5).

A position along a ramp: t in 0..1, clamped.

A slot name, or {:series, i} for the i-th series colour, cycling.

t()

Functions

The built-in theme of a name — :default, :dark, :colour_safe or :print — or nil.

:ok, or {:error, subject, message} for the first ramp or role of the theme that breaks its meaning contract (spec/08 §7.5) — subject is {:ramp, name} or {:role, name} and message names the term broken with the values measured.

The colour-vision-safe theme (spec/08 §7.2): default/0 with its status ramp blue → orange → red, which survives the commonest colour-vision deficiencies, in place of green → amber → red. Every role and ramp position a design names follows it unchanged.

The theme's colour slots — the series, then axis, grid, text, background and surface, then the five roles (spec/08 §7.1).

The custom-property name of a slot atom: --vis- and the slot with _ as -.

A role's cue besides its hue (spec/08 §7.5, D-142): its glyph, a symbol type, and its dash, a stroke pattern (nil for solid), so a status reads in greyscale. Fixed by the library for every theme. Raises ArgumentError for anything but a role.

The dark theme: light text and axes on #1b1e24 (spec/08 §7.2).

The light theme: dark text and axes on white (spec/08 §7.2).

The contrast ink for a text drawn on under (spec/08 §7.3, D-123): the literal of the theme's text or background, whichever has the higher WCAG contrast ratio against it — text on a tie — or #000000/#ffffff, whichever contrasts more, when neither slot reaches 4.5:1. One of black and white always does, so the ink always meets WCAG AA.

The theme with the given fields replaced, ramps and roles merged key by key over the theme's own; the checks of new/1 apply.

The mode a backend needs: :css for Visualize.Backend.SVG, :literal for any other.

default/0 with the given fields replaced; ramps and roles are merged key by key.

The print theme (spec/08 §7.2, spec/15 §12.4): paper. A white background and surface, so a printer lays no ink under the data; near-black text and axes and a grid dark enough to survive a photocopy; the default's series; and the colour-safe theme's blue → orange → red status ramp, since a printed page is read in whatever light and by whatever eyes it reaches, often in greyscale, where the ramp's falling lightness and each role's glyph still carry the order.

The colour at t along the theme's ramp name, as a literal: the anchors are spread evenly over 0..1 and interpolated in CIE Lab, a position on an anchor is the anchor as written, and t is clamped to 0..1 (spec/08 §7.5). A theme that lacks the ramp takes the default theme's — never a series colour.

The four ramps whose meaning the library fixes (spec/08 §7.5).

The slot's value: the stored literal, or a CSS custom-property reference that falls back to it (spec/08 §7.3).

The five roles: the status ramp's three fixed points, then :info and :neutral.

The :series_k slot the positive index i cycles to over the theme's series.

The theme's slot names: :series_1 to :series_n, then the fixed slots.

The .vis-theme-<name> CSS rule declaring every slot of the theme, one line each.

Every ramp and role of the theme that breaks its meaning contract (spec/08 §7.5), as {subject, message} in the order check/1 reads them — the contract run on each alone, so a theme editor names every fault where it is and not only the first (spec/14 §20.4).

Types

mode()

@type mode() :: :css | :literal

How a slot renders: a CSS reference with fallback, or the stored value.

ramp_name()

@type ramp_name() :: :status | :thermal | :diverging | :sequential

One of the four ramps whose meaning the library fixes (spec/08 §7.5).

ramp_position()

@type ramp_position() :: {:ramp, ramp_name(), number()}

A position along a ramp: t in 0..1, clamped.

slot()

@type slot() :: atom() | {:series, pos_integer()}

A slot name, or {:series, i} for the i-th series colour, cycling.

t()

@type t() :: %Visualize.Theme{
  axis: String.t(),
  background: String.t(),
  font_family: String.t(),
  font_size: number(),
  grid: String.t(),
  label_size: number(),
  name: atom(),
  ramps: %{optional(ramp_name()) => [String.t()]},
  roles: %{optional(:info | :neutral) => String.t()},
  series: [String.t(), ...],
  surface: String.t(),
  text: String.t(),
  title_size: number()
}

Functions

builtin(arg1)

@spec builtin(atom()) :: t() | nil

The built-in theme of a name — :default, :dark, :colour_safe or :print — or nil.

iex> Visualize.Theme.builtin(:colour_safe).name
:colour_safe
iex> Visualize.Theme.builtin(:house)
nil

check(theme)

@spec check(t()) :: :ok | {:error, {:ramp | :role, atom()}, String.t()}

:ok, or {:error, subject, message} for the first ramp or role of the theme that breaks its meaning contract (spec/08 §7.5) — subject is {:ramp, name} or {:role, name} and message names the term broken with the values measured.

The check new/1 and merge/2 raise on, for a caller that reports instead.

iex> Visualize.Theme.check(Visualize.Theme.dark())
:ok
iex> theme = %{Visualize.Theme.default() | ramps: %{status: ~w(#c23238 #bd7407 #4ca34b)}}
iex> {:error, {:ramp, :status}, message} = Visualize.Theme.check(theme)
iex> message =~ "good (0) must be green or blue"
true

colour_safe()

@spec colour_safe() :: t()

The colour-vision-safe theme (spec/08 §7.2): default/0 with its status ramp blue → orange → red, which survives the commonest colour-vision deficiencies, in place of green → amber → red. Every role and ramp position a design names follows it unchanged.

colour_slots(theme)

@spec colour_slots(t()) :: [atom(), ...]

The theme's colour slots — the series, then axis, grid, text, background and surface, then the five roles (spec/08 §7.1).

css_name(slot)

@spec css_name(atom()) :: String.t()

The custom-property name of a slot atom: --vis- and the slot with _ as -.

cue(role)

@spec cue(atom()) :: %{glyph: atom(), dash: String.t() | nil}

A role's cue besides its hue (spec/08 §7.5, D-142): its glyph, a symbol type, and its dash, a stroke pattern (nil for solid), so a status reads in greyscale. Fixed by the library for every theme. Raises ArgumentError for anything but a role.

iex> Visualize.Theme.cue(:warning)
%{glyph: :triangle, dash: "6 3"}

dark()

@spec dark() :: t()

The dark theme: light text and axes on #1b1e24 (spec/08 §7.2).

default()

@spec default() :: t()

The light theme: dark text and axes on white (spec/08 §7.2).

ink(theme, under)

@spec ink(t(), String.t() | Visualize.Color.t() | nil) :: String.t()

The contrast ink for a text drawn on under (spec/08 §7.3, D-123): the literal of the theme's text or background, whichever has the higher WCAG contrast ratio against it — text on a tie — or #000000/#ffffff, whichever contrasts more, when neither slot reaches 4.5:1. One of black and white always does, so the ink always meets WCAG AA.

under is a colour string Visualize.Color.parse/1 reads, a %Visualize.Color{}, or nil for nothing under the text — the theme's own background. An unreadable string is nil, and a slot whose own value does not parse is skipped.

iex> theme = Visualize.Theme.default()
iex> Visualize.Theme.ink(theme, "#3b6fa8")
"#ffffff"
iex> Visualize.Theme.ink(theme, "#ffd098")
"#333333"
iex> Visualize.Theme.ink(theme, nil)
"#333333"
iex> Visualize.Theme.ink(theme, "#777777")
"#000000"

merge(theme, fields)

@spec merge(t(), keyword() | map()) :: t()

The theme with the given fields replaced, ramps and roles merged key by key over the theme's own; the checks of new/1 apply.

mode(backend)

@spec mode(module() | atom() | nil) :: mode()

The mode a backend needs: :css for Visualize.Backend.SVG, :literal for any other.

Takes what Visualize.Backend.resolve/1 takes: a module, :svg, :canvas or nil.

new(fields)

@spec new(keyword() | map()) :: t()

default/0 with the given fields replaced; ramps and roles are merged key by key.

An unknown field raises KeyError; an empty series, or a ramp or role that breaks its contract (spec/08 §7.5), raises ArgumentError.

print()

@spec print() :: t()

The print theme (spec/08 §7.2, spec/15 §12.4): paper. A white background and surface, so a printer lays no ink under the data; near-black text and axes and a grid dark enough to survive a photocopy; the default's series; and the colour-safe theme's blue → orange → red status ramp, since a printed page is read in whatever light and by whatever eyes it reaches, often in greyscale, where the ramp's falling lightness and each role's glyph still carry the order.

ramp(theme, name, t)

@spec ramp(t(), ramp_name(), number()) :: String.t()

The colour at t along the theme's ramp name, as a literal: the anchors are spread evenly over 0..1 and interpolated in CIE Lab, a position on an anchor is the anchor as written, and t is clamped to 0..1 (spec/08 §7.5). A theme that lacks the ramp takes the default theme's — never a series colour.

iex> theme = Visualize.Theme.default()
iex> Visualize.Theme.ramp(theme, :status, 1)
"#c23238"
iex> Visualize.Theme.ramp(theme, :thermal, 0.25)
"#8da5cb"

ramp_names()

@spec ramp_names() :: [ramp_name(), ...]

The four ramps whose meaning the library fixes (spec/08 §7.5).

resolve(theme, slot, mode)

@spec resolve(t(), slot() | ramp_position(), mode()) :: String.t() | number()

The slot's value: the stored literal, or a CSS custom-property reference that falls back to it (spec/08 §7.3).

Sizes are numbers under :literal and gain px under :css. A ramp position {:ramp, name, t} is its literal in both modes — CSS has no property for each point of a continuum — except where a status role stands, {:ramp, :status, 0 | 0.5 | 1}, which resolves as :good, :warning or :bad, so a role and its position never disagree.

role_names()

@spec role_names() :: [atom(), ...]

The five roles: the status ramp's three fixed points, then :info and :neutral.

series_slot(theme, i)

@spec series_slot(t(), pos_integer()) :: atom()

The :series_k slot the positive index i cycles to over the theme's series.

slots(theme)

@spec slots(t()) :: [atom()]

The theme's slot names: :series_1 to :series_n, then the fixed slots.

stylesheet(theme)

@spec stylesheet(t()) :: String.t()

The .vis-theme-<name> CSS rule declaring every slot of the theme, one line each.

Include it in a stylesheet and put class="vis-theme-<name>" on any ancestor of the charts that should take the theme.

violations(theme)

@spec violations(t()) :: [{{:ramp | :role, atom()}, String.t()}]

Every ramp and role of the theme that breaks its meaning contract (spec/08 §7.5), as {subject, message} in the order check/1 reads them — the contract run on each alone, so a theme editor names every fault where it is and not only the first (spec/14 §20.4).

iex> theme = %{Visualize.Theme.default() | ramps: %{status: ~w(#c23238 #bd7407 #4ca34b)}, roles: %{neutral: "#ff0000"}}
iex> theme |> Visualize.Theme.violations() |> Enum.map(&elem(&1, 0))
[{:ramp, :status}, {:role, :neutral}]
iex> Visualize.Theme.violations(Visualize.Theme.dark())
[]