5. Layer 2 — Coordinate systems, placement and snapping
Contents of DISL 0.5
Most diagram tools assume a single, infinite pixel plane. Many real diagrams do not fit that assumption: a Gantt chart places tasks along time; a swimlane diagram places activities in categories (lanes); a sequence diagram orders messages along a logical sequence; a floor plan measures in millimetres; a radial mind map uses angles and radii. DISL makes the coordinate system an explicit layer so that positions keep their meaning, can be bound to model data, and can be snapped in domain units.
- An axis maps domain values (numbers, timestamps, categories) to canvas distance along one direction. It is a scale in the sense of charting libraries.
- A coordinate system combines two axes (x and y, or angle and radius) and defines orientation, bounds and the grid display.
- Placement (5.8) defines, per node type, where each coordinate of an element comes from: stored freely in the view, bound to a model attribute (two-way), or computed by an expression (read-only).
- Snapping (5.9–5.11) restricts positions and sizes to discrete values, independently per axis, in the axis's own units.
- Canvas units are the internal unit of rendering at zoom 1. The screen maps canvas units to device pixels via the zoom factor.
Diagram as text (Mermaid)
flowchart LR
accTitle: From domain value to screen pixels
accDescr: An axis scale maps a domain value, such as the date 2026-10-05, to canvas units, such as x = 480. The zoom maps canvas units to screen pixels, such as x = 720 at zoom 1.5. Snapping works on the domain value, in domain units, and the result is then mapped back to canvas units.
domain["<b>domain value</b><br/>#quot;2026-10-05#quot;"]
canvas["<b>canvas units</b><br/>x = 480"]
screen["<b>screen pixels</b><br/>x = 720 at zoom 1.5"]
domain -- axis.scale --> canvas
canvas -- zoom --> screen
canvas -. "snapping works here<br/>(in domain units, then mapped back)" .-> domain{
"coordinates": {
"axes": { "px": { "kind": "linear", "unit": "px" } },
"systems": {
"canvas": {
"kind": "cartesian",
"x": "px", "y": "px",
"orientation": "y-down",
"grid": { "visible": true, "style": "dots", "spacing": 10, "majorEvery": 5 },
"snapping": "grid10"
}
},
"snapProfiles": { "grid10": { "x": { "grid": { "spacing": 10 } }, "y": { "grid": { "spacing": 10 } } } },
"default": "canvas"
}
}
| Property | Type | Description |
|---|---|---|
axes |
map → Axis | Named axes; may also be written inline inside systems. |
systems |
map → CoordinateSystem | Named coordinate systems. |
snapProfiles |
map → Snapping | Reusable snapping definitions. |
default |
name | System used by viewpoints that do not name one. |
doc |
Doc |
If coordinates is absent, the default is a single cartesian system canvas with two linear pixel axes, y pointing down, unbounded, grid hidden and snapping off.
| Property | Type | Description |
|---|---|---|
kind |
"linear", "log", "time", "ordinal", "angular" |
Required. |
label |
LocalizedText | Axis title, shown on rulers and headers. |
doc |
Doc | Explains to users what the axis means ("Calendar time in the project's time zone"). |
min, max |
domain value | Domain bounds; placements outside are rejected by a built-in placement constraint. |
reversed |
bool | Domain increases towards negative canvas direction (for example values increasing upward in a y-down system). |
origin |
domain value | The domain value mapped to canvas coordinate 0. |
ruler |
Ruler | Ruler or header display (5.13). |
expand |
"none", "auto" |
Whether the axis domain grows automatically when elements are placed beyond it. Default "auto" for unbounded axes. |
outOfRange |
OutOfRange | Whether a gesture or typed value past min or max is clamped or refused (4.3). Default: refused both ways, as in 0.1. |
ranges |
AxisRange[] | Named ranges of the domain, drawn as bands (below). |
endLabels |
{min, max} of Message |
Labels at the two ends of the axis line or ruler, by domain end. |
Out of range on an axis. An axis outOfRange works as on an attribute (4.3), with std.axisBounds in place of std.facets: with drag: "clamp", a dragged element stops at the bound, and the bound is applied after snapping, so it wins over the snap grid.
Axis ranges. An AxisRange ($defs/AxisRange) names a part of the domain: id (required), label (Message), from and to (required, domain values, from included and to excluded except at the axis maximum), fill (Paint), style (StyleRef), separator (Stroke, the line drawn at from) and labelPosition ("start", "center" (default) or "end"). Ranges MAY be unequal and need not be contiguous. They are drawn with the grid (6.16), under every element, and are not elements: they cannot be selected, and take no part in layout or findings. axisRange(axis, value) (12.4) returns the id of the range holding a value, so an attribute can derive a stage from the axis instead of repeating its numbers.
"maturity": { "kind": "linear", "label": "Evolution", "min": 0, "max": 1,
"ranges": [
{ "id": "genesis", "label": "Genesis", "from": 0, "to": 0.175, "style": { "fillOpacity": 0.35 } },
{ "id": "custom", "label": "Custom Built", "from": 0.175, "to": 0.4, "style": { "fillOpacity": 0.5 }, "separator": { "dash": [6, 6], "dashScale": "absolute" } },
{ "id": "product", "label": "Product (+rental)", "from": 0.4, "to": 0.7, "style": { "fillOpacity": 0.65 }, "separator": { "dash": [6, 6], "dashScale": "absolute" } },
{ "id": "commodity", "label": "Commodity (+utility)", "from": 0.7, "to": 1, "style": { "fillOpacity": 0.8 }, "separator": { "dash": [6, 6], "dashScale": "absolute" } } ] },
"visibility": { "kind": "linear", "label": "Value chain", "min": 0, "max": 1, "reversed": true,
"endLabels": { "min": "Visible", "max": "Invisible" } }
A Wardley map divides evolution into its four unequal stages and labels the ends of the value chain.
| Property | Type | Description |
|---|---|---|
unit |
string | "px" (default), "pt", "mm", "cm", "in", "m", "unitless", or any custom unit label. |
scale |
number | Canvas units per domain unit at zoom 1. Default: 1 for px; 96/25.4 for mm (CSS reference pixel); 96 for in; 72/… for pt etc. |
precision |
int | Decimal places stored (overrides persistence default for this axis). |
base |
number | For log axes (default 10). Domain values MUST be > 0. |
format |
string | Number format pattern for rulers (ICU/Unicode LDML, for example "#,##0.0 'mm'"). |
{
"mm": { "kind": "linear", "unit": "mm", "precision": 1, "label": "Millimetres",
"doc": "Real-world length on the printed sheet. 1 mm = 3.78 px at 100 %." }
}
Time axes place elements by date or timestamp. They are used for Gantt charts, roadmaps, timelines, event storms with a real time dimension, and schedules.
| Property | Type | Description |
|---|---|---|
valueType |
"date", "datetime", "epoch-ms", "epoch-s", "yearMonth" |
Stored representation of positions on this axis. Default "datetime". yearMonth places by month index (below). |
timezone |
IANA name, "UTC", "floating" |
Zone in which dates are interpreted and calendar snapping is computed. "floating" means local wall-clock time without zone. Default "UTC". A bindable form { "attribute": "timezone" } refers to a diagram attribute. |
scale |
{unit, size} |
How much canvas distance one unit of time occupies at zoom 1, for example { "unit": "day", "size": 40 } (40 canvas units per day). Units ($defs/TimeUnit): millisecond, second, minute, hour, day, week, month, quarter, year, decade, century, millennium. For month and larger units the scale is the average length (a month is 30.436875 days). unit MAY be bound to a diagram attribute, { "attribute": "unit" } (below). |
calendar |
Calendar | Working time definition (below). |
collapse |
"none", "non-working" |
If "non-working", non-working time takes no canvas space (a compressed timeline in which weekends vanish). Default "none". |
min, max |
timestamp or {cel} |
Bounds; may be bound to diagram attributes such as project start and end. |
origin |
timestamp or {cel} |
Timestamp at canvas x = 0. Default: min if set, else the Unix epoch. |
zoomLevels |
ZoomLevel[] | Presets that change scale and the header (for example days, weeks, months). |
today |
object | { "visible": true, "style": StyleRef, "label": "Today" } — a marker line at env.now. |
Calendar
{
"calendar": {
"id": "project",
"workingDays": [1, 2, 3, 4, 5],
"workingHours": [["09:00", "12:30"], ["13:30", "17:30"]],
"holidays": { "attribute": "holidays" },
"firstDayOfWeek": 1,
"doc": "Mon–Fri, 8 working hours; holidays come from the diagram's holiday list."
}
}
| Property | Type | Description |
|---|---|---|
id |
identifier | Name used by CEL time functions (workingDays(a, b, 'project')). |
workingDays |
int[] | ISO weekday numbers (1 = Monday … 7 = Sunday). Default [1,2,3,4,5]. |
workingHours |
[start, end][] |
Working intervals per working day. Default [["00:00","24:00"]]. |
holidays |
date[] or Bindable | Non-working dates. |
exceptions |
{date, workingHours}[] |
Special days (half days, working Saturdays). |
firstDayOfWeek |
int | For week snapping and headers. Default 1 (ISO). |
The grid and header of time axes show non-working time shaded by default; the style is set in ruler.nonWorkingStyle.
Units coarser than a year. decade, century and millennium are accepted wherever a time unit is (scale.unit, zoomLevels, ruler levels and ticks, calendar snap rules, grid spacing, and the CEL functions startOf, endOf and addUnits). Their boundaries are the years divisible by 10, 100 and 1000 in astronomical numbering, so year 0 starts a decade, a century and a millennium.
Month axes. With valueType: "yearMonth", the axis domain is the month index of the yearMonth primitive (4.2): origin, min and max are yearMonth strings, positions are stored as yearMonth values, and the scale's unit is month or coarser. timezone, calendar and collapse do not apply, and a validator MUST reject units finer than month on such an axis. In its formats, LDML u MUST be read as the signed astronomical year; a validator MUST reject y, whose era is ambiguous before year 1.
Bound unit. scale.unit and the unit of a calendar snap rule (5.10) MAY be { "attribute": name }, naming a diagram attribute whose values are time units (an enum whose keys or stored forms are unit names, 4.5). Changing the attribute is a model edit that rescales the axis.
"time": { "kind": "time", "valueType": "yearMonth", "origin": "1900-01",
"scale": { "unit": { "attribute": "unit" }, "size": 4 },
"ruler": { "visible": true, "position": "bottom",
"levels": [ { "unit": "decade", "format": "u", "minSpacingPx": 64 } ] } }
A hype cycle's time axis runs over months from any year, before or after the common era, at four canvas units per step of the unit the diagram chooses.
Ordinal axes place elements into discrete bands (categories): lanes, rows, resources, lifelines, columns of a Kanban board, days of a timetable.
| Property | Type | Description |
|---|---|---|
categories |
Category[] or {cel} or {nodes} |
The bands. Static list, a CEL expression returning a list of {id, label} maps, or { "nodes": "Resource", "orderBy": "self.order" } — one band per node of a type. With nodes, the bands are model elements: they can be edited, reordered and deleted, their node notation is used to draw the band header, their placement is determined by the axis (not by placement), and attributes bound to this axis store a reference to the band node. |
bandSize |
number or "auto" |
Canvas size of each band. "auto" grows each band to fit its content. |
minBandSize, maxBandSize |
number | Limits for auto sizing. |
bandSizes |
{attribute} |
Per-band size stored on the category element. |
padding |
number | Space before the first and after the last band. |
gap |
number | Space between bands. |
reorderable |
bool | Users may drag bands to reorder them (updates the order attribute or category list). |
allowUnassigned |
bool | Whether elements may lie outside any band. Default false. |
header |
object | { "size": 120, "label": {cel}, "style": StyleRef } — band headers (the "lane titles"). |
A Category is { "id": "backlog", "label": "Backlog", "doc": "...", "color": "#…", "size": 200 }.
Position values on an ordinal axis are band references: either the band id as a string, or { "band": "backlog", "offset": 12.5 } where offset is the canvas distance from the band's start edge. The offset lets several elements share a band at different positions (for example activities within a swimlane).
Angular axes (angular) are used with polar coordinate systems (5.7). Properties: unit ("deg" default or "rad"), start (angle of domain 0, default −90 = pointing up), direction ("clockwise" default or "counterclockwise"), wrap (default true, values modulo 360°).
| Property | Type | Description |
|---|---|---|
kind |
"cartesian", "polar" |
Required. |
x, y |
Axis or name | For cartesian systems. |
angle, radius |
Axis or name | For polar systems; radius is linear or ordinal (concentric rings). |
center |
[x, y] |
Canvas point of the polar origin. |
orientation |
"y-down", "y-up" |
Cartesian direction of the positive y axis on screen. Default "y-down" (screen convention); engineering drawings often use "y-up". |
bounds |
{x: [min,max], y: [min,max]} |
Canvas bounds (in domain values); elements must stay inside. |
infinite |
bool | Canvas extends indefinitely. Default true unless bounds is set. |
grid |
GridDisplay | Visible grid (5.13). Independent of snapping, but defaults to the snapping grid. |
snapping |
Snapping or profile name | Default snapping in this system (5.9). |
guides |
Guides | Alignment and distribution guides (5.12). |
nested |
map TypeRef → system name | Coordinate systems local to containers of a type (below). |
label, doc |
Nested coordinate systems. A container node may establish its own coordinate system for its children. The children's positions are then stored relative to the container in the nested system's units. Examples: a Kanban board node whose x axis is the ordinal status column set; a sub-timeline inside a project phase; a pin layout inside a chip outline measured in millimetres.
{
"systems": {
"board": {
"kind": "cartesian",
"x": { "kind": "ordinal", "categories": { "nodes": "Column", "orderBy": "self.position" }, "bandSize": 260, "gap": 16 },
"y": { "kind": "linear", "unit": "px" },
"nested": { "Swimlane": "laneLocal" }
}
}
}
A position in a nested system is converted to canvas coordinates through the container's placement. Frames compose: a node inside a lane inside a pool resolves its canvas position through all three.
Placement tells the runtime, for each node type, where each coordinate of an element comes from. It is declared in the node notation (6.9) under placement, because the same model type may be placed differently in different viewpoints.
| Property | Type | Description |
|---|---|---|
system |
name | Coordinate system. Default: the viewpoint's system, or the nearest container's nested system. |
x, y |
PlacementSource | Position of the element's anchor on each axis. |
x2, y2 |
PlacementSource | Position of the opposite edge; if given, the extent is x2 − x in domain units (for example end − start on a time axis). |
width, height |
PlacementSource | Extent in domain units (a duration on time axes, a number of bands on ordinal axes). Mutually exclusive with x2/y2. |
angle, radius |
PlacementSource | For polar systems. |
anchor |
Anchor | Which point of the node the position refers to: "top-left" (default), "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right", or [fx, fy] fractions. Point-like elements on time axes (milestones) typically use "center". |
movable |
bool or {x, y} |
Whether users may move the element along each axis. Default true. { "x": true, "y": false } constrains dragging to the x direction. |
resizable |
bool or {x, y} |
Whether users may change the extent along each axis (see also size in 6.9). |
stack |
Stack | How elements that share a band are arranged (below). |
clampToParent |
bool | Keep the element within its container's content area. Default true for contained nodes. |
doc |
Doc | Explains placement to users ("Drag horizontally to reschedule; drag vertically to reassign"). |
PlacementSource is one of:
| Form | Kind | Meaning |
|---|---|---|
"free" (or absent) |
free | The value is stored in view data (11.6); moving the element updates the view only. |
{ "attribute": "start" } |
bound | The value is read from and written to a model attribute. Moving the element changes the model (and triggers change hooks and constraints). |
{ "attribute": "start", "offset": "P1D" } |
bound | Bound with a constant offset in domain units. |
{ "cel": "…" } |
computed | Read-only; the element cannot be moved along this axis. |
{ "cel": "…", "write": { "set": { "attr": "value - duration('1h')" } } } |
computed with inverse | The expression computes the position; when the user moves the element, the write actions (an Action, 9.4) run with value bound to the new snapped domain value. This allows bindings such as "x is start, width is durationDays * 1 day". |
{ "layout": true } |
layout | Determined by the layout algorithm (section 10); not user-movable. |
The attribute type MUST be compatible with the axis: number/int for numeric axes, date/datetime for time axes, string, enum or a reference to a category node type for ordinal axes, number for angular axes.
Stacking — when several elements fall into the same ordinal band and overlap along the other axis (two tasks of one resource in the same week), stack decides what happens:
| Property | Type | Description |
|---|---|---|
mode |
"overlap", "stack", "pack" |
overlap: draw over each other; stack: every element gets its own sub-row in order; pack: greedy interval packing into the fewest sub-rows. Default "overlap". |
order |
Expression | Sort key for stacking (for example "self.start"). |
rowSize |
number | Sub-row height. |
growBand |
bool | Band grows to fit sub-rows (requires bandSize: "auto"). |
{
"placement": {
"system": "schedule",
"x": { "attribute": "start" },
"x2": { "attribute": "end" },
"y": { "attribute": "assignee" },
"movable": { "x": true, "y": true },
"resizable": { "x": true, "y": false },
"stack": { "mode": "pack", "order": "self.start", "rowSize": 28, "growBand": true },
"doc": "Drag to reschedule or reassign. Drag the right edge to change the end date."
}
}
Dragging the task horizontally moves both start and end (keeping the duration); dragging its right edge changes end only; dragging it into another lane changes assignee to that lane's category id (or node reference).
Snapping restricts where elements can be placed and how they can be sized, so that diagrams stay tidy and values stay meaningful (a task starts on a day, not at 13:47:12; a component sits on a 2.54 mm pitch).
Snapping is defined per axis. Each axis of a coordinate system can have its own rule, so a diagram type may snap only horizontally, only vertically, or both, with different rules on each. The same rule model applies to positions, sizes, edge bendpoints, label offsets and rotation.
{
"snapping": {
"enabled": true,
"mode": "always",
"x": { "calendar": { "unit": "day", "align": "nearest" } },
"y": { "bands": { "align": "center" } },
"size": { "x": { "calendar": { "unit": "day" }, "min": "P1D" } },
"bendpoints": "inherit",
"targets": ["rule", "objects", "guides"],
"toleranceScreenPx": 8,
"bypassModifier": "Alt",
"feedback": { "showGhost": true, "showValue": true },
"doc": {
"summary": "Tasks snap to whole days and into resource lanes.",
"rationale": "Plans are made in days; sub-day precision would suggest accuracy the plan does not have."
}
}
}
| Property | Type | Description |
|---|---|---|
enabled |
bool | Master switch; users MAY toggle it at run time if userToggle is true. Default true when snapping is present. |
userToggle |
bool | Users may switch snapping on and off (toolbar button, shortcut). Default true. |
mode |
"always", "magnetic" |
always: the value is always replaced by the nearest allowed value; magnetic: only when within toleranceScreenPx of an allowed value (free placement otherwise). Default "always" for rule snapping, "magnetic" for object and guide snapping. |
x, y |
SnapRule | Rule for positions along each axis (5.10). Absent or "none" = continuous. |
both |
SnapRule | Shorthand applying the same rule to x and y (numeric axes only). |
angle, radius |
SnapRule | For polar systems. |
size |
{x, y, both} of SnapRule |
Rules for extents (width/height). Default: follows the position rule of the axis for bound extents, none for free ones. |
reference |
"anchor", "bounds", "center", "edges" |
Which point(s) of the element are snapped: the placement anchor (default), any edge of the bounds (the nearest wins), the center, or leading edges only. |
rotation |
{step, tolerance} |
Rotation snapping in degrees (for example { "step": 15 }). |
bendpoints |
"inherit", "none" or {x, y} |
Snapping for edge bendpoints and orthogonal segments. |
labels |
"none" or LabelSnap |
Snapping for draggable labels (for example to the nearest of start, middle, end on an edge). |
targets |
string[] | Which snapping sources participate, in priority order: "rule" (the axis rules), "objects" (edges and centers of other elements), "guides" (user guides), "ports", "parent" (container edges and padding). Default ["rule"]. |
toleranceScreenPx |
number | Attraction distance for magnetic snapping and object snapping. Default 8. |
applyToProgrammatic |
bool | Whether changes made by operations, hooks and templates are snapped too. Default false. |
bypassModifier |
"Alt", "Shift", "Ctrl", "Meta", "none" |
Holding this key temporarily disables snapping. Default "Alt". |
feedback |
object | showGhost (preview of snapped position), showValue (tooltip with the snapped domain value, formatted by the axis), highlightBand (for ordinal axes), style (StyleRef for snap indicator lines). |
byGesture |
map gesture → Snapping | Snapping for one kind of gesture, merged over this level (below). Keys: move, resize, drop, paste, handle. |
doc |
Doc | Explains the snapping to users; runtimes SHOULD show summary when snapping is toggled or while dragging. |
Where snapping is declared, and precedence. Snapping can be declared in a snap profile, on a coordinate system, on a viewpoint's canvas, on a node or edge notation (snapping property, 6.9 and 6.10), and on a placement. The most specific declaration wins per property and per axis: a node notation that only declares x keeps the system's y rule. "inherit" explicitly refers to the next level.
Snapping per gesture. An entry of byGesture applies to that gesture only and is more specific than the level it is declared on: it MUST be merged over that level per property and per axis, with the rule above. move covers dragging an element, resize dragging its extent, drop a toolbox drop, a create at the pointer (7.2) and a context-menu entry run at a point (7.3), paste pasting, and handle dragging a shape handle (6.8). reference MAY differ per gesture.
(DISL 0.3) A drop (and a create at the pointer or a context-menu add) of a new element uses the snapping of the node notation of the type it creates, merged over its coordinate system's, and the value a rule receives is the position, with the new element's placement anchor at the pointer, of the point reference selects (the placement anchor itself by default).
"snapping": {
"x": { "grid": { "spacing": 1 }, "ties": "away-from-zero" },
"y": { "grid": { "spacing": 1 }, "ties": "away-from-zero" },
"byGesture": { "drop": { "x": { "grid": { "spacing": 1 }, "direction": "floor" }, "reference": "center" } }
}
On a hype cycle a move or resize snaps to the nearest step, while a toolbox drop lands in the step that contains the drop point and finds its row from the element's middle.
A SnapRule is "none", a profile name, or an object with exactly one of the rule keys below, plus optional common properties.
| Rule key | Applies to | Description |
|---|---|---|
grid |
numeric axes | Regular grid: { "spacing": 10, "offset": 0, "subdivisions": 2 }. Positions snap to offset + k·spacing (or to subdivisions when zoomed in beyond subdivideAtZoom). |
values |
numeric, time, angular | Explicit list of allowed values: { "values": [0, 12.5, 25, 50, 100] }. May be {cel} returning a list. |
calendar |
time | Calendar units: { "unit": "day", "step": 1, "align": "nearest", "workingTime": true, "calendar": "project", "at": "09:00" } (details below). |
ticks |
any | Snap to the axis ticks visible at the current zoom level: { "ticks": { "level": "minor" } }. Useful for zoom-adaptive snapping. |
bands |
ordinal | Snap into bands: { "align": "start" | "center" | "end" | "keep-offset", "offsetGrid": 8 }. keep-offset preserves the offset within the band but still snaps offsetGrid if given. |
divisions |
numeric extents, angular | Divide a range into n equal parts: { "count": 12, "of": "parent" } — positions at twelfths of the container (column layouts). |
ratio |
sizes | Keep the aspect ratio or snap to preferred ratios: { "ratios": [1, 1.5, 2] }. |
cel |
any | Custom function: { "cel": "value < 100.0 ? snap(value, 5.0) : snap(value, 25.0)" }. Receives value (domain value), axis, zoom, self, parent; returns the snapped domain value. |
byZoom |
any | Zoom-dependent rules: { "byZoom": [ { "maxZoom": 0.5, "rule": { "grid": { "spacing": 50 } } }, { "rule": { "grid": { "spacing": 10 } } } ] } — the first entry whose maxZoom ≥ current zoom applies; the last entry without maxZoom is the fallback. |
plugin |
any | { "plugin": "acme.snap-to-rails", "args": {…} }. |
Common properties of snap rules are written next to the rule key, not inside it ({ "grid": { "spacing": 10 }, "min": 20 }):
| Property | Type | Description |
|---|---|---|
min, max |
domain value | Clamp the snapped value (for sizes: minimum and maximum extent). |
direction |
"nearest", "floor", "ceil" |
Rounding direction. Default "nearest". |
ties |
"away-from-zero", "toward-zero", "up", "down", "even" |
How a value exactly halfway between two allowed values rounds when direction is nearest. Default "away-from-zero". |
mode |
"always", "magnetic" |
Overrides the snapping-level mode for this rule. |
doc |
Doc | Explanation for this axis. |
Ties. With direction: "nearest", a value exactly halfway between two allowed values MUST round as ties says: away-from-zero to the one farther from zero (on both sides of the origin, as CEL math.round does), toward-zero to the one nearer zero, up to the greater, down to the smaller, even to the one that is an even multiple of the step from the rule's offset. The default makes declared snapping and CEL snapping (snap, 12.4) agree.
Calendar rules snap timestamps using the axis's time zone and calendar:
| Property | Type | Description |
|---|---|---|
unit |
millisecond … millennium, or {attribute} |
Snapping unit ($defs/TimeUnit, 5.5). week respects firstDayOfWeek; month and coarser units snap to calendar boundaries (not to fixed durations). May be bound to a diagram attribute, as scale.unit (5.5). |
step |
int | Multiples of the unit (for example 15 minutes: { "unit": "minute", "step": 15 }). Steps are counted from the start of the next larger unit (15-minute steps restart every hour). |
align |
"start", "end", "nearest" |
Snap to the start of the unit, its end, or whichever boundary is closer. Default "nearest". |
workingTime |
bool | Only working time of calendar is allowed; values in non-working time move to the nearest working boundary (in direction). |
calendar |
id | Calendar to use; default: the axis calendar. |
at |
time | Time of day for day-level snapping (tasks start at 09:00, not midnight). |
Size snapping on time axes uses durations: { "calendar": { "unit": "day" }, "min": "P1D" } makes every task at least one day long and a whole number of days.
Applying rules on an axis — algorithm. When the user drags an element (or a handle, bendpoint or label), for each axis independently:
- Convert the pointer's canvas position to the axis's domain value through the axis scale (and the container's nested frame, if any).
- If snapping is disabled (globally, by the bypass modifier, or for this axis), use the raw value.
- Compute candidates: the rule result (if
"rule"is a target), the nearest object/guide/port/parent alignment candidates within tolerance. - Choose the highest-priority candidate in
targetsorder that exists; formagneticmode, discard candidates farther than the tolerance (measured in screen pixels after mapping back). - Clamp to rule
min/max, axismin/maxand container bounds. - For bound placements, write the domain value to the attribute; for free placements, to the view data.
The rule result MUST be idempotent: snapping an already snapped value returns the same value. Runtimes MUST apply snapping to programmatic changes made through tools and operations only if the specification sets snapping.applyToProgrammatic: true (default false); values typed into forms are never snapped, but a placement constraint (8.4) can reject them.
Grid in both directions (classic node-and-edge diagram):
{
"snapProfiles": {
"grid10": {
"both": { "grid": { "spacing": 10 } },
"size": { "both": { "grid": { "spacing": 10 }, "min": 20 } },
"doc": "Everything aligns to a 10 px grid."
}
}
}
Horizontal only (a timeline of freely stacked cards):
{
"snapping": { "x": { "calendar": { "unit": "week", "align": "start" } }, "y": "none" }
}
Vertical only (a sequence diagram: lifelines are placed by layout on an ordinal x axis, messages snap to 20 px rows):
{
"snapping": { "x": { "bands": { "align": "center" } }, "y": { "grid": { "spacing": 20, "offset": 60 } } }
}
Different rules per axis and zoom (a floor plan in millimetres):
{
"snapping": {
"x": { "byZoom": [ { "maxZoom": 0.25, "rule": { "grid": { "spacing": 500 } } },
{ "maxZoom": 1.0, "rule": { "grid": { "spacing": 100 } } },
{ "rule": { "grid": { "spacing": 10 } } } ] },
"y": "inherit-x",
"rotation": { "step": 90 },
"doc": "Walls snap to 10 cm, 50 cm or 1 cm depending on zoom; rotation in right angles."
}
}
"inherit-x" / "inherit-y" copy the rule of the other axis, a convenience when rules are long.
Working-time snapping (15-minute slots during office hours):
{
"x": { "calendar": { "unit": "minute", "step": 15, "workingTime": true, "calendar": "office" } }
}
Circuit pins on a 2.54 mm pitch with magnetic port snapping:
{
"snapping": { "both": { "grid": { "spacing": 2.54 } }, "targets": ["ports", "rule"], "toleranceScreenPx": 10 }
}
| Property | Type | Description |
|---|---|---|
smart |
bool | Show alignment guides to edges and centers of nearby elements while dragging. Default true. |
distribution |
bool | Show equal-spacing guides. Default true. |
user |
bool | Users may drag guides out of rulers; guides are stored in view data. Default false. |
style |
StyleRef | Style of guide lines. |
The visible grid is separate from snapping (a grid can be displayed without snapping and vice versa) but defaults to mirroring the snapping rules.
| Property | Type | Description |
|---|---|---|
visible |
bool | Default false. |
style |
"lines", "dots", "crosses", "none" |
Default "lines". |
spacing |
number or {x, y} |
Canvas spacing; for time axes, a calendar unit { "unit": "day" }. Default: from snapping. |
majorEvery |
int or {x, y} |
Every n-th line is major. |
minorColor, majorColor |
Paint | Colors (tokens recommended). |
minZoomForMinor |
number | Hide minor lines below this zoom. |
bands |
object | For ordinal axes: { "alternate": true, "fill": [Paint, Paint], "separator": Stroke } — zebra striping of lanes. |
nonWorking |
Style | For time axes: fill for non-working time. |
A Ruler (axis property ruler) configures rulers and headers: visible, position ("top", "bottom", "left", "right"), size, and levels — a list of header rows for multi-level time scales:
{
"ruler": {
"position": "top",
"levels": [
{ "unit": "month", "format": "MMMM yyyy" },
{ "unit": "week", "format": "'W'w", "minZoom": 0.5 },
{ "unit": "day", "format": "EEE d", "minZoom": 1.0 }
]
}
}
Formats use Unicode LDML date patterns for time axes and number patterns for numeric axes; ordinal axes show category labels.
A ruler level also accepts minSpacingPx, which shows the level only while its adjacent labels are at least that many screen pixels apart (an alternative to minZoom and maxZoom), and step, a multiple of unit (default 1). A Ruler gains:
| Property | Type | Default | Description |
|---|---|---|---|
ticks |
{minSpacingPx, steps: [{unit, step, format}]} ($defs/RulerTicks) |
– | A single adaptive row: of steps, listed from finest to coarsest, the finest whose labels are at least minSpacingPx apart on screen is used. ticks and levels MUST NOT both be set. |
boundaryFormats |
map unit → format | – | A tick that falls exactly on the start of a listed unit uses that unit's format; when several apply, the coarsest unit wins. |
attach |
"view", "canvas" |
"view" |
view: the ruler stays at its edge of the pane whatever is scrolled; canvas: it scrolls with the drawing. |
minUnit |
TimeUnit or {attribute} |
– | (DISL 0.3) The shortest step shown: a level, or a step of ticks, whose step is shorter than minUnit MUST NOT be shown, at any zoom. {attribute} binds it to an attribute of the diagram, such as the unit the diagram is drawn in. |
Ticks and level boundaries MUST fall on round boundaries of their unit in the axis's time zone, never on offsets from the edge of the viewport; on a yearMonth axis, on month indices divisible by the step's number of months, counted from year 0.
"ruler": { "visible": true, "position": "bottom", "size": 24,
"ticks": { "minSpacingPx": 80, "steps": [
{ "unit": "hour", "step": 1, "format": "HH:mm" }, { "unit": "hour", "step": 6, "format": "HH:mm" },
{ "unit": "day", "step": 1, "format": "MMM d" }, { "unit": "week", "step": 1, "format": "MMM d" },
{ "unit": "month", "step": 1, "format": "MMM yyyy" }, { "unit": "year", "step": 1, "format": "yyyy" } ] },
"boundaryFormats": { "month": "MMM yyyy", "year": "yyyy" } }
A timeline's one ruler row chooses the finest step that leaves 80 pixels per label, and writes the bare year on the first of January.
"ruler": { "visible": true, "position": "bottom", "minUnit": { "attribute": "unit" },
"levels": [ { "unit": "month", "format": "MMM u", "minSpacingPx": 64 }, { "unit": "year", "format": "u", "minSpacingPx": 64 },
{ "unit": "decade", "format": "u", "minSpacingPx": 64 } ] }
A hype cycle drawn in decades starts its ruler at decades, and one drawn in months at months: a diagram in years never shows the month level, however far it is zoomed in.
Illustrated by
- Causal loop diagram: 1 items in this layer
- Entity–relationship diagram: 2 items in this layer
- Functional decomposition graph: 1 items in this layer
- Gartner hype cycle graph: 6 items in this layer
- Mind map: 2 items in this layer
- State machine: 4 items in this layer
- Project timeline: 4 items in this layer