Operations
Operations are the rules that shape a diagram. Each operation is one constraint or directive that attaches to a class, or to an object. The operations are declarative and order-independent. Each rule narrows the set of acceptable layouts, which turns the raw value graph into the required picture.
- A constraint shapes the geometry. It sets where things go.
- A directive changes the drawing. It sets how things look.
Almost every operation is built from a selector, which selects the atoms
or edges that the operation applies to (see Selectors). Each
operation also takes a few specific arguments. The sections below describe each
operation part by part.
Constraints
orientation: place a target in a direction
selector: the edges to orient.directions: where the target sits relative to its source, given as a list (for example['below', 'left']). Each direction is one ofabove,below,left,right, or an adjacency variant:directlyAbove,directlyBelow,directlyLeft, ordirectlyRight. An adjacency variant also requires that nothing sits between the two atoms.
@spytial.orientation(
selector='{ x : TreeNode, y : TreeNode | x.left = y }', # which edges
directions=['below', 'left'], # where the child goes
)
align: line atoms up on a shared axis
selector: the atoms to align.direction: the axis to share,'horizontal'or'vertical'.
cyclic: arrange atoms in a ring
selector: the atoms or edges that form the cycle.direction: the direction of the ring,'clockwise'or'counterclockwise'.
These value sets are closed, and Spytial checks them at the point where they are written. A common mistake is to borrow another constraint's words.
aligntakes the axis (horizontalorvertical), andorientationtakes a placement (above,left, and so on). spytial-core does not report a swap of the two as an error. An unknown orientation direction drops the constraint without notice, and a misspelledcyclicdirection is read asclockwise. The same applies toflag, which acts on exactlyhideDisconnectedandhideDisconnectedBuiltIns.
group: enclose atoms in a labelled region
selector: the relation, or the atoms, whose members are grouped.name: the label drawn on the bounding box.addEdge(optional): the connector between the group's key and the group. It is'none'(default),'togroup', or'fromgroup'. To also style the connector, pass aGroupEdgeinstead of a bare string.textStyle(optional): styles the group's own label.showLabel(optional, defaultTrue): set toFalseto hide the group's caption and its background pill while keeping the group boundary.
Keyed groups display readable captions such as Team[Alice]. Core adds the
key ID only when needed to distinguish duplicate captions, such as
Team[Alice:k1] and Team[Alice:k2]. Unary groups display only name.
from spytial import GroupEdge, LineStyle, TextStyle
@spytial.group(
selector='Team.members',
name='Team',
addEdge=GroupEdge(points='togroup', lineStyle=LineStyle(pattern='dashed')),
textStyle=TextStyle(color='navy'),
)
To keep the boundary without a caption, use
@spytial.group(selector='Team.members', name='Team', showLabel=False).
The same option works with Group(...) in an Annotated type.
size: set a node's drawn dimensions
heightandwidth: the node dimensions in pixels. Each is required, and each shall be greater than 0.selector(optional): which nodes to resize. Omit it to resize every node.
@spytial.size(selector='Node', height=50, width=50)
@spytial.size(height=50, width=50) # every node
hideAtom: remove atoms from the diagram
selector: the atoms to hide.
@spytial.hideAtom(selector='{ n : Node | n.internal }')
sizeandhideAtomare constraints, not directives. Size fixes the geometry that the layout solves over, and a hidden atom is one atom fewer to place, which can make a spec unsatisfiable against the other constraints. Both were directives before spytial-core 4.3. The directives section still accepts them with the same meaning, behind a deprecation warning, so an older spec continues to render. Spytial writes them underconstraints.
hold: invert a constraint
orientation, align, cyclic, and group each take an optional hold.
It defaults to 'always'. Pass 'never' to require the opposite, so that the
layout shall not satisfy the constraint. Use 'never' to state that atoms are
not grouped, or that y is never below x, instead of leaving the
relationship unconstrained.
@spytial.orientation(selector='children', directions=['below'], hold='never')
@spytial.group(selector='Team.members', name='Team', hold='never')
size and hideAtom do not take hold, although they are constraints.
There is no negation of a fixed width, or of a hidden atom. spytial-core
accepts the key on those two forms and ignores it, so a hold: never written
there reads as a negation and renders as its opposite. Spytial rejects it at
the point where it is written.
Directives
attribute: show a field as inline text
field: the field to fold into the node as a label, instead of a separate box and arrow.textStyle(optional): styles this attribute's line (TextStyle(size=..., color=...)).
@spytial.attribute(field='value')
@spytial.attribute(field='weight', textStyle=spytial.TextStyle(size='small'))
atomStyle and edgeStyle: styling
Styling is built from small style blocks, imported from spytial:
LineStyle(color, pattern, weight, highlight): a drawn edge line.patternis'solid','dashed', or'dotted'.weightis a number greater than 0.TextStyle(size, color): any label.sizeis'small','normal', or'large'.BorderStyle(color, width)andFillStyle(color): an atom's outline and interior.IconStyle(path, placement, opacity): an icon drawn on an atom.placementis'full'(the icon occupies the box) or'badge'(a small corner marker).opacityis a number from 0 to 1.
Every field is optional. Set only the fields that are needed. Plain dicts with the same keys work everywhere the blocks do.
atomStyle:selector(which atoms; omit for all), and any ofborderStyle,fillStyle,iconStyle,textStyle, andshowLabel.edgeStyle:field(which relation), and any oflineStyle,textStyle(the edge's label),showLabel, andhidden, plus an optionalselectororfilter.
from spytial import LineStyle, TextStyle, BorderStyle, FillStyle
@spytial.edgeStyle(field='next', lineStyle=LineStyle(color='crimson', pattern='dashed'))
@spytial.atomStyle(selector='Node', borderStyle=BorderStyle(color='steelblue'),
fillStyle=FillStyle(color='#eef6ff'))
Migrating from 2.x:
atomColorandedgeColorstill work but are deprecated. They are rewritten toatomStyleoredgeStyleand raise aDeprecationWarning. The mapping is:edgeColor.valuebecomeslineStyle.color,edgeColor.stylebecomeslineStyle.pattern, andedgeColor.weightbecomeslineStyle.weight;atomColor.valuebecomesborderStyle.color. The legacy directive colored the border, not the fill, so usefillStyleonly for a filled look.Breaking change in spytial-core 3.0: two style rules that set the same property of the same edge or atom to different values now raise a
StyleCollisionErrorat render time. Version 2.x kept the first rule without notice. Set each property in exactly one matching rule. Spytial warns at spec-collection time for the case that it can detect statically (identicalfield,selector, andfilter).
hideField: remove a relation's edges
field: the relation to hide.selectorandfilter(optional): narrow which edges are hidden.
To hide atoms rather than edges, use the hideAtom
constraint.
inferredEdge: draw a derived edge
selector: the pairs to connect.name: the label for the new edge.lineStyleandtextStyle(optional): style the drawn edge and its label. These use the same blocks asedgeStyle. The inlinecolor,style, andweightarguments are deprecated.draw(optional): where each end attaches. See below.
@spytial.inferredEdge(
selector='{ x : Vertex, y : Vertex | y in x.neighbors }', # which pairs
name='edge', # edge label
)
draw: attach an end to a group's hull
By default, an inferred edge runs atom to atom. draw is a string
'<end> -> <end>'. Each end is either '_' (the atom itself) or the name of a
group constraint. For a group name, that end attaches to the hull of the
group keyed by that end's atom. This is what makes group-to-group and
node-to-group edges expressible.
# A binary group selector: one group per Team, keyed by the Team atom.
@spytial.group(selector='{ t : Team, l : list | l in t.members }', name='regions')
@spytial.inferredEdge(
name='reports to',
selector='{ a : Team, b : Team | b = a.parent }',
draw='regions -> regions', # hull to hull, rather than node to node
)
'_ -> regions' runs from the atom to a group's hull. '_ -> _' is the same as
an omitted draw. In both cases the edge's own selector ranges over atoms. With
draw, a unary edge selector is allowed, and its atom feeds both ends.
A keyed group resolves per atom; an unkeyed group is one hull. A group declared with a binary selector is keyed on its first column, and
drawresolves each end by looking up the group of that name keyed by that end's atom. An atom that keys no group of that name is reported in the browser console. A group built from a unary selector (selector='Team.members') has no key at all, so an end naming it attaches to that single hull whatever the end's atom is. A group name that matches nogroupconstraint raises a warning and the edge is skipped, so a fragment may name a group that another fragment defines (spytial-core 5.0; before it, a hard error at render time).
tag: attach a computed label
toTag: a unary selector for the atoms to tag.name: the literal label name, not a selector.value: a selector returning tuples whose first column is the tagged atom and last column is the displayed value. Intermediate columns form one comma-separated key:(node, low, high, max)displays asf[low,high]: max.textStyle(optional): styles this tag's line (TextStyle(size=..., color=...)).
For a simple field, use the relation itself: @spytial.tag(toTag='Person',
name='age', value='age') displays age: 25. value='Person.age' returns ages
without their owners and cannot attach those values to the Person nodes.
For a computed tag, preserve the owning atom as the first column:
@spytial.tag(
toTag='IntervalNode',
name='f',
value='{n: IntervalNode, l, h, m: Int | n.low = l and n.high = h and n.max = m}',
textStyle=TextStyle(size='large'),
)
If the labels are 1, 10, and 20, the line is f[1,10]: 20. Use and
between the conditions; & is set intersection, not logical conjunction.
icon: draw an atom as an icon
selector: which atoms get the icon.path: the icon source.showLabels: whether the atom's label stays alongside the icon.
iconis deprecated as of spytial-core 4.3. UseatomStylewith aniconStyleblock instead. The singleshowLabelsboolean set the label's visibility and the icon's geometry together, whereasatomStylesplits those intoshowLabelandiconStyle.placement. That split is what makes an icon-only node, or a faded watermark behind a visible label, expressible.
To set a node's dimensions, use the size
constraint.
flag: a rendering switch
name: for example'hideDisconnected', which drops atoms with no edges.
Attaching operations
The same operations can be attached in three ways.
As class decorators (the most common way):
import spytial
@spytial.orientation(selector='children', directions=['below'])
@spytial.attribute(field='value')
class Node:
def __init__(self, value, children=None):
self.value = value
self.children = children or []
On a single object, without a change to the class:
node = Node('root')
spytial.annotate_orientation(node, selector='children', directions=['below'])
Conditionally, with apply_if:
@spytial.apply_if(
lambda cls: cls.__name__.endswith('Node'),
spytial.atomStyle(selector='self', borderStyle=spytial.BorderStyle(color='lightblue')),
)
class Node:
...
Rules in error messages
Every rule carries the text that declared it. Spytial stamps each operation it
emits with a source block, which holds the decorator as it was written and
the place it was written:
orientation:
selector: '{ x : TreeNode, y : TreeNode | x.left = y }'
directions: [below, left]
source:
text: "@spytial.orientation(selector=LEFT_EDGE, directions=['below', 'left'])"
location: tree.py:12
When spytial-core reports a conflict between constraints, it cites this text in place of its own description of the rule. A report therefore names the decorator to edit, rather than the derived constraint that could not be satisfied.
The text is read back from the source file, so a name appears unexpanded: a
selector written as LEFT_EDGE is reported as LEFT_EDGE, which is what the
reader finds at tree.py:12. Where no source file can be read, as in a REPL or
in exec of a string, the call is reconstructed from its arguments and no
location is given.
The block is informational and does not affect layout. De-duplication ignores
it, so two identical rules written in different places remain one rule.
location is a file name and a line, never a full path, because the spec
travels inside the generated HTML file.
Set SPYTIAL_NO_SOURCE=1 to emit no source blocks.
Examples
Try these operations on real data structures in the
Playground. The
spytial-clrs notebooks also show
idiomatic usage on heaps, trees, hash tables, disjoint sets, and graphs.