Object Definition
The document a new object is written as, and the vocabulary it is allowed to use. One JSON file: no JavaScript, no markup, nothing to wire up. It is compiled by the same compiler, drawn by the same renderer and hosted by the same shape as the objects the editor ships with.
The document at a glance
Fill the sections in the order they are written, because each one may only read what the ones above it declare: identity, then parameters, then locals, then the tree.
{
"schemaVersion": "1.0.0",
"type": "my-object",
"category": "component",
"displayName": "My object",
"description": "One sentence, written for the person choosing it from the palette.",
"icon": "fa-light fa-circle-notch",
"tags": ["object", "meter"],
"capabilities": ["radial", "reads-model"],
"preview": { "parameters": { "valueVariable": "64", "unit": "km/h" } },
"parameters": [
{ "id": "valueVariable", "label": "Value", "valueType": "variable", "defaultValue": "0", "category": "model", "unitParameter": "unit" },
{ "id": "maximum", "label": "Maximum", "valueType": "number", "defaultValue": 100, "category": "scale", "minimum": 0 },
{ "id": "unit", "label": "Unit", "valueType": "string", "defaultValue": "", "category": "display" },
{ "id": "showReadout", "label": "Show readout", "valueType": "boolean", "defaultValue": true, "category": "display" },
{ "id": "fillColor", "label": "Fill colour", "valueType": "colour", "defaultValue": "token:stroke.accent", "category": "style" }
],
"locals": [
{ "id": "w", "value": { "parameter": "$width" } },
{ "id": "h", "value": { "parameter": "$height" } },
{ "id": "cx", "formula": "\\frac{w}{2}" },
{ "id": "cy", "formula": "\\frac{h}{2}" },
{ "id": "r", "formula": "\\max\\left(4,\\frac{\\min\\left(w,h\\right)}{2}-4\\right)" },
{ "id": "value", "value": { "parameter": "valueVariable", "as": "number" } },
{ "id": "ratio", "formula": "\\max\\left(0,\\min\\left(1,\\frac{value}{maximum}\\right)\\right)" },
{ "id": "readoutText", "value": { "format": { "parameter": "value" }, "digits": 1 } },
{ "id": "readoutUnit", "fallback": "", "value": { "choose": { "termUnit": { "parameter": "valueVariable" } }, "then": { "termUnit": { "parameter": "valueVariable" } }, "otherwise": { "parameter": "unit" } } }
],
"root": {
"id": "my-object",
"type": "group",
"children": [
{
"id": "body",
"type": "circle",
"bindings": {
"centerX": { "parameter": "cx" },
"centerY": { "parameter": "cy" },
"radius": { "parameter": "r" },
"fill": { "parameter": "fillColor" }
},
"properties": { "stroke": "none" }
},
{
"id": "readout",
"type": "text",
"when": { "parameter": "showReadout" },
"bindings": {
"x": { "parameter": "cx" },
"y": { "parameter": "cy" },
"text": { "parameter": "readoutText" },
"unit": { "parameter": "readoutUnit" }
}
}
]
}
}
Identity
| Field | Rule |
|---|---|
schemaVersion | Required. "1.0.0". |
type | Required. Matches ^[a-z][a-z0-9-]{2,48}$ and must not be a type the editor already ships — a document naming a built-in is refused, so a model cannot freeze a bundled object at an old version. |
category | Required. Always "component". |
displayName | The name in the palette. |
description | One sentence, written for the person choosing it from the palette. It is what the palette search reads. |
icon | A Font Awesome light class, e.g. "fa-light fa-gauge". |
tags | Give it "object" or it will not appear in the board's Objects palette. Low-level components deliberately do not have it. |
capabilities | Free-form marks used by search, e.g. radial, angular, reads-model, writes-model, memory. |
preview | { "parameters": { … } } — the values the palette thumbnail and the catalogue screenshot are drawn with. Needed whenever the defaults draw nothing recognisable: a gauge reading zero, a phasor of length one. A clock does not need it. |
defaultSize | { "width": 320, "height": 64 } — the box the object is placed in when nothing said how big. Left out, the object is placed square, which is what a dial wants; declare it when the drawing is not — a ruler is a strip and a protractor is wider than it is tall. |
art | Provenance for imported drawings: { "rose": "art/compass-rose.svg" }. Nothing reads it at runtime; it is what lets a redrawn file be imported again over the same document. |
Parameters — what the reader edits
Each parameter is { id, label, valueType, defaultValue, category } and becomes a row in the object's toolbar. Parameter values live in the shape, not in the document — which is what gives a new object undo/redo, copy/paste, collaboration and saving with nothing written for the purpose.
valueType | The row is |
|---|---|
number | A number box, honouring minimum, maximum and unit. |
string | A text box, or a drop-down with enumValues, or a button group when enumIcons of the same length is given. |
boolean | A switch. |
colour | A swatch. Default it to a token: "token:stroke.accent". |
variable | A term selector that also takes a plain number, exactly like the terms of a body or a point. |
expression | A LaTeX expression over model variables. |
terms | A list the reader adds rows to, each row naming a term or a pair of them with a colour of its own, reordered by its handle and removed by its bin — what the compass marks its pointers with. Read the rows with a parameter binding and hand them to a component that takes a list, such as pointer-ring. |
memory | Rows the object remembers. See If the object has to remember something. |
character | A character picked from the character library. |
audio | A sound the object makes: a clip chosen from a file or from the catalogue, then the name it was chosen under with a red bin that takes it back, and a button group saying whether the value it follows is heard as the clip's pitch or as its volume. Name the value with valueParameter. See If the object makes a noise. |
object | A structured value the object keeps for itself. |
A label names the thing, not the kind of thing. A variable row already stands in the variables menu under a term selector — so call it Hour, Value, Time, never Hour variable.
Where the row appears
category | Menu |
|---|---|
style | The name and colours menu |
model, orbits | The variables menu |
state | Nowhere — what the object keeps for itself |
anything else (scale, display, sound, general, …) | The settings menu |
Optional fields
| Field | What it does |
|---|---|
description | Help text on the row. |
minimum, maximum | Clamp a number, and bound its box. |
enumValues, enumIcons | A fixed set of alternatives; with icons of the same length it becomes a single-selection button group. |
unit | What the number is measured in, e.g. "deg". |
unitParameter | On a variable: the row where the term is picked is where its unit is picked too, and when the row holds a plain number rather than a term, that is where the picked unit is written. |
colorParameter | On a variable: carries that colour's swatch on its own row, so the colour is chosen beside the term it paints. |
pairedParameter | On a variable: a second selector beside the first, so the row names a pair — how far across and how far up. Read it with a direction binding and leave the paired parameter userEditable: false. |
modeParameter | On a variable that also names a pairedParameter: the row is read as an angle or as an orientation, chosen from a key of its own in the toolbar. The mode parameter is an ordinary string with "enumValues": ["angle", "orientation"], marked userEditable: false. |
valueParameter | On an audio: the parameter whose value the clip follows. It is read the way every other row reads the model — a term, or the plain number a row holding no term carries. |
minimumParameter, maximumParameter | On an audio: the parameters holding the two ends the value is heard between, so an object whose scale the reader can move is heard over the scale it is showing. Falling back, the audio parameter's own minimum and maximum, and then the range every sound naming no scale of its own is heard over. |
valueLocal | On a variable that is read off the drawing rather than written into it — how far across the point of a circle stands, the tangent of the angle it is at. Such a row holds whatever it was last given, which is not what the drawing shows until a gesture or the model writes it; naming the local the object drew from makes the label read that instead, so the number beside a projection is the projection. The row itself is untouched, so what a drag writes and what a file carries are still the row's own. |
writesLocal, writesWhen | On a variable read off the drawing: the row is the object’s to write as well as to show. writesLocal names the local carrying what the object drew, and the row hands the model that value on every row of a run — the circle’s sine goes down beside the angle the model turned it by, so a term the model never works out is still plotted, tabulated and read back like one it does. It is written under the name the row stands at, through the same registration a valueSource uses: the model keeps the last word on a name it works out for itself, a name it has never held is added to it as a term, and the row invites one of the reader’s own. writesWhen names a local saying when the row is the object’s to write, for a row that is the reading in one arrangement and the driver in another — the circle writes the angle it reads off a point the model places, and leaves it alone when the angle is what places the point. What is written is the local as it stands, in the model’s own units. |
valueIcon, valueIconMirrored | The mark a row with no name of its own is told apart by, written where the name would stand — an angle, a wave for a sine, the chart's ruler for a tangent. valueIconMirrored writes it the other way up, which is how a cosine wears the sine's wave reversed. A name the reader gives the row always wins over the mark. |
angleUnitParameter | The choice saying what unit this row is written in. That choice carries a choiceWriting map — one entry per value, with radiansPer (how many radians one written unit is worth: π for a row written in portions of π, π/180 for one written in degrees) and a suffix, the mark that follows the number a space after it. style: "pi" writes that count the way a scale numbered in π is numbered — π/2, 2π/3 where it is a portion a reader knows, 0.286 π where it is not — and reads a portion typed in its place back, and on the row it is typeset rather than spelt out in characters, standing there as the fraction it is. A choice marked enumMultiple may have more than one of its keys on at once, and then the row is read in both — the second in brackets after the first, under the mark its suffixLatex gives it, π/2 (90°) — drawn rather than typed, so the field the reader writes in still holds the first unit alone. A row written this way is rounded in the unit it is read in when a drag writes it. What the row holds is an angle in the unit the model counts angles in, so the choice says how the angle is written and never what it is: changing it leaves the drawing exactly where it stands, whether the row holds a number of its own or names a term of the model's. |
valueAnchor | On a variable: { "x": 0.25, "y": 0.07 }, a point in the object's box as a fraction of its width and height — or { "node": "accelerator-press", "x": 0.5, "y": 0.06 } to read the fractions in one named node's box. The row is then offered the eye every term on the board is shown with, and turning it on draws term = value there, in the badge, font and colour every other term label uses. A row without an anchor has nowhere to put a label, so it is not offered the eye. |
visibleWhen | { "parameter": "showPedals" }, or { "parameter": "turnedBy", "equals": "angle" }, or a list of conditions that all have to hold. The toolbar leaves the row out while that is false, so an optional part takes its rows, ends and colours away with it. It is about the menus alone — what is drawn is decided by when. |
toolbarKey | On a string with enumValues and enumIcons: a key of its own in the toolbar instead of a row in a menu, wearing the icon of the choice it is on. For a choice the other rows hang off — the clock's face-or-digits. Mark it userEditable: false and name a toolbarTooltip translation key. |
required, bindable, agentAccessible, userEditable, structured, termParameters | Whether the parameter must be given, may carry a binding, is offered to the assistant, is offered to the reader, holds a structured value, and which terms a memory's fields belong to. |
Names that come with behaviour
Some parameter names are recognised by the toolbar and bring a shared control with them, for free:
| Declare | Get |
|---|---|
minimumX, maximumX, minimumY, maximumY | The board's own axis-range control — a minimum and a maximum side by side — instead of four separate number boxes. |
autoScale, equalScales | The chart's own two switches, with the chart's own behaviour: auto scale fits both axes to what the object holds, padded the way the chart pads its data; equal scale widens whichever axis needs it. What they work out is shown in the number boxes, disabled, and never written down, so the ends the object was set to survive the switch being turned off. An axis deciding for itself is not an axis to drag, so its tick handles are not drawn. |
backgroundColor, dataAreaColor, axisColor | The chart's colour menu exactly, under the same labels and icons. |
Locals — what the object works out once
Derived values, evaluated in order onto the parameter frame before the tree compiles. Each is { "id": …, "value": <binding> } or { "id": …, "formula": "<latex>" }, with an optional fallback (default 0). A local may read the parameters and any earlier local, so a value the whole tree needs is worked out once rather than per node.
Nothing is implicit. A formula may only read what the document declares. A name that is neither a parameter, an earlier local nor listed in an explicit inputs block is rejected at registration — otherwise it would quietly fall through to a model term of that name. Reach the drawing size, the iteration on screen or the player state by declaring a local bound to a reserved parameter:
| Reserved | Meaning |
|---|---|
$width, $height | The object's box, so it scales on resize |
$iteration | The iteration the board is showing |
$playing | 1 while the player runs — a drawing that follows the pointer stops when it does |
$precision | The decimals the model is read to |
$index, $count | Inside a repeat: which copy this is, and how many there are |
Formulas are Modellus LaTeX, parsed against this layer's own system rather than the model's — so a local named pad or gap never becomes a model variable, and there is no eval anywhere on this path.
The tree — what is drawn
Two node shapes, and only group accepts children:
{ "id": "…", "type": "<primitive>", "properties": {…}, "bindings": {…}, "modifiers": [], "behaviours": [], "children": [] }
{ "id": "…", "type": "<component>", "parameters": {…}, "modifiers": [], "behaviours": [] }
propertiesare constants andbindingsare declarative values for the same names. A binding always wins over a constant.whenon a child drops it before compilation, so a hidden part costs nothing and does not shift its siblings' ids. Use it rather thanvisible: false. A behaviour may carry awhentoo.- An
idis the handle everything else attaches to — a binding, awhen, a modifier, a behaviour, avalueAnchor. Ids must be unique in the document.
Bindings
| Form | Meaning |
|---|---|
{ "constant": 12 } | A fixed value |
{ "parameter": "unit" } | Another parameter or a local; add "as": "number" to read it as a model variable |
{ "variable": "minute", "case": 1 } | A model variable, or a numeric literal |
{ "expression": "minute\\cdot6" } | LaTeX over model variables |
{ "formula": "a+b", "inputs": {…} } | LaTeX whose free names the definition supplies; wired at registration, not per draw |
{ "token": "stroke.accent" } | A design token |
{ "format": <binding>, "digits": 1, "prefix": …, "suffix": … } | A number as text; digits, prefix and suffix are bindings too. "style": "axis" writes it the way a tick on the board’s own axes is written — three decimals, and an exponent once it is too long to read — and "style": "pi" writes it as a multiple of π, which is what an instrument marked in radians reads in |
{ "choose": <binding>, "then": …, "otherwise": … } | A conditional. 0 and "" are false, which is how a gauge guards a zero range and a missing unit without a comparison operator |
{ "choose": …, "equals": …, "then": …, "otherwise": … } | The same conditional comparing two values — what a parameter offering named alternatives picks with |
{ "direction": { "x": <binding>, "y": <binding> } } | The angle a pair points in, in degrees clockwise from straight up |
{ "concat": [ … ] } | Resolved parts joined into one string |
{ "contrast": <colour binding> } | Black or white, whichever reads on that colour |
{ "memory": "history", "row": …, "field": "x", "from": "end" } | A memory, a row, or a field of one |
{ "memoryCount": "history" } | How many rows it holds |
{ "termUnit": { "parameter": "valueVariable" } } | What the term a parameter names is measured in, as the model holds it |
{ "termName": { "parameter": "angleVariable" }, "otherwise": "θ" } | What the term a parameter names is called, spelt the way the board spells it. otherwise is the name the part goes by while no row names it |
{ "model": "radiansPerAngle" } | How many radians one of the model's own angles is worth — 1 where the model works in radians, π/180 where it works in degrees — which is what a drawing multiplies a term by to turn an angle into a picture. "angleUnit" is the name of that unit itself |
Modifiers
translate · rotate · scale · mirror · opacity · visibility · stroke · fill · z-order · repeat
repeat is the one structural modifier: the compiler expands it before anything else and exposes $index and $count to bindings inside the repeated subtree. It is how a scale gets its marks and their numbers from one line and one text.
Behaviours
| Behaviour | Reach for it when |
|---|---|
clickable | A click writes a model variable or one of the object's own parameters. The value is a binding, so a key can write what the model currently holds rather than only a constant. |
press-and-slide | The reader holds the node. Pressing writes nothing — the value stays where it was; sliding up raises it by unitsPerPixel a pixel and down lowers it, clamped between minimum and maximum; letting go walks it back to restValue by returnStep every intervalMs, and a returnStep of zero leaves it where it was released. What a pedal or a throttle needs — and the press, the slide and the fall back are one undo entry. Naming a verticalVariable presses a pair rather than a number, and naming a bearing says which way it is laid down, so the reading carries a sign. |
drag-angle | Dragging a hand around a centre points it at the pointer and writes the angle back, measured from offsetDegrees — zero straight up when that is left at nothing. signed reads the angle the short way round, which is what a wheel wants; left off it runs from nothing to a whole turn, which is what a bearing wants. Naming a lengthVariable makes the node reach the pointer rather than only point at it: how far the pointer stands from the anchor is written as the length, divided by pixelsPerUnit and held between minimumLength and maximumLength, so one drag both turns the thing and stretches it — what the rotating vector's arrow needs. |
drag-rotate | Dragging a rim or a bezel turns it by the angle travelled, not to the pointer — what a rose or a dial ring needs. |
drag-circle-point | A point is dragged round a circle, and everything the circle is read for is written at once: the angle, the radius the pointer stands at where stretch says the drag may write one, how far across and how far up the point is, the tangent and the length of the arc. Each names a variable and a property, so a row holding a plain number is written on the object itself, and each is asked for on its own, so a circle whose angle the model works out is still dragged where it writes the point. snapDivisions makes the angle land on a whole part of the circle. The readings are written together and the model is worked through once, and the whole drag is one undo entry. |
drag-axis-tick | An axis tick rescales the axis: the near end holds still and the far one moves, writing the object’s own maximum. The chart’s interaction and the chart’s arithmetic. scale: "logarithmic" places the ticks by the logarithm of their value and writes the far end as a power of ten; naming a countProperty and a stepValue makes the drag hold the step and write the new count, which is what an axis numbered by its divisions rather than by its step needs. |
keep-time | A key runs a clock: play counts real time from wherever it stands, pause holds it, stop ends the run and clears it. It counts on a clock of its own, so it goes on while the player stands still. |
follow-pointer | The drawing shows what is under the cursor without keeping it: the values are handed to the next compilation rather than written to the shape, so hovering leaves no edit and no undo entry. |
remember, forget, track-pointer | The object keeps a memory: append on click, empty, or record a drag one sample every sampleMs. |
hoverable, tooltip | Cursor and native tooltip. |
respond-to-simulation | Redraw every tick — automatic for model bindings, so it is rarely needed. |
selectable, draggable, resizable, rotatable | Never: every object gets all four from the base shape. They are registered for discovery only. |
Two rules govern anything the reader touches:
- Interaction is in the object's pixels, not the drawing's. A node carrying
drag-angle,drag-rotate,track-pointer,press-and-slideorfollow-pointerhas to be expressed in the object's own box — outside the group that scales imported art. The compass puts its two grab areas beside the art rather than inside it for exactly this reason. - A grab selects the object it belongs to. A grab area answers the pointer before the board does and holds it for the whole drag, so the drag behaviours select on the way in. Without that, pressing an object whose face is nearly all grab area would leave it unselected and its toolbar hidden.
Components to compose with
Do not redraw what one of these already draws.
| Need | Component |
|---|---|
| A dial background | dial-face |
| Ticks and numbers around a centre | tick-ring, label-ring |
| A hand, needle or arrow | pointer-hand |
| As many directions as the reader names, marked round a dial | pointer-ring |
| A key with a label | key-cap |
| A reading spelled out in lamps, the way a digital clock shows one | seven-segment-display |
| Anything cartesian | plot-grid, plot-axes, plot-crosshair |
| A memory shown as a list or a path | memory-list, memory-trace |
| A whole object reused inside another | clock, compass, speedometer, circular-gauge, rotating-vector, orbit-system, steering-wheel, thermometer, calculator, mouse-tracker, ruler, protractor, trigonometric-circle |
Colours, fonts and sizes are not the object's to invent
No definition names a font or an axis colour. Write "defaultValue": "token:font.family", or bind { "token": "strokeWidth.hairline" }. The tokens are read by the chart, the ruler and the shared drawing helpers too, so a preset restyles every object at once and nothing drifts.
The families: surface.*, stroke.*, text.*, selection.*, handle.*, strokeWidth.*, radius.*, spacing.*, font.family, font.family.sans, font.size.*, font.weight.*, axis.*, grid.*, crosshair.*, badge.*, shadow.soft, opacity.*, duration.*, size.default.*. Every value is listed on Block Reference.
What scales with the box and what does not is a decision, not an accident: geometry is worked out from $width and $height, while writing and tick marks keep the sizes font.size.tick, font.size.large, axis.tickLength and axis.minorTickLength hold — so resizing stretches a scale rather than the labels on it.
A reading names its unit, it does not spell it out
Never concatenate a unit into text. Give the text node a unit of its own and the board writes it the way it writes every unit after a reading — a space away, faded against the figures, so 64 in kilometres an hour reads 64 km/h. Where the unit comes from is settled the same way: the term the object reads carries it, and { "termUnit": … } is how the definition asks for it. A unit parameter of the object's own is what is left for an object reading a plain number rather than a term.
A part of a drawing is named the same way. { "termName": { "parameter": "angleVariable" } } is what the reader called the row, spelt the way the board spells it everywhere else, and otherwise is the name the part goes by in the mathematics until a row names it — a wedge is θ only until it is theta. A name written into a definition is a name the reader cannot change, so a part standing for a row should never carry one.
If the object has to remember something
A memory is a parameter with "valueType": "memory", so its rows live in the shape beside the numbers: the model carries them, undo restores them and the clipboard takes them. A row is { text, x, y } and writes only the fields it holds.
{ "id": "samples", "valueType": "memory", "defaultValue": [], "termParameters": { "x": "xVariable", "y": "yVariable" } }
Declare termParameters and the rows stop being private notes: row n is iteration n of those terms, so the board's player replays the recording and everything bound to those variables moves with it. Omit it and the memory stays with the object, as the calculator's history does.
remember appends on click, forget empties, track-pointer records a drag one sample every sampleMs in the node's own units. Limits: 2 000 rows, a limit per behaviour, six decimals. A whole drag is one undo entry.
If the object makes a noise
A sound is a parameter with "valueType": "audio". It holds one thing — the address of an audio clip — and the reader fills it from a file on disk or from an entry in the catalogue's audios, which leave the same address behind. The row offers both ways of choosing while it is empty, and once a clip is chosen it shows the name it was chosen under and the bin that takes it back. Nothing else about the definition changes: the clip is the object's sound wherever it came from.
{ "id": "engineSound", "label": "Engine", "valueType": "audio", "defaultValue": "", "category": "sound",
"valueParameter": "valueVariable", "minimumParameter": "minimum", "maximumParameter": "maximum" }
valueParameter names the value the clip follows, and minimumParameter and maximumParameter the two ends it is heard between — the object's own scale, so a speedometer is heard over the speeds it is showing. The row carries a button group of its own saying what the value does to the clip: heard as pitch, the clip is played faster or slower — two octaves, one either side of its own pitch, spread evenly over the range; heard as volume, it is played at its own pitch and the value is how loud it is. The choice is kept in <id>Modulation beside the address, and the name the clip was chosen under in <id>Name, so the definition declares nothing for either.
The object is heard only while the value is moving: a change starts the clip and the changes after it steer the voice already sounding, so a model that is not running makes no noise and an object standing still goes quiet on its own.
Drawing it instead of writing it
Geometry that is illustration rather than arithmetic does not have to be typed. Draw it in a drawing program, convert it, and paste the nodes into root — they are ordinary primitive nodes, so nothing else about the document changes.
const imported = BlockSvgImport.import(markup);
imported.problems // every element, attribute or paint that was refused or is unsupported
imported.unmapped // colours that matched no design token, so they will not follow a preset
imported.count // how many nodes it costs, against the limit of 2 000
BlockSvgImport.merge(previousNodes, imported.nodes) // re-import, keeping the wiring by id
It converts, it does not embed: no new node type, no markup reaching the renderer, nothing for the compiler, the validator or the serializer to learn — which is also the security boundary, since a definition travels through the model file, the clipboard, a collaboration op, the catalogue and the assistant.
| Treatment | What |
|---|---|
| Converted | svg, g, circle, ellipse, rect, line, polyline, polygon, path, text, image, with fill, stroke, width, dash, linecap, opacity and visibility read from either the attribute or the inline style. transform becomes translate, rotate and scale modifiers. |
| Refused, and reported | script, style, foreignObject, animation elements, on* handlers, links, image sources outside the allow-list, and path data outside the path grammar. Nothing is dropped silently. |
| Unsupported, and reported | use, defs, gradients, masks, filters, clip paths, matrix() and skew(). A drawing leaning on them imports into something that differs from the file, which the author has to know. |
| Mapped | A colour matching a token is written as token:surface.default rather than #ffffff, preferring the family that suits the property. That is what keeps imported art following the presets. What could not be matched is listed in unmapped. |
Import illustration, keep measurement as blocks. Art is authored at one size and has one layout: it cannot put ticks on round numbers, keep a label legible when the object is small, or move one part to make room for another. Those stay components.
A complete object
The circular gauge is the smallest complete object in the editor: a track, a fill and a readout, all worked out from one ratio. It is bundled as circular-gauge.
{
"schemaVersion": "1.0.0",
"type": "circular-gauge",
"category": "component",
"displayName": "Circular gauge",
"description": "Ring gauge that fills clockwise in proportion to a model variable.",
"icon": "fa-light fa-circle-notch",
"tags": ["object", "gauge", "progress", "ring", "meter"],
"capabilities": ["radial", "angular", "reads-model"],
"preview": { "parameters": { "valueVariable": "68", "unit": "%" } },
"parameters": [
{ "id": "valueVariable", "label": "Value", "valueType": "variable", "defaultValue": "0", "category": "model", "unitParameter": "unit" },
{ "id": "minimum", "label": "Minimum", "valueType": "number", "defaultValue": 0, "category": "scale" },
{ "id": "maximum", "label": "Maximum", "valueType": "number", "defaultValue": 100, "category": "scale" },
{ "id": "startAngle", "label": "Start angle", "valueType": "number", "defaultValue": 90, "category": "scale", "unit": "deg" },
{ "id": "spanAngle", "label": "Span", "valueType": "number", "defaultValue": 360, "category": "scale", "unit": "deg", "minimum": 1, "maximum": 360 },
{ "id": "thickness", "label": "Ring thickness", "valueType": "number", "defaultValue": 0.22, "category": "style", "minimum": 0.02, "maximum": 1 },
{ "id": "trackColor", "label": "Track colour", "valueType": "colour", "defaultValue": "token:surface.muted", "category": "style" },
{ "id": "fillColor", "label": "Fill colour", "valueType": "colour", "defaultValue": "token:stroke.accent", "category": "style" },
{ "id": "labelColor", "label": "Label colour", "valueType": "colour", "defaultValue": "token:text.primary", "category": "style" },
{ "id": "digits", "label": "Decimals", "valueType": "number", "defaultValue": 0, "category": "display", "minimum": 0, "maximum": 6 },
{ "id": "unit", "label": "Unit", "valueType": "string", "defaultValue": "", "category": "display" },
{ "id": "showReadout", "label": "Show readout", "valueType": "boolean", "defaultValue": true, "category": "display" }
],
"locals": [
{ "id": "w", "value": { "parameter": "$width" } },
{ "id": "h", "value": { "parameter": "$height" } },
{ "id": "cx", "formula": "\\frac{w}{2}" },
{ "id": "cy", "formula": "\\frac{h}{2}" },
{ "id": "r", "formula": "\\max\\left(4,\\frac{\\min\\left(w,h\\right)}{2}-4\\right)" },
{ "id": "innerRadius", "formula": "r\\cdot\\left(1-\\max\\left(0.02,\\min\\left(1,thickness\\right)\\right)\\right)" },
{ "id": "value", "value": { "parameter": "valueVariable", "as": "number" } },
{ "id": "ratio", "value": { "choose": { "formula": "maximum-minimum" }, "then": { "formula": "\\max\\left(0,\\min\\left(1,\\frac{value-minimum}{maximum-minimum}\\right)\\right)" }, "otherwise": 0 } },
{ "id": "sweep", "formula": "\\max\\left(0.001,\\min\\left(360,spanAngle\\right)\\right)" },
{ "id": "filledSweep", "formula": "\\max\\left(0.001,sweep\\cdot ratio\\right)" },
{ "id": "trackEnd", "value": { "choose": { "formula": "360-sweep" }, "then": { "formula": "startAngle-sweep" }, "otherwise": { "formula": "startAngle-sweep+0.001" } } },
{ "id": "fillEnd", "formula": "startAngle-filledSweep" },
{ "id": "readoutFontSize", "formula": "\\max\\left(10,r\\cdot0.34\\right)" },
{ "id": "readoutWeight", "value": { "token": "font.weight.strong" } },
{ "id": "readoutUnit", "fallback": "", "value": { "choose": { "termUnit": { "parameter": "valueVariable" } }, "then": { "termUnit": { "parameter": "valueVariable" } }, "otherwise": { "parameter": "unit" } } },
{ "id": "readoutText", "value": { "format": { "parameter": "value" }, "digits": { "parameter": "digits" } } }
],
"root": {
"id": "circular-gauge",
"type": "group",
"children": [
{ "id": "track", "type": "arc",
"bindings": { "centerX": { "parameter": "cx" }, "centerY": { "parameter": "cy" }, "radius": { "parameter": "r" },
"innerRadius": { "parameter": "innerRadius" }, "startAngle": { "parameter": "startAngle" },
"endAngle": { "parameter": "trackEnd" }, "fill": { "parameter": "trackColor" } },
"properties": { "stroke": "none" } },
{ "id": "fill", "type": "arc",
"bindings": { "centerX": { "parameter": "cx" }, "centerY": { "parameter": "cy" }, "radius": { "parameter": "r" },
"innerRadius": { "parameter": "innerRadius" }, "startAngle": { "parameter": "startAngle" },
"endAngle": { "parameter": "fillEnd" }, "fill": { "parameter": "fillColor" } },
"properties": { "stroke": "none" } },
{ "id": "readout", "type": "text", "when": { "parameter": "showReadout" },
"bindings": { "x": { "parameter": "cx" }, "y": { "parameter": "cy" }, "text": { "parameter": "readoutText" },
"unit": { "parameter": "readoutUnit" }, "fontSize": { "parameter": "readoutFontSize" },
"fontWeight": { "parameter": "readoutWeight" }, "fill": { "parameter": "labelColor" } },
"properties": { "stroke": "none" } }
]
}
}
Read the nearest example first
| If the new object is | Start from |
|---|---|
| A dial, gauge or meter | circular-gauge — the smallest complete object |
| A scale with a needle | speedometer |
| An instrument that measures rather than reads | ruler and protractor — bound to no term at all: a follow-pointer over the scale reports where the pointer is into three parameters of the object’s own, and the reading and the line under it carry a when on those, so they are drawn while the pointer is there and gone the moment it leaves |
| One object read two ways | ruler again — a linear scale and a logarithmic one in the same document, each set of ticks carried by a when on the same choice. Its decade marks are placed by a \log in a formula over $index, and the ones outside the range are clamped to the edge and cut off by a clip-box, which keeps them out of the box the object reports as its own |
| An instrument marked in π | protractor — what the two ends of its scale read is what it is marked in, so the same object measures degrees, radians or turns; label-ring’s numberFormat and the format binding’s "style": "pi" write those numbers as π/6 rather than as 0.52 |
| A quantity read as a length, up a scale of its own | thermometer — the marks and their numbers are one line and one text under a repeat, and it is where to see what scales with the box and what does not |
| A hand or arrow driven by an angle | rotating-vector |
| Interactive, writing values back | compass — drag-angle and drag-rotate on invisible grab areas |
| A construction read off a circle | trigonometric-circle — six rows for the one point: whichever of them the model works out for itself places the point, chosen between by defines, and every row it leaves free is written by one drag-circle-point. It is where to see a reading marked with the sign of what it measures rather than named, through valueIcon. It is also where to see an angle worked out from a pair without an atan2 to call |
| Drawn rather than written | compass again — its rose and needle are imported SVG, wired by id, with the labels left to label-ring |
| One object drawn several ways, with parts the reader switches on and off | steering-wheel — a drawing per vehicle carried by a when on a choose with an equals, and a press-and-slide area over each pedal |
| Composed of several sub-objects | clock, orbit-system |
| A keypad that keeps its working | calculator — key-cap, remember, memory-list |
| A plot that records | mouse-tracker — the plot components over a memory-trace |
Where the document goes
| Destination | What to do | Who gets it |
|---|---|---|
| Bundled with the editor | Write scripts/blocks/definitions/<type>.json, then regenerate the delivered bundle | Everyone, offline included |
| Published to the catalogue | Paste the same document into Assets → Objects in the community catalogue | Anyone whose palette reads the catalogue |
| Invented on a board | The assistant's save_custom_component, or Copy definition from an object's settings menu | The model that carries it |
The three are the same document. Every JSON file in the definitions directory is registered at load time, so nothing is added anywhere else — and for the same reason, a draft or a template left in that directory would become a component in the palette.
What gets a document refused
Registration rejects an unsupported schemaVersion, a type that is not the pattern or is already shipped, a category other than component, a missing root, a parameter or local with no id, a local colliding with a parameter or an earlier local, and any formula reading a name the document does not declare.
Validation adds unknown block types, bindings on properties that do not accept them, variables that do not exist, expressions that do not parse, the runtime limits — 2 000 nodes, depth 16, component depth 8, repeat count 720, expression length 512 — and then a visual pass: nothing drawn, everything invisible, zero-size interactive targets, nodes far outside the box.
Errors come back structured, with the path that failed and, where there is one, the name it should have been:
{
"valid": false,
"errors": [
{
"code": "UNKNOWN_VARIABLE",
"path": "root.children[2].bindings.rotation",
"message": "The variable 'minutes' does not exist in the current model.",
"suggestion": "minute"
}
]
}
Common codes: UNKNOWN_NODE_TYPE, UNKNOWN_PROPERTY, PROPERTY_NOT_BINDABLE, INVALID_BINDING, BINDING_TYPE_MISMATCH, UNKNOWN_VARIABLE, MISSING_VARIABLE, INVALID_EXPRESSION, EXPRESSION_TOO_COMPLEX, UNKNOWN_PARAMETER, MISSING_REQUIRED_PARAMETER, UNKNOWN_TOKEN, UNKNOWN_MODIFIER, UNKNOWN_BEHAVIOUR, BEHAVIOUR_NOT_SUPPORTED, DUPLICATE_NODE_ID, NESTING_LIMIT_EXCEEDED, NODE_LIMIT_EXCEEDED, NODE_OUTSIDE_BOUNDS, EMPTY_OBJECT, INVISIBLE_OBJECT.
Building it through the assistant's tools
An agent never writes rendering code. It composes the same document through a structured tool surface, validates it, and inserts it as an ordinary editable object.
| Tool | Purpose |
|---|---|
list_building_blocks, search_building_blocks, get_building_block_schema | Discover blocks and read their properties, defaults and ranges from the live registry |
list_model_variables | Read the variables of the open model with their current values |
create_object_draft, get_object_draft | Start and read a draft, optionally seeded with an existing component |
add_primitive, add_component, add_group, add_child | Build the node tree |
set_property, set_parameter, apply_modifier, add_behaviour | Configure nodes |
bind_variable, bind_expression | Drive a property from a model variable or a LaTeX expression |
validate_object, render_object_preview | Check the draft and preview it in isolation |
insert_object, save_custom_component | Insert a valid object into the document, or register it for reuse |
Over the chat tool bridge these are named modellus_blocks_<tool>, for example modellus_blocks_bind_expression.
list_model_variablesandlist_building_blocksto see what existscreate_object_draft, then add nodes, parameters, modifiers, behaviours and bindingsvalidate_object— correct the draft with the same tools and validate againrender_object_previewto check the result, theninsert_object
const draft = modellus.blocks.execute('create_object_draft', { name: 'Clock', componentType: 'clock' });
modellus.blocks.execute('bind_variable', { draftId: draft.draftId, nodeId: 'root', property: 'hourVariable', variable: 'hour' });
modellus.blocks.execute('bind_variable', { draftId: draft.draftId, nodeId: 'root', property: 'minuteVariable', variable: 'minute' });
modellus.blocks.execute('validate_object', { draftId: draft.draftId });
modellus.blocks.execute('render_object_preview', { draftId: draft.draftId });
modellus.blocks.execute('insert_object', { draftId: draft.draftId });
Checklist
typeis new,categoryiscomponent, taggedobject, with aniconand a one-sentencedescription- Every parameter has a
category, and every colour defaults to atoken:… $widthand$heightare reached through locals, so the object scales on resize- No font, axis colour or grid colour is written as a literal
- Anything cartesian is
plot-grid,plot-axesorplot-crosshair, not hand-drawn lines - Optional parts carry
when, and the rows that govern them carryvisibleWhen - A reading gives its
textnode aunitrather than concatenating one - Interactive nodes are expressed in the object's own box, outside any group that scales art
preview.parametersis set if the defaults draw nothing recognisablevalidate_objectcomes back clean before the object is inserted, saved or published