Visualize.Theme (Visualize v0.8.0)

Copy Markdown View Source

The colors, 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 colors, :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. Its colors are one color table (spec/08 §7.1, D-173): the theme's own colors — its hues, such as :red, and names by use, the roles among them — over the default theme's and the CSS named colors, with the furniture and the series as names too, so Visualize.Color.color(:red) is the theme's red. Nothing in the library carries a color 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 color 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 colors interpolated in CIE Lab. {:ramp, name, t} is the color at t in 0..1, and the status roles are by default fixed points on the status ramp: the table holds :good as {:ramp, :status, 0}, :warning as 0.5 and :bad as 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.

Whole styles: the style palette

styles carries the same design over to whole styles (spec/08 §7.7, D-171): style series :style_1 … :style_n, which {:style, i} cycles over; the five roles as whole looks; the view states :hover, :focused, :selected, :active, :disabled, :muted, :emphasis and :breakpoint; the furniture styles :axis, :grid, :label, :title, :caption, :value and :annotation; and the modifiers :bold, :italic, :thin, :thick, :dashed, :dotted and :outline, each changing one thing (D-172). Entries are style nodes in their parts (fill, stroke, font, …) written in slots, the names are the library's, and each entry is held to its group's contract when a theme is built. style/2 reads one, the default theme's where the theme lacks it.

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 color, cycling.

A name of the style palette (spec/08 §7.7): :style_k, a role, a state or a furniture name, or {:style, i} for the i-th style series entry, cycling.

t()

Functions

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

:ok, or {:error, subject, message} for the first ramp, color or palette style of the theme that breaks its contract (spec/08 §7.1, §7.5, §7.7) — subject is {:ramp, name}, {:color, name} or {:style, name} and message names the term broken with the values measured. Ramps are read first, then the color table's entries and the :info and :neutral contracts, then the palette.

Whether name is of the theme's color table (spec/08 §7.1, §7.6): name in color_names(theme), answered without building the list — so a caller that tests many names does not rebuild and sort the table per name (#704, D-184).

Every name of the theme's color table (spec/08 §7.1): its color slots, its own and the default theme's colors, and the CSS named colors, sorted.

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

The rule that switches a chart to the theme's counterpart in the viewer's color scheme (spec/08 §7.3, #668): @media (prefers-color-scheme: dark) — light for a theme that declares a light counterpart — around selector declaring every property of the built-in counterpart as stylesheet/1 writes them. nil for a theme with no counterpart, or one that is not a built-in theme.

The theme's color 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 or a color table name: --vis- and the name with _ as - (spec/08 §7.3). A slot, a CSS named color or a name of the default theme's table; any other atom raises ArgumentError.

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 grayscale. 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 theme's root defaults, every part defined (spec/08 §7.1, spec/14 §3.8, D-174): the library's base — what an element with no value for a key already draws — with the theme's own defaults laid over it by the merge rule.

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, colors and styles 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, colors and styles 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 color-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 grayscale, where the ramp's falling lightness and each role's glyph still carry the order.

The color 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 color.

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 palette entry name as a style node in its parts (spec/08 §7.7, D-172): the theme's own, or the default theme's entry of that name, whole, where the theme lacks it.

The theme's palette names (spec/08 §7.7): its style series :style_1 … :style_n — the default theme's ten when it has none of its own — then the roles, the state styles and the furniture styles.

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

Every ramp, color and palette style of the theme that breaks its contract (spec/08 §7.5, §7.7), 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 color, cycling.

style_name()

@type style_name() :: atom() | {:style, pos_integer()}

A name of the style palette (spec/08 §7.7): :style_k, a role, a state or a furniture name, or {:style, i} for the i-th style series entry, cycling.

t()

@type t() :: %Visualize.Theme{
  axis: String.t(),
  background: String.t(),
  colors: %{
    optional(atom() | String.t()) => String.t() | Visualize.Color.value()
  },
  dark: atom() | String.t() | nil,
  defaults: map(),
  font_family: String.t(),
  font_size: number(),
  grid: String.t(),
  label_size: number(),
  light: atom() | String.t() | nil,
  name: atom() | String.t(),
  ramps: %{optional(ramp_name()) => [String.t()]},
  series: [String.t(), ...],
  styles: %{optional(atom()) => map()},
  surface: String.t(),
  text: String.t(),
  title_size: number()
}

Functions

builtin(name)

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

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

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

check(theme)

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

:ok, or {:error, subject, message} for the first ramp, color or palette style of the theme that breaks its contract (spec/08 §7.1, §7.5, §7.7) — subject is {:ramp, name}, {:color, name} or {:style, name} and message names the term broken with the values measured. Ramps are read first, then the color table's entries and the :info and :neutral contracts, then the palette.

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

color_name?(theme, name)

@spec color_name?(t(), term()) :: boolean()

Whether name is of the theme's color table (spec/08 §7.1, §7.6): name in color_names(theme), answered without building the list — so a caller that tests many names does not rebuild and sort the table per name (#704, D-184).

iex> theme = Visualize.Theme.default()
iex> Enum.map([:red, :bad, :axis, :series_3, :steelblue], &Visualize.Theme.color_name?(theme, &1))
[true, true, true, true, true]
iex> Enum.map([:series_99, :thick, "red"], &Visualize.Theme.color_name?(theme, &1))
[false, false, false]

color_names(theme)

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

Every name of the theme's color table (spec/08 §7.1): its color slots, its own and the default theme's colors, and the CSS named colors, sorted.

iex> names = Visualize.Theme.color_names(Visualize.Theme.default())
iex> Enum.all?([:red, :amber, :bad, :axis, :series_3, :steelblue], &(&1 in names))
true

color_safe()

@spec color_safe() :: t()

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

color_scheme(theme, selector)

@spec color_scheme(t(), String.t()) :: String.t() | nil

The rule that switches a chart to the theme's counterpart in the viewer's color scheme (spec/08 §7.3, #668): @media (prefers-color-scheme: dark) — light for a theme that declares a light counterpart — around selector declaring every property of the built-in counterpart as stylesheet/1 writes them. nil for a theme with no counterpart, or one that is not a built-in theme.

iex> css = Visualize.Theme.color_scheme(Visualize.Theme.default(), "#chart")
iex> String.starts_with?(css, "@media (prefers-color-scheme: dark) {") and css =~ "--vis-series-1: #7fb3e6;"
true
iex> Visualize.Theme.color_scheme(Visualize.Theme.color_safe(), "#chart")
nil

color_slots(theme)

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

The theme's color 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 or a color table name: --vis- and the name with _ as - (spec/08 §7.3). A slot, a CSS named color or a name of the default theme's table; any other atom raises ArgumentError.

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 grayscale. 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).

defaults(theme)

@spec defaults(t()) :: map()

The theme's root defaults, every part defined (spec/08 §7.1, spec/14 §3.8, D-174): the library's base — what an element with no value for a key already draws — with the theme's own defaults laid over it by the merge rule.

iex> Visualize.Theme.default() |> Visualize.Theme.defaults() |> Map.fetch!(:stroke)
%{opacity: 1, width: 1, join: :miter, cap: :butt, placement: :center}
iex> Visualize.Theme.new(defaults: %{stroke: %{width: 1.5}}) |> Visualize.Theme.defaults() |> get_in([:stroke, :width])
1.5

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 color 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, colors and styles merged key by key over the theme's own; the checks of new/1 apply.

iex> theme = Visualize.Theme.new(name: :house, styles: %{muted: %{opacity: 0.4}})
iex> Visualize.Theme.style(theme, :muted)
%{opacity: 0.4}
iex> Visualize.Theme.new(name: :house, colors: %{warning: "#f5c242"})
** (ArgumentError) theme "house": style :warning breaks its contract: fill {:color, :warning} is 1.65:1 against #ffffff; a role style's paint must reach 3:1 (WCAG 1.4.11, non-text contrast)

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, colors and styles are merged key by key.

An unknown field raises KeyError; an empty series, or a ramp, role or palette style that breaks its contract (spec/08 §7.5, §7.7), 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 color-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 grayscale, 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 color 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 color.

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.

style(theme, name)

@spec style(t(), style_name()) :: map()

The palette entry name as a style node in its parts (spec/08 §7.7, D-172): the theme's own, or the default theme's entry of that name, whole, where the theme lacks it.

name is an atom of style_names/1, or {:style, i} for the i-th style series entry, cycling over the theme's style series. In a role's entry :cue — a stroke's dash, a marker's shape — becomes the role's dash and glyph (cue/1), and a solid dash leaves the key out. Any other name raises ArgumentError.

iex> Visualize.Theme.style(Visualize.Theme.default(), :warning)
%{fill: %{type: :solid, color: {:color, :warning}}, stroke: %{type: :solid, color: {:color, :warning}, dash: "6 3"}, marker: %{shape: :triangle}}
iex> Visualize.Theme.style(Visualize.Theme.dark(), {:style, 12}) == Visualize.Theme.style(Visualize.Theme.dark(), :style_2)
true
iex> Visualize.Theme.style(Visualize.Theme.default(), :muted)
%{opacity: 0.25}

style_names(theme)

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

The theme's palette names (spec/08 §7.7): its style series :style_1 … :style_n — the default theme's ten when it has none of its own — then the roles, the state styles and the furniture styles.

iex> Visualize.Theme.default() |> Visualize.Theme.style_names() |> Enum.drop(10)
[:good, :warning, :bad, :info, :neutral, :hover, :focused, :selected, :active, :disabled, :muted, :emphasis, :breakpoint, :axis, :grid, :label, :title, :caption, :value, :annotation, :bold, :italic, :thin, :thick, :dashed, :dotted, :outline]

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 | :color | :style, atom()}, String.t()}]

Every ramp, color and palette style of the theme that breaks its contract (spec/08 §7.5, §7.7), 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.build(Visualize.Theme.default(), ramps: %{status: ~w(#c23238 #bd7407 #4ca34b)}, colors: %{neutral: "#ff0000"})
iex> theme |> Visualize.Theme.violations() |> Enum.map(&elem(&1, 0))
[{:ramp, :status}, {:color, :neutral}]
iex> Visualize.Theme.violations(Visualize.Theme.dark())
[]