Data-driven style

How a mark that holds data takes its looks from that data, through the same attribute that sets them by hand.

On this page

#Style attributes

A mark holds data when it draws one item per row of a dataset or per entry of a list: a scatter, bar, histogram, lollipop, boxplot, violin or heatmap, a network’s vertices and edges, a choropleth, a marker. On such a mark each kind of look is one style attribute, with a singular name, giving every item its own look: colour, size, width, shape, opacity.

A style attribute is an expr position (D68) whose result is a list of looks, one per item. Whatever an author writes there is an ordinary expression, and the compiler types all of it: the look, a column’s cells, the keys of a map. A style attribute is not animatable — a keyframe chain reaches the compiler as text, and text carries no -> and no #self.

A schema never adds a second attribute that groups, categorises or encodes for a style attribute: no groups:, colour-by:, hues:, plural colours: or sizes:, and no separate palette or domain attribute. Everything such an attribute would do is a form of the style attribute’s own value. The one rule beside one is an edge list’s colour-from: (none, ends, gradient), which colours each edge from the vertices it joins rather than from data.

#Four forms

A style attribute’s value takes one of four forms. They are its options, not a checklist: a style attribute offers the forms that mean something for its mark and leaves out any that do not, but it never takes a value outside them.

  1. A look. Every item takes it: colour: red, colour: rgb(20, 120, 200), size: 6, shape: cross. A colour is paint: a hue name or a colour literal. On a mark whose items each read a number of their own — a heatmap’s cells, a choropleth’s regions, a contour’s levels, a surface’s or field’s values — colour is a ramp over those numbers’ own range, so its look is a ramp: colour: temperature, or one hue’s run, colour: teal.
  2. A list of looks. One per item, in written order, cycling when the items outnumber it: colour: grey, grey, red, size: 4, 8, 12. A column whose cells are looks is a list too, one per row. On a mark whose colour is a ramp, the list is the ramp’s stops, low end first: colour: teal, yellow, red.
  3. A map, applied with -> (D69) to a column or to one of the mark’s readings. Names map to looks: colour: #countries.continent -> (oceania: blue; asia: orange; otherwise: grey). Intervals map to bands: colour: #self.value -> (..<0: purple; 0..: pink). A reading no key holds takes the map’s otherwise; with none, it is unset, and its item takes the mark’s default look. Nothing is dealt: a column of categories or numbers written alone is not a list of looks, and the compiler refuses it. On a mark whose colour is a ramp, a map paints each item flat in its key’s colour, a contour each level.
  4. A ramp or a scale. A reading normalised over an interval, -> lo..hi, gives each item its place from 0 to 1, clamped, and the next step turns the place into a look. A colour applies it to a ramp — multicolour, temperature or a bare hue — colour: #countries.gdp -> 0..60000 -> temperature, which the compiler hands the host to finish. A size, width or opacity applies it to an expression of _, the incoming place (D71), drawn linearly: size: #self.degree -> 1..10 -> 1 + 2_. The same placeholder computes from a reading directly, size: #self.value -> _ * k. On a contour, surface or field, colour: #self.value -> -1..1 -> temperature pins the window the ramp spans; a heatmap or choropleth applies its value: column itself, colour: #m.r -> -1..1 -> temperature.

A mark that reads a number of its own reverses its ramp with its invert flag, where it declares one. The same scatter, coloured each way:

scatter(x: #countries.gdp; y: #countries.life; colour: red)
scatter(x: #countries.gdp; y: #countries.life; colour: grey, grey, red)
scatter(x: #countries.gdp; y: #countries.life; colour: #countries.continent -> (oceania: blue; asia: orange; otherwise: grey))
scatter(x: #countries.gdp; y: #countries.life; colour: #countries.gdp -> 0..60000 -> temperature; size: #countries.life -> 60..90 -> 4 + 12_)

#Readings

A style attribute follows a column of a dataset the environment draws, or a reading the mark computes for each of its items. A reading is a derived attribute of the mark whose type is a list, one entry per item, and a style attribute reads it with #self (D68). The graph’s scatter, histogram, bar, lollipop, residuals, contour, surface and field publish #self.value: a scatter’s reading on its value axis, a bar’s value, a histogram bar’s count, a residual, a contour level’s value, a surface’s or field’s value. A network’s vertices publish group, component, community, degree, in-degree, out-degree, betweenness and weight; its edges publish weight. A heatmap and a choropleth have a value: attribute, and so no #self.value. A new data mark declares every reading a style attribute can follow the same way; the first-party tables are VALUE_READING, VERTEX_READINGS and EDGE_READINGS in packages/canvas-library/src/compile/style-attributes.ts.

An environment declares its style attributes as a table of StyleAttributeSpecs, { kind, look?, ramps? }, by value type name, and spreads the valueTypes that styleValueTypes returns into its spec. Each value type is { base: 'expr', type: { base: 'list', item: <look> } }; the look is paint for a colour, number for a size, a width or an opacity, and the mark’s own enum for a shape. A colour declared ramps: true takes ramp-paint, a colour literal or a ramp. That is the whole declaration: there are no refiners, and no check of a column beside the compiler’s, which types each cell where it is used. A new first-party data mark reuses a style value type — colour-style, size-style and their peers in the library’s tables — rather than declaring its own.

import { styleValueTypes } from '@internote/canvas-sdk';
import type { EnvironmentSpec, StyleAttributeSpec } from '@internote/canvas-sdk';

const STYLES: Record<string, StyleAttributeSpec> = {
    'colour-style': { kind: 'colour' },
    'size-style': { kind: 'size' },
    'shape-style': { kind: 'shape', look: { base: 'enum', values: ['circle', 'cross'] } },
};

export const SPEC: EnvironmentSpec = {
    name: 'dots',
    valueTypes: { ...styleValueTypes(STYLES).valueTypes },
    vocabulary: {
        dot: {
            attributes: {
                colour: { type: 'colour-style', aliases: ['color'], default: 'red' },
                size: { type: 'size-style', default: '6' },
                shape: { type: 'shape-style', default: 'circle' },
            },
            // Each dot's own reading, for `#self.value`.
            derived: { value: { type: { base: 'list', item: 'number' } } },
        },
    },
};

The compiler emits every style attribute as a $chalk-expression. A value it knew outright — a look, a list — is unwrapped to that plain value when the document is refined, a colour literal as rgb(…). A column applied to a map, an interval or an expression of _ is folded: the tree is the typed list of looks, one per row, with the last function applied kept under applied. Anything that waits for the reader — a #self reading, a slider’s letter, a ramp — stays a tree with -> nodes, its readings listed in self and free:

{ "$chalk-expression": {
    "source": "#self.community -> (1: orange; otherwise: teal)",
    "tree": { "op": "->", "args": [
        { "self": { "member": "community" } },
        { "map": { "keys": "names",
                   "entries": [{ "value": "orange", "type": "hue", "key": "1" }],
                   "otherwise": "teal", "otherwise_type": "hue" } }
    ] },
    "refs": [], "free": [], "self": ["community"], "type": "list" } }

A surface reads every style attribute through the SDK’s readers — itemColours, blendedItemColours, itemSizes, itemShapes, itemOpacities, and valueColouring with windowPlace on a mark whose colour is a ramp — never from the payload, and falls back to the mark’s declared default (declaredDefault). It hands them an ItemSource: the datasets, each item’s row, and under self each reading the mark publishes, one entry per item. The reader evaluates the expression with the readings as scope.self and the sliders as scope.free, then lines the list up with the items: by item where it read #self, by row where it read a column, and in written order otherwise. Which key holds is the compiler’s to say; a ramp’s { apply } answer is finished here, by appliedRampCss.

const source: ItemSource = {
    datasets,
    rows: dots.map(({ row }) => row),
    self: { value: dots.map(({ reading }) => reading) },
};
const colours = itemColours(attrs['colour'], dots.length, source, params, fallback);
const sizes = itemSizes(attrs['size'], source, params);
const shapes = itemShapes(attrs['shape'], source, params);

#The legend follows the value

Every key of a map a style attribute applies earns its row in the footer’s key, beside its look, without a further attribute; whatever the map’s otherwise takes is one row, other. The SDK reads the map off the value whether the compiler folded it or left it bound — tree.applied.map or tree.args[1].map, through appliedMap — and builds the entries (styleKeyEntries). The ramp of a mark that reads a number of its own earns a scale row. A size scale earns a key — its interval and the size drawn at each end, itemSizes’ key, which the network’s footer draws — only where its reading is bound, #self.degree -> 1..10 -> 1 + 2_; a column’s scale folds while the document compiles and earns none. The environment’s legend is the one switch for every row; a graph’s is off until written.

A mark-level group:, with the environment’s key: map, is a different thing. It names whole marks — one line per series — for the legend, and shares a look across separate marks; it is not per-item encoding. The SDK deals the groups’ hues (groupLooks), and a mark’s own colour beats its group’s.

Last updated