Status: Implemented
This document specifies the general-purpose modules used across the library: Visualize.Color (parsing, colour spaces and manipulation), Visualize.Interpolate (interpolator factories), Visualize.Ease (easing curves), Visualize.Random (seeded random variates), Visualize.Data (statistics and data shaping), Visualize.Data.Table (tabular sources as rows) and Visualize.Theme (the colours, font and sizes every mark draws with). All are pure except Random, which reads and advances the calling process's :rand state.
1. Color
1.1 Struct
%Visualize.Color{r, g, b, opacity} with r, g, b in [0, 255] and opacity in [0, 1] (default 1.0). Constructors round channels to integers; interpolation may leave them as floats.
1.2 Parsing
parse/1 trims and lower-cases its argument and returns a colour or nil. A non-binary argument returns nil. Accepted syntaxes:
- Hex:
#rgb,#rgba,#rrggbb,#rrggbbaa. Short digits are doubled; an alpha byte becomesopacity = a / 255. Any other length after#returnsnil; a non-hex digit raisesArgumentError. rgb(r, g, b)andrgba(r, g, b, a): integer channels, optional decimal alpha, arbitrary whitespace around separators. Percentages are not accepted. A non-matching string returnsnil.hsl(h, s, l)andhsla(h, s, l, a): decimal components,%optional onsandl. A non-matching string returnsnil.- Named colours: the 148 CSS colour keywords (the 147 classic names plus
rebeccapurple, with bothgrayandgreyspellings). Unknown names returnnil.
named/1 returns the lower-case hex string for a keyword (case-insensitively) or nil; named_colors/0 returns the keyword list in unspecified order.
1.3 Constructors
rgb/3,4clamps channels to[0, 255]and opacity to[0, 1]without rounding.hsl/3,4takeshin degrees (truncated to an integer and wrapped into[0, 360)),sandlin[0, 100](clamped); channels are rounded.lab/3,4takes CIE Lab* (l0–100,aandbroughly −128–127), converts through XYZ with reference whiteXn = 0.96422, Yn = 1, Zn = 0.82521(the d3-color constants) to sRGB, clamps and rounds.hcl/3,4takes hue in degrees, chroma and luminance, converts to Lab (a = c cos h,b = c sin h) and delegates tolab/4.
1.4 Conversions
| Conversion | Output |
|---|---|
to_rgb/1 | {r, g, b} rounded integers. |
to_rgba/1 | {r, g, b, opacity}. |
to_hsl/1 | %{h, s, l, opacity} with h a float in [0, 360), s and l in [0, 100]. |
to_lab/1 | %{l, a, b, opacity}. |
to_hcl/1 | %{h, c, l, opacity} with h in [0, 360). |
to_hex/1 | #rrggbb in lower case, with aa appended when opacity < 1. |
to_string/1 | rgb(r, g, b) when opacity == 1, otherwise rgba(r, g, b, opacity) with the opacity printed as-is. |
1.5 Manipulation
brighter/1,2anddarker/1,2:kdefaults to 1; the colour is converted to Lab,Lis increased (decreased) by18 * k, and converted back. The factor is additive in Lab luminance, not multiplicative in RGB.saturate/1,2multiplies HSL saturation byamount(default1.2);desaturate/1,2issaturate/2with default0.8.rotate/2adds degrees to the HSL hue.grayscale/1sets every channel toround(0.299 r + 0.587 g + 0.114 b).invert/1sets each channel to255 - channel.with_opacity/2sets a clamped opacity.luminance/1is WCAG relative luminance in[0, 1];contrast/2is(L_light + 0.05) / (L_dark + 0.05)in[1, 21].mix/2isinterpolate_rgb(c1, c2, 0.5).
1.6 Interpolation
Each interpolate_*/3 takes two colours and t (not clamped) and returns a colour. interpolate_rgb/3 interpolates channels and opacity linearly and leaves them unrounded. interpolate_hsl/3 and interpolate_hcl/3 interpolate in HSL and HCL respectively, taking the shorter arc around the hue circle; interpolate_lab/3 interpolates in Lab. Results other than RGB pass through the corresponding constructor and are rounded.
1.7 Functions
| Function | Contract |
|---|---|
Visualize.Color.parse/1 | Parses hex, rgb(), hsl() or a keyword; nil when unrecognised (1.2). |
Visualize.Color.named/1 | Hex string for a colour keyword, or nil. |
Visualize.Color.named_colors/0 | All 148 colour keywords. |
Visualize.Color.rgb/3 | rgb(r, g, b, 1.0). |
Visualize.Color.rgb/4 | Colour from clamped RGB channels and opacity. |
Visualize.Color.hsl/3 | hsl(h, s, l, 1.0). |
Visualize.Color.hsl/4 | Colour from HSL; hue truncated to a whole degree. |
Visualize.Color.lab/3 | lab(l, a, b, 1.0). |
Visualize.Color.lab/4 | Colour from CIE Lab, clamped to sRGB. |
Visualize.Color.hcl/3 | hcl(h, c, l, 1.0). |
Visualize.Color.hcl/4 | Colour from HCL via Lab. |
Visualize.Color.to_rgb/1 | {r, g, b} integers. |
Visualize.Color.to_rgba/1 | {r, g, b, opacity}. |
Visualize.Color.to_hsl/1 | HSL map. |
Visualize.Color.to_lab/1 | Lab map. |
Visualize.Color.to_hcl/1 | HCL map. |
Visualize.Color.to_hex/1 | Lower-case hex, with alpha when translucent. |
Visualize.Color.to_string/1 | CSS rgb()/rgba() string. |
Visualize.Color.brighter/1 | brighter(color, 1). |
Visualize.Color.brighter/2 | Lab luminance plus 18 k. |
Visualize.Color.darker/1 | darker(color, 1). |
Visualize.Color.darker/2 | Lab luminance minus 18 k. |
Visualize.Color.with_opacity/2 | Copy with clamped opacity. |
Visualize.Color.saturate/1 | saturate(color, 1.2). |
Visualize.Color.saturate/2 | HSL saturation multiplied by amount. |
Visualize.Color.desaturate/1 | saturate(color, 0.8). |
Visualize.Color.desaturate/2 | saturate(color, amount). |
Visualize.Color.rotate/2 | Hue rotated by degrees. |
Visualize.Color.grayscale/1 | Luma-weighted grey. |
Visualize.Color.invert/1 | Channel complement. |
Visualize.Color.luminance/1 | WCAG relative luminance. |
Visualize.Color.contrast/2 | WCAG contrast ratio. |
Visualize.Color.mix/2 | Equal RGB mix. |
Visualize.Color.interpolate_rgb/3 | Linear RGB interpolation, unrounded. |
Visualize.Color.interpolate_hsl/3 | HSL interpolation, shortest hue arc. |
Visualize.Color.interpolate_lab/3 | Lab interpolation. |
Visualize.Color.interpolate_hcl/3 | HCL interpolation, shortest hue arc. |
2. Interpolate
Every function in Visualize.Interpolate returns a one-argument function of t. t is not clamped unless stated; callers pass [0, 1] for the nominal range.
2.1 Colour inputs and output
rgb/2, hsl/2 and hsl_long/2 accept each endpoint as a hex string with or without # (3 or 6 digits), an {r, g, b} tuple, or one of thirteen basic names (red, green, blue, white, black, yellow, cyan, magenta, orange, purple, pink, gray, grey). Anything else is treated as black. %Visualize.Color{} structs are not accepted. The returned function yields #RRGGBB with upper-case hex digits (as produced by Integer.to_string/2).
2.2 Interpolators
| Factory | Input | Returned function |
|---|---|---|
number/2 | Two numbers | t -> a + (b - a) * t. |
round/2 | Two numbers | t -> round(a + (b - a) * t), an integer. |
discrete/1 | Non-empty list of n values | t -> values[clamp(floor(t * n), 0, n - 1)]. |
rgb/2 | Two colours (2.1) | Per-channel linear blend, rounded and clamped, as hex. |
hsl/2 | Two colours | HSL blend with the shorter hue arc, as hex. |
hsl_long/2 | Two colours | HSL blend with the longer hue arc, as hex. |
array/2 | Two equal-length number lists | Element-wise number/2; returns a list. |
datetime/2 | Two DateTime | Millisecond-precision blend, rounded; returns a DateTime. |
date/2 | Two Date | Day-precision blend, rounded; returns a Date. |
string/2 | Two strings | Numbers embedded in b (pattern -?\d+\.?\d*) are blended pairwise with the numbers in a; non-numeric text is taken from b. When a has fewer numbers the missing ones start from 0; extra numbers in a are ignored. A whole result prints as an integer, otherwise as a float rounded to 4 decimals. |
transform/2 | Two SVG transform strings | Recognises the first translate(x[, y]) (missing y is 0), scale(s[, sy]) (a single value applies to both axes) and rotate(a) in each string; rotate(a, cx, cy) parses as 0. A component present in only one string is held constant; one absent from both defaults to identity. Output is translate(x, y) scale(sx, sy) rotate(r) joined by spaces, omitting components equal to identity, or "". Numbers print as integers when whole, otherwise rounded to 4 decimals. |
basis/1 | List of at least two numbers | Uniform cubic B-spline through the values with clamped end conditions; t is clamped to [0, 1], and t = 0 and t = 1 return the first and last values exactly. |
zoom/2 | Two views [cx, cy, w] | van Wijk–Nuij smooth zoom with ρ = 1.4; returns [cx, cy, w]. When the centres coincide (distance below 1.0e-6) only w changes, exponentially. |
2.3 Functions
| Function | Contract |
|---|---|
Visualize.Interpolate.number/2 | Linear numeric interpolator. |
Visualize.Interpolate.round/2 | Linear interpolator rounded to an integer. |
Visualize.Interpolate.discrete/1 | Stepped interpolator over a non-empty list. |
Visualize.Interpolate.rgb/2 | RGB colour interpolator returning hex. |
Visualize.Interpolate.hsl/2 | HSL interpolator, short hue arc. |
Visualize.Interpolate.hsl_long/2 | HSL interpolator, long hue arc. |
Visualize.Interpolate.array/2 | Element-wise interpolator over equal-length lists. |
Visualize.Interpolate.datetime/2 | DateTime interpolator at millisecond precision. |
Visualize.Interpolate.date/2 | Date interpolator at day precision. |
Visualize.Interpolate.string/2 | Interpolates numbers embedded in a string template. |
Visualize.Interpolate.transform/2 | Interpolates translate, scale and rotate of SVG transform strings. |
Visualize.Interpolate.basis/1 | B-spline interpolator through a value list. |
Visualize.Interpolate.zoom/2 | Smooth zoom interpolator between [cx, cy, w] views. |
3. Ease
Visualize.Ease provides 25 easing functions from t in [0, 1] to a float, nominally in [0, 1] (the elastic_* and back_* families overshoot). names/0 returns the 25 atoms below in this order; by_name/1 returns the arity-1 function for an atom and raises FunctionClauseError for any other atom. frames/2 with count > 1 returns count values at t = i / (count - 1) for i in 0..count - 1; frames(_, 1) returns [1.0].
3.1 Formulas
With c1 = 1.70158, c2 = c1 * 1.525, c3 = c1 + 1, c4 = 2π / 3, c5 = 2π / 4.5, n1 = 7.5625, d1 = 2.75:
| Name | Formula |
|---|---|
:linear | t |
:quad_in | t² |
:quad_out | t (2 - t) |
:quad_in_out | t < 0.5: 2t²; else -1 + (4 - 2t) t |
:cubic_in | t³ |
:cubic_out | (t - 1)³ + 1 |
:cubic_in_out | t < 0.5: 4t³; else (2t - 2)³ / 2 + 1 |
:sin_in | 1 - cos(πt / 2) |
:sin_out | sin(πt / 2) |
:sin_in_out | -(cos(πt) - 1) / 2 |
:exp_in | 2^(10(t - 1)); exactly 0.0 for the integer 0 |
:exp_out | 1 - 2^(-10t); exactly 1.0 for the integer 1 |
:exp_in_out | t < 0.5: 2^(20t - 10) / 2; else (2 - 2^(-20t + 10)) / 2; exact at integer 0 and 1 |
:circle_in | 1 - sqrt(1 - t²) |
:circle_out | sqrt(1 - (t - 1)²) |
:circle_in_out | t < 0.5: (1 - sqrt(1 - 4t²)) / 2; else (sqrt(1 - (-2t + 2)²) + 1) / 2 |
:elastic_in | -2^(10t - 10) sin((10t - 10.75) c4); exact at integer 0 and 1 |
:elastic_out | 2^(-10t) sin((10t - 0.75) c4) + 1; exact at integer 0 and 1 |
:elastic_in_out | t < 0.5: -2^(20t - 10) sin((20t - 11.125) c5) / 2; else 2^(-20t + 10) sin((20t - 11.125) c5) / 2 + 1; exact at integer 0 and 1 |
:back_in | c3 t³ - c1 t² |
:back_out | 1 + c3 (t - 1)³ + c1 (t - 1)² |
:back_in_out | t < 0.5: (2t)² ((c2 + 1) 2t - c2) / 2; else ((2t - 2)² ((c2 + 1)(2t - 2) + c2) + 2) / 2 |
:bounce_in | 1 - bounce_out(1 - t) |
:bounce_out | t < 1/d1: n1 t²; t < 2/d1: n1 (t - 1.5/d1)² + 0.75; t < 2.5/d1: n1 (t - 2.25/d1)² + 0.9375; else n1 (t - 2.625/d1)² + 0.984375 |
:bounce_in_out | t < 0.5: (1 - bounce_out(1 - 2t)) / 2; else (1 + bounce_out(2t - 1)) / 2 |
The exact-endpoint clauses match the integer literals 0 and 1 only; the floats 0.0 and 1.0 fall through to the formula (exp_in(0.0) is 2^-10).
Parameterised variants: poly_in/2, poly_out/2 and poly_in_out/2 take an exponent e (default 3): t^e; 1 - (1 - t)^e; t < 0.5: 2^(e-1) t^e else 1 - (-2t + 2)^e / 2. back_in/2 and back_out/2 take an overshoot s replacing c1 (with c3 = s + 1); there is no back_in_out/2.
3.2 Functions
4. Random
Visualize.Random draws from the process-local :rand generator. seed/1 calls :rand.seed(:exsss, {s, 2s, 3s}) and returns the new state, making subsequent draws in the calling process reproducible.
4.1 Distributions
| Family | Parameters and defaults | Result |
|---|---|---|
uniform/0,1,2 | min = 0, max = 1 | Float in [min, max). |
uniform_int/2 | min, max | Integer in [min, max]. |
normal/0,1,2 | mean = 0, stddev = 1 | Box–Muller normal variate. |
log_normal/0,1,2 | mu = 0, sigma = 1 | exp(normal(mu, sigma)). |
exponential/0,1 | lambda = 1 | -ln(1 - u) / lambda. |
pareto/0,1 | alpha = 1 | 1 / (1 - u)^(1 / alpha), minimum 1. |
bernoulli/0,1 | p = 0.5 | 1 with probability p, else 0. |
binomial/2 | n, p | Number of successes in n Bernoulli trials; MUST be 0 for n = 0. |
geometric/1 | p | Number of failures before the first success, trunc(ln(1 - u) / ln(1 - p)). |
poisson/1 | lambda | Knuth's multiplication method. |
gamma/1,2 | shape > 0, scale = 1 | Marsaglia–Tsang; shape < 1 is boosted through shape + 1. |
beta/2 | alpha, beta | x / (x + y) with x = gamma(alpha), y = gamma(beta). |
weibull/1,2 | shape, scale = 1 | scale * (-ln(1 - u))^(1 / shape). |
cauchy/0,1,2 | location = 0, scale = 1 | location + scale * tan(π (u - 0.5)). |
4.2 Points and collections
in_circle/0..3(cx = 0,cy = 0,radius = 1): uniform over the disc usingr = radius * sqrt(u).on_circle/0..3(same defaults): uniform on the circumference.in_rect/4(x0, y0, x1, y1): uniform over the rectangle.samples/2(fun, n): a list ofncalls of the zero-arity function; MUST be[]forn = 0.shuffle/1:Enum.shuffle/1.sample/2(list, n):ndistinct elements of a shuffled list (fewer when the list is shorter).pick/1: a random element, ornilfor[].
binomial(0, p) is 0 and samples(fun, 0) is []: both iterate 1..n//1, which is empty for n = 0 (D-44).
4.3 Functions
5. Data
Visualize.Data shadows Kernel.min/2 and Kernel.max/2 inside the module. Every statistic takes an optional arity-1 accessor as its last argument (nil means the elements are the values).
5.1 Statistics
| Statistic | Empty input | Result |
|---|---|---|
min/1,2, max/1,2 | nil | Least or greatest value by term order. |
extent/1,2 | nil | {min, max}. |
sum/1,2 | 0 | Sum. |
mean/1,2 | nil | Arithmetic mean (float). |
median/1,2 | nil | Middle of the sorted values; mean of the two middles for even counts. |
quantile/2,3 | nil | Linear interpolation at index (n - 1) * p of the sorted values; p MUST be in [0, 1] (otherwise FunctionClauseError). |
variance/1,2 | nil; also nil for one element | Sample variance with n - 1 denominator. |
deviation/1,2 | nil | Square root of variance/2. |
5.2 Grouping and shaping
group/2—Enum.group_by/2: a map from key to the elements with that key, in input order.rollup/3—group/2followed by applying the reducer to each group's list: a map from key to reduced value.bin/1,2— histogram bins. Options::thresholds— an integer countn(default 10), producingn - 1interior thresholds evenly spaced over the domain and hencenbins, or an explicit list of thresholds producinglength + 1bins;:domain—[min, max], defaulting to the data extent. Returns[%{x0, x1, values}]where each value is placed in the first bin withx0 <= v < x1, a value equal to the lastx1goes in the last bin, and values outside the domain are dropped;valueskeep input order.[]yields[].cross/2,3— the Cartesian product of two lists as{a, b}tuples, or ascombine.(a, b)when a two-arity function is given.range/2,3—start, stop, step = 1:max(0, ceil((stop - start) / step))valuesstart + i * step, so the result is empty whenstopis not beyondstartin the direction ofstep; negative steps count down.stepMUST be non-zero (otherwiseFunctionClauseError).ticks/3—start, stop, count > 0: nice tick values. The step is10^ktimes 1, 2, 5 or 10, chosen from|stop - start| / countwith the d3 thresholdssqrt(2),sqrt(10),sqrt(50); ticks run fromceil(start / step) * steptofloor(stop / step) * stepinclusive, rounded to 10 decimals, and are[]when no multiple of the step lies inside the span. A zero span returns[start];start > stopreturns the ticks of the reversed span in descending order.countMUST be positive (otherwiseFunctionClauseError).sort/2,3—Enum.sort_by/3on the accessor,:asc(default) or:desc.unique/1,2—Enum.uniq/1, orEnum.uniq_by/2with an accessor.lttb/2,3andm4/2,3— shape-preserving downsampling of a series (#448): a subsequence of the input elements, never new ones, so a tooltip over a kept point reads a real datum. The accessor returns{x, y}; without one the elements are{x, y}tuples. A datum is defined when bothxandyare numbers; any other datum is a gap. The defined data split at the gaps into runs, and both functions reduce each run separately and keep exactly the first element of every run of gaps, in place — so a line drawn over the result still breaks where the input broke (spec/04'sdefined/2). Both are one pass over the input plus a constant amount per element: O(n) time, and O(n) space for the random access a run's buckets need. Both read the elements in input order and expectxascending within a run, as a series is; neither sorts.lttb(data, n, accessor)— Largest-Triangle-Three-Buckets (Steinarsson 2013, Downsampling Time Series for Visual Representation, §4.2).nis an integer>= 3(otherwiseFunctionClauseError). Input ofnor fewer elements, gaps counted, is returned unchanged. A single run ofL > nelements keeps exactlyn: its first and last, and one per bucket of then − 2buckets that split the interior evenly — bucketi(0 ≤ i < n − 2) is the interior indices[floor(i·e) + 1, floor((i + 1)·e) + 1)withe = (L − 2) / (n − 2)— choosing in each the element that forms the largest triangle with the element chosen in the bucket before it (the first element, for bucket0) and the mean point of the bucket after it (indices[floor((i + 1)·e) + 1, min(floor((i + 2)·e) + 1, L)), which for the last bucket holds the last element); a tie keeps the earliest. With gaps, a run ofLdefined elements out ofDin all has the budgetb = max(min(L, 2), floor(n · L / D)): a run withb ≥ Lis kept whole,b = 2keeps its first and last, and otherwise LTTB withb. The total is therefore aboutnand exactlynwithout gaps.m4(data, width, accessor)— M4 (Jugel et al. 2014, M4: A Visualization-Oriented Time Series Data Aggregation): per pixel column, the first, last, minimum-yand maximum-yelements.widthis an integer>= 1(otherwiseFunctionClauseError), the column count. The columns split the definedxextent[x_min, x_max]evenly: a defined datum's column ismin(width − 1, floor((x − x_min) / (x_max − x_min) · width)), every datum column0whenx_min == x_max. A column visit is a maximal stretch of consecutive defined elements of one run in one column; each visit keeps its first, its last, its earliest leastyand its earliest greatesty, deduplicated and in input order — so at most four per visit, and a run whose every visit has four or fewer elements is kept whole. Drawn as a line on a plotwidthpixels wide whosexscale spans[x_min, x_max], the result covers every pixel column the input covers with the same vertical extent and the same entry and exit points, which is why it is lossless at that resolution: a peak is never dropped.
Neither has an Nx path. LTTB's choice in a bucket depends on the choice in the bucket before it, a serial dependency a vectorised pass cannot express; M4 needs a segmented argmin and argmax per column, which Nx offers no primitive for; and both keep whole elements, which a tensor does not hold, so the cost an Nx path would save — the arithmetic — is not the cost of the call, which is reading the accessor over every element.
Visualize.Shape.LineNx's optional-Nx pattern (spec/04 §10.1) is therefore deferred until a measurement shows the arithmetic, not the reading, is what a reduction costs; the benchmark of #448 (a tagged test, not a gate) is that measurement's baseline.
5.3 Functions
| Function | Contract |
|---|---|
Visualize.Data.min/1 | min(data, nil). |
Visualize.Data.min/2 | Minimum by accessor; nil for []. |
Visualize.Data.max/1 | max(data, nil). |
Visualize.Data.max/2 | Maximum by accessor; nil for []. |
Visualize.Data.extent/1 | extent(data, nil). |
Visualize.Data.extent/2 | {min, max} by accessor; nil for []. |
Visualize.Data.sum/1 | sum(data, nil). |
Visualize.Data.sum/2 | Sum by accessor; 0 for []. |
Visualize.Data.mean/1 | mean(data, nil). |
Visualize.Data.mean/2 | Mean by accessor; nil for []. |
Visualize.Data.median/1 | median(data, nil). |
Visualize.Data.median/2 | Median by accessor; nil for []. |
Visualize.Data.quantile/2 | quantile(data, p, nil). |
Visualize.Data.quantile/3 | Interpolated p-quantile by accessor; nil for []. |
Visualize.Data.variance/1 | variance(data, nil). |
Visualize.Data.variance/2 | Sample variance; nil for fewer than two values. |
Visualize.Data.deviation/1 | deviation(data, nil). |
Visualize.Data.deviation/2 | Sample standard deviation; nil for fewer than two values. |
Visualize.Data.group/2 | Map from key to element list. |
Visualize.Data.rollup/3 | Map from key to reduced group. |
Visualize.Data.bin/1 | bin(data, []). |
Visualize.Data.bin/2 | Histogram bins %{x0, x1, values} (5.2). |
Visualize.Data.cross/2 | Cartesian product as tuples. |
Visualize.Data.cross/3 | Cartesian product combined by a function. |
Visualize.Data.range/2 | range(start, stop, 1). |
Visualize.Data.range/3 | Arithmetic sequence from start toward stop by step. |
Visualize.Data.ticks/3 | Nice tick values between start and stop (5.2). |
Visualize.Data.sort/2 | sort(data, accessor, :asc). |
Visualize.Data.sort/3 | Sort by accessor in :asc or :desc order. |
Visualize.Data.unique/1 | unique(data, nil). |
Visualize.Data.unique/2 | Distinct elements by accessor, first occurrence kept. |
Visualize.Data.lttb/2 | lttb(data, n, nil): the elements are {x, y} tuples. |
Visualize.Data.lttb/3 | Largest-Triangle-Three-Buckets to about n elements, each run of gaps kept as its first element (5.2); unchanged at n or fewer elements; n >= 3. |
Visualize.Data.m4/2 | m4(data, width, nil): the elements are {x, y} tuples. |
Visualize.Data.m4/3 | Per pixel column of width over the defined x extent, each column visit's first, last, least-y and greatest-y elements, each run of gaps kept as its first element (5.2); width >= 1. |
6. Data.Table
Visualize.Data.Table turns the shapes chart data arrives in into the list of rows every generator consumes (04-shapes-and-curves §1). The table package (the Table.Reader protocol and its List and Map impls) is an optional dependency like Nx (D-52): a host that has it reads Explorer frames and any other struct implementing the protocol; without it lists, column maps and tensors still work and a struct source raises UndefinedFunctionError at Table.to_rows/1.
6.1 Sources
Visualize.Data.Table.rows/1 accepts, and distinguishes by shape:
| Source | Rows |
|---|---|
| A list of rows (maps or tuples) | The list itself: no copy, no traversal. |
| A keyword list whose values are equal-length lists | One map per index, keyed by the keys. A keyword list is read as columns before rows, as table reads a list; [{:a, [1]}] is therefore one column. A keyword list whose lists differ in length is rows. |
| A map whose values are equal-length lists | One map per index, keyed by the map's keys (atoms or strings, as given). Any other non-empty plain map raises ArgumentError. |
%Nx.Tensor{} of rank 2 | One tuple per row in column order, which is what the default Visualize.Shape.Line accessors read. Rank 1 yields the list of values; any other rank raises ArgumentError. |
| Any other struct | Table.to_rows/1: rows keyed by the reader's column names as it spells them (Explorer: strings), whether the reader traverses by rows or by columns. A struct with no Table.Reader impl raises Protocol.UndefinedError; a reader answering :none raises ArgumentError. |
[], %{} and the empty keyword list yield []. A rank-1 tensor bound to a dense series stays a first-class source for Visualize.Shape.LineNx and the binary path; Visualize.Data.Table.rows/1 does not stand between them.
6.2 Column names
A source names its columns with atoms or strings and a caller writes accessors in either. Visualize.Data.Table.get/2 reads the row by the given spelling first, then by the other: an atom name falls back to Atom.to_string/1, a string name to the atom key that spells the same. No atom is ever created. nil when neither is a key, so a missing column behaves as an absent map key does in 04-shapes-and-curves §1.1. Visualize.Data.Table.accessor/1 is fn row -> get(row, name) end, and returns a 1-arity function unchanged so a generator normalises every accessor form through one call. A tuple row has no names: get/2 raises BadMapError.
6.3 Time columns
Visualize.Data.Table.time_columns/1 names the columns a time scale could be inferred from: every key of every map row, sorted by term order (a row without the key contributes nil), kept when the column has at least one non-nil value and every value is nil or temporal?/1. Visualize.Data.Table.temporal?/1 is true for DateTime, Date and NaiveDateTime and for a string one of DateTime.from_iso8601/1, NaiveDateTime.from_iso8601/1 or Date.from_iso8601/1 parses; anything else, including a bare year or a non-ISO date, is false. Detection reads the whole column, not the first row as Tucan does: every row is visited and every string parsed, so the cost is the size of the table, and a column that starts temporal and stops being so is not temporal. Tuple rows have no names and yield [].
Visualize.Data.Table.column_types/1 (#369) types every named column of a table as spec/14 §2.3's four, reading the whole column as time_columns/1 does: a column whose non-nil values are all temporal?/1 is :time; all numbers, :number; all strings (none temporal), :text; all atoms or booleans, :category; a column with no non-nil value, or with values of more than one of those shapes, has no type and is left out. The result is a map from name to type, %{} for an empty table or tuple rows. It is a suggestion for a tool to pre-fill a design's types, never something the layer reads at render.
6.4 Functions
| Function | Contract |
|---|---|
Visualize.Data.Table.rows/1 | The row list of any source in 6.1; a list of rows is returned as-is. |
Visualize.Data.Table.get/2 | The named column of a map row by either spelling of the name (6.2); nil when absent. |
Visualize.Data.Table.accessor/1 | fn row -> get(row, name) end; a function is returned unchanged. |
Visualize.Data.Table.time_columns/1 | The names whose whole column is temporal (6.3). |
Visualize.Data.Table.column_types/1 | Every named column's type — :time, :number, :category, :text — read whole; a mixed or empty column is left out (6.3). |
Visualize.Data.Table.temporal?/1 | Whether a value is a DateTime, Date, NaiveDateTime or ISO 8601 string. |
7. Theme
Visualize.Theme names the colours, font and sizes a chart draws with, so that no axis, mark or component carries a colour literal and a consumer restyles every chart from one place — a theme struct in Elixir, or CSS custom properties in a stylesheet (D-55). Ggity's Theme and Tucan's themes are the precedent; the CSS half is this library's own, because its output lives in a page.
7.1 Struct and slots
%Visualize.Theme{name, series, axis, grid, text, background, surface, font_family, font_size, label_size, title_size}. name is an atom naming the theme (:default, :dark, or the consumer's own); series is a non-empty list of colour strings; axis, grid, text, background and surface are colour strings; font_family is a CSS font-family string; the three sizes are numbers in user units (pixels).
A slot names one value of a theme:
| Slot | Field | Used for |
|---|---|---|
:series_1 … :series_n, or {:series, i} | series | the i-th categorical colour; {:series, i} cycles, so on a ten-colour theme {:series, 11} is :series_1, and i is any positive integer |
:axis | axis | domain lines and tick lines |
:grid | grid | grid lines, tree links, and any mark that sits behind the data |
:text | text | tick labels, axis titles and any label beside a mark |
:background | background | the colour the chart is drawn on: separators between marks, and — with text — one of the two inks a label drawn over a mark chooses between (7.3) |
:surface | surface | the plane a chart's data is laid on, between the background and the grid: a map's ocean, and any field that is neither the page nor a data colour and has the grid drawn over it (#422) |
:font_family | font_family | every text |
:font_size | font_size | tick labels |
:label_size | label_size | mark labels and axis titles |
:title_size | title_size | chart titles |
slots/1 lists a theme's slots in that order, :series_1 to :series_n first. series_slot/2 is the :series_k atom that {:series, i} cycles to. A slot that is none of these, or :series_k with k > n or k < 1, raises ArgumentError in every function that takes a slot.
7.2 Built-in themes
| Field | default/0 | dark/0 |
|---|---|---|
name | :default | :dark |
series | #3b6fa8 #d9730d #c8373b #2f8f86 #3f8a37 #a8891a #8f5f99 #c9527f #8a6a4f #6f7480 | #7fb3e6 #f7a35c #f47c7c #6fd3c8 #86d67c #f2d15b #c99bd6 #ffb3c1 #cfa98b #b7bcc6 |
axis | #666666 | #a0a4ab |
grid | #e0e0e0 | #33373f |
text | #333333 | #e6e6e6 |
background | #ffffff | #1b1e24 |
surface | #eef2f6 | #23272f |
font_family | sans-serif | sans-serif |
font_size | 10 | 10 |
label_size | 12 | 12 |
title_size | 16 | 16 |
Both palettes are ten hues in the order of d3's category10 — blue, orange, red, teal, green, yellow, purple, pink, brown, grey — each adjusted until it holds at least 3:1 (Visualize.Color.contrast/2, §1.5) against its own background; text holds at least 4.5:1 and axis at least 3:1. Neither palette is a d3 scheme, because every d3 categorical scheme has a colour under 2.1:1 on white (category10's olive is 2.0:1), and a theme that promises contrast cannot ship one unchanged. 7.4 states the floors.
surface is a step off the background towards the grid — a cool grey on the light theme, a step above #1b1e24 on the dark — so a field reads as a plane under the data without becoming a data colour. new/1 is default/0 with the given keyword fields replaced, so a host theme that names no surface takes the light one, as it takes every other field it leaves out; merge/2 replaces the fields of any theme. An unknown field raises KeyError; an empty series raises ArgumentError.
7.3 Resolution
resolve(theme, slot, mode) is the one door through which anything reads a theme, and the mode is what the render target needs:
:literal— the slot's value as stored: a colour or font string, or a number for a size. A canvas colour is a literal, so every canvas backend resolves this way.:css— a CSS custom-property reference with the literal as its fallback:var(--vis-axis, #666666),var(--vis-series-3, #c8373b),var(--vis-font-size, 10px)(a size gainspx). What the SVG output carries.
css_name/1 is the property name of a slot atom: --vis- and the slot with _ as - (:series_1 → --vis-series-1, :font_family → --vis-font-family). mode/1 maps a backend — a module, :svg, :canvas or nil, resolved by Visualize.Backend.resolve/1 (spec/02 §3.2) — to the mode: Visualize.Backend.SVG is :css, every other backend :literal. Visualize.Axis.render/2 applies it (spec/05 §1.6) and the style grammar of the declarative layer resolves through the same call.
The reference carries its fallback rather than the root declaring the property (D-55). A declaration on the <svg> — inline or in a <style> — would beat every ancestor rule in the cascade, so a consumer could not switch a theme from a class without !important; with the fallback form a chart rendered outside any stylesheet (a standalone .svg, an <img>) still draws in its theme, and a rule on any ancestor, .night { --vis-axis: #a0a4ab }, overrides it. stylesheet/1 writes that rule for a whole theme — .vis-theme-<name> { then one --vis-<slot>: <value>; line per slot of slots/1, sizes with px, then } — so a consumer that includes stylesheet(dark()) in its CSS switches every chart under an element with class="vis-theme-dark" without touching Elixir.
The contrast ink (#486, D-123). ink(theme, under) is the colour a text drawn on under should take: the theme's text or its background, whichever has the higher contrast ratio against under by Visualize.Color.contrast/2 — text on a tie — always as the slot's literal, never a reference. When the higher of the two is under 4.5:1 (WCAG AA for text), it is #000000 or #ffffff instead, whichever contrasts more, and one of those two always holds 4.58:1 or better against any colour, so the ink always meets AA against the colour it is chosen for. 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 — and a string it cannot parse is nil; a slot whose own value does not parse is skipped. It is a literal because the choice is made between literals: a var(--vis-text, …) that a stylesheet re-pointed would undo it. The style grammar's :contrast value resolves through it (spec/14 §3.2, §5.5).
7.4 Contrast
A theme MUST hold, by Visualize.Color.contrast/2 against its own background: text at least 4.5:1 (WCAG AA for text), axis at least 3:1 (WCAG non-text contrast), every series colour at least 3:1, and grid at least 1.2:1 — visible, and faint on purpose. surface MUST differ from grid: the grid is drawn over the surface (a map's graticule over its ocean), and a surface equal to it would swallow every line (#422). Both built-in themes do; a theme from new/1 is not checked, since a house palette is the consumer's choice. Across the colour schemes of Visualize.Scale.Color.schemes/0, every colour parses with Visualize.Color.parse/1 and every scheme has at least one colour at 3:1 or better against the dark background and at 2.5:1 or better against the light one (set2, a pastel scheme, tops out at 2.6:1 on white), so any scheme has a usable colour on either theme; a scheme's lightest colours vanish on the light theme (blues opens at 1.04:1 on white) and its darkest on the dark (blues closes at 1.8:1 on #1b1e24), which is why the built-in themes carry their own palettes (7.2). test/visualize/theme_contrast_test.exs holds both themes and every scheme to this.
7.5 Functions
| Function | Contract |
|---|---|
Visualize.Theme.default/0 | The light theme of 7.2. |
Visualize.Theme.dark/0 | The dark theme of 7.2. |
Visualize.Theme.new/1 | default/0 with the given keyword fields replaced (7.2). |
Visualize.Theme.merge/2 | The theme with the given keyword fields replaced (7.2). |
Visualize.Theme.slots/1 | The theme's slot names in the order of 7.1. |
Visualize.Theme.colour_slots/1 | The theme's colour slots alone — the series, then axis, grid, text, background and surface — for a control that offers a colour as a slot (spec/14 §19.10). |
Visualize.Theme.series_slot/2 | series_slot(theme, i): the :series_k atom the positive index i cycles to (7.1). |
Visualize.Theme.resolve/3 | The slot's value as a literal or as a CSS reference with the literal fallback (7.3). |
Visualize.Theme.css_name/1 | The custom-property name of a slot atom (7.3). |
Visualize.Theme.mode/1 | :css for the SVG backend, :literal for any other (7.3). |
Visualize.Theme.ink/2 | ink(theme, under): the literal of text or background, whichever contrasts more with under, else black or white when neither reaches 4.5:1 (7.3). |
Visualize.Theme.stylesheet/1 | The .vis-theme-<name> rule declaring every slot (7.3). |
8. Signals
Implemented (#244). A signal is a generated source: a description from which Visualize.Signals yields rows, pure and deterministic for a tick, so a chart under design has feeds that move without a host supplying any. A deployed design still binds to the host's pool (spec/14 §7.3) under the same names; signals are the builder's stand-in for it.
8.1 A signal
| Key | Type | Default | Notes |
|---|---|---|---|
kind | :sine | :square | :random | :discrete | required | the wave every value column follows; :discrete is a counter — non-negative integers stepping up by one per tick, wrapping past max |
fields | [atom()] | required | the columns: the first is time and carries the tick number; every other is a value column |
window | pos_integer() | 24 | the rows a tick yields — the last window ticks, ending at the tick |
period | number() | 12 | ticks per cycle of a :sine or a :square |
min, max | number() | 0, 100 | the value range: a sine swings between them, a square alternates them, a random draws within them, a discrete counts from min to max |
seed | integer() | 0 | for :random, so a chart replays the same feed |
Value columns differ from each other: the n-th value column (0-based) of a sine or a square is shifted by n / count of a cycle, so two columns are never the same line; a random column draws its own stream from seed and the column's index; a discrete column starts n higher.
8.2 The rows
rows(signal, tick) yields window rows, one per tick from tick − window + 1 to tick in order — a tick below 0 yields no row, so the window fills over its first ticks — each a map of the signal's fields: the time column the tick, and each value column its kind's value at that tick:
:sine—min + (max − min) · (1 + sin(2π (t / period + phase))) / 2, so at tick0the first column stands at the midpoint;:square—maxfor the first half of a cycle,minfor the second, the column's phase applied;:random— a value in[min, max)from:rand.uniform_real/0under a seed derived fromseed, the column's index and the tick, so the same tick always draws the same value;:discrete—min + rem(t + n, max − min + 1).
The window slides: tick 25 over a window of 24 yields ticks 2..25, and the next tick drops 2 and adds 26. pool(signals, tick) yields a pool, name to rows, for a map of named signals, ready for Visualize.Chart.apply/2.
8.3 Functions
| Function | Contract |
|---|---|
Visualize.Signals.kinds/0 | The four kinds, in the order of 8.1. |
Visualize.Signals.new/2 | new(kind, fields) — a signal of the kind over the fields with 8.1's defaults; new/3 takes the remaining keys as options. |
Visualize.Signals.new/3 | As new/2 with options. |
Visualize.Signals.rows/2 | rows(signal, tick): the window of rows ending at the tick (8.2). |
Visualize.Signals.pool/2 | pool(signals, tick): rows/2 of every signal of a map, under its name. |
9. FFT
9.1 The transform
Visualize.Data.FFT (#375) is the fast Fourier transform in pure Elixir: a radix-2 Cooley–Tukey decimation in time over lists, O(N log N), with no native code. fft/1 takes a sequence whose length is a power of two — numbers read as real, {re, im} pairs as complex — and returns the N complex terms X_k = Σ x_n e^{−2πi kn/N} as {re, im} pairs; any other length raises ArgumentError. magnitudes/1 is sqrt(re² + im²) of every term. Parseval holds: the sum of the squared magnitudes over N equals the sum of the squared samples, to floating precision.
9.2 Windows and the spectrum
window/2 tapers a sequence by :hann (0.5 − 0.5 cos(2πi/(N−1))), :hamming (0.54 − 0.46 cos(2πi/(N−1))) or :none — a sequence shorter than four is not tapered, the Hann of two points being all zeros; windows/0 lists them, :hann first. spectrum/3 is the reading a chart draws: spectrum(values, every, opts) takes the newest samples values — the option, a power of two, else the largest power of two not above the count — subtracts their mean when detrend: true (#376; an offset otherwise leaks from the DC line into its neighbours under a window), tapers them, transforms them and returns samples / 2 lines %{frequency, magnitude, phase}: frequency is k / (samples · every) in hertz for every seconds between samples; magnitude the one-sided amplitude |X_k| · 2 / (samples · gain) with the window's mean gain divided out, so a sine of amplitude A reads A at its frequency whatever the taper, the DC line not doubled; phase the argument of X_k. Fewer than two values, or fewer than samples, yield []. A 4096-point spectrum costs single-digit milliseconds, a 1024-point one about two. The test suite holds the cost's growth by count, not by clock (#462): four times the samples is under six times the work in reductions, as n log n gives and a quadratic transform's sixteen does not; the millisecond figures are a :benchmark test, stated rather than gated (12-testing-and-conformance §6).
9.3 Functions
| Function | Contract |
|---|---|
Visualize.Data.FFT.fft/1 | The N complex terms of a sequence whose length is a power of two (9.1). |
Visualize.Data.FFT.magnitudes/1 | sqrt(re² + im²) of every term. |
Visualize.Data.FFT.window/2 | window(values, kind): the sequence tapered by :hann, :hamming or :none (9.2). |
Visualize.Data.FFT.windows/0 | The three window kinds, :hann first. |
Visualize.Data.FFT.spectrum/2 | As spectrum/3 with no options. |
Visualize.Data.FFT.spectrum/3 | spectrum(values, every, opts): the one-sided amplitude spectrum of the newest power of two of values sampled every every seconds (9.2). |