Visualization
Turning a value into something you can look at, without leaving the box.
62 source file(s), 1728 public symbol(s), 1235 of them carrying KDoc. Everything below is the documentation that lives beside the code — the same text the completion documentation panel shows in the editor. A blank summary means the declaration has no KDoc yet, not that this page dropped it.
keyframeBrowser.kt
Reading a collection of saved keys the way KeyframeScope reads one: a glob of key files in,
a slot-major summary out.
Why slot-major
A directory of keys is a sparse three-axis cube — file × version × slot — and only one of those axes tells you anything you did not already know. You know your file names. What you cannot see from a directory listing is which slots you actually kept pushing on, which have been frozen since the first save, and which are missing from half the files because the scope grew a target after they were written. So this pivots to slots, and every summary here is computed across files rather than within one.
What load reads
load decodes each matched file straight to KeyDTO rather than going through
KeyframeScope.loadKey, which fills KeyframeScope.missingKey defaults for names a file lacks —
exactly right when you are about to apply a key and exactly wrong when you are trying to see
which names a file lacks. The browser wants the file as written.
Version history is read lazily. load calls KeyframeScope.keyVersions for each file, which
lists a directory and parses one manifest, but it does not open the numbered snapshots. Those are
only read when slotHistory is asked for one slot's trajectory — a hundred saves of ten files is
a thousand files, and no one wants that on open.
Failure is per-file
A collection with one unparseable file still browses. load records the failure in
KeyframeCollection.problems and carries on, so one bad file does not make the other nine
invisible.
src/jvmMain/kotlin/afield/animation/keyframes/keyframeBrowser.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun keyFilesMatching(root: Path, pattern: String = everyKeyGlob): List<Path> |
Every regular file under root whose path relative to root matches the glob pattern, sorted by path, excluding anything inside a .versions directory. |
fun friendlyTime(timestamp: String, now: Instant = Instant.now(), zone: ZoneId = ZoneId.systemDefault()): String |
A save's timestamp as a person reads it: relative while that is the useful thing to know, and an ordinary date once it is not. |
Top-level values
| Declaration | Summary |
|---|---|
const val everyKeyGlob: String |
The glob that means "every key in this directory and below", and the default wherever a key collection is opened. |
SkipKind
Why a file under the collection root is not a row in the browser.
Constants: NotAKey, Damaged, Hidden.
SkippedFile
data class SkippedFile(val relative: String, val kind: SkipKind, val detail: String)
One file under the root that is not a browser row, and the one line explaining that.
| Declaration | Summary |
|---|---|
val relative: String |
Path relative to the collection root, /-separated. |
val kind: SkipKind |
|
val detail: String |
One human-readable line. Never a stack trace. |
KeyframeFile
One key file as the browser sees it: what is on disk, plus how many times it has been saved.
| Declaration | Summary |
|---|---|
val id: Int |
Stable index within the collection, used as the wire identity for selection and diffing. |
val path: Path |
|
val relative: String |
path relative to the collection root, with / separators — what the UI shows. |
val schemaVersion: Int |
KeyDTO.schemaVersion as written. Zero both when it was written as zero and when absent. |
val values: Map<String, KValue> |
The file's entries exactly as written — no KeyframeScope.missingKey defaults applied. |
val versions: List<KeyVersionInfo> |
The save history from <file>.versions, oldest first; empty when nothing has been saved. |
val latest: KeyVersionInfo? |
The most recent history row, which carries the note and timestamp the file name cannot. |
KeyframeCollection
A loaded collection of key files and the slot pivot over them.
| Declaration | Summary |
|---|---|
val root: Path |
|
val pattern: String |
|
val files: List<KeyframeFile> |
|
val problems: List<String> |
Per-file failures, one human-readable line each. A collection with problems still browses. |
val scopeSlots: Set<String> |
Slot names the live scope watches, from scope.capture().values.keys. Used to mark a slot the scope has but no file does — the "you have never saved this" row, which is the one thing a collection cannot tell you about itself. |
val scopeAttached: Boolean |
Whether a live scope was passed to load at all, which is not the same question as whether scopeSlots is empty. The orphan marker below needs both answers. |
val skipped: List<SkippedFile> |
Every file under root that is not a row here, and why — the browser's account of what it did not show you. See SkipKind for the three reasons and skipSummary for the one line. |
val skipSurveyTruncated: Boolean |
True when the hidden-key survey stopped at HIDDEN_SCAN_BUDGET rather than finishing. The counts below are then floors, not totals, and skipSummary says so. |
val hiddenCount: Int |
Readable keys that the pattern excluded: keys on disk the glob did not reach. |
val ignoredCount: Int |
Files that were never keys. A notice, not an alarm. |
fun skipSummary(): String? |
One line for what the browser did not show, or null when it showed everything. |
val slotNames: List<String> |
Every slot name in any file, plus any scopeSlots no file has, sorted. |
val versionCount: Int |
|
val schemaVersions: List<Int> |
The distinct KeyDTO.schemaVersions present, ascending — more than one means schema drift. |
val latest: KeyVersionInfo? |
The newest history row across every file, by timestamp. |
fun file(id: Int): KeyframeFile? |
|
fun slots(): List<SlotSummary> |
The pivot: one SlotSummary per name in slotNames, in the same order. |
fun slot(name: String): SlotSummary |
The summary for one slot — what value it takes in each file, and what that set looks like. |
val orphanedSlots: List<String> |
Slot names that some key on disk holds and the live scope does not watch — the saves that now apply to nowhere. |
fun slotHistory(fileId: Int, slot: String): List<Pair<KeyVersionInfo, KValue?>> |
One slot's trajectory through a single file's save history, oldest first — the view that answers "what did I actually keep pushing on". |
SlotBand
data class SlotBand(val label: String, val lo: Double, val hi: Double, val marks: List<Double>)
A low–high band with the individual values marked inside it, as a range strip is drawn.
| Declaration | Summary |
|---|---|
val label: String |
|
val lo: Double |
|
val hi: Double |
|
val marks: List<Double> |
Each value's position in 0..1 across lo..hi. A degenerate band puts every mark at 0.5. |
SlotVariant
One distinct value of a slot that varies discretely, and which files hold it.
SlotDisplay
How a slot's spread should be drawn. The split is not cosmetic: it follows KeyframeScope.interpolate, which blends numbers and vectors and steps strings, StackSL source and source blocks. A slot that steps has no range, only a set of variants, and drawing a bar for one would claim a continuum that does not exist.
SlotSummary
The pivot's output for one slot name.
| Declaration | Summary |
|---|---|
val name: String |
|
val kind: String |
The KValue serial name shared by every present value, "mixed", or "absent". |
val typeLabel: String |
Human label for the type column — float, vec4, pose, stacksl, mixed (num, string). |
val presentIn: Int |
|
val total: Int |
|
val inScope: Boolean |
True when a live scope watches this name. |
val orphaned: Boolean |
True when a key on disk holds this slot and the attached scope does not watch it — a saved value that Apply will silently drop. |
val summaryText: String |
The right-hand column: 2.0 – 11.4, 7 swatches, 3 variants. |
val display: SlotDisplay |
LorisVisualizer.kt
Draws a LorisModel's partial trajectories as an interactive HTML panel next to its waveform.
visualizationHtml plots each partial's breakpoints at their real times on a cents, ERB or Hz axis
(LorisFrequencyAxis), shares the time window, cursor and click-to-seek with the waveform view, and
caps how many partials and points are drawn (LorisViewOptions). The caps affect drawing only.
src/jvmMain/kotlin/afield/audio/analysis/loris/LorisVisualizer.kt — read the source
Used in: Loris: partial tracks and resynthesis
Top-level functions
| Declaration | Summary |
|---|---|
fun LorisModel.visualizationHtml(options: LorisViewOptions = LorisViewOptions()): String |
Actual irregular trajectories, with shared waveform time-window, cursor and click-to-seek events. |
fun LorisResult.visualizationHtml(options: LorisViewOptions = LorisViewOptions()): String |
Adds a real reconstruction player to the synchronized partial map. PCM data itself is unchanged. |
LorisFrequencyAxis
Frequency scale of the trajectory plot.
Constants: CENTS, ERB, HZ.
LorisTrackSelection
enum class LorisTrackSelection
Which partials fill the drawing budget: spread across ERB bands, or the strongest overall.
Constants: BAND_BALANCED, STRONGEST.
LorisViewOptions
Display limits affect drawing only, never the stored native model or resynthesis.
| Declaration | Summary |
|---|---|
val width: Int |
|
val minimumHz: Double |
|
val axis: LorisFrequencyAxis |
|
val selection: LorisTrackSelection |
|
val maxPartials: Int |
|
val syncGroup: String |
interactionnets.kt
Interaction nets as a live-codeable medium: a graph whose nodes rewrite each other wherever two of them touch principal port to principal port, with a DSL for building nets, a rule language for the rewrites, a scheduler that decides which collision fires next, and a force-directed layout so you can watch it happen.
Start at net to build one, rules to say how it rewrites, Reducer to run it, and Net.toFLines or
Box.showInteractionNet to look at it. Lambda is a ready-made vocabulary (APP, LAM, DUP, ERASE, ROOT,
VAR, CONST) with its own rule book, if you want the classical behaviour rather than your own.
A compact, single-file interaction-net playground meant to be easy to lift into a larger codebase, but also meant to be a fertile computational substrate for live coding art.
What is in here: - a small in-memory interaction-net graph model - a Kotlin DSL for building whole nets and reusable graph fragments - two rewrite styles: 1) a declarative "automatic rewiring" DSL built around ext(side, aux) 2) an escape hatch for arbitrary custom rewrites in ordinary Kotlin - configurable active-pair scheduling - a simple force-directed layout with incremental seeding of rewrite RHS nodes at the LHS center - FLine/output rendering plus SVG export for quick inspection - a small lambda-oriented bundle including APP, LAM, DUP, ERASE, ROOT, VAR, CONST
Design intent: - Keep the core tiny and hackable. - Make the common case pleasant without removing low-level control. - Be explicit about where the model is still incomplete. - Treat the system less like a theorem prover and more like a graph chemistry / reactive medium.
The artistic framing: This file is not most interesting as a route to "real lambda calculus". It is more interesting as a local, spatial, rewiring-based substrate for live coding. In that framing:
- principal ports are not just logical redex triggers; they are collision points, activation sites, rhythmic contact surfaces
- auxiliary ports are not just variables; they can be treated as tension channels, color buses, growth directions, continuation hooks, memory strands, or signal paths
- rewrite rules are not merely reductions; they are local laws of motion, branching, fusion, mutation, decay, reflection, gating, or pulse propagation
- active-pair scheduling is not merely evaluation order; it is rhythm, phrasing, emphasis, groove, cascade control
- layout is not merely display; it is motion memory and can become part of the meaning of the system
DUP and ERASE in this context: DUP and ERASE are useful here less as strict lambda-calculus machinery and more as artistic growth and decay primitives.
- DUP can mean branching, echo, motif propagation, splitting a line of force into two descendants, or generating two related but diverging futures
- ERASE can mean fade, pruning, absorption, dissipation, culling, or a sink that removes pressure from a local region
In a more expressive live system, DUP does not need to be perfectly literal duplication. It can create two descendants with slight positional offsets, inherited state, hue drift, phase drift, timing skew, energy split, or generation markers. Likewise ERASE does not need to be instantaneous deletion; it can become gradual attenuation, shrinkage, or multi-frame disappearance.
Why this is promising for live coding art: - Local causality: every visible change comes from a small neighborhood interaction. - Spatial meaning: the graph is data and choreography at once. - Compositionality: a small table of rewrite laws can yield rich large-scale behavior. - Performability: rules, scheduler, layout constants, render mapping, and node injection can all be changed while the system is running.
Activation order as an instrument: Reduction order is a first-class artistic control. A deterministic scheduler can feel mechanical; randomized scheduling can shimmer; energy-biased or cursor-biased scheduling can create bursts, avalanches, attraction, or spotlighting. Treat scheduling as part of the score.
Layout as part of computation: The incremental layout already gives new RHS nodes a positional memory by seeding them at the centroid of the consumed active pair before relaxation. That idea can be pushed further. Rewrite rules can read or influence geometry; branch direction can depend on current edge angle; replication distance can depend on energy; eligibility can depend on local spatial conditions; layout velocity can feed rendering or sound. The graph need not be computed first and visualized second.
A "wet" semantics is often better than a perfectly dry one: For artistic purposes, clean instantaneous replacement is sometimes less interesting than sticky, physicalized replacement. Newly created nodes can inherit centroid and velocity. Consumed edges can leave ghosts. Erased nodes can decay over a few frames. Duplicated nodes can repel strongly at birth and then settle. Rules can emit transient sparks or decorative residues. These choices improve visual legibility and expressiveness during rapid change.
The next high-leverage extension is per-node state: Adding a small mutable state record to each node would make the substrate much richer. Examples:
data class State(
var energy: Double = 1.0,
var phase: Double = 0.0,
var hue: Double = 0.0,
var age: Double = 0.0
)
Then rewrite rules can split energy on DUP, accumulate age, drift hue, mutate phase, and directly map these quantities to radius, stroke weight, opacity, curvature, synthesis parameters, or triggering.
Rendering and sound hooks: A rewrite can be more than a topology update. In a live system it is natural for a rewrite to also emit events such as "flash this edge", "trigger a note", "spawn a particle", "increase camera pull", or "mark this component active". That turns graph rewriting into the event engine for an audiovisual system.
Useful node archetypes for an artistic palette: - Pulse : propagates motion or activation - DUP : bifurcates structure or motive - ERASE : consumes / attenuates / prunes - Delay : holds activity for N frames or beats - Gate : conditionally passes or blocks interaction - Mirror : swaps neighborhoods / reflects flow - Fuse : merges strands - Noise : injects stochastic variation - Anchor : spatial stabilizer - Bloom : expands into decorative local structure
A note on rigor: This file adds DUP and ERASE agents in the usual explicit-resource spirit, but it is not trying to be a complete optimal reducer. There is no full fan/fan commuting story, no full lambda frontend, and no claim of theoretical completeness. That is deliberate. The value here is as a hackable medium with:
- topology that changes
- space that remembers
- order that matters
- small laws that create style
In other words: treat this as a graph chemistry for live coding.
src/jvmMain/kotlin/afield/data/graph/interactionnets.kt — read the source
Used in: Interaction Nets examples, A λ-term you can hear
Top-level functions
LayoutConfig
The knobs on the force-directed layout that Net.relax runs — every node pushing every other apart, every edge pulling its two ends together.
| Declaration | Summary |
|---|---|
val repulsion: Double |
How hard every node pushes every other away. Falls off as 1/d², so it sets the empty space between unconnected nodes without much affecting connected ones. |
val springLength: Double |
The distance an edge is happy at, in position units. The quickest single knob for the overall scale of the picture. |
val springStrength: Double |
How strongly an edge pulls back toward springLength. Large values make the net rigid and jittery. |
val damping: Double |
Velocity retained per step. Below 1 is what makes the simulation settle at all; toward 1 it wobbles for far longer, and at 1 it never stops. |
val timeStep: Double |
Integration step. Scales both the velocity update and the movement, so raising it is a faster but less stable simulation. |
val jitter: Double |
Stored but unused by the current layout step — new nodes get their initial scatter from Net.create instead. |
val clampStep: Double |
Per-step speed limit, in position units. What stops two nearly coincident nodes from firing each other off the canvas. |
NodeState
The mutable per-node payload that turns a bare interaction net into something worth watching: four named quantities the renderer already reads, plus scalars for anything else you want to carry.
| Declaration | Summary |
|---|---|
var energy: Double |
Drives node radius when drawn, and is what the energy-biased PairSelectors rank pairs by. Every operation here floors it at 0, but nothing caps it. |
var phase: Double |
Carried and split, but nothing in this file interprets it — yours to use, though the renderer does fall back to it for a node's port angle when the node has no connected neighbours. |
var hue: Double |
Drives colour, as a fraction of a turn. Wrapped at render time rather than here, so it may drift far outside 0..1 and that is fine. |
var age: Double |
Advanced only by Net.incrementAges, in whatever unit you pass it — so age runs on the clock you choose. Nudges drawn lightness. |
val scalars: MutableMap<String, Double> |
Room for your own named quantities. Deep-copied by copyDeep and blended key-wise by blend, where a key missing from one side counts as 0. |
fun copyDeep(): NodeState |
An independent copy, scalars map included, so later mutation of either cannot reach the other. |
fun evolved(energyScale: Double = 1.0, energyBias: Double = 0.0, phaseShift: Double = 0.0, hueShift: Double = 0.0, ageDelta: Double = 0.0): NodeState |
A copy with the four quantities moved on — the "one node becomes one node, a little different" step for a rewrite rule to hand its replacement. |
fun split(parts: Int, branch: Int, attenuation: Double = 1.0, hueSpread: Double = 0.0, phaseSpread: Double = 0.0, ageDelta: Double = 0.0): NodeState |
One branch's share of this state when a node divides into parts — the state side of a DUP-style rewrite. |
PortKind
Which of a node's two kinds of port this is — and therefore whether an edge through it can react.
Constants: PRINCIPAL, AUXILIARY.
AgentType
data class AgentType(val name: String, val auxPorts: Int)
A kind of node: a name to match rules against, and how many auxiliary ports its instances get.
| Declaration | Summary |
|---|---|
val arity: Int |
Total port count: the auxiliaries plus the one principal port. |
Port
class Port internal constructor(val node: Node, val index: Int, val kind: PortKind)
One port on one node — the thing edges connect. Port 0 is always the principal port and 1..auxPorts the auxiliaries, which is why Node.aux takes a 0-based index and adds one for you.
| Declaration | Summary |
|---|---|
val node: Node |
The node this port belongs to. A port never moves between nodes. |
val index: Int |
Position in the node's ports list: 0 is the principal port, 1..auxPorts the auxiliaries. Note this is the raw index, one more than the number Node.aux takes. |
val kind: PortKind |
Whether this is the one reactive port or an inert auxiliary. Fixed when the node is created. |
val id: String |
"<nodeId>:<portIndex>" — the stable string identity Net uses for its adjacency index. |
override fun toString(): String |
The port's id. |
Node
One node in a net: an AgentType, a stable id, a fixed list of ports and a mutable NodeState.
| Declaration | Summary |
|---|---|
val id: String |
Unique within its net and never reused, so a ReductionStep's removed ids stay unambiguous after the fact. Minted as type_n by IdSource unless you named it. |
val type: AgentType |
Which kind of agent this is — what rules match on, and what fixes the port count. |
val state: NodeState |
This node's mutable payload. Net.create copies whatever you pass, so two nodes never share one by accident; a rewrite rule must copy it explicitly. |
val ports: List<Port> |
All of this node's ports, principal first at index 0, then the auxiliaries in order. |
val principal: Port |
This node's one reactive port. Wire two of these together and you have an ActivePair. |
fun aux(i: Int): Port |
Auxiliary port i, numbered from 0 — so aux(0) is ports[1]. |
override fun toString(): String |
"<id>:<typeName>", e.g. "dup_4:DUP". |
Edge
data class Edge(val a: Port, val b: Port)
An undirected wire between two ports. A port holds at most one edge, so this is a matching, not a graph with multiplicity.
| Declaration | Summary |
|---|---|
fun other(p: Port): Port |
The port at the far end from p — how you walk from a node to its neighbour. |
ActivePair
data class ActivePair(val left: Node, val right: Node)
Two nodes whose principal ports are wired to each other — a redex, the only place a rewrite can happen.
| Declaration | Summary |
|---|---|
fun canonicalKey(): Pair<String, String> |
The two node ids sorted, so the same pair always keys the same way whichever side it arrived on. |
Net
class Net internal constructor(internal val ids: IdSource = IdSource())
The net itself: nodes, the wires between their ports, and — because this is meant to be watched — a position and velocity per node.
| Declaration | Summary |
|---|---|
val nodes: Collection<Node> |
Every node, in creation order. A live view of the net's own map — do not iterate it while rewriting. |
val edges: Collection<Edge> |
Every wire, in creation order. A live view, so a rewrite mid-iteration will upset it. |
fun node(id: String): Node |
The node with this id. |
fun positionOf(node: Node): Vec2 |
Where the node currently sits. (0, 0) for a node this net has never seen, rather than an exception. |
fun velocityOf(node: Node): Vec2 |
The node's current layout velocity — (0, 0) if it has none yet. Carried across rewrites where a rule asks. |
fun setPosition(node: Node, p: Vec2) |
Places the node. Stores the vector by reference, so mutating the Vec2 you passed moves the node afterwards — pass Vec2(p) if that would surprise you. |
fun setVelocity(node: Node, velocity: Vec2) |
Sets the node's layout velocity, copying the vector. A kick here survives into the next relax. |
fun create(type: AgentType, id: String = ids.next(type.name.lowercase()), state: NodeState = NodeState()): Node |
Adds a node of type to the net and returns it, with no wires attached. |
fun remove(node: Node) |
Deletes the node, disconnecting every wire it had first, and forgets its position and velocity. |
fun connected(port: Port): Port? |
The port on the other end of this port's wire, or null if it is free. The one-step graph walk. |
fun connect(a: Port, b: Port) |
Wires two ports together. |
fun disconnect(a: Port, b: Port) |
Removes the wire between two ports, leaving both free. |
fun activePairs(): List<ActivePair> |
Every place the net can currently react: each edge joining two principal ports, de-duplicated by ActivePair.canonicalKey. |
fun reduciblePairs(rules: RuleBook): List<ActivePair> |
The activePairs some rule in rules covers: the places a Reducer could fire next. |
fun isNormal(rules: RuleBook): Boolean |
Whether no rule in rules matches anywhere in the net — what "reduction has finished" means. |
fun incrementAges(dt: Double = 1.0) |
Adds dt to every node's age. Nothing calls this for you, so age advances only on the clock you choose — per frame, per reduction step, per beat. |
fun relax(iterations: Int = 1, config: LayoutConfig = LayoutConfig()) |
Runs the force-directed layout for iterations steps, moving every node. |
fun totalEnergy(): Double |
The sum of every node's energy — a one-number read on the whole net. |
fun averageAge(): Double |
Mean node age, or 0.0 for an empty net. Rises as the net persists, falls as rewrites replace old nodes with new. |
fun toFLines(velocityScale: Double = 18.0): List<FLine> |
Draws the net: one FLine per edge, and several per node — a filled disc and a stroked ring sized and coloured from its NodeState, a spoke per port, a point per port, a halo when the node is half of an active pair, and a streak when it is moving. |
fun summaryHtml(title: String = "Interaction net", maxNodes: Int = 8): String |
A dark-themed HTML card summarising the net — counts, total energy, the highest-energy nodes and the active pairs waiting to fire — for putting in a box output next to the drawing. |
fun toSvg(width: Int = 900, height: Int = 700, padding: Double = 40.0): String |
The net as a standalone SVG document, on white rather than the dark palette the box output uses — the export for putting a net in a paper or a slide. |
NodeRef
class NodeRef internal constructor(val name: String)
A name for a node inside a fragment or a rewrite's right-hand side, standing in for a node that does not exist yet.
| Declaration | Summary |
|---|---|
override fun toString(): String |
The ref's name, as given. |
PortRef
data class PortRef(val node: NodeRef, val portIndex: Int)
A port on a not-yet-created node: a NodeRef plus a raw port index, where 0 is the principal port and the auxiliaries start at 1.
NetFragment
A reusable piece of net — some nodes, the wires between them, and a set of named ports it offers to the outside — that can be stamped into a Net as many times as you like.
| Declaration | Summary |
|---|---|
fun export(name: String, port: PortRef) |
Publishes a port under a name, so whoever embeds this fragment can wire to it without knowing what is inside. |
FragmentBuilder
The receiver inside a fragment block: declare nodes, wire them with to, and expose the ports that should be visible from outside.
| Declaration | Summary |
|---|---|
fun node(name: String, type: AgentType, at: Vec2 = Vec2(), state: NodeState = NodeState(), impulse: Vec2 = Vec2()): NodeRef |
Declares a node in the fragment and returns the NodeRef you wire with. Nothing is created yet. |
fun principal(ref: NodeRef): PortRef |
The reactive port of a declared node — the one that can form an active pair. |
fun aux(ref: NodeRef, i: Int): PortRef |
Auxiliary port i of a declared node, numbered from 0. |
infix fun PortRef.to(other: PortRef) |
principal(a) to aux(b, 0) — records a wire between two ports of this fragment. |
fun expose(name: String, port: PortRef) |
Publishes a port under a name for whoever embeds this fragment. A repeated name replaces the earlier one. |
fun build(): NetFragment |
The finished NetFragment. fragment calls this for you. |
FragmentEmbed
class FragmentEmbed internal constructor(private val nodeMap: Map<NodeRef, Node>)
The result of embedding a fragment: the mapping from the fragment's NodeRefs to the real Nodes that were just created.
| Declaration | Summary |
|---|---|
operator fun get(ref: NodeRef): Node |
The real node created for this ref. |
val nodes: Collection<Node> |
Every node this embedding created, in the fragment's declaration order. |
NetBuilder
class NetBuilder(private val net: Net)
The receiver inside a net block: create nodes, wire them with to, and stamp in NetFragments.
| Declaration | Summary |
|---|---|
fun node(type: AgentType, id: String? = null, at: Vec2 = Vec2(), state: NodeState = NodeState(), impulse: Vec2 = Vec2()): Node |
Creates a node in the net at an absolute position and returns it, ready to wire. |
infix fun Port.to(other: Port) |
a.principal to b.principal — wires two ports, immediately. Same rules (and exceptions) as Net.connect. |
fun connect(a: Port, b: Port) |
Wires two ports. The non-infix form of to, for when the expression reads better this way. |
fun embed(fragment: NetFragment, at: Vec2 = Vec2()): EmbeddedPorts |
Stamps a NetFragment into the net at at and returns its exported ports, ready to wire to. |
EmbeddedPorts
class EmbeddedPorts internal constructor(private val exported: Map<String, Port>)
The ports an embedded fragment published, looked up by the names it exposed — ports["in"].
| Declaration | Summary |
|---|---|
operator fun get(name: String): Port |
The real port behind an exposed name. Throws if the fragment did not export it. |
PairSignature
data class PairSignature(val left: AgentType, val right: AgentType)
The left-hand side of a rewrite rule: the pair of agent types it fires on.
| Declaration | Summary |
|---|---|
fun matches(a: Node, b: Node): Boolean |
Whether these two nodes are this signature's pair, in either order. |
fun bind(a: Node, b: Node): SignatureBinding? |
Matches the two nodes against this signature, returning them in the signature's order — or null if they are not this pair. |
SignatureBinding
data class SignatureBinding(val first: Node, val second: Node, val swapped: Boolean)
A successful PairSignature match: the two nodes in the signature's own order, plus whether that meant swapping them relative to how the edge was found.
RuleMatch
data class RuleMatch(val rule: RewriteRule, val binding: SignatureBinding)
A rule and the binding it matched with — what RuleBook.findMatch returns, and what the scheduler chooses between.
PairContext
Everything a rewrite rule gets told about the collision it is replacing: the two nodes in signature order, what their auxiliary ports were attached to, and the local geometry.
| Declaration | Summary |
|---|---|
val first: Node |
The node matching the rule signature's left type — ordinal 0 throughout the rule DSL, whichever way the edge happened to be stored. About to be deleted. |
val second: Node |
The node matching the signature's right type — ordinal 1. Also about to be deleted. |
val firstAuxPeers: List<Port?> |
What each of first's auxiliary ports was wired to, in aux order; a null entry means that port was already free. These are the loose ends the rule must account for. |
val secondAuxPeers: List<Port?> |
The same for second's auxiliary ports. |
val center: Vec2 |
Midpoint of the two nodes, and the point replacement nodes are seeded at — which is why a rewrite appears to grow out of the collision rather than at the origin. |
val radius: Double |
Roughly half the pair's separation, floored at 12.0 so a coincident pair still gives replacements somewhere to go. The default scale for branchImpulse. |
val firstVelocity: Vec2 |
first's layout velocity at the moment of the collision, so a replacement can inherit its motion. |
val secondVelocity: Vec2 |
second's layout velocity at the moment of the collision. |
val axis: Vec2 |
Unit vector from first toward second. Falls back to (1, 0) for a coincident pair rather than being NaN. |
val normal: Vec2 |
Unit vector perpendicular to axis. The direction branchImpulse throws branches along. |
fun auxPeer(nodeOrdinal: Int, auxIndex: Int): Port? |
What auxiliary port auxIndex of node nodeOrdinal (0 = first, 1 = second) was wired to — the loose end a rule has to reattach. |
fun state(nodeOrdinal: Int): NodeState |
The live state of one of the two nodes — mutating it mutates the doomed node, which is almost never what you want. Use stateCopy to seed a replacement. |
fun stateCopy(nodeOrdinal: Int): NodeState |
An independent copy of one node's state, safe to hand to a new node and then alter. |
fun velocity(nodeOrdinal: Int): Vec2 |
A copy of one node's layout velocity, so a replacement can inherit its motion rather than appearing at rest. |
fun blendedState(weightToFirst: Double = 0.5): NodeState |
The two states averaged — the state for a rewrite that fuses the pair into one node. See NodeState.blend. |
val pairEnergy: Double |
The pair's combined energy. What the energy-biased PairSelectors rank by, and the default event magnitude. |
fun branchImpulse(branch: Int, branches: Int = 2, amount: Double = radius * 0.35): Vec2 |
A sideways kick for branch branch of branches, so replacement nodes fly apart out of the collision instead of being born on top of each other. |
RewriteEvent
Something a rewrite wants the outside world to notice — "flash here", "trigger a note", "spawn a particle" — with a free-text kind, a place, a strength and any extra numbers.
ReductionStep
The record of one rewrite that happened: which rule fired, on what, what it removed, what it created and what it emitted.
| Declaration | Summary |
|---|---|
val ruleName: String |
The RewriteRule.name that fired — which is why it is worth naming rules something you would read in a log. |
val context: PairContext |
The collision as the rule saw it, including the geometry, so a caller can flash the right place on screen. |
val swapped: Boolean |
Whether the pair had to be exchanged to match the rule's signature order. Informational; the rule's behaviour does not depend on it. |
val removedNodeIds: List<String> |
The two consumed nodes' ids. Ids rather than nodes because those nodes are already gone by the time you read this. |
val createdNodeIds: List<String> |
The ids of the replacement nodes, in the order the rule declared them. |
val events: List<RewriteEvent> |
Everything the rule emitted, snapshot-copied — so this survives even though the nodes behind it do not. |
RewriteBuilder
class RewriteBuilder internal constructor(private val ctx: PairContext)
The receiver inside a rule { } body: declare the nodes that replace the active pair, wire them, and say what happens to the loose ends.
| Declaration | Summary |
|---|---|
fun node(name: String, type: AgentType, at: Vec2 = Vec2(), state: NodeState = NodeState(), impulse: Vec2 = Vec2()): NodeRef |
Declares a replacement node and returns its NodeRef. Nothing is created until the rule is applied. |
fun principal(ref: NodeRef): PortRef |
The principal port of a replacement node — wire two of these together and the new net can react again. |
fun aux(ref: NodeRef, i: Int): PortRef |
Auxiliary port i of a replacement node, numbered from 0. |
infix fun PortRef.to(other: PortRef) |
principal(a) to aux(b, 0) — wires two ports inside the replacement. Applied when the rule fires. |
fun reconnect(newPort: PortRef, oldPeer: Port?) |
Attaches a replacement node's port to one of the loose ends the deleted pair left behind — this is how the surrounding net stays connected. |
fun reconnect(newPort: PortRef, nodeOrdinal: Int, auxIndex: Int) |
The same as the other reconnect, naming the loose end by which node of the pair it belonged to (0 = first, 1 = second) and which of its auxiliary ports — the shorter form, and the one that matches ext(...) in the automatic DSL. Silently does nothing if that port was free. |
fun passthrough(a: Port?, b: Port?) |
Wires two of the pair's loose ends directly to each other, bypassing the deleted nodes entirely — how you write an ERASE or a plain "these two were connected through me, now connect them" rule. |
fun emit(kind: String, magnitude: Double = ctx.pairEnergy, at: Vec2 = ctx.center, data: Map<String, Double> = emptyMap()) |
Records a RewriteEvent for whoever is watching the reduction — a flash, a note, a particle. |
fun build(): RewriteResult |
The assembled RewriteResult. RuleBook.rule calls this for you. |
RewriteResult
What a rule body produces: the replacement subgraph, how it attaches to the loose ends, which loose ends were joined to each other, and any events emitted.
RewriteRule
One law of motion: a name, the pair of types it fires on, and a function from the collision to its replacement.
| Declaration | Summary |
|---|---|
val name: String |
What this law is called. Appears in every ReductionStep it produces, so make it readable. |
val signature: PairSignature |
The pair of agent types this fires on, matched in either order. |
val rewrite: (PairContext) -> RewriteResult |
Called afresh on every firing with that collision's context, so the replacement can depend on energy, geometry and velocity rather than being a fixed template. |
AutoEndpoint
One end of a wire in the automatic-rewiring DSL: either a port of a node the rule is creating (InternalEndpoint) or one of the loose ends the deleted pair left behind (ExternalEndpoint).
InternalEndpoint
data class InternalEndpoint(val ref: PortRef) : AutoEndpoint
A port of a node this rule is creating. Produced by principal(...) / aux(...) in an AutoRuleBuilder.
ExternalEndpoint
data class ExternalEndpoint(val nodeOrdinal: Int, val auxIndex: Int) : AutoEndpoint
A loose end of the old pair: auxiliary port auxIndex of pair node nodeOrdinal (0 = first, 1 = second).
AutoRuleBuilder
class AutoRuleBuilder internal constructor(private val ctx: PairContext)
The receiver inside an autoRule { } body: declare the replacement nodes and then just say what connects to what, mixing new ports and the old pair's loose ends freely.
| Declaration | Summary |
|---|---|
fun node(name: String, type: AgentType, at: Vec2 = Vec2(), state: NodeState = NodeState(), impulse: Vec2 = Vec2()): NodeRef |
Declares a replacement node and returns its NodeRef. |
fun principal(ref: NodeRef): InternalEndpoint |
The principal port of a node this rule creates, as an endpoint you can wire with to. |
fun aux(ref: NodeRef, i: Int): InternalEndpoint |
Auxiliary port i (from 0) of a node this rule creates, as a wireable endpoint. |
fun ext(nodeOrdinal: Int, auxIndex: Int): ExternalEndpoint |
One of the old pair's loose ends: auxiliary port auxIndex of pair node nodeOrdinal (0 = first, 1 = second). |
infix fun InternalEndpoint.to(other: InternalEndpoint) |
Wires two ports of the replacement together. |
infix fun InternalEndpoint.to(other: ExternalEndpoint) |
Hands one of the old pair's loose ends to a port of the replacement. Ignored if that port was free. |
infix fun ExternalEndpoint.to(other: InternalEndpoint) |
Hands one of the old pair's loose ends to a port of the replacement, written the other way round. |
infix fun ExternalEndpoint.to(other: ExternalEndpoint) |
Splices two of the old pair's loose ends directly to each other — an erase or short-circuit rule. |
fun emit(kind: String, magnitude: Double = ctx.pairEnergy, at: Vec2 = ctx.center, data: Map<String, Double> = emptyMap()) |
Records a RewriteEvent — at the collision centre and with the pair's energy, unless you say otherwise. Same as RewriteBuilder.emit. |
fun build(): RewriteResult |
Resolves every declared connection into the RewriteResult the reducer applies: internal wires stay internal, anything touching an ext becomes a reconnection, and two exts become a passthrough. |
RuleBook
The table of rewrite laws a net runs under — declare rules into it with rule and autoRule, and it answers "does anything apply to these two nodes".
| Declaration | Summary |
|---|---|
fun rule(name: String, left: AgentType, right: AgentType, rewrite: RewriteBuilder.(PairContext) -> Unit) |
Declares a rule with the full-control RewriteBuilder body — you say what nodes appear and explicitly reconnect or pass through each of the old pair's loose ends. |
fun autoRule(name: String, left: AgentType, right: AgentType, rewrite: AutoRuleBuilder.(PairContext) -> Unit) |
Declares a rule with the declarative AutoRuleBuilder body — you list the connections and the builder works out which are internal, which reattach the old pair's loose ends and which splice two of them together. |
fun find(a: Node, b: Node): RewriteRule? |
The first rule that fires on these two nodes, or null. findMatch when you also need the orientation. |
fun findMatch(a: Node, b: Node): RuleMatch? |
The first rule matching these two nodes, together with the binding that puts them in the rule's own order. |
ActivationOrder
The ready-made choices for which of several possible reactions fires next — reduction order as a dial.
Constants: FIRST_FOUND, LEFTMOST_BY_ID, RIGHTMOST_BY_ID, RANDOM, HIGHEST_PAIR_ENERGY, LOWEST_PAIR_ENERGY.
PairCandidate
data class PairCandidate(val pair: ActivePair, val match: RuleMatch, val context: PairContext)
One reaction that could happen this step: the active pair, the rule that matched it, and the fully built PairContext the rule would be given.
| Declaration | Summary |
|---|---|
val rule: RewriteRule |
The rule that would fire. Shorthand for match.rule. |
PairSelector
The scheduler: given every reaction that could fire, pick one — or return null to stop the reduction.
| Declaration | Summary |
|---|---|
fun select(net: Net, candidates: List<PairCandidate>): PairCandidate? |
Choose the reaction to fire, or null to stop. |
PairSelectors
The stock PairSelectors — the schedulers ActivationOrder names, plus two that it does not.
| Declaration | Summary |
|---|---|
val firstFound |
Takes whichever candidate came first out of the edge set. Cheapest, and the most mechanical-looking. |
val leftmostById |
Prefers the pair containing the alphabetically smallest node id — deterministic, and roughly oldest-first, since ids are minted in creation order. |
val rightmostById |
Prefers the pair containing the alphabetically largest node id — deterministic, and roughly newest-first. |
val highestPairEnergy |
Always fires the hottest pair. Tends to produce avalanches and to keep activity concentrated in one region. |
val lowestPairEnergy |
Always fires the coldest pair. Tends to spread activity out and to clear up dregs before hot spots. |
fun random(random: Random = Random.Default): PairSelector |
Fires a uniformly random candidate — the shimmering, non-repeating schedule. |
fun nearest(point: Vec2): PairSelector |
Fires whichever reaction is closest to point — activity follows the cursor, or a moving attractor. |
fun from(order: ActivationOrder): PairSelector |
The selector for an ActivationOrder. RANDOM yields an unseeded random, so it is not reproducible. |
Reducer
The engine: takes a RuleBook and a scheduler, and runs a net forward one reaction at a time.
| Declaration | Summary |
|---|---|
fun step(net: Net): Boolean |
Fires one reaction. False means nothing fired — the net is in normal form, or no rule covers what is left. |
fun stepDetailed(net: Net): ReductionStep? |
Fires one reaction and reports what happened, or null if nothing could fire. |
fun reduce(net: Net, maxSteps: Int = Int.MAX_VALUE, onStep: ((Int, Net) -> Unit)? = null): Int |
Reduces until nothing can fire or maxSteps have run, returning how many fired. |
fun reduceDetailed(net: Net, maxSteps: Int = Int.MAX_VALUE, onStep: ((Int, Net, ReductionStep) -> Unit)? = null): Int |
reduce, but onStep also receives the ReductionStep — so you can collect the events a whole run emitted rather than just watching the net change. |
IdSource
class IdSource(private var counter: Int = 0)
The counter behind every automatic node id: hands out prefix_0, prefix_1, … from one shared counter, so ids are unique across prefixes and their numeric order is global creation order.
| Declaration | Summary |
|---|---|
fun next(prefix: String): String |
The next id: "${prefix}_$n", advancing the shared counter. |
fun peekCounter(): Int |
The counter's current value without consuming it — how many ids have been handed out. Useful for logging. |
Types
Where agent types come from. A one-function namespace, so Types.agent("PULSE", 2) reads as a declaration.
| Declaration | Summary |
|---|---|
fun agent(name: String, auxPorts: Int): AgentType |
Declares an agent type with auxPorts auxiliary ports — plus the principal port every node has, so its arity is one more than this. |
InteractionStage
A captured drawing/summary for a stage in an interaction-net comparison.
| Declaration | Summary |
|---|---|
val title: String |
|
val summary: String |
|
val lines: List<FLine> |
Copies keep later layout or caller mutations from changing the captured stage. |
Lambda
Lambda calculus as an interaction net: the agent types, and rules for beta-reduction with explicit copying and discarding.
| Declaration | Summary |
|---|---|
val APP |
Application. Its principal port is where the function arrives — that is what meets a LAM to beta-reduce — aux(1) is the argument, and aux(0) is where the result comes back out (the beta rule wires the lambda's body to it). |
val LAM |
Abstraction. aux(0) is the binder (where the argument arrives), aux(1) the body. |
val DUP |
Explicit duplication. aux(0) and aux(1) are the two copies; artistically, a branch or an echo. |
val ERASE |
Explicit deletion — a nullary sink. Artistically, a fade or a drain rather than an instant delete. |
val VAR |
A free variable: a nullary atom with nothing but its principal port. |
val ROOT |
The root of a term — where the answer ends up. A nullary atom, distinguished only by its rules. |
val CONST |
An opaque constant. A nullary atom that DUP copies and ERASE consumes. |
const val FAN_LABEL: String |
The NodeState.scalars key a DUP's or DELIM's Lambdascope level lives under (the name is older than the levels). Two agents of one kind meet and cancel when their levels are equal, and copy each other when they differ; see rules. LambdaTerms.compile builds every fan and delimiter at level 0, and the rules raise levels as agents move. |
fun fanLabelOf(node: Node): Double |
A node's level, or 0.0 for one built without one, so two bare node(DUP)s annihilate. |
fun fan(level: Int, energy: Double = 1.0): NodeState |
A NodeState for a fan (or a delimiter) at level — node(DUP, state = Lambda.fan(3)). |
val DELIM |
A scope delimiter, Lambdascope's one extra agent: principal port outward, toward the context the scope sits in, and aux(0) inward. Beta puts one on the result and one on the argument; LambdaTerms.compile puts one on a variable occurrence for every abstraction between it and its binder. |
fun levelOf(node: Node): Double |
The level of a DUP or DELIM (its FAN_LABEL scalar, 0 when absent). |
fun rules(): RuleBook |
The rule book: Lambdascope (van Oostrom, van de Looij and Zwitserlood, Lambdascope: another optimal implementation of the lambda-calculus, 2004), so sharing is optimal and correct on every term, with the energies as art direction. |
fun betaBridgeDemo(): Net |
A minimal APP/LAM bridge demo. |
fun duplicatedConstantDemo(): Net |
A tiny nonlinear sketch: a single CONST is demanded by two roots through a DUP, so the rewrite system has to copy it. No LAM and no body — betaBridgeDemo is the one with those. This is not a full term encoder, but it gives the rewrite system something resource-sensitive to do. |
fun eraseApplicationDemo(): Net |
Erase an application tree to demonstrate explicit garbage collection. |
lambdaterms.kt
λ-terms as a front end to the interaction nets in interactionnets.kt: text in, a reduced net out, a term back.
The shortest useful thing is LambdaTerms.normalise, which runs parse, compile, reduce and readback in one call:
val run = LambdaTerms.normalise("(\\m n f x. m f (n f x)) (\\f x. f x) (\\f x. f (f x))")
run.printed // "λa. λb. a (a (a b))", Church three, from add one two
run.steps // how many reactions it took
Two public names sit behind that. Term is the syntax tree, with Var, Lam and App nested inside it so that
Term.Lam("x", Term.Var("x")) reads as what it is and none of the three short names leaks into afield.lang.lambda.
LambdaTerms is everything done to one: parsing, printing, compilation to the Lambda agent vocabulary, readback,
and the reference evaluator the net's answer is checked against.
Every answer here is computed twice. The net is one route; LambdaTerms.referenceNormalOrder is the other, and
reduces by capture-avoiding substitution with no nets and no sharing anywhere in it, so the two routes share no
machinery beyond the term type. LambdaTerms.corpus is the set of closed terms both are run over.
The file lands on the Visualization page because a finished run keeps the Net it reduced, and Net.toFLines
draws that net with its active pairs emphasised.
src/jvmMain/kotlin/afield/lang/lambda/lambdaterms.kt — read the source
Term
A λ-term: a variable, an abstraction, or an application.
| Declaration | Summary |
|---|---|
data class Var(val name: String) : Term |
A variable occurrence, bound or free. The name is the source name, or one a readback invented. |
data class Lam(val param: String, val body: Term) : Term |
An abstraction: λ``param``. body. |
data class App(val fn: Term, val arg: Term) : Term |
An application of fn to arg. Applications associate to the left, so f a b is App(App(f, a), b). |
LambdaTerms
A λ-calculus front end for interactionnets.kt: a Term syntax you can type, a compiler from a term to a Net, a readback from a reduced net to a term, and — the part that makes the rest checkable — an ordinary substitution evaluator to compare the net's answer against.
| Declaration | Summary |
|---|---|
fun v(name: String): Term |
Term.Var(name), for when parse is more ceremony than the term is worth. |
fun lam(vararg params: String, body: Term): Term |
λ``params``. body, right-nested — lam("x", "y", body = v("x")) is λx. λy. x. |
fun app(fn: Term, vararg args: Term): Term |
fn applied to each of args in turn, left-associated — app(f, a, b) is f a b. |
fun format(term: Term): String |
A term as source text, with the fewest parentheses that still parse back to the same term: λa. λb. a (a b). |
class ParseFailure(message: String, val offset: Int) : IllegalArgumentException("$message (at offset $offset)") |
What parse throws, carrying the character offset it gave up at. |
fun parse(source: String): Term |
Reads a term. |
fun deBruijnKey(term: Term): String |
The term's de Bruijn form as a string: λλ(#1 (#1 #0)) for Church two, with free variables as @name. |
fun alphaEquivalent(a: Term, b: Term): Boolean |
True when two terms differ only in the names of their bound variables. Compare terms with this, not with ==. |
class ReductionBudgetExceeded(val steps: Int) |
What referenceNormalOrder throws when its budget runs out — which, for a diverging term, is the only outcome. |
class IntermediateTermTooLarge(val nodes: Int) |
What referenceNormalOrder throws when the terms it is building get too big, which a β-step budget does not bound: one substitution can nearly double a term, so a reduction can exhaust memory hundreds of steps inside a budget of thousands. |
fun referenceNormalOrder(term: Term, maxSteps: Int = 1_000_000, maxWorkNodes: Int = 40_000_000): Term |
The oracle: term's normal form under leftmost-outermost (normal-order) reduction, by capture-avoiding substitution. |
fun substitute(body: Term, name: String, value: Term): Term |
body[name := value], renaming any binder of body that would otherwise capture a free variable of value. |
fun freeVariablesOf(term: Term): Set<String> |
The names occurring free in term. Empty for a closed term, which is what corpus asserts of every entry. |
const val ROOT_ID: String |
The id compile gives the single ROOT agent, so readback can still find it after the net is rewritten. |
class CompiledTerm internal constructor(val net: Net, val freeVariables: Map<String, String>, val fans: Int, val erasers: Int, val freeNames: List<String> = emptyList()) |
A Term as a net, plus the little the compiler had to remember in order to read it back. |
fun compile(term: Term): CompiledTerm |
Compiles term to a net over the Lambda vocabulary. |
fun compileSource(source: String): CompiledTerm |
compile, on source text — compile("\\x. x x"). |
fun readback(net: Net, freeVariables: Map<String, String> = emptyMap(), maxNodes: Int = 200_000, maxDepth: Int = 3_000): Term |
Walks a net back to a term, starting from the Lambda.ROOT agent's principal port. |
fun readback(compiled: CompiledTerm, maxNodes: Int = 200_000, maxDepth: Int = 3_000): Term |
readback, on what compile returned — so the free-variable names come along by themselves, including on the copies dup-var made of one, which carry its FREE_VARIABLE index rather than an id the map knows. |
const val FREE_VARIABLE: String |
The NodeState.scalars key a free variable's CompiledTerm.freeNames index lives under. |
val MARKS: Set<String> |
The five single-glyph marks of readback's table. ?id is the sixth and is matched by prefix. |
fun marksOf(term: Term): Set<String> |
Every readback mark in term, as the glyphs of readback's table — empty exactly when the answer is a term the reducer is prepared to stand behind. |
class Run internal constructor(val term: Term, val result: Term, val steps: Int, val budget: Int, val net: Net, val fans: Int) |
One normalisation: what went in, what came out, how many reactions it took, and the net it took them in. |
fun normalise(term: Term, order: ActivationOrder = ActivationOrder.FIRST_FOUND, maxSteps: Int = 200_000, relaxIterations: Int = 0): Run |
Compile term, reduce it over Lambda.rules until nothing fires, and read the answer back. |
fun normalise(source: String, order: ActivationOrder = ActivationOrder.FIRST_FOUND, maxSteps: Int = 200_000, relaxIterations: Int = 0): Run |
normalise, on source text. The one-liner this file's overview opens with. |
fun corpus(): Map<String, String> |
The named closed terms this front end is checked against, as source text: Church numerals with succ, add, mul and pred, Church booleans with if, the combinators S, K and I, terms whose reduction duplicates a redex so that sharing has something to do, and terms that erase an argument entirely. |
PetriAnalysis.kt
What a net can tell you about itself without being played: how long it is, and whether it is about to wedge.
Petri nets come with about fifty years of theory attached, and much of it answers questions a live coder actually has. Two of them are worth the code.
"How long is this?" A T-invariant is a bag of firings that returns the net to the marking it
started from, which is precisely a loop, and a net's minimal T-invariants are its natural musical
periods. A net built from a five-step ring against a three-step ring has two of them, of five
firings and three, and how long the whole net takes to come back round is a separate question:
fifteen steps here, which PetriRun.lasso finds by running it. tInvariants answers the
structural question from the incidence matrix alone, with no notion of time or of the initial
marking; PetriNet.checkLoopIsInvariant asserts that the loop the engine actually ran is itself a
T-invariant (a combination of the minimal ones, in the polyrhythm case), so when it fails the
engine and the algebra disagree and one of them has a bug.
"Is this about to wedge?" A net that starves is silent, and finding out at the wrong moment is
what makes live coding frightening. willDeadlock runs the net forward and tells you before you
commit the change. For the stronger question — whether any interleaving can wedge, rather than
the one this net's conflict policy chose — see PetriStateSpace.kt.
Limits
-
Invariants are structural. They see arc weights and nothing else: inhibitor and read arcs are invisible to them, because neither changes the marking and the incidence matrix is a record of change. A net whose behaviour turns on an inhibitor arc has invariants that describe a net it is not.
usesTestArcsis how you find out, andreportsays so itself. -
Recurrence is detected on
PetriMarking.signature, which compares colours by equality: a net that swaps one pattern for another has changed, and one that brings the same pattern object round again has recurred. A net that makes a new colour on every firing never recurs. -
Everything here is bounded. A net with an unbounded place has infinitely many markings and no loop to find; the answer is null rather than a hang.
src/jvmMain/kotlin/afield/simulation/petri/PetriAnalysis.kt — read the source
Used in: Petri nets, Petri notation cheatsheet, Agent transcript gallery
Top-level functions
| Declaration | Summary |
|---|---|
fun PetriNet.incidence(): Array<IntArray> |
The net's incidence matrix: Cplace`` is the net change in that place's token count when that transition fires once. |
fun PetriNet.incidenceTable(): String |
The incidence matrix as a readable grid, transitions across the top. |
fun PetriNet.tInvariants(maxRows: Int = 4_096): List<Map<String, Int>> |
The net's minimal T-invariants: bags of firings that leave the marking exactly as they found it. |
fun PetriNet.pInvariants(maxRows: Int = 4_096): List<Map<String, Int>> |
The net's minimal P-invariants: weighted sums of token counts that never change, whatever fires. |
fun PetriNet.period(maxSteps: Int = 4_096): PatQ? |
How long the net takes to come back round to where it started, in cycles, or null if it does not within maxSteps. |
fun PetriNet.settle(maxSteps: Int = 4_096): PetriLasso? |
The net's lasso: its run-in, its loop, and the firing counts of one turn. See PetriRun.lasso. |
fun PetriNet.checkLoopIsInvariant(maxSteps: Int = 4_096): String? |
Checks that the loop the net actually runs is a genuine T-invariant — that firing each transition as often as one turn of the loop does really returns the marking to where it was. |
fun PetriNet.willDeadlock(within: Int = 4_096): Boolean |
Does this net stop? The check to run before committing a live edit. |
fun PetriNet.deadlockMarking(within: Int = 4_096): PetriMarking? |
The marking the net stops in, or null if it does not stop within within steps. |
fun PetriNet.deadTransitions(within: Int = 2_048): List<String> |
Transitions that never fire in within steps of the net's own trajectory. |
fun PetriNet.report(maxSteps: Int = 4_096, stateBudget: Int = 4_096): String |
Everything above, in a paragraph — the thing to print at a net while writing it. |
Top-level values
| Declaration | Summary |
|---|---|
val PetriNet.usesTestArcs: Boolean |
True when any arc is a read or inhibitor arc — the ones the incidence matrix cannot see. |
PetriNets.kt
Petri nets: places hold tokens, transitions move them, and the whole thing is a clock. This file is the model and the engine, and the types the rest of the layer is a view onto.
It knows nothing about music. The one thing it takes from afield.audio.synth is PatQ, the
exact rational Field uses for time everywhere. PetriMusic.kt turns a net into a PatPattern,
PetriVisual.kt draws one, PetriNotation.kt parses the #petri text form, and
PetriStateSpace.kt and PetriAnalysis.kt answer questions about one.
Read the surface from the outside in. Build a net: places are auto-declared on first mention, so a two-transition loop is three calls.
val net = petri {
place("kick", tokens = 1)
t("hit", dur = cyc(1, 16), emit = "bd") { from("kick"); to("hats") }
t("back", dur = cyc(1, 16), emit = "sd") { from("hats"); to("kick") }
}
Run it. A PetriRun is a memoised, append-only trajectory: ask it for any instant and it replays
deterministically up to there.
net.run().stepsUntil(PatQ.of(4)).flatMap { it.firings }.forEach(::println)
Tokens carry colour, and a colour can be anything: a note number, a sample name, a whole
PatPattern. That is what makes a net a song structure rather than a drum machine. Colours are
carried through a firing by default, so the pattern held in verse below is the colour of the
token that reaches chorus, with no lambda anywhere.
place("verse", pat("bd*4")) // one token whose colour is a pattern
t("build", dur = cyc(4)) { from("verse"); from("hats"); to("chorus") }
Three timings, and why
-
PetriTiming.STEP— maximal step. Every transition that is enabled at a grid tick fires, all against the marking as it stood at the start of the tick, and produced tokens do not become available until the tick is over. This is the drum-machine semantics, and the same synchronous stepBooleanNetworkSequencing.ktsequences. Conflicts (two transitions wanting the same token) are resolved greedily inPetriConflictorder. -
PetriTiming.EVENT— one transition per tick, the first in conflict order. Interleaving semantics; useful when you want a net to walk rather than pulse. -
PetriTiming.TIMED— firing takes time. A transition consumes its inputs at the instant it starts and produces its outputsdurationlater, so a token can be in flight, and a transition withdur = cyc(4)holds a four-cycle section open. Time advances to the next instant at which something happens, not on a grid.
In STEP and EVENT, duration does not affect token flow at all; it only sets how long the
emitted event lasts. In all three, delay shifts the emitted event and nothing else, so you can
nudge a hit late without perturbing the net's logic.
Why the trajectory is reproducible
PetriMusic.kt presents a net as a PatPattern, and a PatPattern has to be a pure function of
time: the scheduler queries it cycle by cycle and every combinator in the language is free to
query it again, out of order. That only works if the trajectory is reproducible, so
- tokens are consumed FIFO, oldest first, never "some matching token";
PetriConflict.RANDOMhashes(seed, step index, transition name)rather than pulling from a stateful RNG, so the answer does not depend on how many times anything was asked;PetriRunmemoises, so replay is not merely reproducible but literally the same objects.
A net whose conflict policy is genuinely non-deterministic cannot be presented as a pattern at
all. PetriLive performs one directly instead, rebuilding the run from the current marking
whenever you interfere with it.
src/jvmMain/kotlin/afield/simulation/petri/PetriNets.kt — read the source
Used in: Petri nets, A Petri net that throws sparks, Petri notation cheatsheet
Top-level functions
| Declaration | Summary |
|---|---|
fun cyc(n: Int, d: Int = 1): PatQ |
An exact rational number of cycles — cyc(1, 16) is a sixteenth. The time unit of every net. |
fun blackToken(at: PatQ = PatQ.ZERO): PetriToken |
Shorthand for a black token born at at. |
fun PetriNet.enabled(marking: PetriMarking = initial, at: PatQ = PatQ.ZERO): List<String> |
Which transitions could fire against marking, ignoring conflict — two transitions can both be listed here and still not both fire, because they may want the same token. |
fun petri(build: PetriBuilder.() -> Unit): PetriNet |
Builds a net with the Kotlin DSL. See PetriBuilder. |
PetriToken
data class PetriToken(val colour: Any? = null, val born: PatQ = PatQ.ZERO)
One token. colour is the token's value and may be anything at all — null for the classical black token, a String sample name, an Int note, or a whole PatPattern when the net is being used as a song structure.
| Declaration | Summary |
|---|---|
val isBlack: Boolean |
True for the classical indistinguishable token. |
inline fun <reified T> colourAs(): T? |
The colour cast to T, or null if it is absent or another type. |
override fun toString(): String |
PetriMarking
class PetriMarking private constructor(private val byPlace: Map<String, SharedTokenList>)
A marking: which tokens are in which place, in arrival order.
| Declaration | Summary |
|---|---|
operator fun get(place: String): List<PetriToken> |
The tokens in place, oldest first; empty if the place holds none. |
fun count(place: String): Int |
How many tokens are in place. |
val occupied: Set<String> |
Every place that currently holds at least one token. |
val size: Int |
Total tokens across the whole net. |
fun colours(place: String): List<Any?> |
The colours in place, in arrival order — null entries are black tokens. |
fun add(place: String, tokens: List<PetriToken>): PetriMarking |
A copy with tokens appended to the back of place. |
fun withPlace(place: String, tokens: List<PetriToken>): PetriMarking |
A copy with place set to exactly tokens. |
fun toMap(): Map<String, List<PetriToken>> |
The whole marking as a plain map — a defensive copy, safe to keep. |
fun toCounts(): Map<String, Int> |
The marking as counts only, which is what the analysis layer works in. |
fun signature(): String |
A stable identity for recurrence detection: places, and the colours in them, but not the instants those tokens were born. |
fun signatureHash(): Long |
signature as a 64-bit number. |
override fun equals(other: Any?): Boolean |
|
override fun hashCode(): Int |
|
override fun toString(): String |
PetriPlace
data class PetriPlace(val name: String, val capacity: Int? = null)
A place. capacity of null is unbounded; a bounded place disables any transition that would overfill it.
PetriArcKind
What an input arc does to the tokens it looks at.
Constants: NORMAL, READ, INHIBIT.
PetriInput
An input arc: what a transition needs from a place, and which tokens count.
| Declaration | Summary |
|---|---|
val place: String |
|
val weight: Int |
|
val kind: PetriArcKind |
|
val guard: ((PetriToken) -> Boolean)? |
|
override fun toString(): String |
PetriOutput
An output arc: how many tokens a transition puts into a place, and what colour they are.
| Declaration | Summary |
|---|---|
val place: String |
|
val weight: Int |
|
val produce: ((PetriBinding) -> List<PetriToken>)? |
|
override fun toString(): String |
PetriColourFlow
What colour a transition's output tokens take when an output arc has no explicit produce.
Constants: BLACK, CARRY.
PetriTransition
A transition.
| Declaration | Summary |
|---|---|
val name: String |
|
val inputs: List<PetriInput> |
|
val outputs: List<PetriOutput> |
|
val duration: PatQ |
|
val delay: PatQ |
|
val priority: Int |
Higher fires first under PetriConflict.PRIORITY. |
val weight: Double |
Relative likelihood under PetriConflict.RANDOM. Must be > 0. |
val emit: Any? |
|
val emitOf: ((PetriBinding) -> Any?)? |
|
val enabledIf: ((PetriBinding) -> Boolean)? |
A final veto once the tokens are chosen — the place for cross-place conditions. |
val colourFlow: PetriColourFlow |
|
val consumes: List<PetriInput> |
The places this transition consumes from — PetriArcKind.NORMAL inputs only. |
override fun toString(): String |
PetriConflict
How a maximal step picks between transitions competing for the same tokens.
Constants: ORDER, PRIORITY, ROUND_ROBIN, RANDOM.
PetriTiming
See the file header: STEP is a drum machine, EVENT walks, TIMED holds sections open.
Constants: STEP, EVENT, TIMED.
PetriNet
A net: places, transitions, an initial marking, and the semantics under which it runs.
PetriBinding
The tokens a transition is about to consume, and where they came from.
| Declaration | Summary |
|---|---|
val transition: String |
|
val at: PatQ |
The instant the transition fires. |
val cycle: Long |
The 0-based cycle containing at. |
val consumed: List<PetriToken> |
Tokens about to be consumed, in input-arc declaration order. READ arcs are not included. |
val byPlace: Map<String, List<PetriToken>> |
Every token this binding looked at, keyed by place — including the ones READ arcs only tested. |
operator fun get(place: String): List<PetriToken> |
Tokens taken from place (empty if none). |
fun colour(index: Int = 0): Any? |
The index-th consumed colour, or null. |
inline fun <reified T> colourAs(index: Int = 0): T? |
The index-th consumed colour cast to T, or null. |
inline fun <reified T> colours(): List<T> |
Every consumed colour that is a T, in order — the usual way to reach pattern-valued tokens. |
fun token(colour: Any?): PetriToken |
One token of colour, born now. Sugar for output arcs. |
override fun toString(): String |
PetriFiring
One transition firing: when it happened, what it moved, and what it sounds.
| Declaration | Summary |
|---|---|
val transition: String |
|
val at: PatQ |
|
val arc: PatArc |
|
val cycle: Long |
|
val consumed: List<PetriToken> |
|
val produced: List<PetriToken> |
|
val emit: Any? |
|
val index: Long |
Monotone across a whole run — a firing's identity. |
override fun toString(): String |
PetriStep
One instant of a run: everything that happened at at, and the marking either side of it.
| Declaration | Summary |
|---|---|
val index: Long |
|
val at: PatQ |
|
val before: PetriMarking |
|
val after: PetriMarking |
|
val firings: List<PetriFiring> |
|
val quiet: Boolean |
True when the marking is unchanged — only possible for a TIMED step that merely completed. |
override fun toString(): String |
PetriHalt
Why a run stopped, when it did.
Constants: RUNNING, DEADLOCK, EXHAUSTED, FLOODED.
PetriLasso
A trajectory that has started repeating: a run-in of tailSteps, then a loop of loopSteps lasting duration.
| Declaration | Summary |
|---|---|
val tailSteps: Int |
How many steps happen once, before the loop. |
val loopSteps: Int |
How many steps the loop is. |
val startsAt: PatQ |
The instant the loop's first step happens at. |
val duration: PatQ |
How long one turn of the loop takes, in cycles. The net's own bar length. |
val firingCounts: Map<String, Int> |
How many times each transition fires per turn — a realised T-invariant. |
override fun toString(): String |
PetriRun
A deterministic, memoised, append-only trajectory through a net.
| Declaration | Summary |
|---|---|
val net: PetriNet |
|
val from: PetriMarking |
|
val startAt: PatQ |
|
val maxUnroll: Int |
|
val maxTokens: Int |
|
val halt: PetriHalt |
Why the run stopped, or PetriHalt.RUNNING if it has not. Only meaningful after a query. |
val floodedPlace: String? |
The place that passed maxTokens, when that is why the run stopped, and null otherwise. |
val materialised: Int |
How many steps have been materialised so far. |
val reach: PatQ |
The instant the trajectory currently reaches. |
fun steps(count: Int): List<PetriStep> |
The first count steps, extending the trajectory if needed. Shorter if the net halts. |
fun stepsUntil(instant: PatQ): List<PetriStep> |
Every step strictly before instant. |
fun stepsIn(from: PatQ, until: PatQ): List<PetriStep> |
Every step whose instant lies in [from, until). |
fun firingsOver(arc: PatArc): List<PetriFiring> |
Every firing whose event arc intersects arc. |
fun markingAt(instant: PatQ): PetriMarking |
The marking as it stood immediately before instant. |
fun countAt(place: String, instant: PatQ): Int |
How many tokens were in place at instant. The raw material for a control signal. |
fun changes(place: String, until: PatQ): List<Pair<PatQ, Int>> |
Every instant at which place's token count changed, up to until, with the count it changed to. The first entry is always startAt and the initial count, so the list is total. |
fun lasso(maxSteps: Int = 4_096): PetriLasso? |
Where the run settles: how long before it starts repeating, and how long the repeat is. |
fun reset() |
Drop every materialised step and start again from the initial marking. |
override fun toString(): String |
PetriBuilder
class PetriBuilder internal constructor()
Builds a net. Places are auto-declared the first time a transition mentions them, so you only write place when you want an initial marking, a capacity, or coloured tokens.
| Declaration | Summary |
|---|---|
var timing: PetriTiming |
|
var grid: PatQ |
|
var conflict: PetriConflict |
|
var seed: Int |
|
fun place(name: String, tokens: Int = 0, capacity: Int? = null, colours: List<Any?> = emptyList(), at: PatQ = PatQ.ZERO): String |
Declares a place. |
fun place(name: String, vararg colours: Any?): String |
Declares a place holding exactly these coloured tokens — place("verse", pat("bd*4")). |
fun t(name: String, dur: PatQ = PatQ.ZERO, delay: PatQ = PatQ.ZERO, priority: Int = 0, weight: Double = 1.0, emit: Any? = null, colourFlow: PetriColourFlow = PetriColourFlow.CARRY, build: PetriTransitionBuilder.() -> Unit = {}): String |
Declares a transition. The block wires its arcs. |
PetriTransitionBuilder
class PetriTransitionBuilder internal constructor(private val name: String)
Wires one transition's arcs. See PetriBuilder.t.
| Declaration | Summary |
|---|---|
fun from(place: String, weight: Int = 1, guard: ((PetriToken) -> Boolean)? = null) |
Consume weight tokens from place, optionally only those matching guard. |
fun read(place: String, weight: Int = 1, guard: ((PetriToken) -> Boolean)? = null) |
Require weight tokens in place but consume none — a test arc, &p in the notation. |
fun inhibit(place: String, weight: Int = 1, guard: ((PetriToken) -> Boolean)? = null) |
Require fewer than weight tokens in place — an inhibitor arc, ~p in the notation. |
fun to(place: String, weight: Int = 1, produce: ((PetriBinding) -> List<PetriToken>)? = null) |
Produce weight tokens into place. Without produce the transition's PetriColourFlow decides their colour — by default the consumed colours are carried through. |
fun toColoured(place: String, colour: (PetriBinding) -> Any?) |
Produce one token into place whose colour is computed from the binding. |
fun emit(of: (PetriBinding) -> Any?) |
What this firing sounds, when it depends on which tokens were consumed. |
fun onlyIf(predicate: (PetriBinding) -> Boolean) |
A final veto once the tokens are chosen. |
override fun toString(): String |
PetriNotation.kt
The #petri text notation: a net you can retype while it is playing.
The Kotlin DSL in PetriNets.kt is the complete surface; anything you can express, you can
express there, including arbitrary lambdas. This file is the terse surface. Live coding is
editing, and editing a net is only pleasant when the whole net fits in view and one arc is one
line. For players, the whole notation is summarised on the
Petri notation cheatsheet.
#petri
!grid 1/16
kick : 1
kick -[ hit @1/16 : bd ]-> hats
hats -[ back @1/16 : sd ]-> kick
petri turns that text into a PetriNet; petriParse returns the net along with everything it
has to say about the source.
One line, one arc
pre -[ name @dur key=value : emit => combinator ]-> post
Both sides are +-joined lists of weight*place, and either may be empty: -[ spawn ]-> p is a
source and p -[ sink ]-> is a drain. A place is created the first time it is mentioned. A
transition may be named on more than one line, which is how one with several inputs stays
readable; its settings belong on the first of those lines, and a later line's are ignored with a
warning.
verse -[ build ]-> chorus
energy -[ build ]-> chorus
Prefixes on an input mark the two arcs that are not ordinary.
~armed -[ fill ]-> hats // inhibitor: only while armed is empty
&clock -[ tick ]-> bar // read/test: needs a token, consumes none
2*hats -[ roll ]-> fill // weight: needs two
Place declarations
kick : 1 // one black token
dst : 0 ^4 // empty, and bounded at four
verse : "bd ~ sd ~" // ONE token whose colour is that pattern — a section
label : 'lead' // one token coloured with the literal string
notes : #60, #64, #67 // three numeric tokens
The two kinds of quote differ: a double-quoted string is parsed as a pattern and a single-quoted one stays a string, because a place holding a rhythm is what makes a net a song structure rather than a drum machine.
Directives
!timing step | event | timed
!grid 1/16
!conflict order | priority | round-robin | random
!seed 7
Everything else
// starts a comment. A leading #petri line is the editor's marker and is ignored here. Colour
transforms after => come from PetriCombinators, a fixed vocabulary rather than compiled
Kotlin, which keeps the notation quick to parse and the cheatsheet finite. When you want a real
lambda, that is what the Kotlin DSL is for.
Errors are collected rather than thrown at the first one: petriParse hands back everything it
found, so an editor can underline all of it at once. petri is the throwing convenience on top.
src/jvmMain/kotlin/afield/simulation/petri/PetriNotation.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun petri(source: String): PetriNet |
Builds a net from the #petri notation, throwing on the first error. See petriParse. |
fun petriParse(source: String): PetriParseResult |
Parses the #petri notation, collecting every complaint rather than stopping at the first. |
PetriSpan
data class PetriSpan(val start: Int, val end: Int, val line: Int, val column: Int, val text: String)
Where something is in the source. Offsets are into the whole string; line and column are 1-based.
| Declaration | Summary |
|---|---|
override fun toString(): String |
PetriSeverity
Constants: ERROR, WARNING.
PetriDiagnostic
One complaint about a source, with the span to underline.
| Declaration | Summary |
|---|---|
override fun toString(): String |
PetriParseResult
What petriParse found: the net if it could build one, and everything it wants to say.
| Declaration | Summary |
|---|---|
val net: PetriNet? |
|
val diagnostics: List<PetriDiagnostic> |
|
val placeSpans: Map<String, PetriSpan> |
|
val transitionSpans: Map<String, PetriSpan> |
|
val errors: List<PetriDiagnostic> |
|
val warnings: List<PetriDiagnostic> |
|
val ok: Boolean |
|
override fun toString(): String |
PetriCombinators
The named colour transforms the notation's => can reach.
| Declaration | Summary |
|---|---|
val names: List<String> |
Every combinator name, in the order the cheatsheet lists them. |
operator fun get(name: String): ((PetriBinding) -> Any?)? |
The transform called name, or null if there is no such word. |
val docs: Map<String, String> |
A one-line description of each word, for editor hovers and the cheatsheet. |
PetriStateSpace.kt
Every marking a net can reach by any interleaving, and the classical properties read off that: boundedness, safety, deadlock, quasi-liveness, liveness, reversibility, home markings, mutual exclusion. Exactly, and with a counterexample when the answer is no.
PetriAnalysis.kt asks the deterministic question instead: willDeadlock runs the net forward
under its own PetriConflict policy and reports whether that one interleaving wedged, and
deadTransitions calls a transition dead when it did not fire in that one run. For a live
coder that is usually the useful question, because the conflict policy is part of the instrument.
It is not the question the textbook asks, and not the one a five-philosopher table asks either. A symmetric dining table deadlocks under some interleavings and not others, so running it once and finding it still going proves nothing; you have to enumerate. That example is worked through under Checking every possible order.
A positive answer is sound; a negative answer needs a complete search
A deadlock the search found is real whether or not the budget ran out: it came with a firing
sequence, and PetriTrace.verify re-fires that sequence through the engine to prove it. But
"there is no deadlock" is a claim about markings nobody visited, so every accessor that would make
such a claim returns null, meaning unknown, when the search was truncated
(PetriSearchOutcome.BUDGET rather than PetriSearchOutcome.COMPLETE). Nothing here reports a
truncated search as safe.
What this sees that the incidence matrix cannot
The successor relation is PetriNet.fire, which calls the engine's own binding step. So read
arcs, inhibitor arcs, colour guards, enabledIf and place capacities are all handled: an
explicit search asks "is this transition enabled here?" and gets the real answer, where
tInvariants and pInvariants ask the incidence matrix, which is a record of change and so
blind to every arc that does not move a token. A net whose mutual exclusion is enforced by an
inhibitor arc has no P-invariant that proves it, and a state space that does.
The price is the classical one: reachability for a net with inhibitor arcs is undecidable in general, so this may exhaust its budget where the algebra would have answered instantly. The two layers are complements, not replacements.
Two searches, and when to use which
PetriNet.stateSpaceis exact and handles every feature of the model, but only terminates on a bounded net. An unbounded one exhausts the budget and says so.PetriNet.coverabilityis Karp–Miller: it accelerates growth to ω and therefore always terminates, which is how you get per-place boundedness for a net whose state space is infinite. Karp–Miller is only valid for plain place/transition nets, though. Inhibitor arcs, place capacities and colour-dependent behaviour each break the acceleration argument, and each adds an entry toPetriCoverabilityGraph.caveatsrather than being dropped silently.
The state key
PetriMarking.signature is the obvious key and the wrong one here: it sorts the colours within
a place, so it loses arrival order, and consumption is FIFO, so two markings holding the same
multiset in a different order have genuinely different futures.
That makes the key coarser than the state, and a coarse key in a reachability search merges
states whose futures differ, which can hide a reachable deadlock behind an already-visited
marking and call the net safe. An unsound safe is the error that matters, so the key here is
strict: places, and the colours in them in arrival order, compared by the colours' own
equals.
The cost lands in the survivable direction. A net that mints a fresh colour object per firing has
an effectively infinite state space and exhausts the budget, and PetriSearchOutcome.BUDGET
reports that rather than guessing.
PetriToken.born is not in the key. The search fires everything at PatQ.ZERO — these are the
untimed semantics, with timing, grid and conflict policy all discarded — so born never varies
during a search anyway. The residual exposure is a guard or an enabledIf that reads born,
which the state space evaluates at zero and a real run does not.
src/jvmMain/kotlin/afield/simulation/petri/PetriStateSpace.kt — read the source
Used in: Petri nets, A Deadlock, Sculpted and Traced, Petri notation cheatsheet
Top-level functions
| Declaration | Summary |
|---|---|
fun PetriNet.fire(marking: PetriMarking, transition: String): PetriMarking? |
The marking after transition fires alone against marking, or null if it cannot fire there. |
fun PetriNet.successors(marking: PetriMarking): List<Pair<String, PetriMarking>> |
Every marking reachable from marking in one firing, with the transition that reaches it. |
fun PetriNet.stateSpace(budget: Int = PETRI_STATE_BUDGET, reduction: PetriReduction = PetriReduction.NONE): PetriStateGraph |
Every marking reachable from PetriNet.initial under every interleaving, breadth-first, stopping at budget markings. |
fun PetriNet.coverability(budget: Int = PETRI_STATE_BUDGET): PetriCoverabilityGraph |
The Karp–Miller coverability graph: a finite over-approximation of the state space in which a place that can grow without bound is marked ω. |
fun PetriNet.stateSpaceReport(budget: Int = PETRI_STATE_BUDGET): String |
The classical analysis in a paragraph, alongside the deterministic one from PetriNet.report. |
Top-level values
| Declaration | Summary |
|---|---|
const val PETRI_STATE_BUDGET: Int |
How many markings a search will hold before it gives up and says so. |
const val PETRI_OMEGA: Int |
The ω of a coverability graph: "this place grows without bound". |
val PetriNet.isColourDependent: Boolean |
True when anything about this net's behaviour depends on what colour its tokens are — a guard, an enabledIf, an explicit produce, or a coloured token in the initial marking. |
PetriSearchOutcome
Whether a search saw the whole space, and therefore whether its negative answers mean anything.
Constants: COMPLETE, BUDGET.
PetriStateEdge
data class PetriStateEdge(val from: Int, val transition: String, val to: Int)
One labelled edge of a state graph: firing transition in state from reaches state to.
PetriTrace
class PetriTrace(val net: PetriNet, val transitions: List<String>, val markings: List<PetriMarking>)
A firing sequence and the markings it passes through — the deliverable of this whole file.
| Declaration | Summary |
|---|---|
val net: PetriNet |
|
val transitions: List<String> |
|
val markings: List<PetriMarking> |
|
val length: Int |
How many firings. A shortest counterexample's length is a number worth asserting. |
val start: PetriMarking |
Where the trace starts — the initial marking, for anything this file produces. |
val end: PetriMarking |
Where it ends. For a deadlock trace, the marking that enables nothing. |
fun verify(): String? |
Re-fires the sequence from start and checks it lands where claimed, returning null when it does and a description of the first discrepancy when it does not. |
override fun toString(): String |
The firing sequence on one line — grab.0, grab.1, grab.2 — or (nothing) when empty. |
fun describe(ending: String? = null): String |
The trace with a marking under every firing, for reading rather than for a log line. |
PetriStateGraph
Every marking reachable from the net's initial marking, under every interleaving, and the labelled edges between them.
| Declaration | Summary |
|---|---|
val net: PetriNet |
|
val states: List<PetriMarking> |
|
val edges: List<PetriStateEdge> |
|
val outcome: PetriSearchOutcome |
|
val budget: Int |
|
val reduction: PetriReduction |
Whether every interleaving was explored, or a deadlock-sufficient subset of them. |
val caveats: List<String> |
What the search did other than what was asked, in a sentence each: today, a PetriReduction.STUBBORN request on a colour-dependent net, which is searched unreduced. Empty when it did exactly what was asked. report prints these. |
val stateCount: Int |
How many distinct markings were found. Exact when complete. |
val edgeCount: Int |
How many labelled firings connect them. |
val complete: Boolean |
True when the whole space was visited, which is what every negative answer here depends on. |
val exact: Boolean |
True when this graph is the whole reachable state space, and therefore when a property other than deadlock can be read off it. |
fun depth(id: Int): Int |
How many firings from the initial marking to state id — its BFS layer. |
val diameterFound: Int |
The deepest BFS layer in the graph — how far the search got. |
fun pathTo(id: Int): PetriTrace |
The shortest firing sequence from the initial marking to state id. |
val deadMarkings: List<Int> |
States that were fully expanded and enable nothing — the reachable deadlocks. |
fun deadlockTrace(): PetriTrace? |
The shortest firing sequence from the initial marking to a reachable deadlock, or null if there is none — which only means "there is none" when complete. |
val canDeadlock: Boolean? |
True when some interleaving wedges, false when none can, null when the search was truncated and the honest answer is that nobody knows. |
fun observedBound(place: String): Int |
The largest token count seen in place anywhere in the graph. A lower bound, always. |
fun bound(place: String): Int? |
The exact bound on place — its maximum over all reachable markings — or null if unknown. |
val bounds: Map<String, Int>? |
Every place's exact bound, or null when the search was truncated or reduced. |
val isSafe: Boolean? |
True when no place ever holds more than one token — the net is 1-bounded. Null if unknown. |
val quasiLive: Set<String> |
Transitions that fire in some reachable marking — the honest version of PetriNet.deadTransitions, which reports the ones that did not fire in one particular run. |
fun neverFires(): List<String>? |
Transitions that can never fire under any interleaving, or null when the search was truncated — because a transition nobody reached might be waiting in a marking nobody visited. |
val liveTransitions: Set<String>? |
Transitions that stay possible forever: from every reachable marking, some continuation fires them. Null when the search was truncated. |
val isLive: Boolean? |
True when every transition is live — the classical L4-liveness. Null when unknown. |
val isReversible: Boolean? |
True when the initial marking is reachable from every reachable marking: the net can always be put back. Null when the search was truncated. |
val homeMarkings: List<Int>? |
Markings reachable from every reachable marking — the states the net can always get back to. |
fun witness(predicate: (PetriMarking) -> Boolean): PetriTrace? |
The shortest firing sequence to a reachable marking satisfying predicate, or null if none was found — proof that none exists only when complete. |
fun invariant(predicate: (PetriMarking) -> Boolean): PetriTrace? |
The shortest firing sequence that breaks predicate, or null when nothing does — which is a proof of the invariant when complete. |
fun proves(predicate: (PetriMarking) -> Boolean): Boolean? |
True when predicate holds in every reachable marking, false with a counterexample available from invariant, and null when neither can be claimed — the search was truncated or reduced. |
fun mutualExclusionFailure(vararg places: String): PetriTrace? |
The shortest firing sequence that puts tokens in two or more of places at once, or null when no reachable marking does. |
fun mutuallyExclusive(vararg places: String): Boolean? |
True when places never hold tokens simultaneously; null when the search was truncated. |
fun report(): String |
The classical properties in a paragraph — what to print at a net you are about to trust. |
override fun toString(): String |
PetriOmegaMarking
class PetriOmegaMarking internal constructor(internal val counts: Map<String, Int>)
A marking in which a place may hold ω tokens — arbitrarily many, as many as you like.
| Declaration | Summary |
|---|---|
operator fun get(place: String): Int |
Tokens in place, or PETRI_OMEGA. |
fun isOmega(place: String): Boolean |
True when place holds ω. |
val omegaPlaces: Set<String> |
Every place holding ω here. |
override fun equals(other: Any?): Boolean |
|
override fun hashCode(): Int |
|
override fun toString(): String |
PetriCoverabilityGraph
A Karp–Miller coverability graph: finite for every net, at the cost of only being exact about covering rather than about reachability.
| Declaration | Summary |
|---|---|
val net: PetriNet |
|
val nodes: List<PetriOmegaMarking> |
|
val edges: List<PetriStateEdge> |
|
val outcome: PetriSearchOutcome |
|
val caveats: List<String> |
|
val sound: Boolean |
True when Karp–Miller's assumptions actually hold for this net. See the class KDoc. |
val unboundedPlaces: List<String> |
Places that can hold arbitrarily many tokens. |
val isBounded: Boolean |
True when no place is unbounded. |
fun bound(place: String): Int? |
The bound on place: the largest number of tokens it can hold, or null when it is unbounded. |
fun report(): String |
One paragraph: how big the graph is, what grows, and whether to believe any of it. |
override fun toString(): String |
PetriReduction
Whether the search explores every interleaving or a sufficient subset of them.
Constants: NONE, STUBBORN.
PetriVisual.kt
Draws a Petri net as an FLine picture, and lets you click it to add tokens and fire transitions.
A Petri net has a standard picture: circles hold tokens, bars move them, arrows say where. The two things you most need to see while debugging a net are which transitions are ready to go and where the tokens are, so the drawing shows four things the plain graph does not:
- a place is a circle with its tokens as dots inside it, and a token carrying a
PatPatterngets a ring rather than a dot, because a place holding a rhythm is a different kind of thing from a place holding a count; - a transition is a bar, drawn dim when it cannot fire, bright when it is enabled, and filled when it is firing at the instant being drawn;
- an inhibitor arc ends in a small circle and a read arc in no head at all, the standard notation for a distinction a token count cannot show;
- arc weights above one are written on the arc.
Interaction goes through FLine.onClick, so it only works in the interactive outputs. Click a
place to drop a token in, click a transition to make it fire. Both need somewhere for the change to
go, so both are wired to a PetriLive; a PetriRun is an immutable trajectory, and clicking one
only reports on it.
val live = net.live()
box.showPetriNet(live) // click the picture; the net changes under your hands
Layout
PetriLayout.RING draws a cycle as a cycle, which is what most drum nets are, and is chosen
automatically when the net is one. Otherwise PetriLayout.FORCE relaxes a spring model.
PetriLayout.TALA hands the net to d2's diagram layout engine instead, if you have built the
optional helper for it; it falls back to PetriLayout.FORCE if you have not.
PetriDrawing.at pins anything you would rather place yourself.
src/jvmMain/kotlin/afield/simulation/petri/PetriVisual.kt — read the source
Used in: Petri nets, Petri notation cheatsheet
Top-level functions
Top-level values
| Declaration | Summary |
|---|---|
const val PETRI_LABEL_LIMIT |
Default label budget for toFLines: how many places-plus-transitions can carry names. |
const val PETRI_STATE_DRAW_LIMIT: Int |
How many markings PetriStateGraph.toFLines will draw one dot each for before it declines. |
PetriLayout
How frame and Box.showPetriNet arrange places and transitions on the page.
Constants: AUTO, RING, FORCE, LAYERED, TALA.
PetriDrawing
One frame: a net, a marking, what is ready, what is sounding, and where everything sits.
| Declaration | Summary |
|---|---|
val net: PetriNet |
|
val marking: PetriMarking |
|
val enabled: Set<String> |
Transitions that could fire against marking. Drawn bright. |
val firing: Set<String> |
Transitions whose event arc covers the instant being drawn. Drawn filled. |
val at: PatQ |
The instant this frame shows, for the caption. |
val radius: Double |
|
val positions: Map<String, Vec2> |
Where each place and transition sits, in the drawing's own coordinates. |
fun at(name: String, x: Number, y: Number): PetriDrawing |
A copy with name pinned at (x, y). |
fun position(name: String): Vec2 |
|
override fun toString(): String |
ControlLaw.kt
The mapping between a slider's travel and its value: linear, logarithmic, power-taper or bipolar.
ControlLaw names the law and its wire spelling, ControlLawSpec pairs it with a range and maps
travel to value and back, and controlLawDecimals picks how many decimals a readout shows. The
slider(...) builder in the output controls takes a law argument of this type.
src/jvmMain/kotlin/afield/ui/controls/ControlLaw.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun controlLawDecimals(value: Double, significantDigits: Int, stepDecimals: Int): Int |
How many decimals to show for value, given a significant-digit budget. |
ControlLaw
enum class ControlLaw(val wireName: String)
How a control's travel maps onto its value.
Constants: LINEAR, LOGARITHMIC, EXPONENTIAL, BIPOLAR.
| Declaration | Summary |
|---|---|
val wireName: String |
The spelling that travels to the browser in ControllerControl.law. |
ControlLawSpec
A law together with the range it applies to — everything needed to map travel to value and back.
| Declaration | Summary |
|---|---|
val law: ControlLaw |
|
val min: Double |
|
val max: Double |
|
val exponent: Double |
|
val center: Double |
|
fun validate(label: String) |
Rejects a range this law cannot express, naming label so the message points at the control. |
fun valueAt(t: Double): Double |
The value at travel t, where t is a fraction of the track. |
fun normalizedFor(value: Double): Double |
The travel that shows value — the inverse of valueAt, clamped to 0.0..1.0. |
canvasFlight.kt
Filming Field: canvasFlight flies the canvas through a document's bookmarks, live or to an MP4.
The flight opens on the first bookmark and holds, then flies to each next one and holds there. Each leg eases in and out, zooming in log space while the point at the centre of the window travels in a straight line across the canvas. Every three.js view showing on the canvas turns slowly about its own target meanwhile, and gets its camera back afterwards.
Live, the flight runs in every window open on the document, on the browser's own clock. Filmed
(record = "flight.mp4"), it is rendered frame by frame in the capture page and encoded with
ffmpeg: the clock waits for each frame, so the film is smooth however long a frame takes.
The arithmetic is afield.app.canvas.CanvasFlightTimeline; the browser side is
CanvasFlightClient; the recorder is afield.tools.docs.recordCanvasFlight. Docs:
public/docs/filming_the_canvas.md.
src/jvmMain/kotlin/afield/ui/film/canvasFlight.kt — read the source
Used in: Filming the canvas
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.canvasFlight(bookmarks: List<String>, legSeconds: Double = 6.0, holdSeconds: Double = 2.0, orbitDegreesPerSecond: Double = 8.0, pullBack: Boolean = true, record: String? = null, fps: Int = 30, width: Int? = 1920, height: Int? = 1080, pixelRatio: Double = 1.5): CanvasFlight |
Flies the canvas through bookmarks — names as the bookmarks panel shows them, or slots as "1".."9" — spending legSeconds flying to each and holdSeconds holding at each, the first included. Every visible three.js view turns orbitDegreesPerSecond about its own target meanwhile (0 keeps them still). A leg whose straight path would cross empty canvas zooms out on the way unless pullBack is false. |
fun Box.canvasFlight(vararg stops: FlightStop, legSeconds: Double = 6.0, holdSeconds: Double = 2.0, orbitDegreesPerSecond: Double = 8.0, pullBack: Boolean = true, record: String? = null, fps: Int = 30, width: Int? = 1920, height: Int? = 1080, pixelRatio: Double = 1.5): CanvasFlight |
canvasFlight with a leg and hold per stop: FlightStop("plan", legSeconds = 4.0, holdSeconds = 3.0). A stop that leaves them null takes legSeconds and holdSeconds. |
fun Box.stopCanvasFlight() |
Stops a live canvas flight in every window open on this document, leaving the canvas where it is. |
fun resolveFilmPath(path: String): String |
A film's path: ~ is the home directory, and a relative path is against the box's working directory. |
FlightStop
typealias FlightStop = CanvasFlightStop
One stop of a canvasFlight: a bookmark, and optionally its own leg and hold in seconds.
CanvasFlight
A canvas flight under way. A live flight is done at once (the windows fly it on their own); a filmed one when its MP4 is written, and result then says how many frames went into it and where each bookmark was reached.
| Declaration | Summary |
|---|---|
val durationSeconds: Double |
How long the flight lasts, holds included. |
val path: String? |
The MP4 being filmed, or null for a live flight. |
val done: Boolean |
|
val result: AgentCanvasFlightResponse? |
The finished film's report, or null while it is still filming. |
val frames: Int? |
Frames in the finished film, or null while it is still filming. |
fun await(timeoutSeconds: Double = 3600.0): AgentCanvasFlightResponse |
Waits up to timeoutSeconds for the film to finish. Do not call it from box code's main thread. |
override fun toString(): String |
BoxLayout.kt
"Lay out selection" — arranging boxes on the canvas the way a diagram tool would, using the
layout engines in native/field-d2-layout.
A document's boxes and the execution edges between them are a node-link diagram that happens to
be editable, so the same engine that draws a Petri net can tidy one. What makes this different
from drawing a Petri net is that the positions here are the user's: they were placed by hand,
they persist, and they are what the document looks like when it is opened again. So this only
ever runs when asked, only over what was asked for, and every move goes through
OperationTranscript.recordMove so one undo puts everything back.
Unlike afield.simulation.petri.PetriLayout.TALA there is no fallback: Field has no second box-layout
engine to drop to, so an absent helper is reported to the user rather than absorbed.
src/jvmMain/kotlin/afield/ui/layout/BoxLayout.kt — read the source
BoxLayout
Arranges a selection of boxes with a d2 layout engine, recording every move for a single undo.
| Declaration | Summary |
|---|---|
fun organize(selection: List<Box>, engine: D2Layout.Engine = D2Layout.Engine.TALA, direction: D2Layout.Direction = D2Layout.Direction.RIGHT, animateMs: Double = 450.0): Int |
Lays selection out and animates the boxes into place. |
fun organizeDocument(engine: D2Layout.Engine = D2Layout.Engine.TALA, direction: D2Layout.Direction = D2Layout.Direction.RIGHT, animateMs: Double = 450.0): Int |
Lays out every box in the current document, whatever is selected. |
D2Layout.kt
Field's client for the native/field-d2-layout helper — d2's layout engines, TALA in
particular, over a one-shot subprocess.
The helper is optional and this class is built around that. Field does not build it, no
Gradle task references it, and it is absent in a fresh checkout. Every entry point here answers
null rather than throwing when the binary is missing, is the wrong protocol, times out, or
reports an error, and callers are expected to fall back to whatever they did before rather than
surface a failure. See native/field-d2-layout/README.md for the build, the wire format, and
why this is a subprocess and not a wasm module hosted in-process like libfaust.
One process per call. That looks profligate and is not: the helper starts in low single-digit
milliseconds against layouts that cost hundreds, so spawning is lost in the noise, and a
short-lived process means no state to corrupt and nothing to reap if Field exits mid-layout.
Repeated identical requests are served from cache instead, which is what actually matters —
a Petri animation redraws the same net many times.
src/jvmMain/kotlin/afield/ui/layout/D2Layout.kt — read the source
D2Layout
Runs d2 layout engines through the optional native/field-d2-layout helper, answering null when it is unavailable.
alignment.kt
Geometric tidying of box frames on the canvas: aligning, snapping and connection-guided
routing over a list of Rects, plus the palette actions that apply them to a selection.
Three entry points, each taking rectangles and returning new ones in the same order:
alignAndDistributelines the rectangles up in bands, vertically or horizontally (Orientation), spaced byLayoutParams.snapAndTidyLocalsnaps near-equal sizes and edges together, snaps positions, then pushes overlapping rectangles apart.routeAndTidyLocalalso takes the connections between rectangles asEdgeIds. It snaps sizes and edges, arranges the rectangles in layers along those edges (RouteParams), then runs the tidy pass and an optional final columnizing pass.
snapAndTidyLocal and routeAndTidyLocal can translate their result back so the group's
centroid (CentroidMode) does not move: preserve for the first, preserveCentroid in
RouteParams for the second. installAlignment runs on each new root and adds the align,
snap_tidy and route actions, which call these with fixed parameters and write the results
to the selected boxes' frames; align also restores the centroid.
src/jvmMain/kotlin/afield/ui/layout/alignment.kt — read the source
Top-level functions
Orientation
The axis boxes are lined up along.
Constants: VERTICAL, HORIZONTAL.
IntraDistribution
Constants: EQUAL_GAPS, PACK_MIN_MOVE.
LayoutParams
| Declaration | Summary |
|---|---|
val intraGap: Double |
|
val crossGap: Double |
|
val verticalPreferenceRatio: Double |
|
val singleBandOverfillTolerance: Double |
|
val intraDistribution: IntraDistribution |
RouteAxis
Constants: HORIZONTAL, VERTICAL.
RouteParams
GuideMode1D
Constants: MEAN, MEDIAN.
EdgeAnchor
Constants: LEFT, RIGHT, TOP, BOTTOM.
SizeSnapParams
EdgeSnapParams
| Declaration | Summary |
|---|---|
val tol: Double |
|
val anchors: Set<EdgeAnchor> |
|
val maxDeltaPerRect: Double |
|
val onlyIfClusterAtLeast: Int |
LocalTidyParams
| Declaration | Summary |
|---|---|
val posSnapTol: Double |
|
val minGap: Double |
|
val overlapPasses: Int |
EdgeId
data class EdgeId(val fromId: String, val toId: String, val weight: Double = 1.0)
CentroidMode
Constants: UNWEIGHTED, AREA_WEIGHTED.
DarkThemeColour.kt
Colour correction for marks written from code, so that they show as asked under the drawing layer's dark theme rather than as their inverted, hue-rotated selves.
src/jvmMain/kotlin/afield/ui/notation/DarkThemeColour.kt — read the source
DarkThemeColour
Colours as the drawing layer shows them. Excalidraw's dark theme draws the scene through the CSS filter invert(93%) hue-rotate(180deg) (theme="dark" in tools/excalidraw-bundle/entry.jsx), so a colour stored on an element is not the colour on screen. Per channel the inversion is 0.93 - 0.86 c; the hue rotation is the CSS filter matrix H for 180°.
| Declaration | Summary |
|---|---|
const val DEFAULT_INK |
|
fun shown(storedHex: String): String |
What the dark theme shows for a stored #rrggbb. |
fun compensate(requestedHex: String): String |
The #rrggbb to store so the dark theme shows requestedHex; anything that is not #rrggbb passes through. |
Notation.kt
The box-facing API for the drawing layer: notation reads the document's Excalidraw scene as Marks,
writes marks owned by the box through a notation { } block (NotationBuilder), changes existing ones
with Notation.edit, and runs callbacks when a person draws.
Marks a box writes are replaced when that box re-runs, so a re-run redraws rather than accumulates.
Reactions such as notation.onStroke { } fire only for changes made by hand in a client, not for marks
written from code. Coverage is in NotationTest and NotationIntersectionTest; the full API is in
dev_docs/NOTATION.md.
src/jvmMain/kotlin/afield/ui/notation/Notation.kt — read the source
Used in: Drawn Strokes Become Paint, Drawing and Notation, A Drawn Score, Heard
Top-level functions
| Declaration | Summary |
|---|---|
fun NotationBuilder.line(fline: FLine, name: String? = null, stroke: String = this.stroke): MarkRef |
NotationBuilder.line through the points of an FLine (curves are sampled). |
fun NotationBuilder.stroke(fline: FLine, name: String? = null, stroke: String = this.stroke, pressure: ((Double) -> Double)? = null): MarkRef |
NotationBuilder.stroke along an FLine (curves are sampled). |
Top-level values
| Declaration | Summary |
|---|---|
val Box.notation: Notation |
The document's drawing, as seen from this box — read it, write to it, and react to it. |
MarkHandle
Something a mark can be referred to by: a Mark read from the scene, or a MarkRef just built.
| Declaration | Summary |
|---|---|
val id: String |
MarkRef
data class MarkRef(override val id: String) : MarkHandle
A mark created in a notation { } block, before it has been read back from the scene.
Mark
class Mark internal constructor(val json: JsonObject, private val view: Notation) : MarkHandle
One element of the drawing — a stroke, a shape, a text, an arrow — read from the scene.
| Declaration | Summary |
|---|---|
val json: JsonObject |
The element exactly as Excalidraw stores it. |
override val id: String |
|
val kind: MarkType |
|
val bounds: Rect |
World-space bounds (unrotated), in the same units as a box's frame. |
val center: Vec2 |
|
val angle: Double |
|
val points: List<Vec2> |
World-space points of a stroke, line or arrow; empty for other kinds. |
val pressures: List<Double> |
The pen pressure Excalidraw recorded at each of points, 0..1, for a stroke drawn with a pressure-sensitive pen (or written from code with pressure =). Empty when the stroke has none: a mouse stroke, whose width Excalidraw simulates from speed instead. |
fun pressuresAlong(n: Int): List<Double> |
pressures at n stations evenly spaced by distance along the stroke, first point to last. A pen records points closer together where it moves slowly, so the raw list is not evenly spaced; this one is, which is what a PaintCanvas brush's swell and a pencil's pressure profile expect. Empty when the stroke recorded no pressure. |
val start: Vec2? |
|
val end: Vec2? |
|
val stroke: String |
The colour code asked for (kept in customData); a hand-drawn mark's stored colour. |
val fill: String |
|
val strokeWidth: Double |
|
val opacity: Double |
|
val text: String? |
The text of a text mark. |
val boundText: Mark? |
The text bound inside a shape or on an arrow, if there is one. |
val label: String? |
A name given from code (name =), else the mark's own text, else the text bound to it, else — for a frame — the title Excalidraw keeps in the frame's own name field, which is where a frame named by hand has its name. |
val name: String? |
The name given from code with name =, or through Notation.edit. |
val owner: Box? |
The box whose notation { } block wrote this mark; null for a mark drawn by hand. |
val ownerId: String? |
|
val handDrawn: Boolean |
|
val box: Box? |
The box under this mark's centre, if any (the smallest, when boxes overlap). |
val fromBox: Box? |
For an arrow or line: the box its first point is on. |
val toBox: Box? |
For an arrow or line: the box its last point is on. |
val anchoredTo: Box? |
The box this mark follows when boxes move, if it is anchored. |
val frame: Mark? |
The Excalidraw frame this mark is in, if any. |
val groups: List<String> |
The Excalidraw groups this mark is in, innermost first (Excalidraw's own order). |
fun anchorTo(box: Box) |
Makes this mark follow box when it moves; the same as notation.anchor(this, box). |
fun contains(p: Vec2): Boolean |
|
fun toFLine(): FLine |
The mark's geometry in world coordinates, rotation included: a stroke's polyline, or a shape's outline. |
fun yAt(x: Double): List<Double> |
Where this mark crosses the vertical line at world x: its y values there (a shape gives its edges). |
fun xAt(y: Double): List<Double> |
Where this mark crosses the horizontal line at world y: its x values there. |
val length: Double |
The length of the mark's path (a shape's perimeter), in world units. |
override fun toString(): String |
|
override fun equals(other: Any?): Boolean |
|
override fun hashCode(): Int |
NotationEdit
What changed in the drawing, handed to Notation.onChange.
| Declaration | Summary |
|---|---|
val added: List<Mark> |
|
val updated: List<Mark> |
|
val removed: List<Mark> |
|
val notation: Notation |
The drawing as it now stands. |
Notation
class Notation internal constructor(private val box: Box)
The drawing, as seen from one box. notation in box code gives you one; it reads the scene when you ask it something, so a Notation kept in a variable still answers with the current drawing.
| Declaration | Summary |
|---|---|
val marks: List<Mark> |
Every mark in the drawing, back to front. |
val strokes: List<Mark> |
|
val arrows: List<Mark> |
|
val lines: List<Mark> |
|
val texts: List<Mark> |
|
val shapes: List<Mark> |
|
val frames: List<Mark> |
|
val mine: List<Mark> |
The marks this box wrote, from every notation { } block. |
val selected: List<Mark> |
What is selected in the drawing layer right now, in any window showing this document. |
operator fun get(id: String): Mark? |
|
fun named(name: String): Mark? |
The first mark labelled name — a code-given name, its own text, or the text bound to it. |
fun allNamed(name: String): List<Mark> |
|
fun over(box: Box): List<Mark> |
Marks whose centre lies on box. |
fun inside(rect: Rect): List<Mark> |
Marks entirely inside rect. |
fun inside(frame: MarkHandle): List<Mark> |
The marks in an Excalidraw frame. |
fun grouped(mark: MarkHandle): List<Mark> |
The marks that move with mark when it is selected and dragged: everything sharing its outermost group (itself included), or just mark when it is in no group. |
fun near(p: Vec2, radius: Double): List<Mark> |
Marks whose bounds come within radius of p, nearest first. |
fun arrowsFrom(box: Box): List<Mark> |
Arrows (and lines) whose first point is on box. |
fun arrowsTo(box: Box): List<Mark> |
Arrows (and lines) whose last point is on box. |
fun vertical(x: Double): List<Hit> |
Cut the whole drawing with the vertical line at world x; hit values are world y. |
fun horizontal(y: Double): List<Hit> |
Cut the whole drawing with the horizontal line at world y; hit values are world x. |
fun intersect(from: Vec2, to: Vec2): List<Hit> |
Every place the segment from–to crosses a mark, nearest from first; hit values are 0..1 along the segment. |
fun intersect(probe: FLine): List<Hit> |
Every place an FLine (a probe of any shape) crosses a mark, in order along the FLine. |
fun space(region: Rect, x: ClosedFloatingPointRange<Double>, y: ClosedFloatingPointRange<Double>): NotationSpace |
A region of the drawing read with its own axes — see NotationSpace. region is the rectangle; x and y are what its left→right and top→bottom mean. |
fun space(region: Box, x: ClosedFloatingPointRange<Double>, y: ClosedFloatingPointRange<Double>): NotationSpace |
A region that is a box's frame: move the box and the space moves with it. |
fun space(name: String, x: ClosedFloatingPointRange<Double>, y: ClosedFloatingPointRange<Double>): NotationSpace |
A region that is the frame (or any mark) labelled name, wherever it is now. |
fun space(mark: MarkHandle, x: ClosedFloatingPointRange<Double>, y: ClosedFloatingPointRange<Double>): NotationSpace |
A region that is mark — a frame or shape drawn by hand or returned by notation { } — wherever it is now. |
operator fun invoke(key: String = "default", build: NotationBuilder.() -> Unit): List<MarkRef> |
Draws from code. The marks made here belong to this box under key: running the block again replaces them (updating in place, so the drawing does not flicker), and marks drawn by hand are never touched. |
fun clear(key: String? = null) |
Removes the marks this box drew — under key, or under every key when key is null. |
fun edit(mark: MarkHandle, block: MarkEditor.() -> Unit): Mark? |
Changes any mark — including one drawn by hand. |
fun delete(mark: MarkHandle) |
|
fun anchor(mark: MarkHandle, to: Box) |
Makes mark follow box when the box moves. |
fun unanchor(mark: MarkHandle) |
|
fun onChange(key: String = "change", handler: (NotationEdit) -> Unit) |
Calls handler whenever someone changes the drawing by hand. Running the box again replaces the handler registered under the same key; stopping the box removes it. Changes this code makes itself do not call it back. |
fun onStroke(key: String = "stroke", handler: (Mark) -> Unit) |
Calls handler for each stroke drawn by hand, once it is finished. |
fun onArrow(key: String = "arrow", handler: (Mark) -> Unit) |
Calls handler for each arrow drawn by hand, and again when its ends are moved. |
fun onText(key: String = "text", handler: (Mark) -> Unit) |
Calls handler for each text typed by hand, and again when it is edited. |
MarkEditor
class MarkEditor internal constructor(private val original: JsonObject)
Sets the fields of one mark; see Notation.edit. Unset fields keep their values.
| Declaration | Summary |
|---|---|
var x: Double |
|
var y: Double |
|
var width: Double |
|
var height: Double |
|
var angle: Double |
|
var stroke: String |
The colour asked for; stored compensated for the dark theme (DarkThemeColour). |
var fill: String |
|
var strokeWidth: Double |
|
var opacity: Double |
|
var roughness: Double |
How hand-drawn the line looks: 0 architect, 1 artist (the default), 2 cartoonist. |
var strokeStyle: String |
"solid", "dashed" or "dotted". |
var dashed: Boolean |
|
var fillStyle: String |
How a fill is painted: "solid", "hachure", "cross-hatch" or "zigzag". |
var text: String? |
|
var name: String? |
|
fun moveBy(dx: Double, dy: Double) |
NotationBuilder
The drawing vocabulary of a notation { } block. Every call makes one mark owned by the box and returns a MarkRef to it. The vars set defaults for the calls that follow them.
NotationGeometry.kt
Cutting the drawing with a line: the geometry behind reading notation by intersection — a
vertical line is an instant, a horizontal one a pitch (or whatever a NotationSpace says the
axes mean).
Every mark is flattened once per scene revision into world-space polylines with its rotation
applied (MarkGeometry); probes then only walk segments. The results are plain values, safe to
keep and to read from any thread.
src/jvmMain/kotlin/afield/ui/notation/NotationGeometry.kt — read the source
ProbeAxis
Which way a probe line runs.
Constants: VERTICAL, HORIZONTAL.
Hit
Where a probe line meets a mark.
| Declaration | Summary |
|---|---|
val mark: Mark |
|
val point: Vec2 |
The crossing in world coordinates. |
val value: Double |
Position along the probe, in the probe's units (world y for a vertical world probe; pitch for a score's). |
val span: ClosedFloatingPointRange<Double>? |
For closed marks: the stretch of the probe inside the mark, in the same units as value. |
val along: Double |
How far along the mark's own path the crossing is, 0 at its first point and 1 at its last. |
val slope: Double |
The mark's rate of change across the probe there, in the probe's units: d(value)/d(position). |
val pressure: Double? |
Pen pressure at the crossing, for a stroke that recorded it (0..1); otherwise null. |
val isSpan: Boolean |
|
override fun toString(): String |
NotationSpace
A region of the drawing with its own axes — a score, a graph, a map. Horizontal position in the region becomes the x range and vertical position the y range, so notation.space("score", x = 0.0..8.0, y = 84.0..48.0) reads the frame named "score" as eight beats across and a MIDI pitch range down (high at the top, because Field's y grows downward).
| Declaration | Summary |
|---|---|
val x: ClosedFloatingPointRange<Double> |
|
val y: ClosedFloatingPointRange<Double> |
|
val bounds: Rect? |
The region in world coordinates, as it is now (null if it has gone). |
fun toSpace(world: Vec2): Vec2 |
|
fun toWorld(sx: Double, sy: Double): Vec2 |
|
val marks: List<Mark> |
Marks that overlap the region, excluding the frame (or shape) that defines it — found by its id, so a note that happens to share the frame's bounds still counts. A stroke that runs out of the region still counts; what lies outside is clipped away by the cuts and events. |
fun vertical(at: Double): List<Hit> |
Cut the region at at across: what is sounding at that instant, and at what y values. |
fun horizontal(at: Double): List<Hit> |
Cut the region at at along: where marks pass that y value, as x values. |
val events: List<NotationEvent> |
Every mark in the region as an event: where it starts and ends along x, and its y value at its start and end — a note's onset, offset and pitch; a gesture's first and last pitch. Sorted by start. |
fun between(from: Double, until: Double): List<NotationEvent> |
Events starting in [from, until) — what a playhead moving from from to until has just reached. |
fun sounding(at: Double): List<NotationEvent> |
Events sounding at at: started at or before it and not yet ended. |
NotationEvent
A mark read as an event in a NotationSpace: it starts and ends along the space's x axis, with value at its start and endValue at its end on the y axis, and covers range on y.
| Declaration | Summary |
|---|---|
val mark: Mark |
|
val start: Double |
|
val end: Double |
|
val value: Double |
|
val endValue: Double |
|
val range: ClosedFloatingPointRange<Double> |
|
val duration: Double |
|
override fun toString(): String |
NotationScene.kt
The drawing layer's scene as immutable data: NotationScene parses and re-serializes the root box's
drawing property, NotationChange describes what differs between two scenes, and MarkType names the
Excalidraw element kinds.
Merging two copies of a scene keeps, for each element id, the copy Excalidraw's own version rule prefers, so the server can reconcile a client's drawing with marks written from code.
src/jvmMain/kotlin/afield/ui/notation/NotationScene.kt — read the source
NotationScene
The drawing layer's scene as data: the root box's drawing property, a complete .excalidraw document, parsed losslessly.
| Declaration | Summary |
|---|---|
val elements: List<JsonObject> |
Elements in z order, deleted ones included: they are the tombstones that carry a deletion. |
operator fun get(id: String): JsonObject? |
|
val live: List<JsonObject> |
Live (non-deleted) elements in z order. |
fun serialize(): String |
|
fun withElement(element: JsonObject): NotationScene |
Replaces (by id) or appends element, bumping its version past whatever this scene holds. |
fun withElements(changed: List<JsonObject>): NotationScene |
NotationChange
What changed between two scenes, for reactions. Only live elements are reported as added/updated.
| Declaration | Summary |
|---|---|
val added: List<JsonObject> |
|
val updated: List<JsonObject> |
|
val removed: List<JsonObject> |
|
val isEmpty: Boolean |
MarkType
The kinds of mark Excalidraw draws, as Field names them.
Constants: RECTANGLE, ELLIPSE, DIAMOND, LINE, ARROW, STROKE, TEXT, FRAME, IMAGE, OTHER.
| Declaration | Summary |
|---|---|
val isLinear: Boolean |
NotationServer.kt
The server end of the drawing layer: installNotationHandlers registers the websocket handlers that
receive each client's scene and selection, merge the scene into the document's copy, and pass what a
person changed on to the box reactions and to arrow anchoring.
Anchoring ties a mark, or each end of an arrow, to the box under it, so the drawing follows when boxes move. The drawing layer's Anchor Drawing to Boxes command applies it to the selection.
src/jvmMain/kotlin/afield/ui/notation/NotationServer.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun installNotationHandlers() |
The server side of the drawing layer: clients send their scene here (not as a raw set-string), and it is merged into the authoritative one by element version, so a mark written by code and a mark drawn by hand, arriving together, both survive. What a person changed then goes to the reactions (dispatchNotationChange) and to arrow auto-anchoring. |
NotationSceneUpdate
data class NotationSceneUpdate(val scene: String)
A client's drawing, as it now stands in that window.
NotationSelection
data class NotationSelection(val ids: List<String>)
What a client has selected in the drawing layer.
StrokeLayerFrames.kt
What a window is sent to draw the stroke layer: every living stroke's lines as compact JSON, and the point index the strokes use to find each other.
StrokeSketch calls both. StrokeLayerFrames.indexedPositions runs once per stroke per physics
step, over the lines that stroke has just drawn, and the flat x, y array it returns is what
Stroke.closestOutput searches on the step after. StrokeLayerFrames.encode runs once per
published frame, and its JSON reaches every window showing the document as stroke-layer-frame;
strokeLayer.kt in the frontend draws it.
Only what a canvas needs crosses the wire. Coordinates are world units rounded to a tenth, colours
arrive as CSS rather than as vectors, and a per-node colour or dot radius is sent only when some
node sets one. Overshoot ticks (FLine.overshoot) are worked out here and sent as finished
segments. StrokeVerbsTest asserts the paths, colours, dots and ticks of a frame, and that an
ignored line stays out of the index.
src/jvmMain/kotlin/afield/ui/notation/StrokeLayerFrames.kt — read the source
StrokeLayerFrames
One frame of the stroke layer, as the JSON the page draws (strokeLayer.kt in the frontend).
| Declaration | Summary |
|---|---|
fun encode(trail: Double, strokes: List<Pair<List<FLine>, Double>>): String |
The frame for trail and every stroke's lines with its default width. |
fun indexedPositions(lines: List<FLine>): FloatArray |
The positions Stroke.closestOutput searches: every node end point of lines not marked FLine.ignore, as x, y pairs. |
fun css(c: Vec4?): String |
c as a CSS colour; white when null (an FLine's default). |
StrokeSketch.kt
Living strokes: each stroke drawn by hand on the drawing layer becomes an animated, sounding object running code of its own, after the madp line demo.
A box takes the pen with eachStroke. Each stroke then drawn with the drawing layer's pencil is
taken off the scene and becomes a Stroke: physics points that start where the hand went, the
block of the box that held the pen when it was drawn (later edits do not reach it), a draw { t -> }
called sixty times a second, and an optional pattern playing through dough. What the strokes draw
is shown on the stroke layer, a canvas in world coordinates between Field's background and its
boxes, which fades towards transparent for trails.
One StrokeSketch per document keeps the strokes, the pen and the clock. Its frames go to each
window showing the document as stroke-layer-frame, one at a time: the next is sent once the window
answers stroke-layer-ack, so a slow window is never flooded.
src/jvmMain/kotlin/afield/ui/notation/StrokeSketch.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.eachStroke(trail: Double = 0.0, lineWidth: Double = 2.0, keepMarks: Boolean = false, body: Stroke.() -> Unit): StrokePen |
Take the pen: every stroke drawn by hand from now on (Alt+D, the pencil) becomes a living Stroke running body. |
fun Box.clearStrokes() |
Remove every living stroke in this document, and silence them. The pen stays where it is. |
fun installStrokeLayerHandlers() |
The server end of the stroke layer: a window answers each stroke-layer-frame with stroke-layer-ack once it has drawn it, and only then is it sent the next. |
Top-level values
| Declaration | Summary |
|---|---|
val Box.livingStrokes: List<Stroke> |
The living strokes of this document, oldest first. |
StrokePoint
class StrokePoint(var x: Double, var y: Double)
One physics point of a living stroke. Set ax and ay in draw { }; the sketch integrates them.
| Declaration | Summary |
|---|---|
var vx |
|
var vy |
|
var ax |
|
var ay |
|
val at: Vec2 |
Where the point is now. |
override fun toString() |
StrokeHit
A point another stroke has, found by Stroke.closestPoint or Stroke.closestOutput.
StrokePen
What eachStroke made: the block a box gives to every stroke drawn while it holds the pen.
| Declaration | Summary |
|---|---|
val box: Box |
|
val trail: Double |
How much of the last frame each frame keeps, 0..1: 0 clears, 0.9 leaves long trails. |
val lineWidth: Double |
Width of a line that does not set its own thickness. |
val hasPen: Boolean |
Whether strokes drawn now come to this pen. |
override fun toString() |
Stroke
A stroke that has come alive: its points, its place in the drawing (index), and the code it was drawn with. The receiver of the eachStroke block, which runs once when the stroke is drawn (the demo's onStart) and sets up the rest with draw (its manipulatePoints) and sound.
| Declaration | Summary |
|---|---|
val pen: StrokePen |
|
val index: Int |
0 for the first stroke on the page, then 1, 2, ... |
val points: List<StrokePoint> |
The stroke's physics points, where the hand went, about 2 units apart. |
val box: Box |
The box that held the pen when this stroke was drawn. |
var t: Double |
Seconds since this stroke was drawn. |
val curve: List<Vec2> |
The points' current positions, as a curve. |
fun draw(block: (t: Double) -> List<FLine>) |
What this stroke draws, called 60 times a second with t, seconds since it was drawn. Return the lines to show; set ax/ay on points to push them, and the sketch moves them after the call (damping 0.98 a step). Without a draw, the stroke is shown as it was drawn. |
fun sound(pattern: PatPattern<String>) |
Play pattern while this stroke lives, on the sketch's clock (0.5 cycles a second, shared by every stroke, so patterns stay in step). The pattern language's own atoms and params, with the Strudel names s, lpf and signal: pat("c4 e4 g4") with s("sawtooth") with gain(signal { level }). Note names are Tidal's, so c5 is middle C. Gains are Strudel's: the pattern plays at a quarter of dough's level, as quiet as the same gain is in the demo. |
val cycle: Double |
The sketch's sound clock, in cycles: cycle % 1 is how far through the bar it is now. |
fun closestPoint(x: Double, y: Double): StrokeHit? |
The nearest physics point of any other stroke to (x, y), or null when this is the only one. |
fun closestOutput(x: Double, y: Double): StrokeHit? |
The nearest point of what any other stroke drew last frame to (x, y): every node of its lines, except lines marked FLine.ignore. Last frame's, because this frame's is still being drawn. |
fun rms(): Double |
How loud everything the strokes play is right now, measured from dough's voices: about 0.7 for a sine at gain 1. |
fun s(name: String): PatParam |
Strudel's .sound(name) / s(name): the dough sound a note plays on (sawtooth, triangle, square, sine). |
fun lpf(values: PatPattern<Double>): PatParam |
Strudel's .lpf(hz): dough's low-pass cutoff. |
fun lpf(hz: Number): PatParam |
lpf at a fixed cutoff. |
fun signal(read: () -> Double): PatPattern<Double> |
Strudel's signal(() => …): a continuous pattern whose value is read when each event is scheduled. |
override fun toString() |
StrokeSketch
class StrokeSketch internal constructor(val info: SessionInformation)
The strokes of one document, the pen that new strokes go to, and the clock that steps them.
| Declaration | Summary |
|---|---|
val strokes |
The strokes, oldest first. |
var pen: StrokePen? |
Where new strokes go, or null when no box holds the pen. |
var trail: Double |
The trail of the most recent pen: how much of each frame the next keeps. |
val cycle: Double |
The sound clock, in cycles since the first stroke played; 0 before any did. |
override fun toString() |
StrokeVerbs.kt
The drawing vocabulary of a living stroke: Perlin noise, curve maths, and the verbs that turn a
list of points into an FLine.
StrokeVerbs carries the verbs, and Stroke inherits it, so eachStroke { } code calls them
directly. Some are short names over the shared geometry: smooth is catmullRomCentripetal and
atomize is FLine.byAtomizing, while wiggle and transformTo have a curve form of their own
and a line form that calls FLine.byWiggling and FLine.byTransformingTo. The noise, the
resampling and the rest of the curve arithmetic are this file's own.
The three FLine properties here are read by the stroke layer rather than by any verb.
FLine.overshoot runs each open subpath of a line past its ends, a node's overshoot overrides
that for the subpath it ends, and FLine.ignore keeps a line out of the point index
Stroke.closestOutput searches, so a guide or a stitch does not attract the strokes around it.
StrokeVerbsTest pins the resampling, the curve maths and the noise.
src/jvmMain/kotlin/afield/ui/notation/StrokeVerbs.kt — read the source
Top-level values
| Declaration | Summary |
|---|---|
var FLine.overshoot: Double? |
How far past each end the stroke layer draws every subpath of this line, along its tangent, in the line's own units: the sketchy overrun of a pen that does not stop exactly where it means to. null (the default) draws none. Only the stroke layer (eachStroke) draws it. |
var FLine.Node.overshoot: Double? |
Overshoot for the subpath this node ends, overriding the line's FLine.overshoot at both of that subpath's ends. A line of separate segments (StrokeVerbs.atomize) can so overrun each segment by its own amount. |
var FLine.ignore: Boolean |
Leave this line out of what other strokes find with Stroke.closestOutput: guides, stitches and other decoration a neighbouring stroke should not be drawn towards. false by default. |
StrokeVerbs
The vocabulary a living stroke is written in (eachStroke): the madp line demo's noise, curve maths and drawing verbs, under the demo's names.
| Declaration | Summary |
|---|---|
fun perlin2D(x: Double, y: Double): Double |
Classic 2D Perlin noise, about -1..1, with the madp demos' permutation table. |
fun clamp01(v: Double): Double |
v clamped to 0..1. |
fun triWave(x: Double): Double |
A triangle wave of period 1 in -1..1: -1 at whole numbers, 1 half way between. |
fun tri01(x: Double): Double |
triWave in 0..1. |
fun heldNoise(seed: Double, t: Double, rate: Double): Double |
Noise that holds still and then jumps, rate times a second: broken, stepped motion. |
fun driftNoise(seed: Double, t: Double, rate: Double): Double |
Noise that drifts smoothly through time at rate: breathing motion. |
fun length(curve: List<Vec2>): Double |
The length of curve, along it. |
fun resample(curve: List<Vec2>, n: Int): List<Vec2> |
curve resampled to n points evenly spaced along it, ends kept. A curve too short to resample comes back as it is. |
fun resample(points: List<StrokePoint>, n: Int): List<Vec2> |
A stroke's points, at their current positions, resampled to n points. |
fun warp(curve: List<Vec2>, fn: (Vec2) -> Vec2): List<Vec2> |
Every point of curve moved by fn. |
fun wiggle(curve: List<Vec2>, amplitude: Double = 10.0, frequency: Double = 0.05, phase: Double = 0.0): List<Vec2> |
curve pushed about by Perlin noise: amplitude units, frequency cycles per unit, phase slides the field. |
fun wiggle(line: FLine, amplitude: Double = 10.0, frequency: Double = 0.05, phase: Double = 0.0): FLine |
Every node of line pushed about by Perlin noise, as wiggle does to a curve. |
fun transformTo(curve: List<Vec2>, start: Vec2, end: Vec2): List<Vec2> |
curve moved, rotated and scaled as one shape so its first point lands on start and its last on end. |
fun transformTo(line: FLine, start: Vec2, end: Vec2): FLine |
line moved, rotated and scaled so its first node lands on start and its last on end. |
fun add(a: List<Vec2>, b: List<Vec2>): List<Vec2> |
a displaced by b's shape: both are resampled to their average point count, and each point of a moves by how far the matching point of b is from b's centroid. |
fun subtract(a: List<Vec2>, b: List<Vec2>): List<Vec2> |
a displaced against b's shape: add with b's displacements reversed. |
fun project(a: List<Vec2>, b: List<Vec2>): List<Vec2> |
Each point of a, as a displacement from a's centroid, projected onto the direction of the matching point of b from b's centroid: a squeezed into b's directions. |
fun scale(curve: List<Vec2>, factor: Double): List<Vec2> |
curve scaled by factor about its centroid. |
fun scale(curve: List<Vec2>, by: List<Vec2>): List<Vec2> |
curve scaled point by point by how far by's matching point is from by's centroid, relative to its mean radius. |
fun normalAt(curve: List<Vec2>, i: Int): Vec2 |
The unit normal of curve at point i, from its neighbours: the tangent turned a quarter turn. |
fun tangentAt(curve: List<Vec2>, i: Int): Vec2 |
The unit tangent of curve at point i, from its neighbours. |
fun envelope(i: Int, n: Int): Double |
0 at both ends of a curve of n points, 1 in the middle: 1 - \|2i/(n-1) - 1\|. |
fun polyline(curve: List<Vec2>): FLine |
curve as straight segments. |
fun smooth(curve: List<Vec2>): FLine |
curve as a smooth line through every point: a centripetal Catmull-Rom spline (catmullRomCentripetal). |
fun arcs(curve: List<Vec2>, segmentsPerArc: Int = 10): FLine |
curve as a chain of circular arcs: from its third point on, each point is reached along the circle through it and the two points before it (a straight line where they are in a line). |
fun dots(curve: List<Vec2>, radius: Double = 2.0): FLine |
A dot of radius at every point of curve: an unstroked, pointed line. |
fun dots(line: FLine, radius: Double = 2.0): FLine |
A dot of radius at every node of line, each keeping its node's colour. |
fun FLine.dot(x: Number, y: Number, radius: Double, color: Vec4? = null): FLine |
Adds a dot of radius at (x, y) to this line, coloured color (the line's colour when null). Make the line pointed and unstroked to draw dots alone. |
fun FLine.segment(x0: Number, y0: Number, x1: Number, y1: Number, color: Vec4? = null): FLine |
Adds the separate segment (x0, y0) to (x1, y1) to this line, coloured color when given. |
fun atomize(curve: List<Vec2>): FLine |
curve as separate segments, one subpath per step between neighbouring points. |
fun atomize(line: FLine): FLine |
line as separate segments (byAtomizing). |
CharGrid.kt
A grid of glyphs that box code writes into, cell by cell, shown in an output panel: generative text art in the spirit of 10 PRINT, ported from the madp charGrid demo.
The program writes glyph indices with SET, and a charset string (glyphs) turns each index
into a character; a cell can also carry a CSS colour and raw CSS such as transform:rotate(45deg).
charGrid runs its block once per tick with the previous tick readable through PREV; charStream
runs one long suspending program in which every SET waits for the next tick. A glyph can be a
dough sound (Box.sound), so every SET of it plays. The panel pulls only the cells that changed
since it last asked (CharGridBuffer.update), so a 200 × 100 grid at 24 fps sends a frame's
changes, not the whole grid.
src/jvmMain/kotlin/afield/ui/output/CharGrid.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.charGrid(cols: Int = 40, rows: Int = 25, glyphs: String = CHARGRID_GLYPHS, fps: Double = 24.0, stretch: Boolean = true, sounds: List<String> = emptyList(), sound: (GlyphSound.() -> String?)? = null, channel: String = "chargrid", width: Int? = null, body: CharGridFrame.() -> Unit): Frames |
A char grid whose body runs once per tick, the demo's frame mode. |
fun Box.charStream(cols: Int = 40, rows: Int = 25, glyphs: String = CHARGRID_GLYPHS, fps: Double = 24.0, stretch: Boolean = true, sounds: List<String> = emptyList(), sound: (GlyphSound.() -> String?)? = null, channel: String = "chargrid", width: Int? = null, program: suspend CharGridStream.() -> Unit): Frames |
A char grid run by one long program, the demo's stream mode: every SET writes and then waits for the next tick, and waitFrame() waits a tick, so while (true) SET(…) draws one cell a tick. |
Top-level values
| Declaration | Summary |
|---|---|
const val CHARGRID_GLYPHS |
The charset the demo opens with. |
val CHARGRID_ASCII: String |
Printable ASCII, space to tilde: the charset for print and setText programs, so glyphIndex(ch) finds any key. |
CharGridCursor
data class CharGridCursor(val x: Int, val y: Int)
Where the SET(g) cursor is: the column and row the next cursor SET writes.
CharGridBuffer
class CharGridBuffer(cols: Int, rows: Int, glyphs: String)
The cells of a char grid and the encoder that sends their changes to the panel.
| Declaration | Summary |
|---|---|
val cols: Int |
Columns, clamped to 1..MAX_COLS. |
val rows: Int |
Rows, clamped to 1..MAX_ROWS. |
val glyphs: String |
The charset, as given (a space if empty). |
val glyphList: List<String> |
The charset split into characters (code points), the panel's lookup table. |
var epoch: Int |
Bumped when the string tables are rebuilt; a panel on another epoch gets a full frame. |
var cursorX: Int |
The cursor column. |
var cursorY: Int |
The cursor row. |
var frame: Int |
The tick number shown in the panel's status line. |
fun set(x: Int, y: Int, g: Int, color: String? = null, style: String? = null): Boolean |
Write glyph g at (x, y); color null keeps the cell's colour, style null clears its CSS (the demo's rules). Returns false, writing nothing, outside the grid. Moves the cursor there. |
fun setAtCursor(g: Int, color: String? = null, style: String? = null): CharGridCursor |
Write glyph g at the cursor and advance it, wrapping at the right and scrolling at the bottom. Returns where it wrote. |
fun newLine() |
Move the cursor to the start of the next line, scrolling the grid up a row at the bottom. |
fun scrollUp() |
Move every row up one; the bottom row comes in blank. |
fun get(x: Int, y: Int): Int |
The raw glyph index at (x, y), 0 outside the grid or in a blank cell. |
fun prev(x: Int, y: Int): Int |
The glyph index at (x, y) as of the last snapshot. |
fun prevColor(x: Int, y: Int): String |
The colour at (x, y) as of the last snapshot, "" if none. |
fun colorAt(x: Int, y: Int): String |
The colour at (x, y) now, "" if none. |
fun styleAt(x: Int, y: Int): String |
The per-cell CSS at (x, y) now, "" if none. |
fun isShown(x: Int, y: Int): Boolean |
Whether (x, y) has been set since the last clear (and not scrolled out). |
fun snapshot() |
Copy the glyphs and colours into the PREV buffer. |
fun clear() |
Blank every cell, zero PREV, drop the CSS variables and home the cursor. |
fun cssVar(name: String, value: String?) |
Set a CSS custom property on every cell, --spin to "30deg" say; null removes it. |
fun glyphIndex(ch: String): Int |
The index of ch in the charset, 0 if it is not there. |
val tableSize: Int |
The colour and style strings interned so far, "" included. |
fun compact(limit: Int = TABLE_LIMIT): Boolean |
Rebuild the colour and style tables from the cells in use when they hold more than limit strings, so a program that makes a new colour every frame does not grow them without end. The panel then gets one full frame. Returns whether it rebuilt. |
fun update(knownEpoch: Int, knownVersion: Int, knownColors: Int, knownStyles: Int): String |
The panel's poll: what changed since the panel's knownVersion of knownEpoch, given that it already holds knownColors colour and knownStyles style strings. Returns a JSON object: e epoch, v version, full, cols, rows, frame, glyphs (on a full frame), c0/c and s0/s (new table strings from those indices), css (the variables, when they changed), and d, the changed cells as flat [index, glyph, colour id, style id, …] with glyph -1 for a blank cell and the glyph already taken modulo the charset. |
GlyphSound
class GlyphSound(val N: Int, val x: Int, val y: Int, val frame: Int)
What a glyph's sound lambda sees: the glyph index N being set, where, and on which tick.
| Declaration | Summary |
|---|---|
val N: Int |
The glyph index being set (taken modulo the charset). |
val x: Int |
The cell's column. |
val y: Int |
The cell's row. |
val frame: Int |
The tick. |
CharGridSounds
Turns SETs into dough events: a per-glyph path table and/or a path lambda, and, in frame mode, the demo's auto-gain over one tick's SETs.
| Declaration | Summary |
|---|---|
val maxVoices: Int |
The most distinct paths one frame-mode tick plays. |
val hasSounds: Boolean |
Whether any glyph can sound at all. |
fun resolve(N: Int, x: Int, y: Int, frame: Int): String? |
The path glyph N at (x, y) on tick frame plays, or null. |
fun onSet(N: Int, x: Int, y: Int, frame: Int, immediate: Boolean) |
A SET of glyph N: played now if immediate, else counted towards this tick's flush. |
fun voiceGain(): Double |
The demo's frame-mode gain for one voice: 1 / (enabled glyphs · √cells). |
fun flush() |
Play this tick's collected paths and forget them. |
CharGridScope
What both char grid programs see: the size, the charset, the tick, and every verb except SET (which CharGridFrame and CharGridStream give their own timing).
| Declaration | Summary |
|---|---|
val grid: CharGridBuffer |
The grid's cells; what the panel shows. |
val COLS: Int |
Columns. |
val ROWS: Int |
Rows. |
val glyphs: String |
The charset: glyph index i shows as glyphs[i % glyphs.length]. |
var frame: Int |
The tick, from 0. |
fun GET(x: Int, y: Int): Int |
The glyph index at (x, y) now; 0 outside the grid. |
fun PREV(x: Int, y: Int): Int |
The glyph index at (x, y) on the previous tick; 0 outside the grid. |
fun PREVC(x: Int, y: Int): String |
The colour at (x, y) on the previous tick; "" if none. |
fun glyphIndex(ch: Char): Int |
The index of ch in glyphs, 0 if absent. |
fun glyphIndex(ch: String): Int |
The index of ch (one character) in glyphs, 0 if absent. |
fun setText(x: Int, y: Int, str: String, color: String? = null) |
Write str from (x, y) rightwards as glyphs of glyphs, stopping at the right edge. Does not wait. |
fun print(str: String, color: String? = null) |
Write str at the cursor, \n starting a new line. Does not wait, in stream mode either. |
fun newLine() |
Move the cursor to the start of the next line, scrolling at the bottom. |
fun clearGrid() |
Blank the grid, zero PREV, drop CSS variables and home the cursor. |
fun CURSOR(x: Int, y: Int) |
Move the cursor. |
fun getCursor(): CharGridCursor |
Where the cursor is. |
fun CSSVAR(name: String, value: Any?) |
Set CSS custom property name ("--spin") on every cell, for use in per-cell styles; null removes it. |
fun rand(): Double |
A random number in 0 until 1 (the demo's Math.random()). |
fun rand(n: Int): Int |
A random integer in 0 until n. |
CharGridFrame
The receiver of charGrid's block, which runs once per tick.
| Declaration | Summary |
|---|---|
fun SET(g: Int, color: String? = null, style: String? = null) |
Write glyph g at the cursor and advance it; color null keeps the cell's colour, style null clears its CSS. |
fun SET(x: Int, y: Int, g: Int, color: String? = null, style: String? = null) |
Write glyph g at (x, y); nothing outside the grid. color null keeps the cell's colour, style null clears its CSS. |
CharGridStream
The receiver of charStream's program: every SET waits for the next tick, as does waitFrame.
| Declaration | Summary |
|---|---|
suspend fun waitFrame(): Int |
Wait for the next tick and return its number. PREV then holds the grid as it was at that tick. |
suspend fun SET(g: Int, color: String? = null, style: String? = null) |
Write glyph g at the cursor, advance it, and wait for the next tick. |
suspend fun SET(x: Int, y: Int, g: Int, color: String? = null, style: String? = null) |
Write glyph g at (x, y) and wait for the next tick. |
ControlPresets.kt
src/jvmMain/kotlin/afield/ui/output/ControlPresets.kt — read the source
Used in: Visualizers, Displays, and Little Instruments
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.presetDirectory(channel: String = "controls"): Path |
Where a presetBar on channel keeps its keys by default: keys/<box id>/<channel> in the document's files — also the folder to hand keyframeBrowser, or to seed with key.save(...). |
fun ControlGroupBuilder.presetBar(scope: KeyframeScope, directory: Path? = null, morphBetween: List<String> = emptyList(), morph: Boolean = true): PresetBar |
A preset strip for the controls of scope: a name field with store and recall, a button per saved key, and a presetMorphLabel slider blending across morphBetween (every saved key, in name order, when empty). Controls join by being declared with the keyframe* helpers on scope. |
fun Box.recordControl(label: String, channel: String = "controls", curveName: String = label, tolerance: Double = 0.005): ControlRecording |
Arms a recording of the control labelled label in channel; see ControlRecording. |
fun Box.playControl(label: String, curve: KeyframeEvaluator, time: Double, channel: String = "controls", curveY: KeyframeEvaluator? = null): Boolean |
Plays curve at time into the control labelled label: the control moves and its callback runs. Call it from an animation loop. A vec2 also needs curveY. |
fun ControlGroupBuilder.recordToggle(label: String, curveName: String = label, tolerance: Double = 0.005) |
A record button for the control labelled label: press it, move the control, press it again, and the take lands in the keyframe curve curveName (opened below), ready to edit and to playControl back. A button rather than a checkbox because a checkbox's state is restored when the box runs again, which would arm a recording nobody asked for. |
fun Box.midiLearn(channel: String = "controls"): ControlMidiMap |
The MIDI map for channel, one per box and channel however often the box runs; see ControlMidiMap. |
fun ControlGroupBuilder.midiLearnButton(input: MidiInput? = null) |
A MIDI learn button: press it, touch a control, turn a knob. |
Top-level values
| Declaration | Summary |
|---|---|
const val presetMorphLabel |
The label of the blend slider presetBar adds. It is not a scope target, so no key holds it. |
PresetBar
class PresetBar internal constructor(val scope: KeyframeScope, val directory: Path)
The keys a presetBar stores, in directory: one file per name, written by KeyframeScope.saveKey so each store adds to that key's version history and the folder opens in keyframeBrowser.
| Declaration | Summary |
|---|---|
val names: List<String> |
Saved key names, sorted. |
fun path(name: String): Path |
Where key name lives. |
fun store(name: String, note: String? = null): KeyframeScope.Key |
Captures the scope as key name (a new version if it exists) and returns it. |
fun recall(name: String): Boolean |
Applies the latest save of name; false if there is none. |
fun morph(t: Double, between: List<String> = emptyList()): KeyframeScope.Key? |
Applies the blend at t (0..1) along between, in order — every saved key when empty — via KeyframeScope.interpolateAlong. Returns the applied key, or null with fewer than two keys. |
ControlRecording
A take of one control: every change a person makes to it while armed, with its time. stop bakes the take into a keyframes(...) editor named curveName, thinned so no key strays more than tolerance (a fraction of the control's range) from what was played.
| Declaration | Summary |
|---|---|
val box: Box |
|
val channel: String |
|
val label: String |
|
val curveName: String |
|
val tolerance: Double |
|
var armed |
|
val sampleCount: Int |
Number of changes captured so far. |
fun capture(atSeconds: Double, value: Double) |
Adds a sample by hand — what a test, or code generating a gesture, uses in place of a hand. |
fun keys(): List<Keyframe> |
The take so far as keys (time in seconds from the first change). A vec2 gives x; use keysY for y. |
fun keysY(): List<Keyframe> |
The y lane of a vec2 take. |
fun stop(): KeyframeEvaluator |
Disarms and writes the take into the curve curveName (and $curveName.y for a vec2). |
MidiBinding
data class MidiBinding(val key: String, val midiChannel: Int, val cc: Int)
A hardware controller cc on MIDI midiChannel (0-based) bound to the control with control key key.
ControlMidiMap
class ControlMidiMap internal constructor(val box: Box, val channel: String)
Knob bindings for one control group, saved with the box.
| Declaration | Summary |
|---|---|
val isLearning: Boolean |
True while waiting for a control and a CC to bind. |
val bindings: List<MidiBinding> |
The current bindings. |
fun learn() |
Arms learning; see the class doc. |
fun cancel() |
Disarms without binding. |
fun bind(label: String, cc: Int, midiChannel: Int = 0): Boolean |
Binds cc on midiChannel to the control labelled label, in code. |
fun unbind(label: String) |
Removes the binding for the control labelled label. |
fun feed(midiChannel: Int, cc: Int, value: Int) |
A controller value arriving: learns it if armed, then drives whatever it is bound to. |
fun attach(input: MidiInput) |
Listens to hardware input; detach by closing it. |
LiveOutputIframe.kt
Script access to the iframe inside a box's output area as the Electron tab shows it:
outputIframe resolves a box and channel to a LiveOutputIframe, whose calls (JavaScript
evaluation, raw CDP, navigation) return futures so the runloop is not blocked while the tab answers.
src/jvmMain/kotlin/afield/ui/output/LiveOutputIframe.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.outputIframe(channel: String): LiveOutputIframe |
The iframe in one of this box's own output areas. |
fun Box.outputIframe(box: Box, channel: String): LiveOutputIframe |
The iframe in another box's output area; both boxes must belong to the same document. |
fun Box.outputIframe(boxName: String, channel: String): LiveOutputIframe |
Resolve a box by uid or unique name in this box's document. |
OutputIframeTarget
data class OutputIframeTarget(val boxUid: String, val channel: String)
A resolved box/output pair in the current document; the actual DOM iframe is found per call.
LiveOutputIframe
Commands run against the visible Electron tab. Futures keep the Field runloop unblocked.
MarkdownAppend.kt
src/jvmMain/kotlin/afield/ui/output/MarkdownAppend.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.markdownAppend(channel: String, line: String, maxEntries: Int = 200, maxChars: Int = 32_768, width: Int? = null, open: Boolean = true) |
Append markdown to a named output channel, retaining at most maxEntries entries and maxChars UTF-16 characters, including paragraph separators. Trim oldest entries first; a single oversized entry keeps its tail. Calling markdown replaces the text/history. History persists across runs, but a late append from an older run is ignored after a newer run has written this channel. |
MarkdownOutput.kt
Markdown output for box output areas: markdown renders GitHub-flavoured markdown, with raw HTML,
side-by-side :::row columns and {role:text} spans, into a styled card using Field's design
tokens. FieldMarkdown holds the renderer and its full syntax; md and mdEscape escape a value
for interpolation.
src/jvmMain/kotlin/afield/ui/output/MarkdownOutput.kt — read the source
Used in: Visualizers, Displays, and Little Instruments, Measuring dependence between two signals
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.markdown(channel: String, text: String, width: Int? = null, open: Boolean = true) |
Shows text, rendered as markdown, in this box's output area on channel. |
fun Box.showRow(channel: String, parts: List<String>, width: Int? = null) |
Puts parts side by side in one card — the :::row block without the string plumbing. |
fun Box.showRow(channel: String, vararg parts: String, width: Int? = null) |
showRow with the columns written inline rather than collected into a list. |
fun Box.markdown(text: String, width: Int? = null, open: Boolean = true) |
Shows text as markdown on the default output channel. See Box.markdown. |
fun mdEscape(value: Any?): String |
value as literal text inside markdown — the safe way to interpolate a computed value into a Box.markdown figure, and effectively required inside a table cell. |
Top-level values
| Declaration | Summary |
|---|---|
val Any?.md: String |
mdEscape as a property, so a value escapes itself where it is interpolated — "\| ${'$'}{worker.state.md} \|" rather than "\| ${'$'}{mdEscape(worker.state)} \|". The pair matches val Any?.h, which does the same for the HTML visualizer. |
FieldMarkdown
Markdown for output areas — the compact way to put a figure in a box's output pane.
| Declaration | Summary |
|---|---|
val ROLES |
Inline span roles: {accent:text} renders as <span class="field-md-accent">text</span>. |
fun render(text: String): String |
Renders text to an HTML fragment — no card wrapper, so no styling. Usually you want card. |
fun row(parts: List<String>): String |
parts as one :::row block — the source a Box.showRow card is built from, separate so a caller composing a larger figure can splice a row into the middle of it. |
fun card(text: String, width: Int? = null): String |
Renders text wrapped in the field-md card that carries the stylesheet (see field.css). |
NumericFormatting.kt
Fixed-point number formatting for string templates: x.f2 is x with two decimal places,
from f0 to f9, always with a . decimal point regardless of the machine's locale.
src/jvmMain/kotlin/afield/ui/output/NumericFormatting.kt — read the source
Top-level values
| Declaration | Summary |
|---|---|
val Number.f0: String |
Fixed-point text with zero fractional digits and a locale-independent decimal convention. |
val Number.f1: String |
Fixed-point text with one fractional digit. |
val Number.f2: String |
Fixed-point text with two fractional digits. |
val Number.f3: String |
Fixed-point text with three fractional digits, useful inside string templates. |
val Number.f4: String |
Fixed-point text with four fractional digits. |
val Number.f5: String |
Fixed-point text with five fractional digits. |
val Number.f6: String |
Fixed-point text with six fractional digits. |
val Number.f7: String |
Fixed-point text with seven fractional digits. |
val Number.f8: String |
Fixed-point text with eight fractional digits. |
val Number.f9: String |
Fixed-point text with nine fractional digits. |
OutputAreaReplay.kt
The panels a live box has shown, kept so that a page attaching after the box ran still sees them.
Every DefaultOutput holds one as retainedAreas: display(...) puts the rendered HTML in
under its channel name, displayOpen and displayClose record whether a panel was open, and
snapshot replays both as the set-output and output-control messages a client would have
received live. A client asks for them with replay-output-areas on attaching, which is also what
lets a capture run a box first and look at its output later.
Only the latest HTML per channel is held, bounded at 128 channels and eight million characters with the earliest channel dropped first, and none of it is written to disk.
src/jvmMain/kotlin/afield/ui/output/OutputAreaReplay.kt — read the source
callbacks.kt
Per-box callback tables that run when a box's frame, viewport, name or children change.
Box code adds a named entry to onFrameChange, onViewportChange, onNameChange or onTopologyChange. A frame or name callback receives the current and incoming value and returns the value to keep. A callback that throws is reported as an error on the box, and the remaining callbacks for that change are skipped.
src/jvmMain/kotlin/afield/ui/output/callbacks.kt — read the source
Top-level functions
Top-level values
decorations.kt
Editor decorations driven from running code: RawDecoration marks a span of a box's source in
Monaco, and the Marker extensions here — histogram, history, stats, liveNumber, toggle,
beep, bop — attach live values and clickable widgets to the line that called them.
src/jvmMain/kotlin/afield/ui/output/decorations.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun setDecorations(box: Box, decor: List<RawDecoration>) |
Push a set of decorations for box to all active sessions. |
fun Marker.locationInSource(then: String? = null): Location? |
Where this marker's __m("uid") text is in its box's code, or, with then, where then first follows it (.flipbox(), positioned the way locationForTextStringOnStack positions its end = true, offset = 1 results. Null when the text is not in the code. |
fun <K, V> MutableMap<K, in V>.update(key: K, d: V, u: (V) -> V): V |
Update a map entry by computing or inserting a default, then applying u. |
fun Marker.toggle(cb: () -> Unit) |
Attach a clickable "toggle" decoration to the call site. |
fun Marker.beep(tick: Int = properties.update("beep", 0) { it + 1 }) |
Attach an alternating glyph decoration to the call site. |
fun Marker.bop(text: String): BoppableString |
Creates a BoppableString wrapper to drive glyph decorations. |
fun <T> Marker.histogram(value: T, dur: Int = 100): T |
Track a rolling histogram and emit it into the editor output. |
fun <T> Marker.history(value: T, dur: Int = 100): T |
Track a rolling time series and emit it into the editor output. |
fun <T> Marker.stats(value: T, dur: Int = 100): T |
Track rolling summary stats and emit a compact summary panel next to the call site. |
fun locationForTextStringOnStack(code: String, box: Box? = null, st: Array<StackTraceElement> = Exception().stackTrace, end: Boolean = false, offset: Int = 0): Location? |
Locate the call site of code inside a box's current source text. |
fun Marker.liveNumber(defaultValue: Number = 0.0): Double |
Returns a "live number" - a number that is read directly from the source code at this marker's location. When the source code changes, the returned value changes immediately without recompilation or reevaluation. |
Top-level values
| Declaration | Summary |
|---|---|
var Box.code_decorationState: String |
Serialized decoration payload cached on the box and pushed to clients. |
var Box.code_decorationCallbackTable: MutableMap<String, () -> Unit |
Per-decoration callback table keyed by generated action ids. |
val Box.decorationState: MutableMap<String, RawDecoration> |
High-level decoration state for a box. |
RawDecoration
A single Monaco decoration payload for a box.
| Declaration | Summary |
|---|---|
val lineStart: Int |
|
val colStart: Int |
|
val lineEnd: Int |
|
val colEnd: Int |
|
val classname: String |
|
val classDef: Map<String, String> |
|
val clickCallback: (() -> Unit)? |
Location
data class Location(val inside: Box, val property: String, val line: Int, val col: Int)
A precise source location inside a box property.
BoppableString
class BoppableString(val s: String, val inside: Marker) : () -> String
Wraps a string with a decoration emitter.
| Declaration | Summary |
|---|---|
fun bop(index: Int, color: String = "white", filled: Boolean = true) |
Add a small glyph decoration at the call site. |
override fun invoke(): String |
LittleHistory
Minimal rolling history for histogram and time-series sketches.
| Declaration | Summary |
|---|---|
var outputRateLimiter |
|
fun add(v: Number?, size: Int = 100) |
Append a value and cap history length. |
fun histogram(): String |
Render a histogram SVG for the current history. |
fun history(): String |
Render a line chart SVG for the current history. |
fun stats(): String |
Render a summary statistics panel (metrics + histogram + series). |
outputs.kt
Output areas and controls for box code: HasOutput and the display channels, progress bars and
withProgress, streamProcess for running external commands into a panel, the tree, column
and logs embeds, and the controls DSL — slider, checkbox, stringInput, colorPicker,
button, controlGroup and syncToProperty.
Each control renders into a named output channel (controls by default) and calls back into the
box when the browser reports a change.
src/jvmMain/kotlin/afield/ui/output/outputs.kt — read the source
Used in: Visualizers, Displays, and Little Instruments, Box Output: print, display and channels, The keyframe editor: drawing a value over time
Top-level functions
HasOutput
Receives text, display, and error output for a box or runtime.
| Declaration | Summary |
|---|---|
fun print(message: Any?) |
Emits message without appending a newline. |
fun println(message: Any?) |
Emits message followed by a newline. |
fun display(message: Any?) |
Displays message on the default channel, output. |
fun display(channel: String, message: Any?) |
Displays message in the output panel named channel, replacing what the panel held. A String is inserted as HTML; anything else is rendered with .h first. A channel whose name starts with * is a one-line row rather than a panel. |
fun displayError(message: Any?, property: String = "code", line: Int = -1) |
Paints message as an error diagnostic on property at line of this box; a null message at line -1 clears the errors. Named displayError rather than error so that, inside a box and in Box extensions, error("...") is Kotlin's and throws (?: error(...) narrows as it should). |
DefaultOutput
class DefaultOutput : HasOutput
| Declaration | Summary |
|---|---|
var proxy: (Any?, Boolean, Number) -> Unit |
|
var displayProxy: (String, Any?) -> Unit |
|
var errorProxy: (Any?, String, Int) -> Unit |
|
var warningProxy: (String, Int, Int, Int, Int) -> Unit |
A compiler warning for the run being configured: message, line, start column, end line, end column (1-based, -1 unknown). |
override fun print(message: Any?) |
|
override fun display(channel: String, message: Any?) |
|
override fun display(message: Any?) |
|
override fun println(message: Any?) |
|
override fun displayError(message: Any?, property: String, line: Int) |
ProgressBars
Global switch for the transient progress bars offered by progressReporter and Box.tqdm.
| Declaration | Summary |
|---|---|
var enabled: Boolean |
|
var lingerMillis: Long |
|
var showDelayMillis: Long |
ProgressReporter
A transient tqdm-style progress bar bound to whichever Box is currently executing.
| Declaration | Summary |
|---|---|
val active: Boolean |
True when attached to a live box and not yet finished — i.e. it may still draw. |
fun update(fraction: Double, details: String? = null) |
Draw the bar at fraction of the way done (clamped to 0..1). Reaching 1.0 completes it. |
fun update(done: Long, total: Long, details: String? = null) |
Draw the bar at done/total, with a default tqdm-style rate/ETA line if details is null. |
fun step(total: Long, by: Long = 1L, details: String? = null) |
Advance by by out of total and redraw. |
fun pulse(details: String? = null) |
Report indeterminate activity — one "beat" of work whose total is unknown (e.g. a reduce that runs to a fixpoint). Shows an animated bar with a step count and elapsed time rather than a fraction. Never auto-completes; the bar is removed by finish/close. Like every method here, it is a no-op outside a box, while disabled, or during the grace period. |
fun finish() |
Remove the bar immediately. Idempotent, and a no-op when inert or already completed — so an outer finally won't cut short the linger of a bar that finished on its own. This is the abort path: a bar that never reached 100% is removed at once. Sends nothing if the bar was never shown (i.e. it lived and died entirely inside the grace period). |
override fun close() |
SizedIterable
interface SizedIterable<out T> : Iterable<T>
| Declaration | Summary |
|---|---|
val size: Int |
StreamProcessErrorKind
enum class StreamProcessErrorKind
Constants: NO_COMMAND, START_FAILED, STDERR, NON_ZERO_EXIT.
StreamProcessError
| Declaration | Summary |
|---|---|
val kind: StreamProcessErrorKind |
|
val name: String |
|
val label: String |
|
val command: List<String> |
|
val channel: String |
|
val message: String |
|
val exitCode: Int? |
|
val throwable: Throwable? |
StreamProcessEnd
| Declaration | Summary |
|---|---|
val name: String |
|
val label: String |
|
val command: List<String> |
|
val channel: String |
|
val exitCode: Int |
|
val terminated: Boolean |
LogFilterBuilder
SliderConfig
One entry in a sliders group: a labelled numeric range plus its update callback.
| Declaration | Summary |
|---|---|
val label: String |
|
val min: Double |
|
val max: Double |
|
val step: Double |
|
val initialValue: Double |
|
val onUpdate: (Double) -> Unit |
MeterHandle
class MeterHandle internal constructor(val controlId: String, internal val targetDocument: String?)
A live handle on one meter in a control group — the thing a box holds in order to show a level.
| Declaration | Summary |
|---|---|
val controlId: String |
The control id this handle drives; useful when a box wants to log what it is feeding. |
val counts: Pair<Long, Long> |
How many updates passed this meter's rate gate, and how many it coalesced locally. |
fun set(value: Double): Boolean |
Shows value on this meter, if the rate limit and the previous value allow it. |
ControlValueHandle
A writable view of one cached controlGroup value.
| Declaration | Summary |
|---|---|
val controlId: String |
Stable id derived from box, channel, control type, label and occurrence. |
fun set(value: T): Boolean |
Show value everywhere this control appears. Returns false when it was already current. |
ControlGroupBuilder
class ControlGroupBuilder(private val box: Box, private val channel: String)
replacements.kt
A small {{placeholder}} templating system: filter replaces each placeholder from knownValues or templates, and an unknown one becomes empty text.
src/jvmMain/kotlin/afield/ui/output/templates/replacements.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun replaceTemplate(input: String, replacer: (placeholder: String, wholeText: String) -> String): String |
|
fun filter(source: String) |
Top-level values
| Declaration | Summary |
|---|---|
val templates |
Template functions by placeholder name; each receives the placeholder and the whole text. |
val knownValues |
terminalOutput.kt
A real terminal in a box's output area: terminal starts a process in a panel and returns a
FieldTerminal that box code can type into, read, and kill. The process outlives its panel, which
re-attaches with its scrollback.
src/jvmMain/kotlin/afield/ui/output/terminalOutput.kt — read the source
Used in: A Terminal in a Box
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.terminal(channel: String = "terminal", command: List<String> = listOf(System.getenv("SHELL")?.takeIf { it.isNotBlank() } ?: "/bin/zsh", "-l"), directory: Path? = null, width: Int = 760, height: Int = 420, keep: Boolean = false): FieldTerminal |
Puts a real terminal — xterm.js on a pseudo-terminal — in this box's output area on channel. |
fun Box.terminal(channel: String, commandLine: String, directory: Path? = null, width: Int = 760, height: Int = 420, keep: Boolean = false): FieldTerminal |
terminal running one command line through your shell, e.g. terminal("build", "./gradlew build"). |
FieldTerminal
class FieldTerminal internal constructor(val id: String, startAt: Long = 0)
A terminal started by terminal: the process keeps running when its panel is closed or the page reloads, and the panel re-attaches with its scrollback.
| Declaration | Summary |
|---|---|
fun write(text: String): Boolean |
Types text into the terminal; add "\n" (or "\r") to submit a line. False once it has exited. |
fun send(line: String): Boolean |
Types line and a return. |
fun kill() |
Ends the process and everything it started. |
val running: Boolean |
|
val exitCode: Int? |
The exit code, or null while it runs. |
val info: TerminalInfo? |
|
suspend fun awaitReady(timeoutMs: Long = 10_000) |
Suspends until the program has printed something and gone quiet (a shell showing its prompt), or has exited. send already waits for this before typing. |
suspend fun awaitExit(timeoutMs: Long = Long.MAX_VALUE): Int |
Suspends until the process exits, and returns its exit code. |
suspend fun awaitOutput(pattern: Regex, timeoutMs: Long = 30_000): MatchResult |
Suspends until pattern appears in the terminal's output, and returns the match. Colour and cursor escapes are stripped first, so match the text you see. |
suspend fun awaitOutput(text: String, timeoutMs: Long = 30_000): MatchResult |
awaitOutput for a literal string. |
suspend fun sendAndAwait(line: String, pattern: Regex, timeoutMs: Long = 30_000): MatchResult |
Types line and a return, then suspends until pattern appears in the reply. The reply starts after the terminal's echo of line, so \d+ after sending 6 * 7 matches 42. |
override fun toString() |
AudienceBuilder.kt
The HTML an audience slide is written in: kotlinx.html's builders, with scoped Markdown, stylesheets and two picture layouts added.
SlideStepBuilder.audience runs its block against an AudienceBuilder, which is a kotlinx.html
DIV carrying the slide's class, so every standard tag works inside it, along with Kotlin's own
loops, functions and completion. Text added with + is escaped by the library; "# Heading".md
renders Markdown instead, and the unary plus on the AudienceMarkdown it returns inserts the
generated tags unescaped. That pair is how one slide mixes written prose with built HTML.
Use AudienceBuilder.div rather than kotlinx.html's own div for a nested container, since only
this one keeps the Markdown syntax available inside it. AudienceBuilder.stylesheet links a CSS
file from the document's files directory; the audience fetches it past the browser cache each time
a slide is drawn, so an edit to it appears on Refresh slide.
AudienceBuilder.fullScreenImage and AudienceBuilder.imageBeside are the picture layouts. Both
want alt text and take an optional visible credit, and AudienceImageFit chooses between
containing the whole picture and cropping it to fill.
src/jvmMain/kotlin/afield/ui/slides/AudienceBuilder.kt — read the source
AudienceMarkdown
class AudienceMarkdown internal constructor(internal val html: String)
A rendered Markdown fragment, inserted with unary plus inside AudienceBuilder.
AudienceImageFit
enum class AudienceImageFit(internal val css: String)
How an audience image fits its available space.
Constants: Contain, Cover.
AudienceBuilder
Standard HTML slide content, with scoped Markdown, stylesheets and image layouts.
| Declaration | Summary |
|---|---|
val String.md: AudienceMarkdown |
Render an indented Markdown block; ordinary +String still escapes text as HTML. |
operator fun AudienceMarkdown.unaryPlus() |
Insert a rendered Markdown fragment without escaping its generated tags. |
fun div(classes: String? = null, block: AudienceBuilder.() -> Unit = {}) |
A nested HTML container that keeps the slide's scoped Markdown syntax available. |
fun stylesheet(source: String) |
Load a stylesheet relative to this document's files/ directory; Refresh slide reloads it. |
fun fadeIn(durationMs: Int = 350, delayMs: Int = 0, block: AudienceBuilder.() -> Unit) |
Fade this text, image or group in on forward entry; Back shows its completed state. |
fun fullScreenImage(source: String, alt: String, citation: String = "", fit: AudienceImageFit = AudienceImageFit.Contain) |
Fill the audience display with an image and an optional visible credit. |
fun imageBeside(source: String, alt: String, citation: String = "", imageOnLeft: Boolean = true, fit: AudienceImageFit = AudienceImageFit.Contain, block: AudienceBuilder.() -> Unit) |
Put an image beside ordinary HTML or Markdown content, with its own credit. |
LessonCheckpoint.kt
A reset button for a lesson: capture a box's source before an audience changes it, and restore it from a later slide.
capture records the named boxes' local text properties (code by default) in memory, and
restore stops those boxes' tracked jobs and writes the text back. The scope is narrow: unlisted
boxes, other properties, output, files and anything a run created outside a text property are
untouched, so a slide that resets an experiment runs the setup again afterwards rather than
waiting for the old output to reappear.
A read-only document refuses the restore, and a captured box since removed from the document fails it rather than being recreated.
presentation_studio.md pairs this with a checkpoint(...) step, which is how the
templates/presentation document uses it.
src/jvmMain/kotlin/afield/ui/slides/LessonCheckpoint.kt — read the source
LessonCheckpoint
class LessonCheckpoint private constructor(private val entries: List<Entry>)
An in-memory baseline of explicitly named local text properties; never a snapshot of runtime objects.
| Declaration | Summary |
|---|---|
fun restore() |
Stop the captured boxes and restore their text. Run setup explicitly afterwards to recreate output. |
Presentation.kt
Giving a talk from a Field document: Box.presentation builds the same steps a slideShow does,
and each step can carry a slide for a separate audience display.
A step's audience surface is HTML, a real webpage, a YouTube embed, or another document in the
workspace. Write the HTML with SlideStepBuilder.audience for kotlinx.html's type-safe builders
(see AudienceBuilder), with SlideStepBuilder.audienceHtml for a fragment you already have, or
with SlideStepBuilder.audienceMarkdown. SlideStepBuilder.audienceWebpage and
SlideStepBuilder.audienceYouTube put a page or a video in an isolated native Electron view, and
SlideStepBuilder.audienceDocument opens another workspace document with its own editor, outputs
and guide. A step with no audience surface shows its note HTML, templates and all, so a guide
written for a reader presents without being rewritten.
SlideShowInstance.present starts the talk. Each step publishes a presentation-state message to
the windows showing the host box's document, and Field's Electron app opens the audience and
presenter windows from it; in a browser the document and its guides still work, but there is no
separate display. Running the host box again rebuilds the talk and keeps the current step where it
can. Anything bound for Electron is checked before it gets there: a webpage needs an HTTP or HTTPS
URL with a host, a document a plain name in the current workspace, a video the eleven-character
YouTube id, and a fractional rect has to sit inside the audience window.
Presenting with Field is the guide to writing one.
src/jvmMain/kotlin/afield/ui/slides/Presentation.kt — read the source
Used in: Presenting with Field
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.presentation(title: String = name.ifBlank { "Field presentation" }, transitionMs: Int = 450, init: SlideShowBuilder.() -> Unit): SlideShowInstance |
Build a presentation using the existing guide steps, callbacks, notes and annotations. Call SlideShowInstance.present to open its audience display in Field's Electron app. Running the same box again preserves the current step when possible. |
fun SlideShowInstance.present() |
Open this guide as a presentation, using note HTML for steps without an explicit audience surface. |
fun SlideStepBuilder.audience(block: AudienceBuilder.() -> Unit): AudienceContent |
Standard kotlinx.html builders; text is escaped by the library. No JavaScript build is needed. |
fun audienceFragment(block: AudienceBuilder.() -> Unit): AudienceContent |
Reusable slide content. Keep one copy of the markup and shared image paths across slides. |
fun SlideStepBuilder.audience(content: AudienceContent) |
Show a reusable HTML fragment without changing its content. |
fun SlideStepBuilder.audienceOver(previous: AudienceContent, dimPrevious: Double = 0.25, block: AudienceBuilder.() -> Unit) |
Repeat HTML content dimmed behind a new layer; native webpages and documents stay separate surfaces. |
fun SlideStepBuilder.reveal(title: String, dimPrevious: Double = 0.25, block: AudienceBuilder.() -> Unit) |
A cue that retains the preceding HTML view underneath a new layer. |
fun SlideStepBuilder.audienceHtml(html: String) |
Set an HTML fragment for the audience. Use audience for type-safe, escaped HTML construction. |
fun SlideStepBuilder.audienceMarkdown(markdown: String) |
Render Markdown on the audience display using the guide's existing Markdown renderer. |
fun SlideStepBuilder.audienceWebpage(url: String, rect: List<Double>? = null) |
Show a real browser page in an isolated native Electron view, optionally within a fractional rect. |
fun SlideStepBuilder.audienceYouTube(videoId: String, rect: List<Double>? = null) |
Show a YouTube embed; the video must permit embedding. |
fun SlideStepBuilder.audienceDocument(document: String, macro: String? = null, interactiveGuide: Boolean = true, rect: List<Double>? = null) |
Show an existing document in the current workspace, with its normal Field editor and outputs. A named saved macro and the document's interactive guide are explicit presenter controls. |
PresentationOptions
data class PresentationOptions(val title: String, val transitionMs: Int = 450)
Options for showing a guide on a separate audience display.
PresentationSearch.kt
Reduces a slide to the plain text a presenter can search, by reading the audience HTML the talk would actually show.
The slideshow calls text for every step when a client asks for search text, and caches the
result for the built talk. It parses the HTML with the JDK's own parser, drops <style> and
<script> subtrees, keeps image alt text, and adds the one identifying string of the other
audience types: a document name, or a webpage or video URL. A step with no audience content at
all falls back to its presenter note. The result is capped at 16 384 characters.
Indexing stays on authored content. The presenter-note fallback is taken as written rather than with its template functions evaluated, because those can run user code, and a step whose audience provider throws (a slide file not written yet) is skipped so the rest of the talk still opens.
src/jvmMain/kotlin/afield/ui/slides/PresentationSearch.kt — read the source
PresentationSlides.kt
The on-disk format for a talk's editable slides, and the two slides(...) builders that read a
deck into a slideshow.
A deck is a files/<name>.slides.json inside the presentation document: a schema string, a
stylesheet path, and a list of slides carrying Markdown for the audience, presenter notes, an
optional section path and an optional list of reveals. validated() bounds every field of it (at
most 1000 uniquely identified slides, 100 000 characters of Markdown each, 100 reveals), and both
reading and writing run it.
PresentationSlideFiles is the file side, shared by Kotlin box code and by AI4's slide tools in
PresentationTools. A write names the revision it read, so a deck edited underneath a caller is
refused rather than overwritten, and every path is checked to stay inside the document's files/
directory.
A deck carries Markdown and no code; a live action stays an explicit Kotlin guide step.
src/jvmMain/kotlin/afield/ui/slides/PresentationSlides.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun SlideShowBuilder.slides(path: String) |
Insert editable Markdown slides at this point in the talk. Refresh reloads existing slide bodies from disk. |
fun SlideShowBuilder.slides(file: File) |
Load a specific slide file; relative media still resolves in the presentation document's files directory. |
PresentationSlides
Editable content for a talk. Live actions remain explicit Kotlin guide steps.
| Declaration | Summary |
|---|---|
val schema: String |
|
val stylesheet: String |
|
val slides: List<PresentationSlide> |
|
fun validated(): PresentationSlides |
PresentationSlide
| Declaration | Summary |
|---|---|
val notes: String |
PresentationReveal
A content-only reveal replaces or layers the slide body and never executes code.
| Declaration | Summary |
|---|---|
val retainPrevious: Boolean |
PresentationSlideFiles
class PresentationSlideFiles(private val documentDirectory: File)
Document-local files shared by the Kotlin builder and AI4's revision-checked slide tools.
| Declaration | Summary |
|---|---|
data class Snapshot(val revision: String, val deck: PresentationSlides) |
|
fun read(path: String): Snapshot |
|
fun write(path: String, revision: String, deck: PresentationSlides): Snapshot |
Empty revision creates a new file; every replacement must name the exact revision just read. |
SlideShow.kt
SlideShow - A DSL for creating guided tours through Field documents
This system supports creating interactive guides with: - Step-by-step navigation - Custom link callbacks that execute Kotlin code - Template system for displaying live data - Annotations and visual highlighting
Example usage:
// Set up properties that templates can reference
var counter = 0
this.properties["counter"] = counter
slideShow {
step("Introduction") {
note("""
<h2>Welcome!</h2>
<p>Counter value: {{counter}}</p>
<p><a href="#custom:increment">Click to increment</a></p>
<p><a href="#slideshow:next">Continue →</a></p>
""")
// Register a callback for the custom link
linkCallback("increment") {
counter++
here.properties["counter"] = counter
// Refresh to show new value
show.goToStep(show.currentStepIndex)
}
selectBox(myBox)
}
}
Custom Links:
Use #custom:callbackName in href attributes, then register with linkCallback("callbackName") { ... }
Templates:
Use {{propertyName}} to reference box properties or Kotlin variables in the guide's scope
Navigation Links:
- #slideshow:next - Go to next step
- #slideshow:previous - Go to previous step
- #slideshow:home - Go to first step
- #slideshow:stop - Stop the slideshow
src/jvmMain/kotlin/afield/ui/slides/SlideShow.kt — read the source
Used in: Interactive Guides, Presenting with Field
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.slideShow(init: SlideShowBuilder.() -> Unit): SlideShowInstance |
Build a slideshow, store it on this box, and return it. Call SlideShowInstance.start to show it. |
fun Box.selectAndEdit(property: String = "code", line: Int? = null, highlight: Int? = null) |
Quick select and edit a box with optional line navigation |
fun SlideStepBuilder.markdownNote(markdown: String, persistent: Boolean = false, anchorBox: Box? = null, animated: Boolean = false) |
|
fun Box.showNote(html: String, persistent: Boolean = false, anchorBox: Box? = this, animated: Boolean = false, annotations: List<SlideAnnotation> = emptyList()) |
Show a slide note |
fun Box.showMarkdownNote(markdown: String, persistent: Boolean = false, anchorBox: Box? = this, animated: Boolean = false, annotations: List<SlideAnnotation> = emptyList()) |
|
fun hideNote() |
Hide slide notes |
fun Box.withChat(id: String? = null) |
Add a shared chat interface to this box's output area. |
Top-level values
| Declaration | Summary |
|---|---|
var Box.slideShow: SlideShowInstance? |
SlideShowBuilder
Builder for creating slideshows
| Declaration | Summary |
|---|---|
fun step(title: String, id: String? = null, init: SlideStepBuilder.() -> Unit) |
Defines a step in the slideshow |
fun section(title: String, init: SlideShowBuilder.() -> Unit) |
Group steps in the presenter outline. Sections may nest; playback stays in source order. |
fun slideshowId(id: String) |
Set a custom slideshow ID (optional, will generate one if not set) |
fun build(): SlideShowInstance |
SlideStepBuilder
class SlideStepBuilder(val title: String, val hostBox: Box)
Builder for individual slide steps
| Declaration | Summary |
|---|---|
fun cue(title: String, init: SlideStepBuilder.() -> Unit) |
A reveal within this slide. onEnter runs once on forward entry; Back only changes the view. |
fun checkpoint(title: String, reset: suspend () -> Unit) |
Explicit presenter reset. Restoring declared state is separate from navigation. |
fun note(html: String, persistent: Boolean = false, anchorBox: Box? = null, animated: Boolean = false) |
Sets the HTML note to display for this step |
fun anchorNoteTo(box: Box) |
Anchors the note to a specific box (defaults to the slideshow host box) |
fun selectBox(box: Box, property: String = "code", line: Int? = null, highlight: Int? = null) |
Selects a box and optionally scrolls to a specific line |
fun selectBoxByName(name: String, property: String = "code", line: Int? = null, highlight: Int? = null) |
Selects a box by name (searches through children recursively) and optionally scrolls to a specific line |
fun onEnter(action: suspend () -> Unit) |
Code to execute when entering this step |
fun onExit(action: suspend () -> Unit) |
Code to execute when leaving this step |
fun linkCallback(name: String, action: suspend LinkCallbackContext.() -> Unit) |
Register a callback for a custom link in the note HTML. Use in markdown as: <a href="#custom:linkName">Click me</a> or in markdown link syntax: [Click me](#custom:linkName) |
fun withChat(id: String? = null) |
Enable chat/discussion for this slide. When enabled, a collapsible "Discussion" section appears below the slide content where users can post messages and questions. |
fun annotate(id: String, targetBox: Box, color : String = "rgba(255, 180, 100, 0.8)", strokeWidth: Double = 2.0, dashed: Boolean = true) |
Add an annotation line from the guide text to a box |
fun annotateToLine(id: String, targetBox: Box, property: String = "code", line: Int, color: String = "rgba(128, 255, 164, 0.7)", strokeWidth: Double = 2.0, dashed: Boolean = false) |
Add an annotation line from the guide text to a specific line in a box's editor |
fun build(): SlideStep |
LinkCallbackContext
class LinkCallbackContext(val here: Box, val show: SlideShowInstance)
Context provided to link callbacks
| Declaration | Summary |
|---|---|
val here: Box |
|
val show: SlideShowInstance |
SlideStep
Represents a single step in a slideshow
SlideMarkdown
Markdown renderer for slideshow notes with nav shortcuts.
| Declaration | Summary |
|---|---|
fun render(markdown: String): String |
SlideShowInstance
A running instance of a slideshow
| Declaration | Summary |
|---|---|
val hostBox: Box |
|
val steps: List<SlideStep> |
|
var presentationOptions: PresentationOptions? |
|
var currentCueIndex: Int |
|
val slideshowId: String |
|
var currentStepIndex |
|
val currentStep: SlideStep? |
|
suspend fun goToStep(index: Int, cueIndex: Int = 0) |
Goes to a specific step |
suspend fun executeCallback(callbackName: String): Boolean |
Execute a custom link callback by name |
suspend fun next() |
Goes to the next step |
suspend fun previous() |
Goes to the previous step |
suspend fun home() |
Jump back to the first step |
suspend fun start() |
Starts the slideshow from the beginning |
suspend fun stop() |
Stops the slideshow and hides notes |
suspend fun command(command: String) |
Execute a navigation request in order; callbacks may navigate within the same command. |
fun hasNext(): Boolean |
Returns true if there is a next step |
fun hasPrevious(): Boolean |
Returns true if there is a previous step |
AudioVisualizer.kt
The recorded-audio waveform player: a zoomable, seekable view of a recording, as one self-contained HTML fragment for a box output.
It shows audio that already exists, rather than a spectrum or a live meter, with the interaction an editor expects (wheel to zoom, drag to pan, click to seek, a playhead that tracks playback) and the browser's own transport underneath.
What it draws is a min/max envelope of the visible window, not the whole file decimated. Where
a server can summarise a window (AudioVisualizerSource.waveformUrl — Field Audio's waveform
endpoint, for instance) the component asks for one envelope per view, so an hour-long recording
costs the same as a bar of it; where there is no server it decodes the clip in the browser and
does the same arithmetic locally. Min/max rather than an average is what keeps a single-sample
click visible in a window drawn at 1000 points.
Start from Box.displayAudio for a URL, Box.displayAudioSamples for a FloatArray you
generated, or Box.displayAudioBytes for an encoded clip you have in memory.
src/jvmMain/kotlin/afield/ui/visualization/AudioVisualizer.kt — read the source
Used in: Visualizers, Displays, and Little Instruments, Multi-instrument transcription with MuScriptor, Render Field music, then analyse it
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.displayAudio(source: AudioVisualizerSource, channel: String = "audio", options: AudioVisualizerOptions = AudioVisualizerOptions()): String |
Display a URL-backed recording in this Field box. |
fun Box.displayAudioSamples(samples: FloatArray, sampleRate: Int, channel: String = "audio", options: AudioVisualizerOptions = AudioVisualizerOptions(), wavBytes: ByteArray? = null, label: String? = null): String |
Display generated or otherwise in-memory mono samples in this Field box. |
fun Box.displayAudioBytes(bytes: ByteArray, mediaType: String = "audio/wav", channel: String = "audio", durationSeconds: Double? = null, label: String? = null, options: AudioVisualizerOptions = AudioVisualizerOptions()): String |
Display a short browser-decodable WAV, MP3, Ogg, or similar byte buffer in this Field box. |
AudioVisualizerSource
Where a waveform player gets its audio, and how it gets the envelope it draws.
AudioVisualizerOptions
How a waveform player looks and what it lets the user do.
AudioVisualizer
General-purpose waveform player for Field output areas.
DataFrameCharts.kt
A typed frame, charting its own columns.
DataFrameVisualizer already teaches a frame to render as a Table for .h and to stream its
rows for .h2, which is the right compact form: a frame is rows and columns first. What it had no
answer for is the other question a reader has about a numeric column, which is what shape is it.
This is that answer, and it is thin — nine lines of column selection over Chart.
The interesting decision is which columns, and the rule is the one that makes frame.chart() with
no arguments do the obvious thing:
- a numeric x column and the rest numeric: lines against it, one series per column;
- a categorical first column (strings, or anything not numeric) and one numeric column: bars,
with the category names on the axis — which is what a
groupBy(...).count()looks like, and the commonest frame anyone actually plots; - no x column named and none obvious: the row index.
Non-numeric cells inside a numeric column become gaps rather than zeroes. A missing measurement is not a measurement of zero, and drawing it as one is the single most common way a plot lies.
src/jvmMain/kotlin/afield/ui/visualization/DataFrameCharts.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun DataFrame<*>.chart(x: String? = null, y: List<String>? = null, title: String? = null, points: Boolean = true): Chart |
A Chart of this frame's numeric columns. |
fun Box.showChart(frame: DataFrame<*>, x: String? = null, y: List<String>? = null, title: String? = null, channel: String = "output"): Chart |
A DataFrame charted straight into an output channel — the plot counterpart of letting a frame be a box's last expression, which gives you the table. |
DataFrameSchemas.kt
src/jvmMain/kotlin/afield/ui/visualization/DataFrameSchemas.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun <T> DataFrame<T>.typed(name: String): DataFrame<T> |
Makes a DataFrame's columns nameable in later executions of the same box — the part the compiler plugin cannot do on its own. |
DataFrameVisualizer.kt
Makes Kotlin DataFrame values render as Field values (.h, .h2, display and box return
values) without DataFrame having to know Field exists.
DataFrame is a third-party interface, so it cannot implement ToHTML the way Table and Img
do. Both registries exist for exactly this: ValuePrinterRegistry for the compact .h form, and
H2ValueRegistry for .h2. Everything here is a thin adapter onto primitives that already exist,
so a frame gets the same styling, the same expander and the same paging as every other value.
The compact .h form
table already renders a column-oriented table in Field's output style, so a frame's compact
form is that table over the first HEAD_ROWS rows, plus a line giving the shape. Only the head is
touched: each column is indexed for its first HEAD_ROWS cells rather than the frame being
materialised, so printing a million-row frame costs about what printing ten rows costs.
The .h2 scroller
H2ValueSnapshot adapts one value into another for the lazy renderer rather than producing HTML.
So .h2 on a frame adapts it into a private list-of-rows view that reads a row only when asked,
and the h2 explorer (h2explorer.kt, reached as an iframe onto /h2-explorer?root=<token> and fed
over the /ws __h2_explorer__ channel) streams windows of it by index. The server holds the
frame rather than a rendering of it behind an .h2 token, and each scroll materialises only the
window being looked at, which is what makes a large frame a scroller instead of a large HTML dump.
ValueVisualizer's /lazy-value-fragment renderer is a separate path, for expanding a value in
place rather than scrolling one.
Going through the registries rather than a bespoke widget also means a frame nested inside another value, a list of frames or a data class with a frame field, expands with the same machinery.
Where the adapters get registered
install is idempotent and runs during server start-up. A bare jvm-main harness does not run
that, so tests call it explicitly; DataFrameVisualizerSmokeTest is the worked example.
src/jvmMain/kotlin/afield/ui/visualization/DataFrameVisualizer.kt — read the source
DataFrameVisualizer
The registration point, and the four renderers behind it: a frame, a single column, a single row and a grouped frame, each as one HTML fragment.
| Declaration | Summary |
|---|---|
const val HEAD_ROWS: Int |
Rows shown in the compact .h form. .h2 streams the whole frame instead. |
fun install() |
Register every DataFrame renderer. Idempotent, and safe to call from any thread. |
DataFrameVisualizerSmokeTest.kt
Checks that DataFrame values render through Field's value registries — DataFrameVisualizer.
The assertions are deliberately about dispatch and laziness rather than exact markup, which would make this a change-detector on the table renderer:
- the registry is reached at all (a frame must not fall through to
toString()); .hshows a head and says how much it is hiding, without materialising the frame;.hon a large frame produces BOUNDED output — it must not contain the last row's data. That is the observable form of "only the head was rendered". An earlier version of this test counted lambda invocations on an added column instead and passed with a count of zero, becauseaddmaterialises its column eagerly and cell access never re-invokes the lambda: the assertion was vacuous. Output content is the property worth pinning, since it is what a box actually shows.
install() is called explicitly because Server.main — which calls it in the app — does not run
in a bare jvm-main harness.
src/jvmMain/kotlin/afield/ui/visualization/DataFrameVisualizerSmokeTest.kt — read the source
DataFrameVisualizerSmokeTest
object DataFrameVisualizerSmokeTest
| Declaration | Summary |
|---|---|
fun runTests(): Boolean |
|
fun main(args: Array<String>) |
FLineEditableSvg.kt
src/jvmMain/kotlin/afield/ui/visualization/FLineEditableSvg.kt — read the source
GeometryFinding.kt
GeometryFinding, the record a geometry checker or agent writes about a piece of geometry for
review; it never changes whether a build is accepted.
src/jvmMain/kotlin/afield/ui/visualization/GeometryFinding.kt — read the source
GeometryFinding
Review metadata shared by checkers and agents. A finding never changes build acceptance.
| Declaration | Summary |
|---|---|
val key: String |
|
val kind: String |
|
val stage: String |
|
val runId: String |
|
val producer: String |
|
val consequence: String |
|
val prompt: String |
|
fun json(): JsonObject |
H2DomainAdapters.kt
.h2 adapters for the Field types where the reflective default in H2Reflect.kt reads badly.
H2Reflect lifted .h2 from "types someone registered" to "everything", and for a data class or
a plain domain object a property tree is exactly right. Four families it is wrong for, each for
its own reason:
Box— reflection would touchBox.ki, alazyVarthat builds a K2 REPL engine on read. Exploring a box would compile one. This is the concrete case behind the side-effect limitH2Reflect's KDoc admits it cannot detect, and the reasonBoxis served here.Functions.Fn— its only non-derived field is a private(Double) -> Double. A function's content is its values, so the adapter samples it.FLine— a polyline's content is its node list, andnodesis one property among four.Throwable— ajava.*type, refused by the platform-package narrowing, and the value a box produces most often when something is wrong.
Each adapter returns a shape the existing explorer machinery already renders well —
a LinkedHashMap gets map paging, and a List<Map<String, Any?>> hits the explorer's RECORD
mode and becomes a real table with one column per key. That is deliberate: an adapter that
invented its own rendering would have to be maintained in three places.
src/jvmMain/kotlin/afield/ui/visualization/H2DomainAdapters.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun registerH2DomainAdapters() |
Registers the four adapters above, for Box, Functions.Fn, FLine and Throwable. |
H2Reflect.kt
The reflective half of .h2: what a domain object's children are, computed once and shared by
every surface that shows them.
.h2 renders a value three separate times — the Browsable tree behind /tree and /browse,
the row/column stream behind the embedded /h2-explorer iframe, and the summary line in the
widget itself. This file computes the children all three read, reflectively and for any type. A
per-type H2ValueSnapshot adapter is the alternative, and stays the route for a type whose
getters this cannot walk safely; without either, a non-collection falls through
LazyValueVisualizer.visualize's opening guard to ValueVisualizer and .h2 renders as .h.
properties is the single enumerator, so /tree and the iframe cannot disagree about how many
fields a value has.
Getters are opaque
A KProperty1 getter is opaque: there is no way to tell a field read from a computed property
that opens a file, allocates a GPU buffer or builds a K2 REPL engine. Box.ki is a lazyVar
that compiles on read, which is why Box is served by a hand-written adapter in
H2DomainAdapters.kt rather than by this file. The two narrowings in readsWellInline (platform
packages, and types whose .h rendering is the value) mitigate the general case rather than
settling it. If a type's getters are expensive or effectful, give it an adapter.
src/jvmMain/kotlin/afield/ui/visualization/H2Reflect.kt — read the source
ImagingVisualizer.kt
Inline visualizers for the CPU imaging and computer-vision family (afield.stdlib/imaging*.kt):
what a gradient, a pyramid, a flow field or a list of corners looks like when a box evaluates one.
The imaging layer is around 250 public symbols of raster and feature code, and Img is the only
one of its types that carries a picture of its own. Everything else falls through to a reflective
object tree unless something here claims it, and there a list of forty Corners is a wall of
{x=…, y=…, response=…} and a GaussianPyramid is GaussianPyramid@3f1a2b. Those are exactly
the values you want to look at.
Where this hooks in
Three one-line hooks, small enough that this file can grow without touching ValueVisualizer.kt:
Img.toHTMLdelegates toimg.ImgimplementsToHTML, whichValueVisualizerdispatches before its built-in table, so improving theImgpicture has to happen there.ValueVisualizer.visualizeBuiltIn's fallback branch triesformatfirst, which covers the non-data classes (Gradient,GaussianPyramid,LaplacianPyramid,FlowField,ComponentLabelling).ValueVisualizer.formatListtriesformatListfirst, which covers the homogeneous detector outputs (List<Corner>,List<HoughLine>,List<HoughCircle>,List<FlowVector>,List<Img>).
Turning a float image into pixels
The family is float-channel and unbounded: a Sobel dx, a Laplacian and a
difference-of-Gaussians are legitimately signed, an unnormalised response legitimately exceeds 1,
and a division somewhere upstream legitimately produces a NaN. A visualizer that clamps all of
those to 0..1 shows a flat white or a flat black rectangle and tells you nothing, so the raster
renderer picks one of three modes:
- direct — every finite sample already sits in
0..1. Shown as-is; nothing is rescaled. - signed ±m — some finite sample is negative. The range is made symmetric about zero
(
m = max(|min|, |max|)) so that zero is a fixed, recognisable colour. A 1-channel image is additionally run through thecoolwarmdiverging ramp fromColormaps(blue negative, pale zero, red positive), because grey cannot carry the sign of a gradient. Multi-channel signed data is offset and scaled per sample without a ramp. - auto-ranged — everything is non-negative but the maximum exceeds 1. Linearly rescaled, with the data's true range printed beside it, so a bright pixel is never mistaken for 1.0.
The caption names the mode whenever the mode rescaled something; the statistics table inside the disclosure names it in every case, including the fourth one, where no sample is finite at all.
Non-finite samples (NaN, ±∞) are counted, excluded from every statistic, and painted magenta
so they are impossible to miss. Excluding them is what keeps one NaN out of the minimum and the
maximum, where it would take the whole display range with it and quantise to a uniformly black
thumbnail captioned range NaN…NaN.
A 4-channel image is drawn over a checkerboard, so transparent and black are distinguishable.
The disclosure
Each raster visualizer carries a closed <details> with the per-channel strip, a per-channel
histogram computed over the full-resolution samples, and the numeric stats. It is a plain
<details> element rather than a lazy-value-fragment expander link, so it needs no running
server and works identically in a live box, in a docs capture and in a saved HTML file.
src/jvmMain/kotlin/afield/ui/visualization/ImagingVisualizer.kt — read the source
ImagingVisualizer
The dispatch surface: format for a single imaging value, formatList for a homogeneous list of them, and img for the Img thumbnail that Img.toHTML calls directly.
| Declaration | Summary |
|---|---|
fun format(value: Any): String? |
Render one of the imaging family's non-list types, or null if value is not one of them (in which case ValueVisualizer carries on with its reflective/toString fallback). |
fun formatList(list: List<*>): String? |
Render a homogeneous list of imaging values as one picture, or null when the list is not homogeneously one of the supported element types (empty lists and mixed lists fall through to ValueVisualizer's ordinary list rendering). |
fun img(image: Img): String |
The inline picture for an Img: an aspect-correct thumbnail of the actual pixels under the range policy this file's overview sets out, a one-line readout, and a disclosure holding the per-channel strip, histogram and statistics. |
LazyValueVisualizer.kt
The lazy half of value display: rather than inline a large structure, show one summary line plus links into the server-side browsers.
LazyValueVisualizer.visualize — reached in a box as h2 — passes anything small or
unrecognised straight to ValueVisualizer. For maps, lists, arrays and FloatBuffer /
IntBuffer it instead retains the value under a token and emits a summary, outliner and
columns links, and an embedded /h2-explorer iframe. Those tokens live in an in-memory
retention table (10 minutes, 256 entries), so the links only resolve against the running server
and go stale after that.
The private Browsable wrappers behind those routes page rather than copy: children are grouped
into range-labelled directories of at most 96 entries, and a map with more than 20,000 entries is
iterated on demand instead of snapshotted. registerH2Value adds a per-type adapter returning an
H2ValueSnapshot when a type should be summarised or unwrapped differently, and
lazyInlineExpanderHtml is the inline "… +N more" link ValueVisualizer emits for elided
content.
src/jvmMain/kotlin/afield/ui/visualization/LazyValueVisualizer.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
inline fun <reified T : Any> registerH2Value(noinline adapter: (T) -> H2ValueSnapshot) |
Registers a .h2 adapter for T, so that a value of that type is summarised or unwrapped the way adapter says rather than by the reflective default. |
Top-level values
| Declaration | Summary |
|---|---|
val Any?.h2: String |
The box-facing entry point: someBigThing.h2 is the explorable rendering, where .h is the inline one. |
H2ValueSnapshot
data class H2ValueSnapshot(val value: Any?, val summary: String? = null, val title: String? = null)
What a .h2 adapter returns: the value to explore in place of the one it was handed, and the wording to show for it.
H2ValueRegistry
The table of per-type .h2 adapters, and the rule that decides which one serves a given value.
| Declaration | Summary |
|---|---|
fun <T : Any> register(clazz: Class<T>, adapter: (T) -> H2ValueSnapshot) |
Records adapter against clazz itself, replacing any adapter already registered for that exact class. |
LazyValueVisualizer
The renderer behind .h2, and the only public entry point into it.
| Declaration | Summary |
|---|---|
fun visualize(value: Any?, appendable: Boolean = false): String |
Renders value as the .h2 panel: a summary line, outliner and columns links into the server-side browsers, and an embedded explorer iframe. |
SignalViewer.kt
A zoomable viewer for functions of time: each SignalChannel is a lane drawn as a min/max
envelope, resampled as you zoom.
SignalViewer holds the lanes and answers the browser's window queries; Box.displaySignal
shows it in an output channel and keeps it alive while it is visible. A lane built with
certifiedSignalChannel also draws the interval-arithmetic bound of its function behind the
sampled envelope.
src/jvmMain/kotlin/afield/ui/visualization/SignalViewer.kt — read the source
Used in: A function, played and read back, Visualizers, Displays, and Little Instruments
Top-level functions
| Declaration | Summary |
|---|---|
fun certifiedSignalChannel(name: String, fn: Functions.Fn, minimum: Double = 0.0, maximum: Double = 1.0): SignalChannel |
A lane whose sampled envelope and certified band both come from fn. |
fun Box.displaySignal(viewer: SignalViewer, channel: String = "signal", options: SignalViewerOptions = SignalViewerOptions()): String |
Replace a box output, releasing its previous signal's evaluator references. |
SignalChannel
One lane, with an optional earlier version drawn in dashed grey. Evaluators must be pure.
| Declaration | Summary |
|---|---|
val name: String |
|
val value: (Double) -> Double |
|
val minimum: Double |
|
val maximum: Double |
|
val comparison: ((Double) -> Double)? |
|
val certified: Functions.Fn? |
Opt-in: the same function as an Functions.Fn, whose per-bin certified bound (certifiedEnvelope) is drawn as a band behind the sampled envelope — the bound no feature between samples can escape. Must compute what value computes; certifiedSignalChannel builds both from one Fn. A graph with formula-less ops is refused, and the lane says so. |
SignalViewerOptions
SignalTrace
data class SignalTrace(val low: List<Double>, val high: List<Double>)
SignalLaneWindow
| Declaration | Summary |
|---|---|
val current: SignalTrace, val comparison: SignalTrace? |
|
val certified: SignalTrace? |
Per-bin certified bounds over the same bins as current (one per sample interval in waveform mode). |
SignalWindow
| Declaration | Summary |
|---|---|
val sampleSpacing: Double, val lanes: List<SignalLaneWindow>) |
|
fun toJson(): String |
The wire form: JSON with every non-finite value (an empty or gap bin) written as null. |
SignalViewer
A range-query source for the frontend signal viewer. Like AudioVisualizer, it sends only the visible envelope; unlike recorded audio, functions are evaluated more finely as you zoom in. Envelopes preserve extrema on the sampling grid, not arbitrarily narrow features between samples. Keep this object alive while its HTML is visible, or use Box.displaySignal, which does so for you.
| Declaration | Summary |
|---|---|
val start: Double |
|
val end: Double |
|
val sampleStep: Double |
Largest requested sampling interval. A query is capped at 500,000 intervals per lane. |
val gaps: Boolean |
Treat non-finite values as gaps instead of refusing the query. A bin with no finite value is sent as null, which the viewer draws as a break; Fn.plot uses this for functions with holes. |
val channels |
|
val id: String |
|
override fun close() |
|
fun window(from: Double, to: Double, points: Int, checkActive: () -> Unit = {}): SignalWindow |
Bounded response size; checkActive permits a server request to cancel between sample batches. |
fun renderHtml(options: SignalViewerOptions = SignalViewerOptions()): String |
TimeViewer.kt
The HTML shell of Field's one time-axis viewer, shared by SignalViewer, Fn.plot,
AudioVisualizer and the Audio AI analysis views. It writes the chrome (title, status chip,
zoom/fit buttons, a rounded plot frame, a readout line and an overview strip) plus a JSON config,
and inlines signal-viewer.js, which draws with Field's bundled Plotly.
Callers choose a source in render's config: range (a /signal-viewer/{id} query per
visible window), embedded (a static window in the page), audio (an <audio> element and
its envelope) or analysis (a Field Audio preview). Everything else is shared: the view window,
the time bus (field-time-window, -cursor, -seek, -transport), viewKey persistence and
the DOM-overlay playhead. See dev_docs/SIGNAL_VIEWER.md.
src/jvmMain/kotlin/afield/ui/visualization/TimeViewer.kt — read the source
ValueVisualizer.kt
The default value display for Field output areas: a Kotlin value in, a compact HTML fragment out.
ValueVisualizer.visualize is the entry point; Any.h and Box.viz are how it is usually
reached — .h hands back the fragment, viz displays it into a channel of a live box. A type
can take over its own rendering either by implementing ToHTML or by registering a printer with
registerValuePrinter; both are trusted to return well-formed HTML, while ordinary strings,
characters, keys and reflected values are escaped here.
Output is bounded — ValueVisualizer's own KDoc below covers dispatch order, the
built-in formatters and the size limits. Whatever those limits elide (the tail of a long string
or list, extra fields of an object) becomes an expander link, resolved later by
renderLazyFragment from the server's /lazy-value-fragment route, so the rest of the value is
only ever rendered if someone asks for it.
src/jvmMain/kotlin/afield/ui/visualization/ValueVisualizer.kt — read the source
Used in: Agent transcript gallery, DataFrames in a Box, Visualizers, Displays, and Little Instruments
Top-level functions
| Declaration | Summary |
|---|---|
fun Box.viz(channel: String = "viz", value: Any?) |
Extension function to visualize a value in a Box's display output |
inline fun <reified T : Any> registerValuePrinter(noinline printer: (T) -> String) |
Register a custom printer for a type |
fun jsonLeafValue(p: kotlinx.serialization.json.JsonPrimitive): Any? |
The Kotlin value a kotlinx JSON leaf holds: null, a Boolean, a Long or Double, or the string's content without its quotes. What .h and .h2 show in place of the JsonPrimitive itself. |
Top-level values
| Declaration | Summary |
|---|---|
val Any?.h : String |
ToHTML
Interface for objects that can provide their own HTML representation
| Declaration | Summary |
|---|---|
fun toHTML(): String |
ValuePrinterRegistry
Registry for custom value printers
| Declaration | Summary |
|---|---|
fun <T : Any> register(clazz: Class<T>, printer: (T) -> String) |
Register a custom printer for a specific type |
ValueVisualizer
Render a Kotlin value as a compact, escaped HTML fragment for Field output areas.
| Declaration | Summary |
|---|---|
fun visualize(value: Any?, appendable: Boolean = false): String |
Visualize any value and return compact HTML representation |
charts.kt
Plots — the structure, the scales, and the one renderer for them.
Chart is to a plot what Table is to a grid: build it, and it arrives on screen in the repo's
style with no markup in the way. It implements ToHTML, so display(channel, chart), chart.h
and a box whose last expression is a Chart all render through Chart.toHTML; Box.showChart
is the terse form.
This file or plotly.kt
plotly.kt — showSignals, linePlot, scatter, violin — is the default, and better at being
a chart: real text rather than glyph geometry, a plot that reflows to its panel instead of being
fitted and centred, and log, subplot and box axes this file does not carry. Reach for a Chart
for the three things Plotly cannot do: composing a plot with other FLine geometry in one drawing
under a shared transform, picking, and Chart.expect.
Axis labels here are real glyph geometry (labelLines), filled and with their counters intact,
because showPlaneGrid has nowhere to put text. niceTicks is Heckbert's nice-number tick
algorithm in public. dev_docs/chart_primitive.md holds the Plotly comparison capture and the
survey of what each medium in the repo was drawing for itself.
Marks pick themselves up
Box.lines switches to the interactive SVG renderer the moment any line carries
FLine.onClick or FLine.hoverColor (someSkiko.kt). Point and bar marks set both, so hovering
a bar and clicking a datum need no configuration — see Chart.pickable for the escape hatch and
the cost.
Chart.expect
Exactly as in Table. A plot in a doc or a live box usually carries a claim — "no residual
exceeds 1e-6", "every voice stayed inside the guard band". State the claim and it is drawn as a
band, the data outside it recolours, the footnote says how many points broke it, and
Chart.deviations hands the same list to a test.
src/jvmMain/kotlin/afield/ui/visualization/charts.kt — read the source
Used in: Charts as FLines, Filling a solid: tetrahedral meshing with TetGen
Top-level functions
Top-level values
| Declaration | Summary |
|---|---|
val CHART_MUTED: Vec4 |
Muted grey for axes, ticks and notes — the plot counterpart of TABLE_MUTED. |
val CHART_GRID: Vec4 |
The gridline colour: the same grey at a tenth of the weight, so data always wins. |
val CHART_BAD: Vec4 |
What a failed Chart.expect recolours to, matching CellRole.BAD's intent. |
ScaleKind
Which arithmetic a Scale uses between data space and pixel space.
Constants: LINEAR, LOG.
Scale
One axis: a data interval, a pixel interval, and the arithmetic between them.
| Declaration | Summary |
|---|---|
val lo: Double |
The low end of the data interval. |
val hi: Double |
The high end of the data interval. |
val pixelLo: Double |
The pixel lo maps to. |
val pixelHi: Double |
The pixel hi maps to. May be less than pixelLo; see the class note on y. |
val kind: ScaleKind |
Linear by default. |
fun toPixel(value: Double): Double |
Where value lands, in pixels. NaN when the value has no place on this scale. |
fun toData(pixel: Double): Double |
The inverse of toPixel — what data value a pixel names. The thing a click needs. |
fun ticks(target: Int = 6): List<Double> |
Round tick values covering the domain: niceTicks when linear, logTicks when log. |
fun tickStep(target: Int = 6): Double |
How far apart ticks came out, for tickLabel's benefit. Zero when there is one tick. |
override fun toString(): String |
MarkKind
What a ChartMark draws.
Constants: POINTS, LINE, AREA, BARS, HISTOGRAM, HRULE, VRULE, BAND.
ChartMark
One layer of a Chart: what to draw, the numbers to draw it from, and how it looks.
| Declaration | Summary |
|---|---|
val kind: MarkKind |
What this mark draws. |
val label: String |
The series name: the legend entry, and the head of a datum's hover label. |
val x: DoubleArray |
Data-space x per datum. Integer positions 0 until n for MarkKind.BARS. |
val y: DoubleArray |
Data-space y per datum. For MarkKind.BAND and MarkKind.HRULE the upper edge. |
val baseline: DoubleArray? |
The lower edge, for MarkKind.AREA, MarkKind.BARS, MarkKind.HISTOGRAM and MarkKind.BAND. |
val binWidth: Double |
Bar/bin width in data units; 0 means "derive it from the spacing". |
var color: Vec4 |
Colour, RGBA in 0..1. Defaults to the next unused slot in signalPalette — next unused, so passing a colour explicitly leaves the slot for the following mark rather than burning it. |
var size: Double |
Dot radius, in pixels. |
var thickness: Double |
Stroke width, in pixels. |
val count: Int |
How many data points this mark has. |
val isPickable: Boolean |
Whether a reader can hover and click individual data of this mark. |
override fun toString(): String |
ChartPick
One datum of one mark, as reported to Chart.onPick when a reader clicks it.
| Declaration | Summary |
|---|---|
val series: String |
The ChartMark.label of the series clicked. |
val index: Int |
Which datum of that series, by index. |
val x: Double |
Its data-space x. For a bar, its slot. |
val y: Double |
Its data-space y. |
val category: String? |
The category name, for a bar; null otherwise. |
val hit: FLineIntersection |
What the interactive SVG renderer reported about the hit itself. |
ChartDeviation
One datum that failed a Chart.expect check: which series and index, its value, and the reason.
| Declaration | Summary |
|---|---|
val series: String |
|
val index: Int |
|
val x: Double |
|
val y: Double |
|
val because: String? |
Chart
Marks, axes, and the claims made about them.
| Declaration | Summary |
|---|---|
var title: String? |
Drawn above the plot in the first palette colour. |
var xLabel: String? |
Drawn under the x axis, centred. |
var yLabel: String? |
Drawn to the left of the y axis, rotated a quarter turn. |
var note: String? |
A footnote under the figure. Deviation counts are appended to it, as in Table.note. |
var width: Double |
Figure width in pixels. |
var height: Double |
Figure height in pixels. See width on why the default aspect is panel-shaped. |
var xKind: ScaleKind |
The x axis's arithmetic. |
var yKind: ScaleKind |
The y axis's arithmetic. |
var xDomain: Pair<Double, Double>? |
An explicit x domain, or null to take it from the data. |
var yDomain: Pair<Double, Double>? |
An explicit y domain, or null to take it from the data. |
var xTickTarget: Int |
About how many x ticks to aim for. |
var yTickTarget: Int |
About how many y ticks to aim for. |
var legend: Boolean |
Whether to draw the series legend. Off automatically when there is one unnamed series. |
var grid: Boolean |
Whether to draw gridlines at the ticks. |
var clip: Boolean |
Whether the figure is kept inside the frame it declared. On, and there is a published figure behind it. |
var pickable: Boolean |
Whether point, bar and bin marks are individually hoverable and clickable. |
var pickLimit: Int |
Above this many data in one mark, that mark stops being individually pickable and is drawn as a single line — the dots are still there, they just share one path. |
var maxPoints: Int |
How many data of one mark are drawn before the rest are elided, and the footnote says so — the plot counterpart of Table.maxRows. |
var onPick: ((ChartPick) -> Unit)? |
What a click on a datum calls. null means the datum's own label is printed, which is the behaviour that makes a fresh chart worth clicking before anyone has written a handler. |
val marks: List<ChartMark> |
The layers added so far, in draw order. |
val xCategories: List<String>? |
The category names on the x axis, when a bars mark put them there. |
fun points(label: String, x: List<Number>, y: List<Number>, color: Vec4? = null, size: Double = 3.0): Chart |
A dot per datum. Pickable, so this is what makes a chart clickable. |
fun points(label: String, y: List<Number>, color: Vec4? = null, size: Double = 3.0): Chart |
points against the sample index. |
fun line(label: String, x: List<Number>, y: List<Number>, color: Vec4? = null, thickness: Double = 1.6): Chart |
A polyline through the data in the order given. |
fun line(label: String, y: List<Number>, color: Vec4? = null, thickness: Double = 1.6): Chart |
line against the sample index — the shape showSignals draws. |
fun area(label: String, x: List<Number>, y: List<Number>, base: Number = 0.0, color: Vec4? = null): Chart |
A line with the region down to base filled at a fifth of the alpha. |
fun bars(label: String, categories: List<String>, values: List<Number>, color: Vec4? = null, base: Number = 0.0): Chart |
One bar per category, which puts the category names on the x axis. |
fun bars(label: String, vararg values: Pair<String, Number>, color: Vec4? = null): Chart |
bars written inline: bars("cost", "gates" to 12, "depth" to 4). |
fun histogram(label: String, values: List<Number>, bins: Int = 0, color: Vec4? = null): Chart |
values binned and drawn as bars on a numeric x axis. |
fun rule(y: Number, label: String = "", color: Vec4 = CHART_MUTED): Chart |
A horizontal reference line at y, across the whole plot. |
fun vrule(x: Number, label: String = "", color: Vec4 = CHART_MUTED): Chart |
A vertical reference line at x, down the whole plot. |
fun band(lo: Number, hi: Number, label: String = "", color: Vec4 = CHART_GRID): Chart |
A shaded horizontal band between lo and hi. |
fun expect(min: Double = Double.NEGATIVE_INFINITY, max: Double = Double.POSITIVE_INFINITY, series: String? = null, because: String? = null): Chart |
States that every datum's y lies in [min, max]. |
fun expect(series: String? = null, because: String? = null, ok: (x: Double, y: Double) -> Boolean): Chart |
States a claim about every datum, given its x and y — the general form, with no band to draw. |
val deviations: List<ChartDeviation> |
Every datum that failed an expect check — the same list the drawing recolours, so a test can assert on the claim the figure makes rather than on its geometry. |
fun resolvedXDomain(): Pair<Double, Double> |
The x domain that will actually be drawn: xDomain if set, else derived from the data. |
fun resolvedYDomain(): Pair<Double, Double> |
The y domain that will actually be drawn: yDomain if set, else derived from the data. |
fun toFLines(): List<FLine> |
The whole figure, in pixels, as lines — the one output everything else is built on. |
override fun toHTML(): String |
The same HTML Box.lines would display for toFLines — how .h and display work. |
fun toText(): String |
Title, both domains with their kinds, and one line per mark, for a log or a failure message. |
override fun toString(): String |
|
fun plotRect(): Rect |
The plot area, in figure pixels — the rectangle the axes bound and mark geometry is clipped to. |
flipbox.kt
A checkbox drawn in the editor that re-runs one block of a box without re-running the box.
flipbox extends Marker, so it is reached through a marker uid in the source
(__m("aVq3x-k2").flipbox(true) { … }), and the checkbox is drawn at that call: over a bare
true or false argument where there is one, and before the call otherwise.
The rest of the file is that decoration. A RawDecoration carries the glyph, its hover colour
and a click callback, and is rewritten after every evaluation and after every click, so the
editor always shows the current state rather than the argument's.
It belongs with the other marker decorations in decorations.kt (toggle, beep, bop,
liveNumber), and is the one that owns a block rather than a value.
src/jvmMain/kotlin/afield/ui/visualization/flipbox.kt — read the source
Top-level functions
| Declaration | Summary |
|---|---|
fun Marker.flipbox(initial: Boolean = false, block: () -> Unit): Boolean |
A block with a checkbox drawn in the editor over its argument: it runs when the box evaluates with the box ticked, and you tick it to run the block again. Each evaluation unticks it. |
heatmap.kt
A rectangular array of numbers as a colour-mapped picture in a box output, in one call.
The file is two layers. heatmapImage is pure: a rectangular matrix in, a RasterImage of ARGB
pixels out, with the colour scale, the optional log transform, the bilinear upsample and the
overlay trace all resolved there, so a test or a docs capture can render one without a Box.
Box.showHeatmap adds the two steps a box wants, encoding to PNG and displaying it into an
output channel, and returns the bytes so the caller can save or re-display them. Box.showImage
is that same second layer over a pixel buffer you already have.
Two conventions decide which way up the picture comes out. The matrix is read row-major, outer
index the row and inner index the column, so rows are the vertical axis; and flipY defaults to
true, drawing row 0 at the bottom, which is what a spectrogram's frequency bins want. A
spectrogram stored the other way round, frame-outer and bin-inner, is the transpose = true
case: frames become x and bins become y.
Colour is a Colormaps name and a range. Under normalize the range is the data's own minimum
and maximum; vmin and vmax pin it instead, which is what makes two captures comparable;
logScale runs each value through log10(max(v, 1e-12)) first, for magnitude spectra and
anything else spanning orders of magnitude.
src/jvmMain/kotlin/afield/ui/visualization/heatmap.kt — read the source
Top-level functions
RasterImage
class RasterImage(val pixels: IntArray, val width: Int, val height: Int)
A colour-mapped raster produced by heatmapImage: ARGB pixels laid out row-major, width × height. Call png for PNG bytes ready to display or persist.
| Declaration | Summary |
|---|---|
fun png(): ByteArray |
Encode to PNG bytes (ARGB, sRGB) via PngEncoder. |
intervalPicture.kt
Pictures of what interval arithmetic proved about a function: Functions.Fn.intervalPicture
draws the search enclosures and certified bounds behind a sampled curve, and Box.showIntervals
displays one in a box's output.
src/jvmMain/kotlin/afield/ui/visualization/intervalPicture.kt — read the source
Used in: Proving a Function's Range
Top-level functions
| Declaration | Summary |
|---|---|
fun Functions.Fn.intervalPicture(a: Double, b: Double, tolerance: Double = 1e-6, bins: Int = 240, title: String? = null, maxEnclosures: Int = 50_000): FnIntervalPicture |
Proves this function's range on [a, b] and draws the proof — see FnIntervalPicture. |
fun Box.showIntervals(fn: Functions.Fn, a: Double, b: Double, channel: String = "intervals", tolerance: Double = 1e-6, title: String? = null): FnIntervalPicture |
Draws fn's intervalPicture into channel and returns it. |
FnIntervalPicture
A picture of what interval arithmetic proved about a function, and where it had to work to prove it.
| Declaration | Summary |
|---|---|
val fn: Functions.Fn |
|
val a: Double |
|
val b: Double |
|
val range: FnCertifiedRange |
|
val envelope: FnCertifiedEnvelope |
|
val pieces: List<FnSearchPiece> |
|
val samples: Int |
|
val title: String? |
|
var width: Double |
|
var height: Double |
|
var maxBoxes: Int |
At most this many search boxes are drawn — the first ones, which are the widest. |
override fun toHTML(): String |
|
fun svg(): String |
plotly.kt
Plotly helpers — the short way to get a chart out of a box, wrapping space.kscience.plotly.
linePlot, scatter, scatterSized and violin cover the common shapes (lists of numbers,
Vec2, Vec3) and hand back a Plot already run through darkMode, the repo dark theme with
data-first margins. showSignals goes one step further: pass "label" to values pairs and it
traces them against a shared x with the signalPalette, optionally min-max normalized and with
dashed reference marks, then displays the plot into a channel of a live Box — it is already on
screen when it returns, so do not display it again.
toHtml renders a Plot into an HTML fragment via PlotlyIntegration. That fragment calls
Plotly.react in the browser, so the page has to have the Plotly library loaded already;
without it the fragment logs to the console and draws nothing.
src/jvmMain/kotlin/afield/ui/visualization/plotly.kt — read the source
Used in: Subharmonic Synthesis: an Octave Below, Looking at Data, Values over time: interpolants & keyframes
Top-level functions
| Declaration | Summary |
|---|---|
fun linePlot(vararg data: List<Number>): Plot |
A dark-mode line plot of one or more lists of numbers against their own sample index — the fastest way to look at a signal. Each list becomes its own hairline trace, so lists of different lengths are fine: they simply run out at different x. |
fun linePlot(vararg data: List<Vec2>): Plot |
A dark-mode line plot of one or more Vec2 polylines, using each point's own x and y — the variant to reach for when x is a real abscissa (time, frequency, arc length) rather than a sample index. Points are joined in list order, so this draws a path and will happily double back on itself. |
fun scatter(vararg data: List<Vec2>): Plot |
A dark-mode markers-only scatter of one or more Vec2 lists, at a fixed 3px marker — for point clouds where the order of the points means nothing and a connecting line would lie about it. |
fun scatterSized(vararg data: List<Vec3>): Plot |
A dark-mode scatter of Vec3 lists in which x and y place the marker and z is the marker size — a bubble chart, for carrying a third quantity per point. |
fun violin(vararg data: List<Number>): Plot |
A dark-mode violin plot, one violin per list of numbers, for comparing whole distributions rather than sequences — order within a list is irrelevant here, only the spread of values. |
fun violin(vararg groups: Pair<String, List<Number>>, references: List<Number> = emptyList(), referenceColor: String = "#ffe066", yRange: ClosedFloatingPointRange<Double>? = null, yTitle: String? = null, spanQuantiles: ClosedFloatingPointRange<Double>? = 0.01..0.99, outlierOpacity: Double = 0.25): Plot |
Named violins side by side, each with an optional reference value drawn as a dashed line across its own violin — for "what was asked for against what came out" over several groups: a target radius against the measured radii, a set point against the readings. |
fun Box.showSignals(vararg series: Pair<String, List<Number>>, x: List<Number>? = null, channel: String = "output", title: String? = null, xTitle: String? = null, yTitle: String? = null, marks: List<Pair<String, Number>> = emptyList(), markColor: String = "#ffe066", normalize: Boolean = false, regions: List<SignalRegion> = emptyList(), width: Int? = null, height: Int? = null, floatingLegend: Boolean = false): Plot |
Put one or more named signals on the screen as a dark-mode Plotly line plot, and return the Plot. |
fun Box.showSpectrogram(audio: FloatArray, sampleRate: Int = 48_000, maxHz: Double = 600.0, fftSize: Int = 8192, hopSize: Int = 1024, rangeDb: Double = 70.0, channel: String = "output", title: String? = null): Plot |
Put a spectrogram of audio on the screen as a Plotly heatmap with time and frequency axes, and return the Plot. |
fun Plot.darkMode(): Plot |
Restyle a Plot into the repo's dark theme and hand back the same instance — near-black paper and plot backgrounds, muted grid and tick colours, tick labels held horizontal, a vertical legend just outside the plot's right edge, and data-first margins that reclaim most of the ~100px Plotly reserves up top for a title. |
fun Plot.toHtml(): String |
Render a Plot to an inline HTML fragment: a plain wrapper div around the plot's own div, which carries a fresh random id and a script that calls Plotly.react on it. Not an iframe and not a whole document — this is exactly what a box output does with a Plot, so the result drops straight into the editor's output pane. |
Top-level values
| Declaration | Summary |
|---|---|
val signalPalette: List<String> |
Cyan/orange-led dark-mode palette for signal traces (leads cyan, then orange). |
SignalRegion
A labelled x-span for showSignals' regions: a translucent band from from to to.
PlotlyIntegration
public class PlotlyIntegration : PlotlyRenderer
The PlotlyRenderer behind toHtml: it emits a bare div plus a Plotly.react call against it, rather than the self-contained page plotly-kt's own renderers produce. That is what makes the output embeddable in a box output or a docs page that already loads Plotly.
| Declaration | Summary |
|---|---|
override fun FlowContent.renderPlot(plot: Plot, plotId: String, config: PlotlyConfig): Plot |
Emit plot into the surrounding kotlinx.html flow as a div with id plotId, followed by an inline script handing the trace data, layout and config to Plotly.react. Returns the plot unchanged. |
fun renderFragment(fragment: PlotlyFragment): String |
Render a whole PlotlyFragment — a piece of kotlinx.html that may hold several plots and ordinary markup between them — to an HTML string, with every plot inside it going through this renderer. The result is wrapped in a single enclosing div; the same "Plotly must already be loaded" caveat applies as for toHtml. |
someSkiko.kt
Drawing FLines into a box's output as SVG: Box.lines and Box.l display them, linesToHTML
and drawToSVG return the markup, and outputTo draws one line onto a Skia Canvas.
Lines carrying FLine.onClick or FLine.hoverColor are drawn as interactive SVG with click
callbacks; everything else goes through Skia's SVG canvas.
src/jvmMain/kotlin/afield/ui/visualization/someSkiko.kt — read the source
Top-level functions
tables.kt
Tabular output — the structure, and the one renderer for it.
Table is the counterpart of showSignals for data that is rows and columns rather than a
signal: build it, hand it to Box.showTable (or just let a box's last expression be one), and it
arrives on screen in the repo's output style. It implements ToHTML, so ValueVisualizer
dispatches it ahead of everything else — display(channel, table), table.h, and a returned
Table all render through Table.toHTML with no extra wiring.
Why this exists: a box that wants a table has, until now, hand-rolled
<table style='border-collapse:collapse…'> with its own font stack, paddings and greys. A dozen
files under src/ do exactly that and no two agree, which is a lot of markup standing between a
reader and what the box is actually saying.
The part that is not just formatting is Table.expect. A table in a doc or a live box usually
carries a claim — "every builder advanced one frame per pump", "no residual exceeds 1e-6" — and
a reader is otherwise asked to verify it by scanning cells. State the claim and the cells that
break it colour themselves, the footnote says how many did, and Table.deviations hands the same
list back to a test. That is the difference between showing a grid and showing an assertion you
can watch fail.
Trust model, same as the rest of value display: text produced by default formatting is escaped;
a string returned from a Table.format lambda is trusted HTML and inserted as-is.
src/jvmMain/kotlin/afield/ui/visualization/tables.kt — read the source
Used in: Frames and the animation loop, How completions are ranked, DataFrames in a Box
Top-level functions
| Declaration | Summary |
|---|---|
fun table(vararg columns: Pair<String, List<Any?>>): Table |
A Table from named columns — the column-major entry point, shaped like showSignals: table("pump" to pumps, "calls" to calls). Columns of unequal length are padded with null. |
fun facts(pairs: List<Pair<String, Any?>?>): Table |
A label→value summary, which is the commonest figure shape there is: what a toHTML() writes about its own object, what a probe reports, what sits beside a thumbnail. |
fun facts(vararg pairs: Pair<String, Any?>?): Table |
facts with the pairs written inline. |
fun recordTable(items: List<*>): Table? |
A Table over a list of records — a uniform list of data classes (columns from the primary constructor), of maps (columns from the keys), or of equal-length lists (columns by index). |
fun Box.showTable(table: Table, channel: String = "output"): Table |
Put a Table on the screen and hand it back — the terse "this table, on screen, now" helper, the tabular counterpart of showSignals. |
fun Box.showTable(vararg columns: Pair<String, List<Any?>>, channel: String = "output", title: String? = null, note: String? = null): Table |
Builds a Table from named columns with table and displays it. |
fun Box.showTable(items: List<*>, channel: String = "output", title: String? = null, note: String? = null): Table |
Displays a list of records as a table via recordTable, falling back to a single value column when the list is not uniform enough to have columns. |
Top-level values
| Declaration | Summary |
|---|---|
const val TABLE_MUTED: String |
Muted grey for headers, notes and elisions — the same #888 the rest of value display uses. |
const val TABLE_TEXT: String |
Default body text colour, matching ValueVisualizer's wrapper. See TABLE_MUTED on the hex. |
Align
Horizontal alignment of a Table column. Numeric columns default to RIGHT, everything else LEFT.
Constants: LEFT, RIGHT, CENTER.
Deviation
data class Deviation(val row: Int, val column: String, val value: Any?, val because: String?)
One cell that failed a Table.expect check: its row index, column name, value, and the reason given.
CellRole
enum class CellRole(internal val cssClass: String, internal val cssVar: String)
The semantic colour roles, shared with markdown()'s {role:text} spans — same six names, same field-md-* classes, so the same --field-* design token backs both. A cell that means "good" and a markdown span that means "good" are therefore the same colour by construction, and neither spells a hex literal.
Constants: ACCENT, MUTED, DIM, OK, WARN, BAD.
Table
class Table(header: List<String>, var title: String? = null, var note: String? = null) : ToHTML
Rows, columns, and the claims made about them.
| Declaration | Summary |
|---|---|
var title: String? |
Optional line drawn above the table, in the accent colour. |
var note: String? |
Optional footnote spanning every column. Deviation counts are appended to it. |
val header: List<String> |
The column names, in order. |
val rows: List<List<Any?>> |
The rows appended so far, each padded to header's width. |
var maxRows: Int |
How many rows to draw before eliding the tail. |
var showHeader: Boolean |
Whether to draw the header row. A label→value summary built by facts has no header worth showing — "key" and "value" over two obvious columns is furniture — so that turns this off. |
fun row(vararg values: Any?): Table |
Appends one row. Short rows are padded with null; a row wider than header is an error. |
fun row(values: List<Any?>): Table |
Appends one row from a list. Short rows are padded with null; too-wide rows are an error. |
fun rows(values: List<List<Any?>>): Table |
Appends many rows. |
fun align(column: String, align: Align): Table |
Overrides the inferred alignment of a column. |
fun color(column: String, color: String): Table |
Colours a column's cells. |
fun accent(vararg columns: String): Table |
Colours these columns from signalPalette, in order — so a table and a plot of the same quantities agree on which colour means which series. |
fun role(column: String, role: CellRole): Table |
Gives a column's cells a semantic CellRole instead of a colour. |
fun roleBy(column: String, role: (Any?) -> CellRole?): Table |
Gives each cell of a column a CellRole chosen from its value — the per-cell counterpart of color, for a column that means something different row by row (ran / did not run, converged / diverged). Return null to leave a cell alone. |
fun bar(column: String, max: Double? = null, width: Int = 120): Table |
Draws a numeric column's cells as proportional bars as well as text — the shape a ranked score wants: top-k labels, vote tallies, per-stage timings. |
fun format(column: String, format: (Any?) -> String): Table |
Formats a column's cells. The returned string is trusted HTML and is not escaped, which is how a cell gets a link, a unit, or a nested fragment; escape it yourself if it came from outside. |
fun expect(column: String, because: String? = null, ok: (Any?) -> Boolean): Table |
States a claim about one column: cells for which ok is false take CellRole.BAD, gain a tooltip, are counted in the footnote, and appear in deviations. |
fun expect(because: String? = null, ok: (row: Int, column: String, value: Any?) -> Boolean): Table |
States a claim about every cell, given its row index, column name and value. |
val deviations: List<Deviation> |
Every cell that failed an expect check — the same list the rendering colours, so a test can assert on the claim the figure makes rather than on its markup. |
fun toText(): String |
A plain-text rendering, for a terminal, a log, or a test's failure message. |
override fun toHTML(): String |
The rendered table — dispatched by ValueVisualizer for display, .h and box return values. |
override fun toString(): String |
A shape summary; toText is the one that prints the contents. |
visualizers.kt
Grouped controls whose value is more than one number: multisliders, matrices, range sliders, menus and recorded gestures.
They are named after the Max/MSP objects they borrow from:
multislider (multiSlider), matrixctrl (matrix), rslider (rangeSlider) and umenu
(menu). All four travel the same way — the whole value as a list of doubles in one
array-update message (ArrayControlUpdate) — and are cached as comma-separated text in
ControllerControl.text, so a rerun restores them like any other grouped control. The pointer
handling is src/commonMain/resources/arrayControls.js.
The multislider, matrix and range slider also have a playhead: a position the box writes and
the person only sees. It is pushed like a meter reading (MeterHandle: rate-limited, in memory,
never cached, never calling back), so a sequencer can drive it from its clock every tick.
gesture records a pointer path as a looping take (GestureHandle); ArcRange and
arcContains answer range questions on a circular dial.
src/jvmMain/kotlin/afield/ui/visualization/visualizers.kt — read the source
Used in: Visualizers, Displays, and Little Instruments, Field from your phone
Top-level functions
ArrayControlHandle
What multiSlider, matrix and rangeSlider return: the control's value, and its playhead.
| Declaration | Summary |
|---|---|
val controlId: String |
Stable id of the control, shared with the tablet surface. |
fun set(value: T): Boolean |
Shows value on the control without calling its callback. See ControlValueHandle.set. |
fun playhead(position: Double): Boolean |
Moves the playhead to position — a bar or column index for a multislider or matrix (the fractional part is ignored), a value on the track for a range slider. A negative position on a multislider or matrix hides it. Returns false when the update was rate-limited away. |
fun playhead(index: Int): Boolean |
playhead for an integer index. |
fun hidePlayhead(): Boolean |
Hides the playhead. |
ArcRange
An interval on a circle, as a polar rangeSlider reports it: clockwise from start to endInclusive, wrapping through the top when start > endInclusive. value in arc and isEmpty follow the arc, where an ordinary 1700.0..400.0 would read as empty.
GestureHandle
What gesture returns: the recorded take, and a way to replay it.
| Declaration | Summary |
|---|---|
val pen: Boolean |
True for gesture(pen = true): the take keeps pressure, tilt and twist with each position. |
val xRange: Pair<Double, Double> |
The pad's value at its left and right edges. |
val yRange: Pair<Double, Double> |
The pad's value at its bottom and top edges. |
val controlId: String |
Stable id of the control, shared with the phone. |
val hasTake: Boolean |
True once a take has been recorded (or given as initialTake). |
val duration: Double |
Length of the take in seconds; 0.0 with no take. |
fun at(seconds: Double): Vector2f? |
Where the take is seconds into its loop, interpolated between the recorded samples, or null with no take. Seconds past the end wrap round, so at(clock) loops forever. |
fun playhead(seconds: Double): Boolean |
Shows a dot on the pad where the take is seconds into its loop. Negative hides it. |
val samples: List<Pair<Double, Vector2f>> |
The take as (seconds, position) pairs in time order — the form initialTake accepts. |
val penSamples: List<PenSample> |
The take with everything the pointer reported at each sample. A take recorded without pen has none of it: pressure is NaN and tilt and twist 0. |
fun penLane(dimension: String, tolerance: Double = 0.02): List<Keyframe> |
One of the pen's dimensions — "pressure", "tiltX", "tiltY" or "twist" — reduced to a linear lane the way toKeyframes reduces x and y: the fewest samples that stay within tolerance (in that dimension's units: pressure 0..1, degrees for the rest) of the take. Empty for a take recorded without pen. |
fun toKeyframes(tolerance: Double = 0.02): GestureLanes |
The take reduced to two linear lanes, x and y, sharing their key times: the fewest recorded samples that keep both axes within tolerance (pad units) of the take at every moment. The lanes start at 0 and end at duration, so they loop as the take does. See Box.bakeGesture. |
fun fit(x: KeyframeEvaluator, y: KeyframeEvaluator): GestureFit |
How closely x and y follow this take; see GestureFit. |
fun setTake(samples: List<Pair<Double, Vector2f>>) |
Replaces the take with samples, as if it had been recorded: the pad shows the new trail and the take is saved with the box. Positions are clamped to the pad and time starts at 0. |
fun setTake(samples: List<PenSample>) |
Replaces the take with samples, pressure, tilt and twist included (a pen take keeps them). |
fun setTake(x: KeyframeEvaluator, y: KeyframeEvaluator, samplesPerSecond: Double = 60.0) |
Replaces the take with the curves x and y — lanes edited in the keyframe editor — so the edited movement replays through the pad. See lanesToTake. |
PenSample
One sample of a gesture take with what the pointer reported besides its position, as the browser's PointerEvent gives it: pressure 0 to 1 (a mouse with a button down reports 0.5), tiltX and tiltY the pen's lean in degrees toward +x (right) and toward the user (down the screen), -90 to 90, and twist its turn about its own axis, 0 to 359 degrees. A take recorded without pen has NaN pressure and no tilt or twist.
| Declaration | Summary |
|---|---|
val t: Double, val x: Double, val y: Double |
|
val pressure: Double |