Pure motion and structural exploration
moonrockz/diagrams/animation builds immutable catalogs and finite or illustrative
plans over BPMN, sequence and swimlane models. It reads no clock, DOM, environment or
network. A sample takes explicit integer milliseconds and returns active
occurrence keys, playback ordinals and integer progress fractions.
PlaybackPlan is the full API. The earlier MotionPlan constructor and its
single-flow sample remain available for the initial HTML tracer. moonrockz/diagrams-svg scenes and moonrockz/diagrams-animated HTML consume
PlaybackPlan with the same source checks.
Explicit scenarios
A Catalog binds occurrence keys to an immutable source diagram. BPMN keys name
render connections while retaining their original source identity and flow value.
Sequence keys name containment paths through events, frame sections and nested
events. Repeated playback of one source occurrence has distinct step ordinals.
Motion::serial orders children. Motion::parallel starts children together and
waits for the longest branch before the next serial child. Parallel branches can
contain their own serial order; they do not wait at artificial per-message barriers.
Constructors copy input arrays. A completed finite plan has no active occurrences.
///|
test "sample an explicit concurrent scenario" {
let view = @bpmn.Bpmn::new(
"P",
[@bpmn.Node::new("A", Task), @bpmn.Node::new("B", Task)],
[
@bpmn.Flow::new("AB", "A", "B", condition="amount > 0"),
@bpmn.Flow::new("BA", "B", "A"),
],
)
let catalog = @animation.Catalog::bpmn(view).catalog.unwrap()
let a = catalog.flow("AB").unwrap().key
let b = catalog.flow("BA").unwrap().key
let motion = @animation.Motion::serial([
@animation.Motion::parallel([Step(a, 1000L), Step(b, 500L)]),
Step(a, 1000L),
])
let plan = @animation.PlaybackPlan::scenario(catalog, motion).plan.unwrap()
assert_eq(plan.duration_ms, 2000L)
assert_eq(plan.sample_at(250L).active.length(), 2)
assert_eq(plan.sample_at(1000L).active[0].ordinal, 2)
assert_true(plan.sample_at(2000L).finished)
assert_eq(
catalog.flow("AB").unwrap().flow.unwrap().condition,
Some("amount > 0"),
)
}
PlaybackPlan::illustrative visits selected authored connections independently
and repeats its period. It includes sequence messages and, by default, BPMN
sequence/message flows. Other BPMN connection kinds can be selected explicitly.
Alternative sections shown in this mode are separate illustrations, not one
asserted execution. Sampling wraps at the period; negative time starts at zero.
Finite scenarios clamp at their end. Conditions and event settings stay text.
No plan evaluates conditions, schedules timers or reports execution outcomes.
matches(catalog) compares the view identity and immutable source diagram.
A route supplied to another source produces animation/plan-mismatch, even if
local element IDs happen to match. The HTML renderer makes the same check before emitting output.
Sequence frames and choices
The catalog retains the complete frame tree, section labels and message paths.
PlaybackPlan::sequence requires explicit Selection values for encountered alt,
opt and loop frames. Choose one alt section, include or skip an opt frame, and
provide a finite loop count. Zero repetitions are valid. Unselected branches
remain part of the source model, but contribute no scheduled steps. Choices for
nested frames are required only when that branch is selected.
Par and par-over sections become concurrent branches with their own order. Other frame kinds retain grouping and authored order. Frame labels remain descriptions; the planner does not interpret break conditions or model runtime cancellation or locking.
///|
test "choose a bounded sequence loop" {
let message = @sequence.Event::Message(
@sequence.Message::new("A", "A", "retry"),
)
let sequence = @sequence.Sequence::new([@sequence.Participant::new("A")], [
Frame(
@sequence.Frame::new(
Loop,
@sequence.Section::new([message], label="retry condition"),
),
),
])
let catalog = @animation.Catalog::sequence(sequence, view_id="Retry").catalog.unwrap()
let selection = @animation.Selection::new(
catalog.frames[0].key,
Repetitions(2),
)
let plan = @animation.PlaybackPlan::sequence(catalog, selections=[selection]).plan.unwrap()
assert_eq(plan.steps.length(), 2)
assert_eq(plan.steps[0].occurrence.path, [0, 0, 0])
assert_eq(plan.steps[0].occurrence.key, plan.steps[1].occurrence.key)
assert_eq(plan.steps[1].ordinal, 1)
}
Structural queries
catalog.graph exposes immutable nodes and edges. route returns one directed
shortest route with authored edge order as the tie-breaker. reachable returns
breadth-first discovery including the start once. Both accept explicit kinds,
forward/reverse/both direction and limits. They distinguish invalid endpoints,
endpoints outside the selected view, exhausted bounds and unreachable targets.
BPMN defaults to sequence flows. Message flows are opt-in; associations and data associations never become control transitions. Graph endpoints match actual semantic source identities within their process instance. A pool, collapsed subprocess or activity used as a display anchor never becomes a replacement semantic endpoint. Hidden actual endpoints stay outside the graph, while a drawn connection can still be a motion occurrence. Diagram queries are tested against the standalone document graph, including imports, shared process instances, cycles and equal-length ties.
Sequence graph vertices are message occurrences and frame controls. They preserve
alternative/parallel branches and one authored structural loop iteration. Finite
repetitions come from scenario choices. A -> B -> A does not create a participant
cycle. PlaybackPlan::route can illustrate a selected structural route. Reverse
queries retain authored connection direction; they never reverse an arrow.
A structural path makes no claim that gateway conditions or synchronization permit
an execution.
Bounds and findings
Durations use Int64 milliseconds. Each step must be positive; the total must be
at most 9007199254740991, JavaScript's largest exact integer. Plans contain at
most 100,000 steps. Defaults also bound catalogs/queries to 10,000 nodes, 50,000
edges, 10,000 results, one million work units and depth 256. Work can exhaust
before a size bound; increase Limits explicitly when needed. Exhaustion returns
no partial catalog or plan. Empty finite scenarios finish immediately.
Planning diagnostics include animation/invalid-identity, ambiguous-endpoint,
invalid-duration, invalid-selection, selection-required, plan-mismatch,
plan-limit and no-flow, all under the animation/ prefix. BPMN scope validation
also retains bpmn/invalid-reference and bpmn/layout-limit. Query failures are
typed values rather than unreachable results.
BPMN visibility comes from moonrockz/diagrams/bpmn.Bpmn::scope, shared with SVG
geometry. Ownership, collapsed/black-box flags and attachments determine it;
category groups never hide their members. A boundary follows its attached
activity's visibility, while a boundary on a visible collapsed subprocess stays
visible outside that subprocess. The standalone XML module has no dependency on
this package or SVG.
Swimlane connections and traversal orientation
Catalog::swimlane retains typed node and connection sources without BPMN
substitutes. Lanes and visible groups are selectable endpoints, but containment
creates no graph arcs. Collapsed descendants report OutsideView; a visible
handoff to their display anchor can still be played independently.
Directed connections use SwimlaneFlow, included in default route/reachability
selection. Bidirectional connections provide both oriented arcs. Headless
connections require explicit Undirected route/reachability selection. The
illustrative plan can show every visible connection independently, including a
headless line, without asserting a directed route. Invisible layout constraints
provide no occurrence or arc. Route.reversed records whether each traversal
runs backward along the rendered connection, independently of global RL or
BT layout. Motion::OrientedStep(key,duration,reversed) retains this value in
scheduled steps and samples. Existing Motion::Step remains forward.
///|
test "play a bidirectional swimlane return" {
let view = @swimlane.Swimlane::new(
"Review",
[@swimlane.Node::new("A"), @swimlane.Node::new("B")],
[@swimlane.Connection::new("AB", "A", "B", source_marker=Arrow)],
)
let catalog = @animation.Catalog::swimlane(view).catalog.unwrap()
guard catalog.graph.route("B", "A") is Found(route) else {
fail("missing return")
}
assert_eq(route.reversed, [true])
let plan = @animation.PlaybackPlan::route(catalog, route).plan.unwrap()
assert_true(plan.sample_at(250L).active[0].reversed)
}
Railroad and data catalogs and node visits
Catalog::regex, Catalog::ebnf and Catalog::data_tree accept the new
models. A railroad catalog has one Visit(RailroadTerm(_)) occurrence for each
visible term and one Railroad(role) occurrence for each drawn connector, in the
drawing-walk order of diagrams/railroad-trace. Equal labels never merge
occurrences. A data catalog has a Visit(DataBox) for each container box and
scalar root, a Visit(DataRow) for each member row and a DataConnector only
where a nested container has a drawn connector.
Motion::Visit(key, duration) is a timed visit to a visible term, row or box. It
has no path. Scheduled steps and samples have a visit flag. A step that names a
visit target, or a visit that names a connection, gives
animation/invalid-target-kind. Connection steps and orientation keep their
meaning. Catalog::motion chooses the right motion for an occurrence key.
Railroad graphs use the drawn points as nodes and the connectors and term
traversals as EdgeKind::Railroad edges, so a route follows branch, bypass and
return connectors in their drawn direction. Catalog::reference_target names
the rule of an EBNF reference; references add no edge between rules. Data graphs
have only EdgeKind::Containment edges from parent to child. A hierarchy route
visits rows and boxes and follows a connector only where one is drawn. Equal
values and adjacent rows create no relation. Default illustrations are finite,
also for unbounded repeats and recursive rules, and they never compute a match
or a parse.
///|
test "visit an inline row halfway" {
let tree = @data_tree.DataTree::new("order", "root", [
@data_tree.Node::object("root", [@data_tree.Member::named("m0", "id", "v0")]),
@data_tree.Node::new("v0", Text("A-1")),
])
let catalog = @animation.Catalog::data_tree(tree).catalog.unwrap()
let row = @animation.data_key("order", "row", "m0")
let plan = @animation.PlaybackPlan::scenario(catalog, Visit(row, 1000L)).plan.unwrap()
let sample = plan.sample_at(500L)
assert_true(sample.active[0].visit)
assert_eq(sample.active[0].numerator, 500L)
let wrong = @animation.PlaybackPlan::scenario(catalog, Step(row, 1000L))
assert_eq(wrong.diagnostics[0].code, "animation/invalid-target-kind")
}
State structure and supplied scenarios
Catalog::state(view) records visible states, pseudostates, regions, notes and
transitions. States and notes have node-visit occurrences. Transition occurrences
retain their own typed transition, source order and identity, including parallel
transitions and self-loops. Regions, ownership, descriptions, notes and history
add no route edges. Collapsed descendants return OutsideView; an unknown ID
returns InvalidEndpoint. A disconnected valid endpoint returns Unreachable.
///|
test "repeat a state transition and visit an isolated state concurrently" {
let view = @state.StateDiagram::new(
"state-scenario",
[
@state.State::new("Ready", descriptions=["entry / show"]),
@state.State::new("Aside"),
],
[
@state.Transition::new(
"tick",
"Ready",
"Ready",
label="tick [ready] / log",
),
],
)
let catalog = @animation.Catalog::state(view).catalog.unwrap()
let tick = catalog.connection("tick").unwrap().key
let aside = catalog.graph.lookup("Aside").unwrap().occurrence.unwrap()
let motion = @animation.Motion::serial([
@animation.Motion::parallel([Step(tick, 500L), Visit(aside, 750L)]),
Step(tick, 250L),
])
let plan = @animation.PlaybackPlan::scenario(catalog, motion).plan.unwrap()
assert_eq(plan.duration_ms, 1000L)
assert_eq(plan.sample_at(250L).active.length(), 2)
assert_true(plan.sample_at(1000L).finished)
assert_eq(catalog.graph.route("Ready", "Aside"), Unreachable)
}
State routes use StateTransition edges by default. Unheaded relations need
kinds=[Undirected]. Reverse searches keep each transition's real path and mark
its traversal as reversed. A self-route can become a node visit. Explicit motion
retains repeat occurrences, durations and caller-chosen concurrent groups.
PlaybackPlan::illustrative uses each catalog occurrence once per finite period,
without unfolding cycles. Its repeating display does not claim a feasible state
execution. Guards, actions, internal behavior and history are inspectable text;
no API infers active states, evaluates a guard, schedules an event or restores
history. The shared offline player uses the scene's final connector paths and
canonical static SVG for download.