Skip to content

Styling ​

How to decide what every node and edge in a graph looks like.

The one stack ​

An element's appearance is a stack of layers, and every layer is the same shape: a selector that says which elements it is about, and a set of channel values that says what those elements look like. Layers paint in order, bottom first, so a layer later in the stack paints over one earlier in it.

typescript
const element = document.querySelector("graphty-element");

await element.session.styles.add({
    name: "Servers are blue",
    target: "node",
    selector: { match: "expression", where: "data.type == 'server'" },
    set: { "node.color": "#3B82F6", "node.size": 2 },
});

Everything about a layer is addressed by its id, which the element mints and which survives every insertion, removal and reorder around it:

typescript
const layer = await element.session.styles.add(spec);

await element.session.styles.update(layer.id, { set: { "node.color": "#EF4444" } });
await element.session.styles.move(layer.id, null); // null means "to the top"
await element.session.styles.remove(layer.id);

There is no addLayer(index), no removeLayerByIndex and no reorderLayers(from, to). An index is not an identity: it changes under every edit around it, and the one consumer who kept an index map beside the element deleted the element's own base layer with an off-by-one.

Reading is free, writing is a command ​

list, get, validate, legend, explain and agreement answer from what the session already holds and cost nothing. add, update, remove, move, removeBySource, encode and highlightvalidate and repaint, so each one returns a Run: it can report progress on a large graph, take an AbortSignal, and be fired from a click handler and forgotten.

The model moves only after the paint succeeds. add() followed immediately by list() does not show the new layer; await add() does.

No write verb throws. A malformed spec, an unknown id or a locked layer arrives as a rejected run. The door for "is this valid" before anything is committed is validate(), which is synchronous, writes nothing, and reports every problem at once:

typescript
const verdict = element.session.styles.validate(spec);

if (!verdict.ok) {
    for (const problem of verdict.errors) {
        console.error(problem.message); // names the path and the character it went wrong at
    }
}

Selectors ​

A selector says which elements a layer is about. There are six kinds, and they are spelled out rather than implied:

typescript
{ match: "everything" }                          // every node, or every edge
{ match: "has", path: "results.degree.value" }   // every element that carries a value there
{ match: "ids", nodes: ["alice", "bob"] }        // exactly these
{ match: "top", path: "results.degree.value", n: 10 } // the top 10 by a run's field, whole ties only
{ match: "expression", where: "data.type == 'server'" }
{ match: "member", of: { set: id } }           // the members of a set, or any other scope

A set ​

{ match: "member", of: scope } paints the members of a kept set, or of anything else a scope names ("selection", { define }). The layer follows its scope: redefine the set, change the data a rule set reads, or re-run the run a followed set reads, and the layer repaints the elements that joined or left without being touched. A set has no colour of its own -- colouring one is this layer. A layer naming a set that was removed keeps painting the members the set's kept record names, so removing a set never blanks a layer.

The top N ​

{ match: "top", path, n } paints the n highest elements by one field a run published, such as "label the ten best-connected nodes". The path is a run's field, results.<run>.<field>: build it with session.results.path(run, field). The layer follows the run: it reads the run's values each time it paints, so after a re-run the top is taken again from the new values.

Ties are never split. Elements with equal values are painted as a group or not at all, and a group is painted only when ALL of it fits inside n. So a top-10 layer never paints more than ten elements, but it can paint fewer -- and it paints none on a graph whose highest value is shared by more than ten elements (every node of a ring has the same degree). To find out why, ask the run: result.top(field, n) returns the same elements, plus leftOut (the tie group that did not fit), reason (a sentence saying so) and threshold (the above value that selects exactly the same elements). A { top } selection target (session.selection.apply({ top: { run, field, n } })) uses the same rule, so a layer and a selection never disagree about which elements are the top n.

The expression language ​

An expression is a declared subset of JMESPath, compiled once when the layer is added and then run as a closure. It supports paths, comparisons (==, !=, <, <=, >, >=), &&, ||, ! and parentheses.

It supports no functions at all -- no starts_with, no length, no projections, slices, pipes, multi-selects or wildcards. Everything outside the subset is refused where it was typed, by name, with the offset. A selector that is accepted means exactly what JMESPath means by it.

Two spelling rules catch everyone once:

  • A record's own fields live under data. So a node imported as {id: "n1", type: "server"} is selected with data.type == 'server', not type == 'server'.
  • A number or a boolean goes between backticks, as JMESPath requires: data.weight > `0.5` and data.active == `true`. A bare 5 is refused with a message saying so. A string literal takes single quotes: 'server'.

An edge's endpoints are data.source and data.target, the ids of the nodes it leaves and reaches, whatever keys the edge record used for them (src/dst by default). So data.source == 'A' selects the edges leaving A, and { match: "has", path: "data.target" } matches every edge.

The same expression works outside a style layer, and matches the same elements there: session.scope.count({ where }), session.selection.apply({ where }) and a visibility filter { kind: "expression", where } (nodes) or { kind: "edges", where } (edges) all evaluate it with the same engine.

An algorithm's results are read the same way, under the id of the run that produced them:

typescript
const run = await element.run("degree");

await element.session.styles.add({
    name: "Hubs",
    target: "node",
    selector: { match: "expression", where: `results.${run.id}.value > \`10\`` },
    set: { "node.color": "#EF4444" },
});

Values the element provides ​

A path that starts graphty. reads a value the element itself provides, not a column of your data. Today those are the three note values:

PathValue on a node or an edge
graphty.notes.countHow many notes name it; no value when none do
graphty.notes.latestThe text of the newest of those notes
graphty.notes.latestTimeThe time of the newest of those notes

They work in a layer's selector and in a binding's by. A label or tooltip bound to one is drawn as literal text: a note reading <bold>x</bold> shows the tags, never bold text. See Notes.

The whole graphty. root is reserved for the element, now and in later releases. A data column whose name starts graphty. is still reachable, as data.graphty.<name>, and session.styles.validate(spec) lists a bare graphty. path that a column of the same name would have answered in shadowedPaths. A graphty. path this release does not know has no value, and the layer is reported unbound rather than painting anything.

Channels ​

A channel is one visual property with one name. These are all of them:

Node channelTakes
node.colorany CSS colour
node.sizea number
node.shapesphere, box, cylinder, icosphere, ...
node.labelthe words to draw
node.labelStyle{font, sizePx, weight, color, background, outline, padding, ...}
node.tooltipthe words to show on hover
node.tooltipStyleas node.labelStyle, for the tooltip
node.opacity0 to 1
node.outlinea colour
node.glowa colour
node.glowStrengtha number
node.wireframetrue or false
node.flattrue or false
Edge channelTakes
edge.colorany CSS colour
edge.widtha number
edge.opacity0 to 1
edge.stylesolid, dash, dot, zigzag, ...
edge.patternCounthow many dots or dashes to draw, 2 or more (zigzag and sinewave ignore it)
edge.curvaturetrue or false (a bezier)
edge.arrowHeadnormal, inverted, diamond, none, ...
edge.arrowHeadSizea number, 1 being the element's own size
edge.arrowHeadColora colour
edge.arrowHeadOpacity0 to 1
edge.arrowHeadTextwords drawn beside the head cap
edge.arrowHeadTextStyleas node.labelStyle
edge.arrowTailthe same arrows
edge.arrowTailSizea number
edge.arrowTailColora colour
edge.arrowTailOpacity0 to 1
edge.arrowTailTextwords drawn beside the tail cap
edge.arrowTailTextStyleas node.labelStyle
edge.animationSpeeda number
edge.labelthe words to draw
edge.labelStyleas node.labelStyle

Writing node.label or edge.label is what switches a label on. To switch node labels on without choosing the words, write { enabled: true } to node.labelStyle: each node is labelled with its own id.

typescript
await element.session.styles.add({
    name: "Labels",
    target: "node",
    selector: { match: "everything" },
    set: { "node.labelStyle": { enabled: true } },
});

The five ...Style channels merge field by field across layers instead of replacing each other. A layer that writes { color: "#FF0000" } over one that wrote { sizePx: 24 } draws a red label at 24 px, and each field takes the value from the highest layer that set it.

Glowing nodes are drawn through one mesh per distinct node.glow and node.glowStrength pair. A handful of glow styles costs nothing; a strength encoded from data, with a different value on every node, gives up instancing for the glowing nodes.

Drawing a style editor ​

CHANNEL_DESCRIPTORS, from @graphty/graphty-element/catalog, describes every channel as plain data, so an editor can draw a row per channel without restating anything the element knows:

typescript
import { channelsFor } from "@graphty/graphty-element/catalog";

for (const channel of channelsFor("edge")) {
    // e.g. "Head size", "arrows", 1
    console.log(channel.shortName, channel.group, channel.default);
}
FieldWhat it holds
plainNamethe full name, "Arrow Head Size"
shortNamethe name once the target is already said, in sentence case: "Head size"
groupshape, color, effects or text for a node; line, arrows or text for an edge
acceptsthe kind of control: color, number, text, boolean, enum, labelStyle, nothing
valuesevery choice, for an enum
min, maxthe bounds, for a number
defaultwhat the element draws when no layer sets the channel (absent when that is nothing); an unset arrow cap color follows edge.color
unsupportedReasonwhy the channel cannot be set, for a disabled control; present only when it cannot

Labels that would overlap ​

By default every label a style asks for is drawn, so labeled nodes that sit close together on screen draw their words over each other. Turn on labelDeclutter to thin them out:

javascript
element.labelDeclutter = true;
html
<graphty-element label-declutter></graphty-element>

It is a preference of the view, not part of the project: switching it records no undo step and is not saved in a project file, so it suits a reader's "Show all labels" checkbox (element.labelDeclutter = !showAll.checked). It is the same switch as layoutBehavior.labels.declutter.

With it on, the element keeps the label of a selected node first, then the label of the node with more edges, and hides any label whose words would cover the words of a label it has already kept. Only the words count: two labels whose padding or background overlap, but whose text does not, are both drawn. A hidden label comes back as soon as its node is clear, for example after the camera or the layout moves. Nothing in the style changes when this happens, and setting labelDeclutter back to false shows every label again on the next frame.

The element works this out again only when something that decides it changes -- the camera, the size of the viewport, a label, a node's position or visibility, the selection or the edges -- so a still graph pays almost nothing for it. On a camera that is moving it costs roughly 1 to 1.5 ms a frame per thousand labels. It is a preference of the view, so a saved project does not keep it. element.nodeLabelCounts says how many labels it hid: see Labels.

A tooltip on a node ​

A tooltip is drawn when the pointer rests on a node and taken down when it leaves, which is the whole difference between a tooltip and a label: a label is part of the picture, a tooltip is an answer to pointing at something. So a graph at rest shows none, and a layer that writes one changes nothing on screen until a reader points at the node.

typescript
await element.session.styles.add({
    name: "What each city is",
    target: "node",
    selector: { match: "everything" },
    encode: { "node.tooltip": { by: "data.note", scale: "passthrough" } },
    set: { "node.tooltipStyle": { sizePx: 32, color: "#FFFFFF", background: "#10B981", cornerRadius: 12 } },
});

node.tooltip carries the words and node.tooltipStyle carries how they are drawn, in the same vocabulary node.labelStyle takes. The words are what switch a tooltip on, so a layer that writes only the appearance draws nothing.

An edge has no tooltip. edge.tooltip was published through 1.x and drawn by nothing in any released version, and it was withdrawn in 2.0: a tooltip needs the pointer to land on the thing it belongs to, and an edge is not pickable -- the same reason the element emits no edge-click. Put the words on the edge itself with edge.label, or at one of its ends with edge.arrowHeadText and edge.arrowTailText.

An arrow that is told nothing about its own appearance follows the line it caps, at either end:

typescript
await element.session.styles.add({
    name: "Big red heads on the heavy edges",
    target: "edge",
    selector: { match: "expression", where: "data.weight > `5`" },
    set: { "edge.arrowHead": "normal", "edge.arrowHeadSize": 2.5, "edge.arrowHeadColor": "#EF4444" },
});

Each end is named separately -- arrowHead and arrowTail -- because each is drawn separately, and each property is its own channel so it can be bound to a value in the data: encode: {"edge.arrowHeadSize": {by: "data.weight", scale: "linear", range: [0.5, 2]}}.

Words at the ends of an edge ​

An edge carries words in three places and they are three different things. edge.label puts words at the MIDDLE of the line. edge.arrowHeadText and edge.arrowTailText put words at the two ENDS, hanging from the cap drawn there -- what Graphviz calls a headlabel and a taillabel and Cytoscape calls a source and target label. Each end has a second channel carrying the whole of how those words are drawn, in the same vocabulary node.labelStyle takes:

typescript
await element.session.styles.add({
    name: "Who calls whom",
    target: "edge",
    selector: { match: "everything" },
    set: {
        "edge.arrowTail": "normal",
        "edge.arrowTailText": "caller",
        "edge.arrowHeadText": "callee",
        "edge.arrowHeadTextStyle": { sizePx: 28, color: "#B91C1C", background: "#FEE2E2", padding: 8 },
    },
});

Two things are worth knowing before writing one. A caption hangs from a cap, so an end drawn with no arrow carries none -- an edge's tail has no cap until a layer asks for one, which is why the example above sets edge.arrowTail. And the WORDS are what switch a caption on, so a layer that writes only a ...TextStyle draws nothing.

A label is sized to the words in it, and there is no automatic wrapping: to draw a label on two lines, put a newline in the words. The parser measures, aligns and draws each line on its own, so textAlign, lineHeight and the four margins all apply across them.

What a label renderer can do beyond the labelStyle vocabulary is not a channel. That vocabulary is a closed list named for what a reader can see -- the typeface, the panel, the margins, the speech-bubble pointer, the outline, the shadow, the badge -- and the renderer's own canvas settings, such as the resolution of the texture a label is drawn on, are deliberately not in it. The row above lists the fields a reader reaches for first, not all of them.

Literal values and bound values ​

set writes the same value to every element the selector matched. encode binds a channel to a value in the data, through a scale:

typescript
await element.session.styles.add({
    name: "Colour by community",
    target: "node",
    selector: { match: "has", path: "data.community" },
    encode: { "node.color": { by: "data.community", scale: "ordinal", palette: "tableau10" } },
});

await element.session.styles.add({
    name: "Label every node with its own name",
    target: "node",
    selector: { match: "everything" },
    encode: { "node.label": { by: "data.name", scale: "passthrough" } },
});

A binding takes by (the path), scale (linear, log, sqrt, bins, quantile, ordinal, passthrough, ...), and optionally a palette, a domain, a clamp, a range, a map, an overflow policy and a missing rule. missing defaults to skip, which leaves an element with no value exactly as the layers underneath painted it. overflow ("other", "shape" or "extend") says what a categorical colour does with more groups than its palette has colours; encode() writes "other" by default -- see Algorithms.

Painting an algorithm's result ​

A finished run knows what it measured, so the shortest route from a result to a picture is to let it suggest one:

typescript
const run = await element.run("betweenness");

await element.session.styles.encode({ run, channel: "node.size" });

encode() REPLACES the layer already painting that channel from that run rather than stacking a second one on it, so running an algorithm twice does not leave two legend blocks on one channel with one of them invisible under the other.

For an algorithm that chooses a subset -- a route, a cut, a matching -- the verb is highlight(), and a highlight is exclusive: a second route replaces the first rather than being painted beside it.

The element's own layers ​

Every graph starts with two layers the element owns: the defaults every node and edge is drawn from, and the one that paints the selection. They are locked: removing, editing or moving one is refused with E_PROTECTED, and add() will not mint an element source at all. You paint over them by adding your own layer on top, which is what a layer on top is for.

What is not a style ​

A layer says what the elements of a graph look like. Four things that look like styling are properties of the element instead, because they are about the whole picture rather than about any element in it:

html
<graphty-element
    view-mode="2d"
    background='{"backgroundType":"color","color":"#101014"}'
    starting-camera-distance="60"
    layout="ngraph"
></graphty-element>
typescript
element.viewMode = "2d";
element.background = { backgroundType: "skybox", data: "https://example.com/sky.jpg" };
element.startingCameraDistance = 60;
element.layout = "ngraph";

Reading the picture back ​

typescript
element.session.styles.list(); // every layer, bottom first
element.session.styles.legend(); // what a reader needs to interpret the picture
element.session.styles.explain({ node: "alice" }); // why this node looks like this

explain() answers the question a screenshot cannot: which layer decided each channel of one element, and what the layers under it had said before it did.

Several elements at once ​

agreement() answers the same question for many elements: the selection, a saved set, or a list of node ids. For each channel it says whether they all look the same, and if not, how they split.

typescript
const { channels } = element.session.styles.agreement("selection");

for (const entry of channels) {
    if (entry.state === "agree") {
        // every selected element has entry.value, decided by the layer entry.layerId
        console.log(entry.channel, entry.value, entry.layerId);
    } else {
        // entry.breakdown: one { value, layerId, count } per look, most common first
        console.log(entry.channel, entry.breakdown);
    }
    // entry.unpainted: how many elements no layer painted on this channel
}

element.session.styles.agreement({ nodes: ["alice", "bob"] }, "node.color"); // one channel only

Two elements agree only when they show the same value and the same layer decided it, so a layer that repeats the value of the layer under it still reads as mixed. The answer holds counts, never lists of ids, so it stays small however many elements are selected.

Interactive Examples ​