diff --git a/apps/api/src/cora/api/_status_push.py b/apps/api/src/cora/api/_status_push.py
index f573cb0a7df..953fbb91a85 100644
--- a/apps/api/src/cora/api/_status_push.py
+++ b/apps/api/src/cora/api/_status_push.py
@@ -200,6 +200,9 @@
from cora.infrastructure.projection import encode_cursor
from cora.infrastructure.record_export import render_value
from cora.infrastructure.routing import NIL_SENTINEL_ID, SYSTEM_PRINCIPAL_ID
+from cora.operation.errors import UnauthorizedError as _OperationUnauthorizedError
+from cora.operation.features.list_procedures import ListProcedures
+from cora.recipe.features.list_plans import ListPlans
from cora.run.errors import UnauthorizedError as _RunUnauthorizedError
from cora.run.features.get_run_history import GetRunHistory
from cora.run.features.list_runs import ListRuns
@@ -229,6 +232,10 @@
EventActivityRow,
EventActivityTrail,
)
+ from cora.operation.features.list_procedures.handler import (
+ Handler as ListProceduresHandler,
+ )
+ from cora.recipe.features.list_plans.handler import Handler as ListPlansHandler
from cora.run.features.get_run_history.handler import Handler as GetRunHistoryHandler
from cora.run.features.get_run_history.handler import RunHistoryView
from cora.run.features.list_runs.handler import Handler as ListRunsHandler
@@ -312,6 +319,7 @@
_DecisionUnauthorizedError,
_SafetyUnauthorizedError,
_EnclosureUnauthorizedError,
+ _OperationUnauthorizedError,
)
@@ -382,8 +390,36 @@ def _render_progress_trail(
}
+async def _plan_names(list_plans: ListPlansHandler, deps: Kernel) -> dict[UUID, str]:
+ """Every plan's name, by id.
+
+ A Plan is a TEMPLATE: it has no lifetime of its own during a shift, so it
+ is never a row on a live view. What a viewer needs is the other direction,
+ which run is executing which plan, and that is an attribute of the run.
+ Only the name travels; a `plan_id` alone would be an opaque uuid the page
+ could only print back.
+
+ Drained whole rather than looked up per run. The set is small, static
+ across a shift, and `ListPlans` has no id filter, so N lookups for a
+ handful of open runs would be more queries for the same rows.
+ """
+ items = await _drain_all(
+ lambda cursor: list_plans(
+ ListPlans(cursor=cursor, limit=_PAGE_LIMIT),
+ principal_id=SYSTEM_PRINCIPAL_ID,
+ correlation_id=deps.id_generator.new_id(),
+ surface_id=NIL_SENTINEL_ID,
+ )
+ )
+ return {item.plan_id: item.name for item in items}
+
+
async def _drain_open_runs(
- list_runs: ListRunsHandler, deps: Kernel, *, witness_recorder: RunWitnessRecorder | None
+ list_runs: ListRunsHandler,
+ deps: Kernel,
+ *,
+ witness_recorder: RunWitnessRecorder | None,
+ plan_names: dict[UUID, str],
) -> tuple[list[dict[str, Any]], list[UUID]]:
"""Returns the rendered (JSON-safe) rows AND the raw run_id UUIDs.
@@ -409,6 +445,23 @@ async def _drain_open_runs(
"run_id": render_value(item.run_id),
"name": item.name,
"status": item.status,
+ # Structure, not decoration. `campaign_id` is what a Run
+ # belongs to and `subject_id` is what it is measuring, both
+ # already on the projection (they back `list_runs`'s own
+ # `?campaign_id=` filter); a consumer without them can show
+ # a Run but cannot place it among the others. `started_at`
+ # is `running_since` where the Run has actually started and
+ # `created_at` otherwise, so a span always has a left edge.
+ "campaign_id": render_value(item.campaign_id),
+ "subject_id": render_value(item.subject_id),
+ # The template this run is an instance OF, by name. A plan
+ # has no lifetime during a shift and so never earns a row
+ # of its own; naming it here is how the template layer
+ # becomes visible at all. Absent when the plan is not in
+ # the projection, which reads as unknown rather than as
+ # a run with no plan.
+ "plan_name": plan_names.get(item.plan_id),
+ "started_at": render_value(item.running_since or item.created_at),
"progress": _render_progress(item.run_id, witness_recorder),
"progress_trail": _render_progress_trail(item.run_id, witness_recorder),
}
@@ -431,7 +484,12 @@ async def _drain_open_subjects(
)
)
rows.extend(
- {"subject_id": render_value(item.subject_id), "name": item.name, "status": item.status}
+ {
+ "subject_id": render_value(item.subject_id),
+ "name": item.name,
+ "status": item.status,
+ "created_at": render_value(item.created_at),
+ }
for item in items
)
return rows
@@ -485,7 +543,61 @@ async def _drain_datasets_for_runs(
"dataset_id": render_value(item.dataset_id),
"name": item.name,
"status": item.status,
+ # Both ends of what a dataset came from. `producing_run_id`
+ # was already here; `subject_id` is the same class of fact and
+ # answers the question the run id cannot, which is what was
+ # being measured when a dataset that no longer has an open run
+ # was written.
"producing_run_id": render_value(item.producing_run_id),
+ "subject_id": render_value(item.subject_id),
+ }
+ for item in items
+ )
+ return rows
+
+
+async def _drain_procedures_for_runs(
+ list_procedures: ListProceduresHandler, deps: Kernel, *, run_ids: list[UUID]
+) -> list[dict[str, Any]]:
+ """Procedures belonging to an on-screen Run, one query per run_id.
+
+ Bounded by the open-run count exactly as `_drain_datasets_for_runs` is,
+ and for the same reason: what a live page needs is the phases of the runs
+ on screen, never every Procedure the facility has ever registered.
+
+ This is the third level of the containment tree -- campaign holds runs
+ hold procedures -- and it is the level a consumer cannot reconstruct from
+ the activity stream alone. That stream carries a Procedure's `stream_id`
+ and so can tell one Procedure from another, but nothing in it says which
+ Run a Procedure is a phase OF: `parent_run_id` lives on the projection and
+ only here.
+
+ `last_status_reason` is deliberately NOT sent. It is operator free text
+ (see `cora.shared.text_bounds`), which is exactly the shape that carries
+ incidental personal data, and nothing on a status page needs it. `kind`
+ is a deployment-declared discriminator and `name` is the same class of
+ value as the Run `name` already on this payload.
+ """
+ rows: list[dict[str, Any]] = []
+ for run_id in run_ids:
+ items = await _drain_all(
+ lambda cursor, run_id=run_id: list_procedures(
+ ListProcedures(parent_run_id=run_id, cursor=cursor, limit=_PAGE_LIMIT),
+ principal_id=SYSTEM_PRINCIPAL_ID,
+ correlation_id=deps.id_generator.new_id(),
+ surface_id=NIL_SENTINEL_ID,
+ )
+ )
+ rows.extend(
+ {
+ "procedure_id": render_value(item.procedure_id),
+ "name": item.name,
+ "kind": item.kind,
+ "parent_run_id": render_value(item.parent_run_id),
+ "status": item.status,
+ "registered_at": render_value(item.registered_at),
+ "last_status_changed_at": render_value(item.last_status_changed_at),
+ "iteration_count": item.iteration_count,
}
for item in items
)
@@ -508,7 +620,32 @@ async def _drain_active_clearances(
"clearance_id": render_value(item.clearance_id),
"template_code": item.template_code,
"risk_band": item.risk_band,
+ "status": item.status,
+ # A clearance is cover over a RANGE, so the viewer needs both ends
+ # of it, not just when it runs out. `registered_at` is the
+ # fallback the page draws from when cover was granted without an
+ # explicit start.
+ #
+ # `title` and `last_status_reason` stay off the wire. The title is
+ # operator-authored and the reason is free text written at the
+ # moment of an incident, which is the shape that carries
+ # incidental personal data; `template_code` names the clearance
+ # without either.
+ "valid_from": render_value(item.valid_from),
"valid_until": render_value(item.valid_until),
+ "registered_at": render_value(item.registered_at),
+ # WHAT the cover covers. A clearance drawn as a bar over a range
+ # says only that cover existed; these say whether it reaches the
+ # run on screen, which is the question anyone looking at a
+ # clearance on a live page is actually asking. Opaque ids of
+ # entities the same payload already names.
+ #
+ # `asset_binding_ids` is left off: nothing on this page draws an
+ # asset, so it would be a field on the wire that no consumer can
+ # resolve to anything.
+ "run_binding_ids": [render_value(i) for i in item.run_binding_ids],
+ "procedure_binding_ids": [render_value(i) for i in item.procedure_binding_ids],
+ "subject_binding_ids": [render_value(i) for i in item.subject_binding_ids],
}
for item in items
]
@@ -1075,6 +1212,7 @@ def build_snapshot(
subjects: list[dict[str, Any]],
campaigns: list[dict[str, Any]],
datasets: list[dict[str, Any]],
+ procedures: list[dict[str, Any]],
clearances: list[dict[str, Any]],
enclosures: list[dict[str, Any]],
decisions: list[dict[str, Any]],
@@ -1094,6 +1232,7 @@ def build_snapshot(
"subjects": subjects,
"campaigns": campaigns,
"datasets": datasets,
+ "procedures": procedures,
"clearances": clearances,
"enclosures": enclosures,
"decisions": decisions,
@@ -1282,8 +1421,17 @@ def build_activity_message(
"""Assemble one activity push -- flowing mode's entire feed. The
browser accumulates these into its own rolling window; this producer
holds no window of its own, only the tail cursor. Event metadata only
- (`stream_type`, `stream_id`, `event_type`, timestamps): never
- `event.payload`, see `EventActivityTrail`'s own module docstring.
+ (`stream_type`, `stream_id`, `event_type`, timestamps, and the three
+ relationship fields): never `event.payload`, see `EventActivityTrail`'s
+ own module docstring for why those three are not a breach of that rule.
+
+ `schema_version` stays 1. Adding keys is additive by the repo's own
+ versioning stance, and the relay and the producer deploy to different
+ hosts, so a page served by an older relay must keep working against a
+ newer producer and vice versa. Every consumer treats `correlation_id`,
+ `causation_id` and `cause_occurred_at` as absent-by-default rather than
+ required, which is also what makes this safe to roll out to the live
+ 2-BM page one host at a time.
Unlike `build_snapshot`, never sent when `rows` is empty: there is no
heartbeat need here, since "no message this tick" already means
@@ -1296,11 +1444,21 @@ def build_activity_message(
"generated_at": generated_at,
"events": [
{
+ "event_id": render_value(row.event_id),
"stream_type": row.stream_type,
"stream_id": render_value(row.stream_id),
"event_type": row.event_type,
"occurred_at": render_value(row.occurred_at),
"recorded_at": render_value(row.recorded_at),
+ "correlation_id": render_value(row.correlation_id),
+ "causation_id": (
+ render_value(row.causation_id) if row.causation_id is not None else None
+ ),
+ "cause_occurred_at": (
+ render_value(row.cause_occurred_at)
+ if row.cause_occurred_at is not None
+ else None
+ ),
}
for row in rows
],
@@ -1314,7 +1472,9 @@ async def _build_payload_fields(
list_subjects: ListSubjectsHandler,
list_campaigns: ListCampaignsHandler,
list_datasets: ListDatasetsHandler,
+ list_procedures: ListProceduresHandler,
list_clearances: ListClearancesHandler,
+ list_plans: ListPlansHandler,
list_enclosures: ListEnclosuresHandler,
decision_tail: _DecisionTail,
list_decisions: ListDecisionsHandler,
@@ -1340,13 +1500,19 @@ async def _build_payload_fields(
`_RunHistoryTail`'s, `_EnclosureTimelineTail`'s, and `_ActivityTail`'s
module docstrings), so none of them may enter `_content_hash`'s
change-detection input."""
- runs, raw_run_ids = await _drain_open_runs(list_runs, deps, witness_recorder=witness_recorder)
+ runs, raw_run_ids = await _drain_open_runs(
+ list_runs,
+ deps,
+ witness_recorder=witness_recorder,
+ plan_names=await _plan_names(list_plans, deps),
+ )
enclosures, raw_enclosure_ids = await _drain_active_enclosures(list_enclosures, deps)
fields = {
"runs": runs,
"subjects": await _drain_open_subjects(list_subjects, deps),
"campaigns": await _drain_open_campaigns(list_campaigns, deps),
"datasets": await _drain_datasets_for_runs(list_datasets, deps, run_ids=raw_run_ids),
+ "procedures": await _drain_procedures_for_runs(list_procedures, deps, run_ids=raw_run_ids),
"clearances": await _drain_active_clearances(list_clearances, deps),
"enclosures": enclosures,
"decisions": await decision_tail.poll(list_decisions, deps),
@@ -1384,7 +1550,9 @@ async def _push_loop(
list_subjects: ListSubjectsHandler,
list_campaigns: ListCampaignsHandler,
list_datasets: ListDatasetsHandler,
+ list_procedures: ListProceduresHandler,
list_clearances: ListClearancesHandler,
+ list_plans: ListPlansHandler,
list_enclosures: ListEnclosuresHandler,
list_decisions: ListDecisionsHandler,
get_run_history: GetRunHistoryHandler,
@@ -1456,7 +1624,9 @@ async def _push_loop(
list_subjects=list_subjects,
list_campaigns=list_campaigns,
list_datasets=list_datasets,
+ list_procedures=list_procedures,
list_clearances=list_clearances,
+ list_plans=list_plans,
list_enclosures=list_enclosures,
decision_tail=decision_tail,
list_decisions=list_decisions,
@@ -1531,7 +1701,9 @@ async def status_push_lifespan(
list_subjects: ListSubjectsHandler,
list_campaigns: ListCampaignsHandler,
list_datasets: ListDatasetsHandler,
+ list_procedures: ListProceduresHandler,
list_clearances: ListClearancesHandler,
+ list_plans: ListPlansHandler,
list_enclosures: ListEnclosuresHandler,
list_decisions: ListDecisionsHandler,
get_run_history: GetRunHistoryHandler,
@@ -1594,7 +1766,9 @@ async def status_push_lifespan(
list_subjects=list_subjects,
list_campaigns=list_campaigns,
list_datasets=list_datasets,
+ list_procedures=list_procedures,
list_clearances=list_clearances,
+ list_plans=list_plans,
list_enclosures=list_enclosures,
list_decisions=list_decisions,
get_run_history=get_run_history,
diff --git a/apps/api/src/cora/api/main.py b/apps/api/src/cora/api/main.py
index 5bb464187ff..b45909e5226 100644
--- a/apps/api/src/cora/api/main.py
+++ b/apps/api/src/cora/api/main.py
@@ -1507,7 +1507,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
list_subjects=app.state.subject.list_subjects,
list_campaigns=app.state.campaign.list_campaigns,
list_datasets=app.state.data.list_datasets,
+ list_procedures=app.state.operation.list_procedures,
list_clearances=app.state.safety.list_clearances,
+ list_plans=app.state.recipe.list_plans,
list_enclosures=app.state.enclosure.list_enclosures,
list_decisions=app.state.decision.list_decisions,
get_run_history=app.state.run.get_run_history,
diff --git a/apps/api/src/cora/infrastructure/adapters/in_memory_event_activity_trail.py b/apps/api/src/cora/infrastructure/adapters/in_memory_event_activity_trail.py
index ad893dd169f..60fe860be12 100644
--- a/apps/api/src/cora/infrastructure/adapters/in_memory_event_activity_trail.py
+++ b/apps/api/src/cora/infrastructure/adapters/in_memory_event_activity_trail.py
@@ -32,19 +32,34 @@ async def read_since(
self, *, cursor: EventActivityCursor, limit: int
) -> tuple[list[EventActivityRow], EventActivityCursor]:
cursor_key = _cursor_key(cursor)
+ all_events = self._store.all_events()
newer = sorted(
- (e for e in self._store.all_events() if (e.transaction_id, e.position) > cursor_key),
+ (e for e in all_events if (e.transaction_id, e.position) > cursor_key),
key=lambda e: (e.transaction_id, e.position),
)[:limit]
if not newer:
return [], cursor
+ # Stands in for the Postgres adapter's LEFT JOIN on `event_id`. Built
+ # over every event in the store, not just the page being returned: a
+ # cause is usually older than the page that carries its effect, so
+ # resolving against `newer` alone would report almost every cause as
+ # unresolvable and the two adapters would disagree.
+ occurred_by_event_id = {e.event_id: e.occurred_at for e in all_events}
rows = [
EventActivityRow(
+ event_id=event.event_id,
stream_type=event.stream_type,
stream_id=event.stream_id,
event_type=event.event_type,
occurred_at=event.occurred_at,
recorded_at=event.recorded_at,
+ correlation_id=event.correlation_id,
+ causation_id=event.causation_id,
+ cause_occurred_at=(
+ occurred_by_event_id.get(event.causation_id)
+ if event.causation_id is not None
+ else None
+ ),
)
for event in newer
]
diff --git a/apps/api/src/cora/infrastructure/adapters/postgres_event_activity_trail.py b/apps/api/src/cora/infrastructure/adapters/postgres_event_activity_trail.py
index 28e2736e558..7c7efa631ef 100644
--- a/apps/api/src/cora/infrastructure/adapters/postgres_event_activity_trail.py
+++ b/apps/api/src/cora/infrastructure/adapters/postgres_event_activity_trail.py
@@ -24,14 +24,21 @@
"""
_READ_SINCE_SQL = """
-SELECT stream_type, stream_id, event_type, occurred_at, recorded_at,
- transaction_id::text AS transaction_id_text, position
-FROM events
-WHERE (transaction_id, position) > ($1::xid8, $2)
- AND transaction_id < pg_snapshot_xmin(pg_current_snapshot())
-ORDER BY transaction_id ASC, position ASC
+SELECT e.event_id, e.stream_type, e.stream_id, e.event_type, e.occurred_at, e.recorded_at,
+ e.correlation_id, e.causation_id, cause.occurred_at AS cause_occurred_at,
+ e.transaction_id::text AS transaction_id_text, e.position
+FROM events e
+LEFT JOIN events cause ON cause.event_id = e.causation_id
+WHERE (e.transaction_id, e.position) > ($1::xid8, $2)
+ AND e.transaction_id < pg_snapshot_xmin(pg_current_snapshot())
+ORDER BY e.transaction_id ASC, e.position ASC
LIMIT $3
"""
+"""The LEFT JOIN rides `events_event_id_unique`, so resolving a cause is one
+index lookup per row and at most `limit` of them. It is LEFT rather than
+INNER because a null `causation_id` is the common case, not an anomaly: every
+operator-originated command arrives over REST with no cause, and an INNER
+join would silently drop exactly those rows."""
# xid8 0 is Postgres's own invalid-transaction-id sentinel: never assigned
# to a real transaction, so it compares strictly less than any row that
@@ -65,11 +72,15 @@ async def read_since(
return [], cursor
activity = [
EventActivityRow(
+ event_id=row["event_id"],
stream_type=row["stream_type"],
stream_id=row["stream_id"],
event_type=row["event_type"],
occurred_at=row["occurred_at"],
recorded_at=row["recorded_at"],
+ correlation_id=row["correlation_id"],
+ causation_id=row["causation_id"],
+ cause_occurred_at=row["cause_occurred_at"],
)
for row in rows
]
diff --git a/apps/api/src/cora/infrastructure/ports/event_activity_trail.py b/apps/api/src/cora/infrastructure/ports/event_activity_trail.py
index e04516c89a3..26dd1edd959 100644
--- a/apps/api/src/cora/infrastructure/ports/event_activity_trail.py
+++ b/apps/api/src/cora/infrastructure/ports/event_activity_trail.py
@@ -9,13 +9,22 @@
promoting a single-consumer port to the shared kernel would be the reverse
of the rule-of-three this codebase applies to new cross-cutting primitives.
-Ships `stream_type`, `stream_id`, `event_type`, `occurred_at`, `recorded_at`
-only. NEVER `payload`. `test_run_events_carry_no_pii.py` (and its Access-BC
-sibling) are the only two fitness tests that guard event field names against
-personal data, and they cover exactly two of the twenty-five stream types
-this port's data spans; shipping raw payloads across every BC would carry
-that guarantee somewhere it does not hold. A lane needs to know THAT
-something happened and WHAT KIND, never the values inside it.
+Ships `event_id`, `stream_type`, `stream_id`, `event_type`, `occurred_at`,
+`recorded_at`, `correlation_id`, `causation_id` and `cause_occurred_at` only.
+NEVER `payload`. `test_run_events_carry_no_pii.py` (and its Access-BC sibling) are
+the only two fitness tests that guard event field names against personal
+data, and they cover exactly two of the twenty-five stream types this port's
+data spans; shipping raw payloads across every BC would carry that guarantee
+somewhere it does not hold. A lane needs to know THAT something happened and
+WHAT KIND, never the values inside it.
+
+The three relationship columns do not weaken that. They are opaque
+identifiers and one timestamp drawn from the envelope, never from
+`payload`, so no BC's field names ride out on them and the guarantee the two
+fitness tests actually make is unchanged. They answer "which events belong to
+one operator action" and "which event caused this one", both of which are
+structure, not content. Anything requiring a VALUE from inside an event still
+has to come from a domain-specific read, not from here.
Cursor discipline mirrors `cora.infrastructure.ports.event_store`'s own
documented rule: `position` alone is unsafe (sequences advance on rollback,
@@ -51,11 +60,25 @@ class EventActivityCursor:
@dataclass(frozen=True)
class EventActivityRow:
+ event_id: UUID
+ """This event's own identity, and the thing a `causation_id` points AT.
+ Without it a consumer holds a cause it can never resolve: it can tell that
+ an event was caused, but not by which of the events it already has."""
+
stream_type: str
stream_id: UUID
event_type: str
occurred_at: datetime
recorded_at: datetime
+ correlation_id: UUID
+ causation_id: UUID | None
+ cause_occurred_at: datetime | None
+ """When the causing event happened, resolved by the query rather than left
+ to the reader. A consumer holding a bounded window cannot resolve a
+ `causation_id` older than that window, and without this it can only say
+ "no cause" for an event that certainly had one. Carrying the time turns an
+ unresolvable parent into "caused by something at 14:31:30, before this
+ window" instead of silence. `None` only when `causation_id` is."""
class EventActivityTrail(Protocol):
diff --git a/apps/api/tests/integration/test_event_activity_trail_postgres.py b/apps/api/tests/integration/test_event_activity_trail_postgres.py
index 094fd5551ac..0eddf8d3308 100644
--- a/apps/api/tests/integration/test_event_activity_trail_postgres.py
+++ b/apps/api/tests/integration/test_event_activity_trail_postgres.py
@@ -33,6 +33,10 @@ async def _write_event(
stream_type: str = "Run",
stream_id: UUID | None = None,
event_type: str = "RunStarted",
+ event_id: UUID | None = None,
+ correlation_id: UUID | None = None,
+ causation_id: UUID | None = None,
+ occurred_at: datetime = _NOW,
) -> UUID:
stream_id = stream_id or uuid4()
await store.append(
@@ -41,12 +45,13 @@ async def _write_event(
0,
[
NewEvent(
- event_id=uuid4(),
+ event_id=event_id or uuid4(),
event_type=event_type,
schema_version=1,
payload={"irrelevant": "never read back by this trail"},
- occurred_at=_NOW,
- correlation_id=uuid4(),
+ occurred_at=occurred_at,
+ correlation_id=correlation_id or uuid4(),
+ causation_id=causation_id,
principal_id=None,
)
],
@@ -125,6 +130,59 @@ async def test_read_since_advances_the_cursor_so_a_second_call_sees_nothing_new(
assert second_rows == []
+@pytest.mark.integration
+async def test_read_since_carries_correlation_and_a_null_cause_for_an_operator_command(
+ db_pool: asyncpg.Pool,
+) -> None:
+ """A command arriving over REST has no `causation_id`. That is the common
+ case, not an anomaly, so the LEFT JOIN must return the row rather than
+ filter it out the way an INNER join would."""
+ store = PostgresEventStore(db_pool)
+ trail = PostgresEventActivityTrail(db_pool)
+ cursor = await trail.head()
+ correlation_id = uuid4()
+
+ await _write_event(store, correlation_id=correlation_id, causation_id=None)
+ rows, _cursor = await trail.read_since(cursor=cursor, limit=10)
+
+ assert len(rows) == 1
+ assert rows[0].correlation_id == correlation_id
+ assert rows[0].causation_id is None
+ assert rows[0].cause_occurred_at is None
+
+
+@pytest.mark.integration
+async def test_read_since_resolves_the_cause_s_time_even_when_the_cause_is_not_in_the_page(
+ db_pool: asyncpg.Pool,
+) -> None:
+ """The join resolves against the whole table, not the page being returned.
+ A cause is almost always older than the effect that cites it, so a lookup
+ limited to the current page would report nearly every cause unresolvable
+ and hand the browser a window it cannot distinguish from "uncaused"."""
+ store = PostgresEventStore(db_pool)
+ trail = PostgresEventActivityTrail(db_pool)
+
+ cause_event_id = uuid4()
+ cause_at = datetime(2026, 6, 21, 11, 20, 0, tzinfo=UTC)
+ await _write_event(store, event_id=cause_event_id, occurred_at=cause_at)
+
+ # Baseline AFTER the cause, so the cause is deliberately outside the page.
+ cursor = await trail.head()
+ await _write_event(
+ store,
+ stream_type="Caution",
+ event_type="CautionRegistered",
+ causation_id=cause_event_id,
+ )
+
+ rows, _cursor = await trail.read_since(cursor=cursor, limit=10)
+
+ assert len(rows) == 1
+ assert rows[0].event_type == "CautionRegistered"
+ assert rows[0].causation_id == cause_event_id
+ assert rows[0].cause_occurred_at == cause_at
+
+
@pytest.mark.integration
async def test_read_since_limit_truncates_and_the_cursor_still_advances(
db_pool: asyncpg.Pool,
diff --git a/apps/api/tests/unit/api/test_status_push.py b/apps/api/tests/unit/api/test_status_push.py
index 4593f7fbcbb..92259b820f1 100644
--- a/apps/api/tests/unit/api/test_status_push.py
+++ b/apps/api/tests/unit/api/test_status_push.py
@@ -70,6 +70,15 @@
from cora.infrastructure.ports.event_store import NewEvent
from cora.infrastructure.projection import decode_cursor, encode_cursor
from cora.infrastructure.routing import NIL_SENTINEL_ID
+from cora.operation.features.list_procedures import ListProcedures
+from cora.operation.features.list_procedures.handler import (
+ ProcedureListPage,
+ ProcedureSummaryItem,
+)
+from cora.recipe.features.list_plans import (
+ ListPlans,
+)
+from cora.recipe.features.list_plans.handler import PlanListPage, PlanSummaryItem
from cora.run.errors import UnauthorizedError as RunUnauthorizedError
from cora.run.features.get_run_history import GetRunHistory
from cora.run.features.get_run_history.handler import RunHistoryEvent, RunHistoryView
@@ -96,6 +105,7 @@ def test_build_snapshot_shape() -> None:
subjects=[],
campaigns=[],
datasets=[],
+ procedures=[],
clearances=[],
enclosures=[],
decisions=[],
@@ -113,6 +123,7 @@ def test_build_snapshot_shape() -> None:
"subjects": [],
"campaigns": [],
"datasets": [],
+ "procedures": [],
"clearances": [],
"enclosures": [],
"decisions": [],
@@ -367,6 +378,37 @@ async def list_datasets(
return list_datasets
+def _make_list_procedures(items: list[ProcedureSummaryItem]):
+ async def list_procedures(
+ query: ListProcedures,
+ *,
+ principal_id: UUID,
+ correlation_id: UUID,
+ surface_id: UUID = NIL_SENTINEL_ID,
+ ) -> ProcedureListPage:
+ matching = [
+ i
+ for i in items
+ if query.parent_run_id is None or i.parent_run_id == query.parent_run_id
+ ]
+ return ProcedureListPage(items=matching, next_cursor=None)
+
+ return list_procedures
+
+
+def _make_list_plans(items: list[PlanSummaryItem]):
+ async def list_plans(
+ query: ListPlans,
+ *,
+ principal_id: UUID,
+ correlation_id: UUID,
+ surface_id: UUID,
+ ) -> PlanListPage:
+ return PlanListPage(items=list(items), next_cursor=None)
+
+ return list_plans
+
+
def _make_list_clearances(items: list[ClearanceSummaryItem]):
async def list_clearances(
query: ListClearances,
@@ -460,7 +502,9 @@ def _default_handlers(**overrides: Any) -> dict[str, Any]:
"list_subjects": _make_list_subjects([]),
"list_campaigns": _make_list_campaigns([]),
"list_datasets": _make_list_datasets([]),
+ "list_procedures": _make_list_procedures([]),
"list_clearances": _make_list_clearances([]),
+ "list_plans": _make_list_plans([]),
"list_enclosures": _make_list_enclosures([]),
"list_decisions": _make_list_decisions([]),
"get_run_history": _make_get_run_history(),
@@ -1395,14 +1439,20 @@ async def test_run_history_tail_on_reconnect_repushes_a_still_open_run_promptly(
@pytest.mark.unit
-def test_build_activity_message_shape() -> None:
+def test_build_activity_message_shape_for_an_operator_originated_event() -> None:
stream_id = uuid4()
+ correlation_id = uuid4()
+ event_id = uuid4()
row = EventActivityRow(
+ event_id=event_id,
stream_type="Run",
stream_id=stream_id,
event_type="RunStarted",
occurred_at=_NOW,
recorded_at=_NOW,
+ correlation_id=correlation_id,
+ causation_id=None,
+ cause_occurred_at=None,
)
message = build_activity_message(rows=[row], generated_at="t0", producer_id="p1")
@@ -1414,16 +1464,86 @@ def test_build_activity_message_shape() -> None:
"generated_at": "t0",
"events": [
{
+ "event_id": str(event_id),
"stream_type": "Run",
"stream_id": str(stream_id),
"event_type": "RunStarted",
"occurred_at": _NOW.isoformat(),
"recorded_at": _NOW.isoformat(),
+ "correlation_id": str(correlation_id),
+ "causation_id": None,
+ "cause_occurred_at": None,
}
],
}
+@pytest.mark.unit
+def test_build_activity_message_lets_a_receiver_match_a_cause_to_the_event_that_caused_it() -> None:
+ """A `causation_id` names an event's `event_id`. Shipping the first without
+ the second hands a receiver a pointer with nothing to point at: it can tell
+ an event was caused, but not by which of the events it already holds. This
+ asserts the two are resolvable against each other, which the shape test
+ above cannot, since it only ever looks at one row."""
+ cause = EventActivityRow(
+ event_id=uuid4(),
+ stream_type="Enclosure",
+ stream_id=uuid4(),
+ event_type="EnclosurePermitObserved",
+ occurred_at=_NOW - timedelta(seconds=2),
+ recorded_at=_NOW,
+ correlation_id=uuid4(),
+ causation_id=None,
+ cause_occurred_at=None,
+ )
+ effect = EventActivityRow(
+ event_id=uuid4(),
+ stream_type="Run",
+ stream_id=uuid4(),
+ event_type="RunAborted",
+ occurred_at=_NOW,
+ recorded_at=_NOW,
+ correlation_id=cause.correlation_id,
+ causation_id=cause.event_id,
+ cause_occurred_at=cause.occurred_at,
+ )
+
+ events = build_activity_message(rows=[cause, effect], generated_at="t0", producer_id="p1")[
+ "events"
+ ]
+
+ by_id = {e["event_id"]: e for e in events}
+ caused = next(e for e in events if e["event_type"] == "RunAborted")
+ assert by_id[caused["causation_id"]]["event_type"] == "EnclosurePermitObserved"
+
+
+@pytest.mark.unit
+def test_build_activity_message_carries_a_reacted_event_s_cause_and_its_time() -> None:
+ """A subscriber reacting to an event sets `causation_id`, and the cause is
+ usually older than the receiver's own window. Both the id and the cause's
+ time have to ride out, or a viewer holding fifteen minutes cannot tell an
+ event whose cause scrolled away from one that never had a cause at all."""
+ causation_id = uuid4()
+ cause_at = _NOW - timedelta(minutes=40)
+ row = EventActivityRow(
+ event_id=uuid4(),
+ stream_type="Caution",
+ stream_id=uuid4(),
+ event_type="CautionRegistered",
+ occurred_at=_NOW,
+ recorded_at=_NOW,
+ correlation_id=uuid4(),
+ causation_id=causation_id,
+ cause_occurred_at=cause_at,
+ )
+
+ message = build_activity_message(rows=[row], generated_at="t0", producer_id="p1")
+
+ event = message["events"][0]
+ assert event["causation_id"] == str(causation_id)
+ assert event["cause_occurred_at"] == cause_at.isoformat()
+
+
# ---------- _ActivityTail ----------
@@ -1519,20 +1639,50 @@ async def test_activity_tail_never_ships_the_event_payload() -> None:
# ---------- real socket: push against a local WebSocket server ----------
+def _procedure_item(
+ procedure_id: UUID,
+ *,
+ parent_run_id: UUID | None,
+ name: str = "center_alignment",
+ kind: str = "alignment",
+) -> ProcedureSummaryItem:
+ return ProcedureSummaryItem(
+ procedure_id=procedure_id,
+ name=name,
+ kind=kind,
+ target_asset_ids=[],
+ parent_run_id=parent_run_id,
+ status="Running",
+ activity_logbook_id=None,
+ registered_at=_NOW,
+ last_status_changed_at=None,
+ last_status_reason="operator said something private",
+ interrupted_at=None,
+ iteration_count=3,
+ )
+
+
def _run_item(
- run_id: UUID, *, name: str = "smoke-run", status: RunStatusFilter = "Running"
+ run_id: UUID,
+ *,
+ name: str = "smoke-run",
+ status: RunStatusFilter = "Running",
+ campaign_id: UUID | None = None,
+ subject_id: UUID | None = None,
+ running_since: datetime | None = _NOW,
+ plan_id: UUID | None = None,
) -> RunSummaryItem:
return RunSummaryItem(
run_id=run_id,
name=name,
- plan_id=uuid4(),
- subject_id=None,
+ plan_id=plan_id or uuid4(),
+ subject_id=subject_id,
raid=None,
status=status,
created_at=_NOW,
- running_since=_NOW,
+ running_since=running_since,
override_parameters_present=False,
- campaign_id=None,
+ campaign_id=campaign_id,
snr_limit=None,
expected_observation_interval_seconds=None,
conduct_mode="Witnessed",
@@ -1573,6 +1723,12 @@ async def handler(ws: ServerConnection) -> None:
"run_id": str(run_id),
"name": "smoke-run",
"status": "Running",
+ "campaign_id": None,
+ "subject_id": None,
+ # No plan in the projection for this run, which is unknown
+ # rather than "this run has no plan": every run has one.
+ "plan_name": None,
+ "started_at": _NOW.isoformat(),
"progress": {},
"progress_trail": {},
}
@@ -1780,6 +1936,219 @@ async def handler(ws: ServerConnection) -> None:
assert [d["name"] for d in snapshot["datasets"]] == ["onscreen-ds"]
+async def _first_snapshot(**handlers: Any) -> dict[str, Any]:
+ """Boot the push loop against a throwaway relay and return the first
+ snapshot it sends, so a test asserting on payload SHAPE does not have to
+ restate the socket plumbing."""
+ received: asyncio.Queue[str] = asyncio.Queue()
+
+ async def handler(ws: ServerConnection) -> None:
+ async for message in ws:
+ await received.put(message if isinstance(message, str) else message.decode())
+
+ async with serve(handler, "127.0.0.1", 0) as server:
+ port = next(iter(server.sockets)).getsockname()[1]
+ kernel = _kernel(
+ status_push_enabled=True,
+ status_push_url=f"ws://127.0.0.1:{port}/ingest",
+ status_push_tick_seconds=0.1,
+ )
+ async with status_push_lifespan(kernel, **_default_handlers(**handlers)):
+ raw = await asyncio.wait_for(received.get(), timeout=5)
+ result: dict[str, Any] = json.loads(raw)
+ return result
+
+
+@pytest.mark.unit
+async def test_run_rows_carry_what_places_a_run_among_the_others() -> None:
+ """A Run row without `campaign_id` and `subject_id` can be listed but not
+ PLACED: nothing else on the wire says what it belongs to or what it is
+ measuring. Both are already on the projection; this asserts they reach
+ the payload."""
+ run_id, campaign_id, subject_id = uuid4(), uuid4(), uuid4()
+ snapshot = await _first_snapshot(
+ list_runs=_make_list_runs(
+ [_run_item(run_id, campaign_id=campaign_id, subject_id=subject_id)]
+ )
+ )
+ (row,) = snapshot["runs"]
+ assert row["campaign_id"] == str(campaign_id)
+ assert row["subject_id"] == str(subject_id)
+ assert row["started_at"] == _NOW.isoformat()
+
+
+@pytest.mark.unit
+async def test_run_row_membership_is_null_for_a_standalone_run() -> None:
+ """The absence has to arrive as an explicit null rather than a missing
+ key: a consumer that saw no `campaign_id` could not tell "standalone"
+ from "this producer is too old to send it"."""
+ snapshot = await _first_snapshot(list_runs=_make_list_runs([_run_item(uuid4())]))
+ (row,) = snapshot["runs"]
+ assert row["campaign_id"] is None
+ assert row["subject_id"] is None
+
+
+@pytest.mark.unit
+async def test_run_started_at_falls_back_to_created_at_before_it_runs() -> None:
+ """A Held-from-genesis Run has no `running_since`. A span still needs a
+ left edge, and a missing one would draw as a track starting at the window
+ edge, which reads as "started when you opened the page"."""
+ snapshot = await _first_snapshot(
+ list_runs=_make_list_runs([_run_item(uuid4(), status="Held", running_since=None)])
+ )
+ (row,) = snapshot["runs"]
+ assert row["started_at"] == _NOW.isoformat()
+
+
+@pytest.mark.unit
+async def test_procedures_are_pushed_for_onscreen_runs_only() -> None:
+ """Bounded by the open-run count, exactly as datasets are: a live page
+ needs the phases of the runs on screen, never every Procedure the
+ facility has registered."""
+ onscreen, offscreen = uuid4(), uuid4()
+ mine, theirs = uuid4(), uuid4()
+ snapshot = await _first_snapshot(
+ list_runs=_make_list_runs([_run_item(onscreen)]),
+ list_procedures=_make_list_procedures(
+ [
+ _procedure_item(mine, parent_run_id=onscreen, name="center_alignment"),
+ _procedure_item(theirs, parent_run_id=offscreen, name="dark_field"),
+ ]
+ ),
+ )
+ assert [p["name"] for p in snapshot["procedures"]] == ["center_alignment"]
+ (row,) = snapshot["procedures"]
+ assert row["parent_run_id"] == str(onscreen)
+ assert row["kind"] == "alignment"
+ assert row["iteration_count"] == 3
+ assert row["registered_at"] == _NOW.isoformat()
+
+
+@pytest.mark.unit
+async def test_procedure_rows_never_carry_operator_reason_text() -> None:
+ """`last_status_reason` is operator free text, which is the shape that
+ carries incidental personal data. Nothing on a status page needs it, so
+ it must not be on the wire -- and the fixture sets it to a recognisable
+ string so this cannot pass by the field merely being empty."""
+ run_id = uuid4()
+ snapshot = await _first_snapshot(
+ list_runs=_make_list_runs([_run_item(run_id)]),
+ list_procedures=_make_list_procedures([_procedure_item(uuid4(), parent_run_id=run_id)]),
+ )
+ (row,) = snapshot["procedures"]
+ assert "last_status_reason" not in row
+ assert "private" not in json.dumps(snapshot)
+
+
+@pytest.mark.unit
+async def test_subject_rows_carry_their_own_left_edge() -> None:
+ snapshot = await _first_snapshot(
+ list_subjects=_make_list_subjects(
+ [
+ SubjectSummaryItem(
+ subject_id=uuid4(), name="SMP-115", status="Mounted", created_at=_NOW
+ )
+ ]
+ )
+ )
+ (row,) = snapshot["subjects"]
+ assert row["created_at"] == _NOW.isoformat()
+
+
+@pytest.mark.unit
+async def test_procedures_section_is_present_and_empty_with_no_runs() -> None:
+ """An empty list, not a missing key. A consumer branching on presence
+ would read "no open runs" as "this producer does not send procedures"."""
+ snapshot = await _first_snapshot()
+ assert snapshot["procedures"] == []
+
+
+@pytest.mark.unit
+async def test_lifespan_names_the_plan_a_run_is_executing() -> None:
+ """A Plan is a template with no lifetime, so it never earns a row on a
+ live view. Naming it on the run that is an instance of it is the only way
+ the template layer becomes visible at all."""
+ received: asyncio.Queue[str] = asyncio.Queue()
+
+ async def handler(ws: ServerConnection) -> None:
+ async for message in ws:
+ await received.put(message if isinstance(message, str) else message.decode())
+
+ async with serve(handler, "127.0.0.1", 0) as server:
+ port = next(iter(server.sockets)).getsockname()[1]
+ kernel = _kernel(
+ status_push_enabled=True,
+ status_push_url=f"ws://127.0.0.1:{port}/ingest",
+ status_push_tick_seconds=0.1,
+ )
+ plan_id = uuid4()
+ run = _run_item(uuid4(), name="R-1", plan_id=plan_id)
+ # A second plan the run is NOT executing, so a lookup that simply took
+ # the first row would pass without matching anything.
+ other = PlanSummaryItem(
+ plan_id=uuid4(),
+ name="not-this-one",
+ practice_id=uuid4(),
+ method_id=uuid4(),
+ status="Versioned",
+ version_tag=None,
+ created_at=_NOW,
+ default_parameters_present=False,
+ )
+ mine = PlanSummaryItem(
+ plan_id=plan_id,
+ name="tomography-standard",
+ practice_id=uuid4(),
+ method_id=uuid4(),
+ status="Versioned",
+ version_tag=None,
+ created_at=_NOW,
+ default_parameters_present=False,
+ )
+
+ async with status_push_lifespan(
+ kernel,
+ **_default_handlers(
+ list_runs=_make_list_runs([run]),
+ list_plans=_make_list_plans([other, mine]),
+ ),
+ ):
+ raw = await asyncio.wait_for(received.get(), timeout=5)
+
+ assert json.loads(raw)["runs"][0]["plan_name"] == "tomography-standard"
+
+
+@pytest.mark.unit
+async def test_lifespan_run_with_no_plan_in_the_projection_pushes_a_null_plan_name() -> None:
+ """Absent must not read as a positive finding: a plan missing from the
+ projection is unknown, never "this run has no plan"."""
+ received: asyncio.Queue[str] = asyncio.Queue()
+
+ async def handler(ws: ServerConnection) -> None:
+ async for message in ws:
+ await received.put(message if isinstance(message, str) else message.decode())
+
+ async with serve(handler, "127.0.0.1", 0) as server:
+ port = next(iter(server.sockets)).getsockname()[1]
+ kernel = _kernel(
+ status_push_enabled=True,
+ status_push_url=f"ws://127.0.0.1:{port}/ingest",
+ status_push_tick_seconds=0.1,
+ )
+ async with status_push_lifespan(
+ kernel,
+ **_default_handlers(
+ list_runs=_make_list_runs([_run_item(uuid4(), name="R-1")]),
+ list_plans=_make_list_plans([]),
+ ),
+ ):
+ raw = await asyncio.wait_for(received.get(), timeout=5)
+
+ row = json.loads(raw)["runs"][0]
+ assert "plan_name" in row
+ assert row["plan_name"] is None
+
+
@pytest.mark.unit
async def test_lifespan_pushes_active_clearances_only() -> None:
received: asyncio.Queue[str] = asyncio.Queue()
@@ -1849,6 +2218,83 @@ async def handler(ws: ServerConnection) -> None:
assert snapshot["clearances"][0]["template_code"] == "ESAF"
+@pytest.mark.unit
+async def test_lifespan_clearance_row_carries_its_range_and_no_free_text() -> None:
+ received: asyncio.Queue[str] = asyncio.Queue()
+ bound_run = uuid4()
+ bound_subject = uuid4()
+
+ async def handler(ws: ServerConnection) -> None:
+ async for message in ws:
+ await received.put(message if isinstance(message, str) else message.decode())
+
+ async with serve(handler, "127.0.0.1", 0) as server:
+ port = next(iter(server.sockets)).getsockname()[1]
+ url = f"ws://127.0.0.1:{port}/ingest"
+ kernel = _kernel(
+ status_push_enabled=True, status_push_url=url, status_push_tick_seconds=0.1
+ )
+ started = _NOW + timedelta(minutes=5)
+ ends = _NOW + timedelta(hours=8)
+ item = ClearanceSummaryItem(
+ clearance_id=uuid4(),
+ template_id=uuid4(),
+ template_code="ESAF",
+ facility_code="cora",
+ title="Beryllium handling, hutch B",
+ external_id=None,
+ status="Active",
+ risk_band="Yellow",
+ subject_binding_ids=[bound_subject],
+ asset_binding_ids=[uuid4()],
+ run_binding_ids=[bound_run],
+ procedure_binding_ids=[],
+ parent_id=None,
+ registered_at=_NOW,
+ last_status_changed_at=None,
+ last_status_reason="raised by the floor coordinator after the swap",
+ last_reviewed_by=uuid4(),
+ valid_from=started,
+ valid_until=ends,
+ next_review_due_at=None,
+ )
+
+ async with status_push_lifespan(
+ kernel, **_default_handlers(list_clearances=_make_list_clearances([item]))
+ ):
+ raw = await asyncio.wait_for(received.get(), timeout=5)
+
+ row = json.loads(raw)["clearances"][0]
+ # Pinned as a SET, not field by field. A clearance is drawn as a bar
+ # over a range, and a dropped end silently turns it back into a dot;
+ # asserting only the fields this test remembers to name is how a field
+ # goes missing without a red test.
+ assert set(row) == {
+ "clearance_id",
+ "template_code",
+ "risk_band",
+ "status",
+ "valid_from",
+ "valid_until",
+ "registered_at",
+ "run_binding_ids",
+ "procedure_binding_ids",
+ "subject_binding_ids",
+ }
+ assert row["valid_from"] == started.isoformat()
+ assert row["valid_until"] == ends.isoformat()
+ # What the cover covers, so a viewer can ask whether it reaches the
+ # run on screen. Asset bindings stay off: nothing on that page draws
+ # an asset, so the ids would resolve to nothing.
+ assert row["run_binding_ids"] == [str(bound_run)]
+ assert row["subject_binding_ids"] == [str(bound_subject)]
+ assert row["procedure_binding_ids"] == []
+ # Operator free text and reviewer identity never leave the API host.
+ assert "Beryllium" not in raw
+ assert "floor coordinator" not in raw
+ assert str(item.last_reviewed_by) not in raw
+
+
@pytest.mark.unit
async def test_lifespan_pushes_active_enclosures_only() -> None:
received: asyncio.Queue[str] = asyncio.Queue()
diff --git a/infra/status-relay/design/fixtures.js b/infra/status-relay/design/fixtures.js
new file mode 100644
index 00000000000..94b01828e94
--- /dev/null
+++ b/infra/status-relay/design/fixtures.js
@@ -0,0 +1,237 @@
+/* Synthetic activity for the scrubber harness, plus the measurements
+ `check.mjs` asserts on. Shared by the browser harness and the headless
+ check, so both exercise identical documents.
+
+ Weights follow the measured 2-BM record (2026-08-28): Run and Decision are
+ 91% of all events, the rest share 9%. The default shape is BURSTY, because
+ that is what the real feed does and it is the only shape that reproduces the
+ overprinting this renderer exists to fix; a uniform sprinkle at the same
+ rate looks fine under the old code too and would have hidden the bug.
+
+ Event names are the real vocabulary, not placeholders, because label WIDTH
+ is the constrained resource: `ProcedureActivitiesLogbookOpened` is 32
+ characters, roughly a quarter of the plot. */
+(function (root) {
+ "use strict";
+
+ var CATALOG = {
+ Runs: [["RunAdjusted", 46], ["RunStarted", 7], ["RunCompleted", 6],
+ ["RunObservationLogbookOpened", 5], ["RunResumed", 3], ["RunStopped", 2],
+ ["RunTruncated", 2], ["RunAborted", 1]],
+ Procedures: [["ProcedureIterationStarted", 9], ["ProcedureIterationEnded", 9],
+ ["ProcedureStarted", 4], ["ProcedureCompleted", 4], ["ProcedureRegistered", 3],
+ ["ProcedureActivitiesLogbookOpened", 2], ["ProcedureAborted", 1]],
+ Subjects: [["SubjectMounted", 4], ["SubjectMeasured", 4], ["SubjectDismounted", 3],
+ ["SubjectStored", 2], ["SubjectDiscarded", 1]],
+ Campaigns: [["CampaignRunAdded", 4], ["CampaignSteeringDeclared", 2], ["CampaignHeld", 1]],
+ Datasets: [["AcquisitionRecorded", 10], ["DatasetRegistered", 4], ["DatasetPromoted", 3],
+ ["DatasetDemoted", 1], ["DatasetDiscarded", 1]],
+ Clearances: [["ClearanceReviewStepAppended", 3], ["ClearanceApproved", 2],
+ ["ClearanceExpired", 1], ["ClearanceRejected", 1]],
+ Cautions: [["CautionRegistered", 2], ["CautionRetired", 1]],
+ Enclosures: [["EnclosurePermitObserved", 8], ["EnclosureDecommissioned", 1]],
+ Decisions: [["DecisionRegistered", 40], ["DecisionRated", 8], ["DecisionLogbookOpened", 3],
+ ["DecisionDebriefRequested", 1]],
+ Other: [["ActorRegistered", 3], ["CalibrationRecorded", 4], ["AllocationGranted", 2]],
+ };
+ var LANE_WEIGHT = { Runs: 47, Decisions: 44, Procedures: 2.4, Datasets: 2.2, Subjects: 1.2,
+ Enclosures: 1.1, Campaigns: 0.8, Clearances: 0.6, Other: 0.5, Cautions: 0.2 };
+ var LANE_ORDER = ["Runs", "Procedures", "Subjects", "Campaigns", "Datasets",
+ "Clearances", "Cautions", "Enclosures", "Decisions", "Other"];
+
+ // Mirrors page.html's own EVENT_TIER. Duplicated deliberately: the harness
+ // must be able to disagree with the page, so a tier accidentally dropped
+ // there shows up here as a failing assertion rather than as a matching
+ // change on both sides.
+ var TIER = {
+ RunAborted: 2, ProcedureAborted: 2, CautionRegistered: 2, ClearanceExpired: 2,
+ ClearanceRejected: 2, DatasetDiscarded: 2, EnclosureDecommissioned: 2,
+ RunStopped: 1, RunTruncated: 1, RunResumed: 1, CampaignHeld: 1,
+ SubjectDiscarded: 1, DatasetDemoted: 1, CautionRetired: 1, DecisionDebriefRequested: 1,
+ };
+
+ function mulberry32(a) {
+ return function () {
+ a |= 0; a = (a + 0x6D2B79F5) | 0;
+ var t = Math.imul(a ^ (a >>> 15), 1 | a);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+ }
+ function pick(rng, pairs, idx) {
+ var total = 0, i;
+ for (i = 0; i < pairs.length; i++) total += pairs[i][idx];
+ var r = rng() * total;
+ for (i = 0; i < pairs.length; i++) { r -= pairs[i][idx]; if (r <= 0) return pairs[i]; }
+ return pairs[pairs.length - 1];
+ }
+
+ function activityDocument(windowSec, perHour, shape, seed) {
+ var rng = mulberry32(seed || 7);
+ var n = Math.max(0, Math.round((perHour * windowSec) / 3600));
+ if (shape === "empty") n = 0;
+ if (shape === "single") n = 1;
+ if (shape === "pileup") n = 14;
+
+ var laneList = LANE_ORDER.map(function (k) { return [k, LANE_WEIGHT[k]]; });
+ var byLane = {}, anchors = [], all = [];
+ LANE_ORDER.forEach(function (k) { byLane[k] = []; });
+
+ for (var i = 0; i < n; i++) {
+ var t;
+ if (shape === "pileup") {
+ t = windowSec * 0.5 + rng() * 1.5;
+ } else if (shape === "uniform") {
+ t = (i / Math.max(1, n)) * windowSec;
+ } else if (anchors.length && rng() < 0.62) {
+ t = anchors[Math.floor(rng() * anchors.length)] + rng() * 2.2;
+ } else {
+ t = rng() * windowSec;
+ anchors.push(t);
+ if (anchors.length > 16) anchors.shift();
+ }
+ t = Math.max(0, Math.min(windowSec, t));
+ var lane = shape === "pileup" ? "Runs" : pick(rng, laneList, 1)[0];
+ var name = pick(rng, CATALOG[lane], 1)[0];
+ var e = { lane: lane, name: name, t: t, tier: TIER[name] || 0 };
+ byLane[lane].push(e);
+ all.push(e);
+ }
+
+ var base = Date.UTC(2026, 7, 31, 14, 47, 0) - windowSec * 1000;
+ var iso = function (secs) { return new Date(base + secs * 1000).toISOString(); };
+
+ // Identity and relationships. Causation is attached only where a subscriber
+ // would react, matching the production paths: an event fired within a few
+ // seconds of an earlier one, in a DIFFERENT lane, is treated as a reaction
+ // to it. Everything else is an operator action and stays a root, which is
+ // roughly the mix the real record has.
+ all.sort(function (a, b) { return a.t - b.t; });
+ all.forEach(function (e, i) {
+ e.id = "ev-" + i;
+ e.corr = null;
+ e.cause = null;
+ e.cause_at = null;
+ });
+ var corrN = 0;
+ all.forEach(function (e, i) {
+ if (e.corr === null) {
+ e.corr = "c" + corrN++;
+ }
+ for (var j = i + 1; j < all.length && all[j].t - e.t < 2.5; j++) {
+ var other = all[j];
+ if (other.cause || other.lane === e.lane) continue;
+ other.cause = e.id;
+ other.cause_at = iso(e.t);
+ other.corr = e.corr;
+ }
+ });
+ // One event whose cause is deliberately unresolvable, so the off-window
+ // stub has something to draw. Its cause_at is real; the id is not in the
+ // buffer, exactly as it would be for a cause that scrolled away.
+ var orphan = all.find(function (e) { return e.cause === null && e.t > windowSec * 0.3; });
+ if (orphan) {
+ orphan.cause = "ev-before-window";
+ orphan.cause_at = iso(-90);
+ }
+
+ // A deliberate cascade, longer than MAX_CHAIN_HOPS. The generated traffic
+ // above only ever produces one-hop stars, which would leave upstream
+ // edges, hop-scaled thickness and the truncation notice unexercised: every
+ // failure in those paths would look like a pass. This is the shape the
+ // real record makes when a permit drop takes a run down with it.
+ if (shape === "bursty" && windowSec >= 300) {
+ var CASCADE = [
+ ["Enclosures", "EnclosurePermitObserved", 2],
+ ["Runs", "RunAborted", 2],
+ ["Procedures", "ProcedureAborted", 2],
+ ["Cautions", "CautionRegistered", 2],
+ ["Decisions", "DecisionRegistered", 0],
+ ["Decisions", "DecisionDebriefRequested", 1],
+ ["Datasets", "DatasetDiscarded", 2],
+ ];
+ var at = windowSec * 0.62;
+ var corr = "c-cascade";
+ var prev = null;
+ CASCADE.forEach(function (spec, i) {
+ var e = {
+ lane: spec[0], name: spec[1], t: at + i * 6, tier: spec[2],
+ id: "casc-" + i, corr: corr,
+ cause: prev ? prev.id : null,
+ cause_at: prev ? iso(prev.t) : null,
+ };
+ prev = e;
+ byLane[spec[0]].push(e);
+ all.push(e);
+ });
+ }
+
+ return {
+ subject_lane_id: "__no_subject__",
+ title: "Live activity",
+ subtitle: all.length + " events",
+ live: true,
+ domain: { from: iso(0), to: iso(windowSec) },
+ _events: all,
+ lanes: LANE_ORDER.map(function (k) {
+ byLane[k].sort(function (a, b) { return a.t - b.t; });
+ return {
+ lane_id: "domain:" + k.toLowerCase(),
+ label: k,
+ render: "markers",
+ points: byLane[k].map(function (e) {
+ return {
+ t: iso(e.t), label: e.name, tier: e.tier,
+ id: e.id, corr: e.corr, cause: e.cause, cause_at: e.cause_at,
+ };
+ }),
+ };
+ }),
+ };
+ }
+
+ /* Read the rendered SVG back. Overlap is measured from real getBBox output,
+ not from the estimate the seating pass used, so a wrong advance-width
+ constant surfaces as an overlap rather than hiding inside the maths. */
+ function measure(stageEl, doc) {
+ var svg = stageEl.querySelector("svg");
+ if (!svg) return { events: 0, marks: 0, clusters: 0, labels: 0, overlaps: 0, tier2: 0, tier2Labelled: 0 };
+ var labels = [].slice.call(svg.querySelectorAll("text.cs-life-label"));
+ var rows = {};
+ labels.forEach(function (l) {
+ var b = l.getBBox();
+ var y = Math.round(b.y);
+ (rows[y] = rows[y] || []).push([b.x, b.x + b.width, l.textContent]);
+ });
+ var overlaps = 0;
+ Object.keys(rows).forEach(function (y) {
+ var xs = rows[y].sort(function (a, b) { return a[0] - b[0]; });
+ for (var i = 1; i < xs.length; i++) if (xs[i][0] < xs[i - 1][1]) overlaps++;
+ });
+
+ var events = doc && doc._events ? doc._events : [];
+ var tier2 = events.filter(function (e) { return e.tier === 2; });
+ var text = labels.map(function (l) { return l.textContent; }).join(" | ");
+ var tier2Labelled = 0;
+ var seen = {};
+ tier2.forEach(function (e) {
+ if (seen[e.name]) return;
+ seen[e.name] = true;
+ var noun = e.lane.replace(/s$/, "");
+ var short = e.name.indexOf(noun) === 0 ? e.name.slice(noun.length) : e.name;
+ if (text.indexOf(short) !== -1) tier2Labelled++;
+ });
+
+ return {
+ events: events.length,
+ marks: svg.querySelectorAll("rect.cs-mark").length,
+ clusters: svg.querySelectorAll("rect.cs-mark--cluster").length,
+ labels: labels.length,
+ overlaps: overlaps,
+ tier2: Object.keys(seen).length,
+ tier2Labelled: tier2Labelled,
+ };
+ }
+
+ root.ScrubberFixtures = { activityDocument: activityDocument, measure: measure, TIER: TIER };
+})(typeof window !== "undefined" ? window : globalThis);
diff --git a/infra/status-relay/design/harness.html b/infra/status-relay/design/harness.html
new file mode 100644
index 00000000000..0bcdefd027c
--- /dev/null
+++ b/infra/status-relay/design/harness.html
@@ -0,0 +1,140 @@
+
+
+
+
+scrubber harness
+
+
+
+
+
+
scrubber.js renderer harness
+
+
Data extent
+
View window
+
Traffic
+
Shape
+
+
+
+
+
+
+
+
+
diff --git a/infra/status-relay/design/page-preview.html b/infra/status-relay/design/page-preview.html
new file mode 100644
index 00000000000..8a5347c0eea
--- /dev/null
+++ b/infra/status-relay/design/page-preview.html
@@ -0,0 +1,449 @@
+
+
+
+
+page preview
+
+
+
+
+
+
+
diff --git a/infra/status-relay/page.html b/infra/status-relay/page.html
index b6ad9fe0a8d..244fd269933 100644
--- a/infra/status-relay/page.html
+++ b/infra/status-relay/page.html
@@ -13,6 +13,7 @@
--stale: #d29922;
--dead: #da3633;
--row-border: #262b33;
+ --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
body {
margin: 0;
@@ -45,42 +46,7 @@
#banner.stale { background: var(--stale); color: #2b1c00; }
#banner.dead { background: var(--dead); color: #2b0a09; }
#content.dimmed { opacity: 0.5; }
- table {
- border-collapse: collapse;
- width: 100%;
- max-width: 60rem;
- }
- th, td {
- text-align: left;
- padding: 0.5rem 0.75rem;
- border-bottom: 1px solid var(--row-border);
- font-size: 0.9rem;
- vertical-align: top;
- }
- th { color: var(--muted); font-weight: 600; }
.empty { color: var(--muted); font-style: italic; padding: 0.25rem 0 0.75rem 0; }
- .progress-role {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- margin: 0.15rem 0;
- white-space: nowrap;
- }
- .progress-role .role-label { color: var(--muted); width: 8rem; flex-shrink: 0; }
- .progress-bar-track {
- background: var(--row-border);
- border-radius: 3px;
- width: 8rem;
- height: 0.5rem;
- overflow: hidden;
- flex-shrink: 0;
- }
- .progress-bar-fill { background: var(--live); height: 100%; }
- .progress-count { color: var(--fg); font-variant-numeric: tabular-nums; }
- .no-progress { color: var(--muted); font-style: italic; }
- .mono { font-variant-numeric: tabular-nums; color: var(--muted); }
- tr.clickable-row { cursor: pointer; }
- tr.clickable-row:hover { background: var(--row-border); }
/* ---- Rewind scrubber, adapted from docs/stylesheets/extra.css's
.cora-scrubber block (the paper demo's own styling). Font stacks
@@ -95,6 +61,17 @@
--cs-ink: #e7ecf2;
--cs-sub: #aab4c2;
--cs-mute: #768091;
+ /* Marks wear a mark colour, never the text colour. Routine traffic was
+ drawn in --cs-ink, the same near-white as a heading, which made every
+ ordinary event as loud as an alarm and left the severity tiers nothing
+ to escalate FROM. Measured against the rest of what is on screen at
+ once (validate_palette, dark, surface #10141b): normal-vision floor
+ 18.0 against the permit green, worst CVD pair 7.5 deutan between the
+ green ribbon and the red mark, which are a bar and a square on
+ different rows. #3f9dc9 -- the hue the prototype used -- fails that
+ floor at 14.9 against the same green. */
+ --cs-mark: #4c86c4;
+ --cs-mark-dim: #33556f;
--cs-good: #3fb985;
--cs-warn: #e6b24a;
--cs-alarm: #f0644b;
@@ -123,7 +100,17 @@
}
.cs-title { font-weight: 600; font-size: 1.02rem; }
.cs-subtitle { font-family: var(--cs-mono); font-size: 0.7rem; color: var(--cs-mute); }
- .cs-controls { margin-left: auto; display: flex; gap: 0.4rem; flex-wrap: wrap; }
+ .cs-controls { margin-left: auto; display: flex; gap: 0.4rem; flex-wrap: wrap; align-items: center; }
+ .cs-depth { display: flex; align-items: center; gap: 0.35rem; }
+ .cs-depth-label {
+ font-size: 0.66rem; letter-spacing: 0.06em; text-transform: uppercase;
+ color: var(--cs-mute);
+ }
+ .cs-depth-pick {
+ background: transparent; color: var(--cs-sub); font: inherit; font-size: 0.72rem;
+ border: 1px solid var(--cs-line); border-radius: 4px; padding: 0.15rem 0.3rem;
+ }
+ .cs-depth-pick:focus-visible { outline: 2px solid var(--cs-warn); outline-offset: 2px; }
.cs-btn {
font-size: 0.72rem; font-weight: 500; color: var(--cs-sub); background: var(--cs-panel-2);
border: 1px solid var(--cs-line); border-radius: 7px; padding: 0.34rem 0.66rem;
@@ -132,40 +119,284 @@
.cs-btn:hover { color: var(--cs-ink); border-color: color-mix(in srgb, var(--cs-warn) 60%, var(--cs-line)); }
.cs-play[aria-pressed="true"] { color: #1a130a; background: var(--cs-warn); border-color: var(--cs-warn); }
.cs-btn:focus-visible { outline: 2px solid var(--cs-warn); outline-offset: 2px; }
- .cs-slider:focus-visible { outline: none; }
- .cs-slider:focus-visible .cs-slider-thumb { outline: 2px solid var(--cs-warn); outline-offset: 2px; }
- .cs-stage { overflow-x: auto; margin-top: 0.7rem; }
- .cora-scrubber__svg { display: block; width: 100%; min-width: 640px; height: auto; touch-action: none; cursor: ew-resize; }
+ .cs-stage { overflow-x: auto; margin-top: 0.7rem; position: relative; }
+ /* `grab` announces the one gesture that is not discoverable: that the chart
+ body can be dragged. Marks override it back to a pointer so a clickable
+ thing still looks clickable. */
+ .cora-scrubber__svg { display: block; width: 100%; min-width: 640px; height: auto; touch-action: none; cursor: grab; }
+ .cora-scrubber__svg.cs-grabbing { cursor: grabbing; }
+ .cora-scrubber__svg:focus-visible { outline: 2px solid var(--cs-warn); outline-offset: 2px; }
+ .cora-scrubber__svg * { pointer-events: none; }
+ .cs-hit, .cs-mark, .cs-gutter-hit { pointer-events: auto; }
+ /* The whole gutter cell, not the 9px label in it. `help` rather than
+ `pointer`: hovering explains the row, clicking does nothing. */
+ .cs-gutter-hit { fill: transparent; cursor: help; }
+ .cs-hit { fill: transparent; cursor: pointer; }
+ .cs-mark--hit { cursor: pointer; }
+ /* Follows the pointer, so it must never be under it. */
+ .cs-tip {
+ position: absolute; z-index: 3; pointer-events: none; opacity: 0;
+ transition: opacity 0.09s ease; min-width: 190px; max-width: 300px;
+ background: var(--cs-panel); border: 1px solid var(--cs-line);
+ border-radius: 9px; padding: 0.5rem 0.6rem;
+ box-shadow: 0 6px 22px rgb(0 0 0 / 45%);
+ }
+ .cs-tip[data-on="1"] { opacity: 1; }
+ .cs-tip-head {
+ font-size: 0.78rem; font-weight: 600; color: var(--cs-ink);
+ margin-bottom: 0.35rem;
+ }
+ .cs-tip-row, .cs-tip-item {
+ display: flex; justify-content: space-between; gap: 0.8rem;
+ padding: 0.1rem 0; font-size: 0.7rem;
+ }
+ /* The key never wraps: a long value pushing "caused by" onto two lines
+ turns the card into a paragraph. The value wraps instead. */
+ .cs-tip-k { color: var(--cs-sub); white-space: nowrap; }
+ .cs-tip-v {
+ font-family: var(--cs-mono); color: var(--cs-ink); text-align: right;
+ overflow-wrap: anywhere;
+ }
+ .cs-tip-row--up .cs-tip-v { color: var(--cs-alarm); }
+ .cs-tip-row--down .cs-tip-v { color: var(--cs-warn); }
+ /* Kinship is a THIRD relation beside cause and effect, so it gets its own
+ hue rather than borrowing one of theirs: a bound row is not something the
+ event caused. */
+ .cs-tip-row--kin .cs-tip-v { color: var(--cs-mark); }
+ /* A row card leads with a sentence, an event card with a clock. The note
+ wraps and the rows below it do not, so the shape of the two cards stays
+ recognisably the same. */
+ .cs-tip-note {
+ font-size: 0.68rem; line-height: 1.45; color: var(--cs-sub);
+ margin-bottom: 0.45rem; padding-bottom: 0.4rem;
+ border-bottom: 1px solid var(--cs-line);
+ }
+ .cs-tip-list {
+ max-height: 8.4rem; overflow-y: auto; margin-bottom: 0.35rem;
+ padding-bottom: 0.3rem; border-bottom: 1px solid var(--cs-line);
+ }
+ /* The traced chain, in words. Its own block rather than more .cs-tip-rows:
+ these are events with clocks, not facts about one event, and a scroll cap
+ keeps a deep trace from turning the card into a page. */
+ .cs-tip-chain {
+ max-height: 7.6rem; overflow-y: auto;
+ margin: 0.25rem 0 0.35rem 0; padding: 0.2rem 0;
+ border-top: 1px solid var(--cs-line); border-bottom: 1px solid var(--cs-line);
+ }
+ .cs-tip-chain .cs-tip-k { overflow-wrap: anywhere; white-space: normal; }
+ .cs-tip-item .cs-tip-k { color: var(--cs-mute); }
+ .cs-tip-item--head .cs-tip-k { color: var(--cs-ink); font-weight: 600; }
+ /* The same two hues the arrows use, so a row in the card and the edge it
+ stands for are recognisably the same relation. All three are the same
+ specificity as `.cs-tip-item .cs-tip-k`, so they have to sit BELOW it:
+ written above they lost every hue to it without any warning. */
+ .cs-tip-item--up .cs-tip-k { color: color-mix(in srgb, var(--cs-alarm) 72%, var(--cs-sub)); }
+ .cs-tip-item--down .cs-tip-k { color: color-mix(in srgb, var(--cs-warn) 66%, var(--cs-sub)); }
+ .cs-tip-item--cut .cs-tip-k { color: var(--cs-mute); font-style: italic; }
+ /* The second channel. Opacity already says "something on this row is in
+ the chain you are tracing"; this says "the record binds this row to the
+ pinned event's row", which is a different claim from a different source
+ and must stay tellable apart from it. */
+ .cs-kin-tick { fill: var(--cs-mark); }
+ .cs-selected { stroke: var(--cs-ink); stroke-width: 1.6; }
+ /* The pack a pinned event came out of. A fill, not a second ring: the ring
+ already means "this event", and two strokes a few units apart would read
+ as one thick box round the wrong thing. */
+ .cs-sel-group {
+ fill: color-mix(in srgb, var(--cs-ink) 13%, transparent);
+ stroke: color-mix(in srgb, var(--cs-ink) 34%, transparent);
+ stroke-width: 1;
+ }
+ /* Everything outside the pinned event's chain and correlation group recedes
+ rather than disappearing: the shape of the surrounding traffic is still
+ context, it just stops competing. */
+ .cs-dim { opacity: 0.14; }
+ /* A bar recedes further than a mark would let it: 0.14 on a lifetime that
+ spans the whole chart erases the skeleton, and the shape of the tree is
+ what tells you WHERE the lit part sits. */
+ .cs-track.cs-dim, .cs-track-cap.cs-dim { opacity: 0.24; }
+ /* Direction is never in doubt, the head always sits at the effect. Hue
+ separates "why this happened" from "what it set off". */
+ .cs-edge { fill: none; }
+ .cs-edge--up { stroke: var(--cs-alarm); }
+ .cs-edge--down { stroke: var(--cs-warn); opacity: 0.85; }
+ .cs-edge--stub { stroke-dasharray: 3 3; }
+ .cs-edge-note {
+ fill: var(--cs-alarm); font-family: var(--cs-mono); font-size: 8.5px;
+ }
+ .cs-root-ring { fill: none; stroke: var(--cs-ink); stroke-width: 1.3; }
+ .cs-row-val.cs-tone--tier2 { color: var(--cs-alarm); }
+ .cs-row-val.cs-tone--tier1 { color: var(--cs-warn); }
+ /* Page scope, so only the :root tokens are in reach: the --cs-* set is
+ declared on .cora-scrubber and does not resolve out here. */
+ #flowing-controls {
+ display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap;
+ margin-bottom: 0.7rem;
+ }
+ .flowing-controls__label {
+ font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.12em;
+ text-transform: uppercase; color: var(--muted);
+ }
+ #flowing-controls .seg {
+ display: flex; border: 1px solid var(--row-border); border-radius: 5px; overflow: hidden;
+ }
+ #flowing-controls .seg button {
+ font-family: var(--mono); font-size: 0.72rem; padding: 0.25rem 0.6rem;
+ border: 0; border-right: 1px solid var(--row-border); background: transparent;
+ color: var(--muted); cursor: pointer;
+ }
+ #flowing-controls .seg button:last-child { border-right: 0; }
+ #flowing-controls .seg button:hover { color: var(--fg); }
+ #flowing-controls .seg button[aria-pressed="true"] {
+ background: var(--stale); color: #1a130a; font-weight: 600;
+ }
+ #flowing-controls .seg button:focus-visible { outline: 2px solid var(--stale); outline-offset: -2px; }
+ .cs-hint {
+ font-family: var(--cs-mono); font-size: 0.62rem; color: var(--cs-mute);
+ margin-top: 0.6rem; letter-spacing: 0.02em;
+ }
+ .cs-hint kbd {
+ font-family: var(--cs-mono); background: var(--cs-panel-2);
+ border: 1px solid var(--cs-line); border-radius: 3px; padding: 0 3px;
+ }
.cs-baseline, .cs-axis, .cs-tick { stroke: var(--cs-line); stroke-width: 1; }
- .cs-lane-label { fill: var(--cs-sub); font-size: 11px; }
+ .cs-baseline--track { opacity: 0.45; }
+ /* Same treatment as a track's KIND column: small mono caps. A flat lane
+ has no instance to name, so the domain IS its identity and one word in
+ the same voice as the rest of the gutter is the whole label. */
+ .cs-lane-label {
+ fill: var(--cs-mute); font-family: var(--cs-mono); font-size: 8px;
+ letter-spacing: 0.1em; text-transform: uppercase;
+ }
+ /* A zone caption groups the rows under it by the SHAPE of the relation they
+ carry, not by name. It is the reason the rows are in that order, so it
+ stays quiet once the eye has learned them. */
+ .cs-zone {
+ fill: var(--cs-mute); font-family: var(--cs-mono); font-size: 7.6px;
+ letter-spacing: 0.16em; text-transform: uppercase;
+ }
+ .cs-zone-rule { stroke: var(--cs-line); stroke-width: 1; opacity: 0.5; }
+ .cs-row-kind {
+ fill: var(--cs-mute); font-family: var(--cs-mono); font-size: 7.6px;
+ letter-spacing: 0.1em;
+ }
+ /* An instance name identifies a row; it is not the row's content. At 10px
+ near-white it was the loudest thing on the chart, so a gutter of names
+ outshouted the events they were there to label. Depth still reads,
+ through a two-step ramp rather than through weight. */
+ .cs-track-label { fill: var(--cs-mute); font-size: 8.6px; letter-spacing: 0.01em; }
+ .cs-track-label--d0 { fill: var(--cs-sub); font-weight: 500; }
+ .cs-track-label--d2 { font-size: 8.2px; }
+ /* Thickness carries depth, so containment reads without a second colour or
+ an extra rule: the campaign is visibly the thing holding the runs, which
+ hold the procedures. */
+ /* Same hue as the marks that sit on them, so a track and its events read as
+ one thing; depth is carried by thickness and weight, not by another hue. */
+ .cs-track { fill: var(--cs-mark-dim); }
+ .cs-track--d0 { fill: color-mix(in srgb, var(--cs-mark-dim) 70%, transparent); }
+ .cs-track--d1 { fill: var(--cs-mark-dim); }
+ .cs-track--d2 { fill: color-mix(in srgb, var(--cs-mark-dim) 75%, transparent); }
+ /* Tones are STATES OF A CONDITION over a range, which is what separates a
+ permit ribbon from a row of samples. Unknown is its own state and never
+ blended into either neighbour: not-observed is not the same as fine. */
+ /* Measured against the plain track it sits among, not picked. At 70% a
+ good bar composited to luminance 0.191 where every other bar on the chart
+ is 0.083, so the quietest state on the page was more than twice the
+ weight of everything around it -- and colour spent on "nothing is wrong"
+ is colour the alarm tiers no longer have to escalate from. At 45% it
+ lands on 0.089, the same weight as a plain bar, still unmistakably
+ green. */
+ .cs-track--good { fill: color-mix(in srgb, var(--cs-good) 32%, transparent); }
+ .cs-track--bad { fill: var(--cs-alarm); }
+ .cs-track--warn { fill: var(--cs-warn); }
+ .cs-track--unknown {
+ fill: color-mix(in srgb, var(--cs-mute) 40%, transparent);
+ stroke: var(--cs-line); stroke-width: 1; stroke-dasharray: 2 3;
+ }
+ .cs-track-cap { fill: var(--cs-mute); opacity: 0.7; }
.cs-tick-label { fill: var(--cs-mute); font-family: var(--cs-mono); font-size: 10px; }
.cs-run-line { stroke: var(--cs-sub); stroke-width: 1.6; fill: none; }
.cs-life-label { font-size: 9.5px; font-weight: 500; fill: var(--cs-sub); }
- .cs-mark { stroke: var(--cs-surface); stroke-width: 1; transition: opacity 0.12s ease; }
- .cs-mark--setpoint { fill: var(--cs-ink); }
+ /* One mark family: every event is the same small square, and a burst is
+ those squares packed side by side into a bar whose length is how many
+ happened. No stroke -- the MARK_GAP between them is real space, so the
+ squares read as separate without a ring thickening each one. */
+ .cs-mark { transition: opacity 0.12s ease; }
+ .cs-mark--single, .cs-mark--packed, .cs-mark--many { fill: var(--cs-mark); }
+ /* A mark drawn ON a bar needs the bar held off it. Blue on the old green
+ was a contrast ratio of 1.15, which is not a mark on a bar, it is one
+ colour: the events on a subject or a clearance row were invisible. A ring
+ in the surface colour separates a mark from whatever it sits on and
+ leaves the ratio a property of mark-against-ring, so it holds whatever
+ the bar underneath is doing. Only on tracks -- on open chart the ring
+ would just thicken every square against its own background. */
+ .cs-mark--on-track { stroke: var(--cs-surface); stroke-width: 0.9; }
.cs-mark--acquire { fill: var(--cs-sub); }
.cs-mark--check { fill: var(--cs-warn); }
- .cs-future { opacity: 0.25; }
- .cs-cursor { stroke: var(--cs-alarm); stroke-width: 1.4; stroke-dasharray: 4 3; }
- .cs-cursor-handle { fill: var(--cs-alarm); stroke: var(--cs-surface); stroke-width: 1.2; }
- .cs-slider {
- position: relative; height: 22px; margin: 0.4rem 0 0.2rem; display: flex;
- align-items: center; cursor: pointer; touch-action: none;
- }
- .cs-slider-track {
- position: relative; flex: 1; margin: 0 2.61% 0 9.13%; height: 5px; border-radius: 3px;
- background: var(--cs-panel-2); border: 1px solid var(--cs-line);
+ /* Severity, reusing the page's existing status hues rather than a new set.
+ Tier 0 is deliberately the neutral ink: colour here means "this is worth
+ your attention", so routine traffic must not spend any. */
+ .cs-tier--1 { fill: var(--cs-warn); }
+ .cs-tier--2 { fill: var(--cs-alarm); }
+ text.cs-tier--1 { fill: var(--cs-warn); font-weight: 600; }
+ text.cs-tier--2 { fill: var(--cs-alarm); font-weight: 700; }
+ /* Hop distance, on the tone channel. Two-class selectors so the ramp sits
+ above the tier fills without touching them: tier owns HUE, depth owns
+ TONE, and the two never contend for the same property.
+
+ Seven steps, one per reachable hop, spaced by PERCEPTION rather than by
+ even alpha. Composited over the surface these land about four L* apart
+ at every step (5.7, 4.9, 4.6, 4.2, 4.3, 3.9), which even alpha does not:
+ the same alpha delta near the top of the range is worth far more L* than
+ near the bottom.
+
+ Hops 4, 5 and 6 shared one value until the fixture could reach past hop
+ 3, and then the whole tail of the ramp said "far" and nothing else. The
+ dial goes to six, so the ramp has to.
+
+ The floor is 0.40 against a .cs-dim of 0.14: three times the luminance,
+ L* 27 against 14. That margin is the point. The far end of a traced chain
+ and an event outside it entirely are opposite answers, and a ramp running
+ down into the dim level would make the deepest hop indistinguishable from
+ a bystander exactly when the dial is turned up to look at it. */
+ .cs-mark.cs-hop-0 { opacity: 1; }
+ .cs-mark.cs-hop-1 { opacity: 0.87; }
+ .cs-mark.cs-hop-2 { opacity: 0.76; }
+ .cs-mark.cs-hop-3 { opacity: 0.66; }
+ .cs-mark.cs-hop-4 { opacity: 0.57; }
+ .cs-mark.cs-hop-5 { opacity: 0.48; }
+ .cs-mark.cs-hop-6 { opacity: 0.4; }
+ /* `stroke-opacity`, not `opacity`, so an edge keeps the direction tone set
+ by .cs-edge--down instead of having it overwritten by whichever rule the
+ cascade happened to see last. */
+ .cs-edge.cs-hop-1 { stroke-opacity: 0.87; }
+ .cs-edge.cs-hop-2 { stroke-opacity: 0.76; }
+ .cs-edge.cs-hop-3 { stroke-opacity: 0.66; }
+ .cs-edge.cs-hop-4 { stroke-opacity: 0.57; }
+ .cs-edge.cs-hop-5 { stroke-opacity: 0.48; }
+ .cs-edge.cs-hop-6 { stroke-opacity: 0.4; }
+ /* Correlation is a set with no depth, so it must not land anywhere on the
+ ramp above. Hollow is the signal; the tone only keeps it behind the chain
+ it is sharing a screen with. */
+ .cs-mark.cs-mark--corr {
+ fill: var(--cs-surface); stroke: var(--cs-mark); stroke-width: 1.2; opacity: 0.75;
}
- .cs-slider-fill {
- position: absolute; inset: 0 auto 0 0; width: 0;
- background: linear-gradient(90deg, color-mix(in srgb, var(--cs-alarm) 55%, var(--cs-warn)), var(--cs-alarm));
+ .cs-cluster-count {
+ font-family: var(--cs-mono); font-size: 7.5px; font-weight: 700;
+ fill: var(--cs-surface); pointer-events: none;
}
- .cs-slider-thumb {
- position: absolute; top: 50%; left: 0; width: 16px; height: 16px; border-radius: 50%;
- background: var(--cs-alarm); border: 2px solid var(--cs-surface);
- transform: translate(-50%, -50%); transition: transform 0.1s ease; pointer-events: none;
+ .cs-future { opacity: 0.25; }
+ /* The present, in a flowing window: a quiet dashed rule at the right edge
+ and nothing else. Green because that is what LIVE means on this page;
+ the alarm hue below belongs to REWIND's cursor, which is a control the
+ viewer drives rather than a fact about the record. */
+ .cs-now { stroke: var(--cs-good); stroke-width: 1.2; stroke-dasharray: 3 4; opacity: 0.75; }
+ .cs-now-label {
+ fill: var(--cs-good); font-family: var(--cs-mono); font-size: 8.5px;
+ letter-spacing: 0.14em; opacity: 0.9;
}
- .cs-slider:hover .cs-slider-thumb, .cs-slider:focus-visible .cs-slider-thumb { transform: translate(-50%, -50%) scale(1.18); }
+ .cs-cursor { stroke: var(--cs-alarm); stroke-width: 1.4; stroke-dasharray: 4 3; }
+ .cs-cursor-handle { fill: var(--cs-alarm); stroke: var(--cs-surface); stroke-width: 1.2; }
.cs-panels { display: grid; grid-template-columns: 1fr; gap: 0.7rem; margin-top: 0.6rem; }
.cs-readout { background: var(--cs-panel); border: 1px solid var(--cs-line); border-radius: 10px; padding: 0.7rem 0.85rem; }
.cs-readout-head { font-family: var(--cs-mono); font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.14em; color: var(--cs-mute); margin-bottom: 0.55rem; }
@@ -176,8 +407,25 @@
.cs-tone--good { color: var(--cs-good); }
.cs-tone--warn { color: var(--cs-warn); }
.cs-omitted-note { font-size: 0.7rem; color: var(--cs-mute); margin-top: 0.5rem; }
+ /* Title left, rewind right, on one line. Rewind is a way IN to a second
+ view rather than a section of this one, so it belongs with the page's
+ own chrome and not at the bottom of a stack of tables. */
+ #page-head {
+ display: flex; align-items: flex-start; justify-content: space-between;
+ gap: 1rem; flex-wrap: wrap;
+ }
+ #page-head h1 { margin: 0; }
+ #rewind-control { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; justify-content: flex-end; }
+ .rewind-label {
+ font-family: var(--mono); font-size: 0.68rem;
+ letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted);
+ }
#rewind-picker { background: var(--row-border); color: var(--fg); border: 1px solid var(--row-border); border-radius: 6px; padding: 0.35rem 0.5rem; font-size: 0.9rem; }
- #rewind-back { margin-left: 0.5rem; }
+ /* The whole width of the control when it wraps, so the note sits under the
+ picker it is about rather than beside it. */
+ #rewind-control .empty { flex-basis: 100%; text-align: right; font-size: 0.75rem; margin: 0; }
+ #rewind-stage:empty { display: none; }
+ #rewind-stage { margin-bottom: 1.2rem; }
#flowing-status { color: var(--muted); font-size: 0.85rem; margin: 0 0 0.5rem 0; min-height: 1.1em; }
/* Not `.cs-btn`: that class reads `--cs-*` custom properties scoped to
`.cora-scrubber`, and this button sits outside it (scaffold()
@@ -196,77 +444,36 @@
-
CORA / 2-BM: live status
+
+
CORA / 2-BM: live status
+
+
+
+
+
+ no runs pushed since the relay started
+
+
+
connecting
+
+
+
Live activity
-
-
-
-
-
Runs
-
-
Run
Status
Progress
-
-
-
no open runs
-
-
Subjects
-
-
Subject
Status
-
-
-
no open subjects
-
-
Campaigns
-
-
Campaign
Intent
Status
Runs
-
-
-
no open campaigns
-
-
Datasets from runs on screen
-
-
Dataset
Status
Run
-
-
-
no datasets from an open run yet
-
-
Active clearances
-
-
Template
Risk
Valid until
-
-
-
no active clearances
-
-
Enclosures
-
-
Enclosure
Permit status
-
-
-
no active enclosures
-
-
Recent decisions
-
-
Choice
Confidence
Decided by
When
-
-
-
- no decisions since this page's producer started
+
+ Window
+
+
+
+
-
Rewind
-
-
-
-
-
- no runs pushed since the relay started -- rewind only reaches runs seen while this relay has been up
-
-
@@ -304,150 +511,6 @@
Rewind
return id ? String(id).slice(0, 8) : "";
}
- function textCell(text) {
- var cell = document.createElement("td");
- cell.textContent = text === null || text === undefined ? "" : String(text);
- return cell;
- }
-
- function renderProgressCell(progress) {
- var cell = document.createElement("td");
- var roles = progress ? Object.keys(progress) : [];
- if (roles.length === 0) {
- cell.className = "no-progress";
- cell.textContent = "no progress reported";
- return cell;
- }
- roles.sort().forEach(function (role) {
- var reading = progress[role];
- var line = document.createElement("div");
- line.className = "progress-role";
-
- var label = document.createElement("span");
- label.className = "role-label";
- label.textContent = role;
- line.appendChild(label);
-
- if (reading.commanded_total) {
- var track = document.createElement("div");
- track.className = "progress-bar-track";
- var fill = document.createElement("div");
- fill.className = "progress-bar-fill";
- var pct = Math.max(0, Math.min(100, (reading.value / reading.commanded_total) * 100));
- fill.style.width = pct + "%";
- track.appendChild(fill);
- line.appendChild(track);
- }
-
- var count = document.createElement("span");
- count.className = "progress-count";
- count.textContent = reading.commanded_total
- ? reading.value + " / " + reading.commanded_total
- : String(reading.value);
- line.appendChild(count);
-
- cell.appendChild(line);
- });
- return cell;
- }
-
- // Generic section renderer for every domain except Runs, which alone
- // needs the progress-bar cell above.
- function renderSection(name, items, rowBuilder) {
- var tableEl = document.getElementById(name + "-table");
- var bodyEl = document.getElementById(name + "-body");
- var emptyEl = document.getElementById(name + "-empty");
- bodyEl.innerHTML = "";
- if (!items || items.length === 0) {
- tableEl.style.display = "none";
- emptyEl.style.display = "block";
- return;
- }
- tableEl.style.display = "table";
- emptyEl.style.display = "none";
- items.forEach(function (item) {
- bodyEl.appendChild(rowBuilder(item));
- });
- }
-
- function renderRuns(runs) {
- renderSection("runs", runs, function (run) {
- var row = document.createElement("tr");
- row.appendChild(textCell(run.name));
- row.appendChild(textCell(run.status));
- row.appendChild(renderProgressCell(run.progress));
- return row;
- });
- }
-
- function renderSubjects(subjects) {
- renderSection("subjects", subjects, function (s) {
- var row = document.createElement("tr");
- row.appendChild(textCell(s.name));
- row.appendChild(textCell(s.status));
- return row;
- });
- }
-
- function renderCampaigns(campaigns) {
- renderSection("campaigns", campaigns, function (c) {
- var row = document.createElement("tr");
- row.appendChild(textCell(c.name));
- row.appendChild(textCell(c.intent));
- row.appendChild(textCell(c.status));
- row.appendChild(textCell(c.run_count));
- return row;
- });
- }
-
- function renderDatasets(datasets) {
- renderSection("datasets", datasets, function (d) {
- var row = document.createElement("tr");
- row.appendChild(textCell(d.name));
- row.appendChild(textCell(d.status));
- var runCell = textCell(shortId(d.producing_run_id));
- runCell.className = "mono";
- row.appendChild(runCell);
- return row;
- });
- }
-
- function renderClearances(clearances) {
- renderSection("clearances", clearances, function (c) {
- var row = document.createElement("tr");
- row.appendChild(textCell(c.template_code));
- row.appendChild(textCell(c.risk_band));
- row.appendChild(textCell(c.valid_until ? c.valid_until : "no expiry set"));
- return row;
- });
- }
-
- function renderEnclosures(enclosures) {
- renderSection("enclosures", enclosures, function (e) {
- var row = document.createElement("tr");
- row.appendChild(textCell(e.name));
- row.appendChild(textCell(e.permit_status));
- row.className = "clickable-row";
- row.addEventListener("click", function () {
- showEnclosureTimeline(e.enclosure_id);
- });
- return row;
- });
- }
-
- function renderDecisions(decisions) {
- renderSection("decisions", decisions, function (d) {
- var row = document.createElement("tr");
- row.appendChild(textCell(d.choice));
- row.appendChild(textCell(d.confidence_band ? d.confidence_band : "n/a"));
- var byCell = textCell(shortId(d.decided_by));
- byCell.className = "mono";
- row.appendChild(byCell);
- row.appendChild(textCell(d.created_at ? new Date(d.created_at).toLocaleTimeString() : ""));
- return row;
- });
- }
-
function renderBanner() {
if (!socketOpen || !producerConnected) {
bannerEl.className = "dead";
@@ -484,8 +547,15 @@
Rewind
// update) and there is no single subject with a "current status" -- a
// busy hour touches many Runs, many Decisions -- so every lane here is
// read the same generic "last event" way, none of them primary.
- var FLOWING_WINDOW_MS = 15 * 60 * 1000;
- var FLOWING_RESLIDE_MS = 60 * 1000;
+ // How much record the page RETAINS, which since panning exists is no longer
+ // the same question as how much it shows. The relay backfills this much on
+ // connect (`_ACTIVITY_BUFFER_SECONDS`), so a viewer can drag back through a
+ // whole shift rather than through the last quarter hour. What is on screen
+ // at once is `flowing.viewSpanSecs`, chosen from the window picker.
+ var FLOWING_WINDOW_MS = 24 * 60 * 60 * 1000;
+ // Sub-pixel per step at a 15-minute window (0.45 of a user unit), so the
+ // right edge creeps rather than ticking, at half the rebuild cost of 250ms.
+ var FLOWING_RESLIDE_MS = 500;
// Many-to-one by design, not one lane per stream_type: the measured
// 2-BM record has 25 distinct stream types, but `Run` and `Decision`
@@ -500,38 +570,169 @@
Rewind
// catch-all below with Actor, Agent, Calibration, Allocation, and the
// rest of the 42-type event vocabulary. Lane count is still fixed
// either way, so the layout never reflows.
+ // Every one of the record's 42 aggregate types is filed here, not just the
+ // twelve the tables happen to show. An unmapped type used to fall into
+ // "Other", and "Other" sits under the last zone heading, so a Mount or an
+ // Allocation was being drawn as a JUDGEMENT -- the same category error the
+ // zones exist to prevent. Mapping one costs nothing at rest: a domain with
+ // no events in the buffer emits no lane, so the quiet ones are invisible
+ // until the day they are not.
var STREAM_TYPE_TO_DOMAIN = {
Run: "runs",
Procedure: "procedures",
- Subject: "subjects",
Campaign: "campaigns",
+ Plan: "recipes",
+ Recipe: "recipes",
+ Method: "recipes",
+ Practice: "recipes",
+ Capability: "recipes",
+
+ Subject: "subjects",
+ Asset: "equipment",
+ Assembly: "equipment",
+ Mount: "equipment",
+ Frame: "equipment",
+ Fixture: "equipment",
+ Family: "equipment",
+ Model: "equipment",
+ Role: "equipment",
+ Supply: "supplies",
+ Actor: "actors",
+ Agent: "actors",
+ LanguageModel: "actors",
+ Credential: "actors",
+
+ Enclosure: "enclosures",
+ Clearance: "clearances",
+ ClearanceTemplate: "clearances",
+ Visit: "visits",
+ Allocation: "allocations",
+ Policy: "authority",
+ Ratification: "authority",
+ Surface: "authority",
+ Zone: "authority",
+ Conduit: "authority",
+ Permit: "authority",
+ Seal: "authority",
+ Facility: "authority",
+
Dataset: "datasets",
Distribution: "datasets",
Acquisition: "datasets",
- Clearance: "clearances",
- ClearanceTemplate: "clearances",
+ Edition: "datasets",
Caution: "cautions",
- Enclosure: "enclosures",
Decision: "decisions",
+ Attestation: "attestations",
+ Calibration: "calibrations",
};
var DOMAIN_ORDER = [
- "runs", "procedures", "subjects", "campaigns", "datasets", "clearances", "cautions",
- "enclosures", "decisions", "other",
+ "runs", "procedures", "campaigns", "recipes",
+ "subjects", "equipment", "supplies", "actors",
+ "enclosures", "clearances", "visits", "allocations", "authority",
+ "datasets", "decisions", "cautions", "attestations", "calibrations",
+ "other",
];
+ // Short enough to sit in a gutter that also has to hold an indent and an
+ // instance name. These are set in the same small mono caps as a track's
+ // KIND column, so they are read as a column and not as prose.
var DOMAIN_LABEL = {
runs: "Runs",
- procedures: "Procedures",
+ procedures: "Procs",
+ campaigns: "Camps",
+ recipes: "Recipes",
subjects: "Subjects",
- campaigns: "Campaigns",
- datasets: "Datasets",
- clearances: "Clearances",
- cautions: "Cautions",
+ equipment: "Equipment",
+ supplies: "Supplies",
+ actors: "Actors",
enclosures: "Enclosures",
+ clearances: "Clearances",
+ visits: "Visits",
+ allocations: "Budget",
+ authority: "Authority",
+ datasets: "Datasets",
decisions: "Decisions",
- other: "Other",
+ cautions: "Cautions",
+ attestations: "Attests",
+ calibrations: "Calibs",
+ other: "Unfiled",
+ };
+ // The aggregate's own name, which is what an event type is prefixed with.
+ // Separate from the label because a label is written to fit the gutter:
+ // "Calibs" leaves the stem "Calib", and stripping that off
+ // "CalibrationRecorded" leaves the word fragment "rationRecorded". Only the
+ // domains whose label is not already the noun need an entry.
+ var DOMAIN_NOUN = {
+ procedures: "Procedure",
+ campaigns: "Campaign",
+ attestations: "Attestation",
+ calibrations: "Calibration",
+ allocations: "Allocation",
};
- var flowing = { buffer: [], following: true, pausedAtMs: 0 };
+ // Severity rank per event type: 1 notable, 2 critical, absent means routine.
+ // It lives here and not in scrubber.js because which events matter is domain
+ // vocabulary, and that module is deliberately subject-neutral. An event type
+ // missing from this table renders as routine, which is the safe default for
+ // a vocabulary that grows: a new event is quiet, never falsely loud.
+ //
+ // Tier 2 is reserved for "an operator would want to know within seconds".
+ // Keep it small. Everything promoted here competes for the same label slots
+ // as a real alarm, so a generous tier 2 is the same as no tier 2 at all.
+ var EVENT_TIER = {
+ RunAborted: 2,
+ ProcedureAborted: 2,
+ CautionRegistered: 2,
+ ClearanceExpired: 2,
+ ClearanceRejected: 2,
+ DatasetDiscarded: 2,
+ EnclosureDecommissioned: 2,
+ RunHeld: 1,
+ RunStopped: 1,
+ RunTruncated: 1,
+ RunResumed: 1,
+ ProcedureTruncated: 1,
+ ProcedureResumed: 1,
+ CampaignHeld: 1,
+ CampaignAbandoned: 1,
+ ClearanceSuperseded: 1,
+ SubjectDiscarded: 1,
+ DatasetDemoted: 1,
+ DistributionDiscarded: 1,
+ CautionRetired: 1,
+ CautionDraftConflicted: 1,
+ DecisionDebriefRequested: 1,
+ };
+
+ // How much of the buffer is on screen at once, and therefore also how far a
+ // drag travels per pixel: a narrow window pans slowly and precisely, a wide
+ // one covers ground. Nothing here selects the whole 24 hours, because 812
+ // plot units over a day is about 106 seconds each and every burst would
+ // collapse into one pill; a day is something to drag THROUGH, not to look
+ // at. 15m stays the default, which is what the page showed before it could
+ // pan at all.
+ var FLOWING_WINDOWS = [
+ ["2m", 120],
+ ["5m", 300],
+ ["15m", 900],
+ ["1h", 3600],
+ ["6h", 6 * 3600],
+ ];
+
+ var flowing = {
+ buffer: [],
+ following: true,
+ pausedAtMs: 0,
+ viewSpanSecs: 900,
+ // Set from the relay's replay when its own cap dropped events. The left
+ // edge then does not mean "nothing happened before this", and the page
+ // has to say which of the two it is.
+ replayTruncated: false,
+ // Latest snapshot rows, or empty until the first one lands. Empty is a
+ // real state and not an error: the chart then has no instance tracks and
+ // falls back to one flat lane per domain, which is what it drew before
+ // any of this existed.
+ snapshot: null,
+ };
var flowingStageEl = document.getElementById("flowing-stage");
var flowingResumeEl = document.getElementById("flowing-resume");
var flowingStatusEl = document.getElementById("flowing-status");
@@ -543,20 +744,707 @@
Rewind
});
}
- function activityToTimelineDocument(buffer, domainFromIso, domainToIso) {
- var lanesByDomain = {};
- DOMAIN_ORDER.forEach(function (key) {
- lanesByDomain[key] = {
- lane_id: "domain:" + key,
- label: DOMAIN_LABEL[key],
- render: "markers",
- points: [],
+ // ---- Four zones, grouped by the SHAPE of the relation each carries ----
+ //
+ // Not by name and not by how the record is filed, because the shape is what
+ // decides the mark:
+ //
+ // EXECUTION campaign > run > procedure containment tree -> nest it,
+ // thickness carries depth
+ // PARTICIPANTS subjects, equipment, own lifetime, many-to-many
+ // supplies, actors with runs -> their own bars
+ // GOVERNANCE enclosure permits, a CONDITION over a range ->
+ // clearances, visits, a ribbon. As dots you learn
+ // allocations it was sampled; as a ribbon
+ // you learn whether it held
+ // JUDGEMENT datasets, decisions, point conclusions, no
+ // cautions, attestations lifetime -> flat lanes
+ //
+ // A row per instance costs vertical space, so the tree is capped and the
+ // subtitle reports what the cap dropped.
+ var MAX_TRACK_ROWS = 26;
+ // One word each. A caption used to carry its own rule ("a condition over a
+ // range"), which is worth reading once and is then four lines of standing
+ // text competing with the rows underneath it. The rule is written above, in
+ // the place that decides it.
+ var ZONES = {
+ execution: "Execution",
+ participants: "Participants",
+ governance: "Governance",
+ judgement: "Judgement",
+ };
+ // Each zone is a PRECONDITION for the next, and the page is read downward.
+ // Cover and a permit have to hold before a sample can be mounted; a sample
+ // has to be mounted before a run can measure it; a run has to have run
+ // before there is anything to conclude. Reading down the chart is reading
+ // the order in which things have to become true.
+ //
+ // It also puts the two short, slow-changing blocks at the top and the tall
+ // one in the middle: the permit ribbon used to sit below a tree that can
+ // reach twenty-six rows, so the one thing an operator glances at was the
+ // one thing they had to scroll for. And because the row cap is spent in
+ // this order, a busy tree can no longer crowd the ribbon off the chart.
+ var ZONE_ORDER = ["governance", "participants", "execution", "judgement"];
+ // What the gutter cannot say in one word. A zone caption is one word, a
+ // KIND is a four-letter abbreviation, and a flat lane's label says nothing
+ // about which of the record's forty-two stream types land on it. All three
+ // are hoverable, and this is what they say.
+ var ZONE_ABOUT = {
+ governance: {
+ note:
+ "A condition over a range, so it is drawn as a ribbon rather than as dots. " +
+ "From dots you would learn that something was sampled, never whether it held.",
+ reads: "must hold before anything runs",
+ },
+ participants: {
+ note:
+ "Things with a lifetime of their own that a run involves. Many-to-many: " +
+ "one sample can be measured by several runs, and one run can involve several.",
+ reads: "must exist before a run can measure it",
+ },
+ execution: {
+ note:
+ "A containment tree. A campaign holds runs, a run holds procedures, and bar " +
+ "thickness carries the depth so the nesting reads without a second colour.",
+ reads: "what is happening",
+ },
+ judgement: {
+ note:
+ "Point conclusions with no lifetime of their own, so these are flat lanes of " +
+ "marks rather than bars.",
+ reads: "what came out",
+ },
+ };
+ // The abbreviation in the KIND column, spelled out.
+ var KIND_NAME = {
+ CAMP: "Campaign",
+ RUN: "Run",
+ PROC: "Procedure",
+ SUBJ: "Subject",
+ ENCL: "Enclosure permit",
+ CLR: "Clearance",
+ };
+ // Which stream types land on each flat lane, inverted from the table that
+ // files them. Read off that table rather than written out again, so the two
+ // can never drift: the lane card answers "what counts as Equipment" with
+ // the list the page actually routes by.
+ var DOMAIN_TYPES = {};
+ Object.keys(STREAM_TYPE_TO_DOMAIN).forEach(function (t) {
+ var d = STREAM_TYPE_TO_DOMAIN[t];
+ (DOMAIN_TYPES[d] = DOMAIN_TYPES[d] || []).push(t);
+ });
+
+ // One line per channel: "812 / 1500, 54%". The table this replaces drew a
+ // bar, which a chart made mostly of bars cannot afford a second meaning
+ // for: every other bar here is a span of TIME, and one that was a fraction
+ // of work instead would be read as the first.
+ function progressLines(progress) {
+ var roles = progress ? Object.keys(progress) : [];
+ return roles.sort().map(function (role) {
+ var r = progress[role];
+ if (!r.commanded_total) return [role, String(r.value)];
+ var pct = Math.round((r.value / r.commanded_total) * 100);
+ return [role, r.value + " / " + r.commanded_total + ", " + pct + "%"];
+ });
+ }
+
+ // Named, not counted. "685 events" says a lane is busy; "tomo-4471-a,
+ // Registered, from R-4471" is the row the dropped table carried.
+ // Guarded against a null snapshot, not just a missing list: the page paints
+ // once before the first one lands, which is the whole reason `buildRows`
+ // reads every list through an accessor rather than off the object.
+ function datasetLines(snap) {
+ return ((snap && snap.datasets) || []).map(function (d) {
+ return [d.name, d.status];
+ });
+ }
+ function decisionLines(snap) {
+ return ((snap && snap.decisions) || []).map(function (d) {
+ return [
+ d.choice,
+ (d.confidence_band || "no band") +
+ (d.created_at ? ", " + clockOf(d.created_at) : ""),
+ ];
+ });
+ }
+
+ function clockOf(iso) {
+ if (!iso) return null;
+ var d = new Date(iso);
+ if (isNaN(d)) return null;
+ var p = function (n) { return String(n).padStart(2, "0"); };
+ return p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds());
+ }
+ // Which zone each domain's flat lane belongs to. A leftover event -- one
+ // whose instance is not on screen -- lands in its OWN zone rather than at
+ // the bottom of the page: a stray Run event is execution, not a judgement,
+ // and filing it under the last heading would make the zones lie about what
+ // they group.
+ //
+ // `other` deliberately has NO zone. It is the catch-all for a stream type
+ // this page has never heard of, and there is no honest zone for that:
+ // filing an unknown under the last heading is how a Mount came to be drawn
+ // as a judgement. It renders after every zone, under no caption.
+ var DOMAIN_ZONE = {
+ runs: "execution",
+ procedures: "execution",
+ campaigns: "execution",
+ recipes: "execution",
+ subjects: "participants",
+ equipment: "participants",
+ supplies: "participants",
+ actors: "participants",
+ enclosures: "governance",
+ clearances: "governance",
+ visits: "governance",
+ allocations: "governance",
+ authority: "governance",
+ datasets: "judgement",
+ decisions: "judgement",
+ cautions: "judgement",
+ attestations: "judgement",
+ calibrations: "judgement",
+ other: null,
+ };
+ // A lane whatever happens, so its zone is never a bare caption and so an
+ // empty one reads as "nothing happened here" rather than as a row nobody
+ // drew. Every other domain appears only when it holds something.
+ var ALWAYS_ON = ["datasets", "decisions", "cautions", "other"];
+ // Domains that get instance TRACKS above their flat lane. Only for these
+ // can a flat lane mean "the ones with no row of their own"; for the rest it
+ // is the domain's only home, and calling it elsewhere would be a lie.
+ var TRACKED_DOMAINS = ["runs", "procedures", "campaigns", "subjects", "enclosures", "clearances"];
+ // `Unknown` is its own state, never blended into a neighbour: not-observed
+ // is not the same as fine, and smoothing over it would assert a safety
+ // condition nobody measured.
+ var PERMIT_TONE = { Permitted: "good", NotPermitted: "bad", Unknown: "unknown" };
+
+ // `shortId` returns "" for a missing id, which is right for a table cell
+ // and wrong for a row heading: a blank row says nothing.
+ function instanceLabel(name, id) {
+ return name || shortId(id) || "unnamed";
+ }
+
+ function eventPoint(e, kin) {
+ return {
+ kin: (kin && kin[e.stream_id]) || null,
+ t: e.occurred_at,
+ label: e.event_type,
+ tier: EVENT_TIER[e.event_type] || 0,
+ // Relationship fields ride through untouched. A producer older than
+ // this page simply omits them and every one reads undefined, which is
+ // the same state as an event that genuinely has no cause: the chart
+ // then behaves exactly as it did before they existed.
+ id: e.event_id,
+ corr: e.correlation_id,
+ cause: e.causation_id,
+ cause_at: e.cause_occurred_at,
+ };
+ }
+
+ // Each observation opens a segment running to the next, and the last runs
+ // open to now: a permit is a standing claim, not a reading that expires the
+ // moment it was taken.
+ function permitSegments(doc) {
+ if (!doc || !doc.lanes) return null;
+ var lane = null;
+ doc.lanes.forEach(function (l) {
+ if (l.lane_id === "permit") lane = l;
+ });
+ if (!lane || !lane.points.length) return null;
+ return lane.points.map(function (p, i) {
+ var next = lane.points[i + 1];
+ return {
+ from: p.t,
+ to: next ? next.t : null,
+ tone: PERMIT_TONE[p.state] || "unknown",
+ h: 9,
};
});
+ }
+
+ // Every activity event lands somewhere: on its instance's track when that
+ // instance is on screen, otherwise in its domain's flat lane. Nothing is
+ // dropped for want of a home -- an event vanishing because its Run had
+ // already finished would make a busy window look quiet.
+ // ---- What the record BINDS to what ----------------------------------
+ //
+ // A second channel beside the causal chain, and a strictly separate one:
+ // causation comes off an event, kinship comes off the projection, and
+ // merging them into one visual would leave no way to tell "this caused
+ // that" from "the record says these belong together".
+ //
+ // Only relations the read models actually carry are drawn. Three that a
+ // viewer would reasonably expect are NOT here, because the record does not
+ // hold them, and inferring one from co-occurrence in time would be the
+ // chart asserting a fact CORA never recorded:
+ //
+ // permit <-> run EnclosureSummaryItem has no run and a run has no
+ // enclosure. They share a clock, nothing more.
+ // decision <-> run DecisionSummaryItem carries `decided_by` and
+ // `parent_id`. Neither is a run or a subject.
+ // caution <-> anything Cautions are not in the snapshot at all; the page
+ // sees them only as events on a flat lane.
+ //
+ // Containment is absent too, deliberately. Campaign holds run holds
+ // procedure is already drawn, by nesting and by bar thickness, so a tick
+ // repeating it would spend the channel on the one thing the layout already
+ // answers.
+ // Keyed by STREAM id, not by lane. A run has a row of its own and a
+ // dataset does not, so kinship has to be able to hang off one event sitting
+ // on a lane it shares with hundreds of others. The two directions are
+ // recorded independently, because a relation can be drawable one way and
+ // not the other: a dataset event can tick the run that wrote it, and the
+ // run cannot tick back at a row that does not exist.
+ function buildKin(snap, laneIdFor) {
+ var kin = {};
+ function point(fromId, toId, why) {
+ var lane = laneIdFor(toId);
+ if (!fromId || !lane) return;
+ if (laneIdFor(fromId) === lane) return;
+ (kin[fromId] = kin[fromId] || {})[lane] = why;
+ }
+ // Each side says its own half. The card prints the phrase belonging to
+ // whatever is pinned, so a run reads "measuring SMP-115" and the sample
+ // reads "measured by R-4471" -- one relation, stated from wherever the
+ // reader happens to be standing.
+ function bind(aId, bId, aSays, bSays) {
+ point(aId, bId, aSays);
+ point(bId, aId, bSays);
+ }
+ var rows = function (key) {
+ return (snap && snap[key]) || [];
+ };
+ rows("runs").forEach(function (r) {
+ bind(r.run_id, r.subject_id, "measuring", "measured by");
+ });
+ rows("datasets").forEach(function (d) {
+ bind(d.dataset_id, d.producing_run_id, "written by", "wrote");
+ bind(d.dataset_id, d.subject_id, "written from", "source of");
+ });
+ // Cover either reaches a row or it does not. This is the question anyone
+ // looking at a clearance on a live page is actually asking, and until the
+ // bindings rode the wire the ribbon could not answer it.
+ rows("clearances").forEach(function (cl) {
+ ["run_binding_ids", "procedure_binding_ids", "subject_binding_ids"].forEach(function (k) {
+ (cl[k] || []).forEach(function (id) {
+ bind(cl.clearance_id, id, "covers", "covered by");
+ });
+ });
+ });
+ return kin;
+ }
+
+ function buildRows(buffer, snap, nowIso) {
+ // Every list is read through `rows`, never off `snap` directly. The page
+ // renders once before the first snapshot lands, so `flowing.snapshot`
+ // starts as a stub, and each list this function learns to use has to be
+ // added to that stub too or the first paint throws. Reading them here
+ // instead means the stub can never fall behind: the day a new list is
+ // added, an old producer that does not send it and the pre-snapshot paint
+ // both take the same empty path.
+ var rows = function (key) {
+ return (snap && snap[key]) || [];
+ };
+ var lanes = [];
+ var byStream = {};
+ var pendingFlat = {};
+ buffer.forEach(function (e) {
+ (byStream[e.stream_id] = byStream[e.stream_id] || []).push(e);
+ });
+ var claimed = {};
+ var trackCount = 0;
+ var dropped = 0;
+
+ // Which streams get a row of their own. Built before anything is drawn,
+ // because kinship names the lane it points AT and a relation to a row
+ // that will not exist cannot be drawn either way.
+ var hasRow = {};
+ ["runs", "procedures", "campaigns", "subjects", "clearances"].forEach(function (key) {
+ var idKey = { runs: "run_id", procedures: "procedure_id", campaigns: "campaign_id",
+ subjects: "subject_id", clearances: "clearance_id" }[key];
+ rows(key).forEach(function (r) { hasRow[r[idKey]] = true; });
+ });
+ var kin = buildKin(snap, function (id) {
+ return id && hasRow[id] ? "track:" + id : null;
+ });
+
+ function pointsFor(streamId) {
+ claimed[streamId] = true;
+ return (byStream[streamId] || []).map(function (e) { return eventPoint(e, kin); });
+ }
+ function track(kind, label, depth, from, to, streamId, extra) {
+ if (trackCount >= MAX_TRACK_ROWS) {
+ dropped += 1;
+ return;
+ }
+ trackCount += 1;
+ var row = {
+ lane_id: "track:" + streamId,
+ label: label,
+ render: "track",
+ kin: kin[streamId] || null,
+ kind: kind,
+ depth: depth,
+ from: from,
+ to: to || null,
+ points: pointsFor(streamId),
+ };
+ if (extra) {
+ for (var k in extra) row[k] = extra[k];
+ }
+ // Assembled here rather than at each call site so every track row gets
+ // the same three facts in the same order, and a caller only has to
+ // supply what is particular to its own kind.
+ row.about = {
+ head: label,
+ note: (extra && extra.note) || null,
+ rows: [["kind", KIND_NAME[kind] || kind]]
+ .concat((extra && extra.facts) || [])
+ .concat([
+ ["begins", clockOf(from) || "before this window"],
+ ["ends", clockOf(to) || "open, still running"],
+ ["events on this row", String(row.points.length)],
+ ]),
+ };
+ delete row.facts;
+ delete row.note;
+ lanes.push(row);
+ }
+ function zone(key) {
+ var a = ZONE_ABOUT[key];
+ var mine = DOMAIN_ORDER.filter(function (k) { return DOMAIN_ZONE[k] === key; });
+ lanes.push({
+ lane_id: "zone:" + key,
+ label: ZONES[key],
+ render: "zone",
+ points: [],
+ about: {
+ head: ZONES[key],
+ note: a.note,
+ rows: [
+ ["reads as", a.reads],
+ // Why THIS zone is at this height, which is the question a
+ // reordered page invites and the one a caption cannot answer.
+ [
+ "position",
+ ZONE_ORDER.indexOf(key) + 1 + " of " + ZONE_ORDER.length +
+ ", each a precondition for the next",
+ ],
+ ["groups", mine.map(function (k) { return DOMAIN_LABEL[k]; }).join(", ")],
+ ],
+ },
+ });
+ }
+ // Called at the END of each zone, once its tracks have claimed what they
+ // can, so a leftover sits under the heading it actually belongs to.
+ function flatFor(zoneKey) {
+ DOMAIN_ORDER.forEach(function (key) {
+ if (DOMAIN_ZONE[key] !== zoneKey) return;
+ var points = pendingFlat[key] || [];
+ if (ALWAYS_ON.indexOf(key) === -1 && !points.length) return;
+ var tracked = TRACKED_DOMAINS.indexOf(key) !== -1;
+ lanes.push({
+ lane_id: "domain:" + key,
+ label: tracked ? DOMAIN_LABEL[key] + " \u00b7 elsewhere" : DOMAIN_LABEL[key],
+ noun: DOMAIN_NOUN[key] || DOMAIN_LABEL[key],
+ render: "markers",
+ points: points,
+ hint: tracked
+ ? DOMAIN_LABEL[key] +
+ " with no row of their own above: the instance ended before this " +
+ "window opened, or it fell past the " + MAX_TRACK_ROWS + "-row cap."
+ : key === "other"
+ ? "Stream types this page has no domain for. Not a zone, because " +
+ "there is no honest zone for an unknown."
+ : null,
+ about: {
+ head: DOMAIN_LABEL[key],
+ // The instances behind the marks, where the snapshot names them
+ // and the set is bounded. Datasets are capped by the open-run
+ // count and decisions by the producer's own ring, so neither can
+ // run away; no other domain ships instances at all, and inventing
+ // a list for one that does not would be worse than the marks
+ // alone.
+ list:
+ key === "datasets"
+ ? datasetLines(snap)
+ : key === "decisions"
+ ? decisionLines(snap)
+ : null,
+ note: tracked
+ ? "Events whose instance has no row of its own above: it ended before " +
+ "this window opened, or it fell past the " + MAX_TRACK_ROWS + "-row cap. " +
+ "Nothing is dropped for want of a home."
+ : key === "other"
+ ? "The catch-all for a stream type this page has no domain for. It sits " +
+ "under no zone heading, because there is no honest zone for an unknown."
+ : "These have no lifetime of their own on this chart, so every event in " +
+ "the domain lands on one lane rather than on a row per instance.",
+ rows: [
+ [
+ "records",
+ (DOMAIN_TYPES[key] || []).join(", ") || "anything this page cannot file",
+ ],
+ ["events on this lane", String(points.length)],
+ [
+ "zone",
+ DOMAIN_ZONE[key] ? ZONES[DOMAIN_ZONE[key]] : "none, by design",
+ ],
+ ],
+ },
+ });
+ });
+ }
+
+ // Every track claims its own stream first. Only then is it known which
+ // events have no home, and a flat lane emitted before that pass would
+ // claim events a later track was about to take.
+ function claimAll() {
+ rows("runs").forEach(function (r) { claimed[r.run_id] = true; });
+ rows("procedures").forEach(function (pr) { claimed[pr.procedure_id] = true; });
+ rows("campaigns").forEach(function (c) { claimed[c.campaign_id] = true; });
+ rows("subjects").forEach(function (sj) { claimed[sj.subject_id] = true; });
+ Object.keys(enclosureTimelines).forEach(function (id) { claimed[id] = true; });
+ rows("clearances").forEach(function (cl) { claimed[cl.clearance_id] = true; });
+ DOMAIN_ORDER.forEach(function (key) { pendingFlat[key] = []; });
+ buffer.forEach(function (e) {
+ if (claimed[e.stream_id]) return;
+ pendingFlat[STREAM_TYPE_TO_DOMAIN[e.stream_type] || "other"].push(eventPoint(e, kin));
+ });
+ }
+ claimAll();
+
+ function execution() {
+ var procsOfRun = {};
+ rows("procedures").forEach(function (pr) {
+ (procsOfRun[pr.parent_run_id] = procsOfRun[pr.parent_run_id] || []).push(pr);
+ });
+ var runsOfCampaign = {};
+ var loose = [];
+ rows("runs").forEach(function (r) {
+ if (r.campaign_id) (runsOfCampaign[r.campaign_id] = runsOfCampaign[r.campaign_id] || []).push(r);
+ else loose.push(r);
+ });
+ function emitRun(r, depth) {
+ track("RUN", instanceLabel(r.name, r.run_id), depth, r.started_at, null, r.run_id, {
+ note:
+ "One measurement, from the moment it started to now. The marks on the bar are " +
+ "its own events; the rows indented under it are its phases.",
+ facts: [["status", r.status]]
+ // A Plan is a TEMPLATE. It has no lifetime during a shift, so it
+ // never earns a row of its own, and a row per template would be a
+ // page of empty bars. What is worth knowing is the other direction:
+ // which template THIS run is an instance of. Absent reads as
+ // unknown, never as a run with no plan, because every run has one.
+ .concat(r.plan_name ? [["executing", r.plan_name]] : [])
+ .concat(progressLines(r.progress)),
+ });
+ (procsOfRun[r.run_id] || []).forEach(function (pr) {
+ // A Procedure that has reached a terminal status stops where it
+ // stopped. `last_status_changed_at` is when that happened; without it
+ // the bar would run to now and claim the phase is still going.
+ var open = pr.status === "Running" || pr.status === "Held" || pr.status === "Defined";
+ track(
+ "PROC",
+ instanceLabel(pr.name || pr.kind, pr.procedure_id),
+ depth + 1,
+ pr.registered_at,
+ open ? null : pr.last_status_changed_at,
+ pr.procedure_id,
+ {
+ note:
+ "A phase of the run above it. A procedure that has stopped stops where it " +
+ "stopped, so a bar ending mid-chart is a phase that finished, not one cut off.",
+ facts: [
+ ["kind of phase", pr.kind],
+ ["status", pr.status],
+ ["iterations", pr.iteration_count === undefined ? null : String(pr.iteration_count)],
+ ],
+ }
+ );
+ });
+ }
+ rows("campaigns").forEach(function (c) {
+ var mine = runsOfCampaign[c.campaign_id];
+ // A Campaign with nothing running under it is a table row, not a track:
+ // an empty bar spanning the window says only that the record has one.
+ if (!mine || !mine.length) return;
+ var starts = mine
+ .map(function (r) { return r.started_at; })
+ .filter(Boolean)
+ .sort();
+ track("CAMP", instanceLabel(c.name, c.campaign_id), 0, starts[0] || null, null, c.campaign_id, {
+ note:
+ "A programme of work. The runs indented under it belong to it; a campaign with " +
+ "nothing running is a table row rather than a bar, so an empty one never appears here.",
+ facts: [["intent", c.intent], ["status", c.status], ["runs", String(mine.length)]],
+ });
+ mine.forEach(function (r) { emitRun(r, 1); });
+ });
+ loose.forEach(function (r) { emitRun(r, 0); });
+ }
+
+ function participants() {
+ rows("subjects").forEach(function (sj) {
+ track("SUBJ", instanceLabel(sj.name, sj.subject_id), 0, sj.created_at, null, sj.subject_id, {
+ tone: sj.status === "Mounted" || sj.status === "Measured" ? "good" : null,
+ note:
+ "What is being measured. A sample has its own lifetime and several runs can " +
+ "involve it, which is why it is a row of its own rather than a field on a run.",
+ facts: [["status", sj.status]],
+ });
+ });
+
+ }
+
+ var permitRows = 0;
+ function governance() {
+ Object.keys(enclosureTimelines).forEach(function (encId) {
+ var segs = permitSegments(enclosureTimelines[encId]);
+ if (!segs) return;
+ permitRows += 1;
+ var encName = instanceLabel(enclosureTimelines[encId].title, encId);
+ // Ribbon and observations get SEPARATE rows. On one row the
+ // observations pack into a bar that covers the very condition they were
+ // folded into. The ribbon answers "did it hold"; the row under it keeps
+ // each observation pickable, which is what a causal edge into a permit
+ // drop has to land on.
+ if (trackCount < MAX_TRACK_ROWS) {
+ trackCount += 1;
+ lanes.push({
+ lane_id: "ribbon:" + encId,
+ label: encName,
+ render: "track",
+ kind: "ENCL",
+ depth: 0,
+ from: segs[0].from,
+ to: null,
+ segments: segs,
+ points: [],
+ about: {
+ head: encName,
+ note:
+ "Whether the hutch was safe to be in, over time. Each segment runs to the " +
+ "next observation, because a permit is a standing claim rather than a " +
+ "reading that expires the moment it was taken. Unknown is its own state " +
+ "and is never blended into a neighbour.",
+ rows: [
+ ["kind", KIND_NAME.ENCL],
+ ["now", segs[segs.length - 1].tone === "good" ? "Permitted"
+ : segs[segs.length - 1].tone === "bad" ? "Not permitted" : "Unknown"],
+ ["changes held", String(segs.length)],
+ ["observations", String((byStream[encId] || []).length)],
+ ],
+ },
+ });
+ }
+ lanes.push({
+ lane_id: "domain:permit:" + encId,
+ label: encName + " \u00b7 observed",
+ render: "markers",
+ points: pointsFor(encId),
+ about: {
+ head: encName + ", observed",
+ note:
+ "The observations the ribbon above was folded from, kept on their own row. " +
+ "On one row they would pack into a bar covering the very condition they " +
+ "describe, and a causal arrow into a permit drop has to land on one of them.",
+ rows: [
+ ["records", "Enclosure"],
+ ["events on this lane", String((byStream[encId] || []).length)],
+ ["zone", ZONES.governance],
+ ],
+ },
+ });
+ });
+
+ // A clearance is a CONDITION OVER A RANGE, the same shape as a permit and
+ // not the shape of a conclusion: it carries `valid_from` and
+ // `valid_until`, and drawn as dots you learn only that somebody touched
+ // it, never whether cover was in force while a run was going. It was in
+ // Judgement, which was the wrong zone for it.
+ rows("clearances").forEach(function (cl) {
+ // Cover already granted and not yet expired runs OPEN to the right
+ // edge. An expiry in the future is a date, not an ending, and drawing
+ // the bar to it would stop it somewhere inside the chart and read as
+ // cover that has already lapsed.
+ var until = cl.valid_until && Date.parse(cl.valid_until) <= Date.parse(nowIso)
+ ? cl.valid_until
+ : null;
+ track(
+ "CLR",
+ instanceLabel(cl.template_code, cl.clearance_id),
+ 0,
+ cl.valid_from || cl.registered_at || null,
+ until,
+ cl.clearance_id,
+ {
+ tone: cl.status === "Active" ? "good" : "unknown",
+ note:
+ "Cover over a range. The bar is when it is in force; what it reaches is " +
+ "listed below, and a clearance that reaches nothing on screen lists nothing.",
+ facts: [
+ ["status", cl.status],
+ ["risk band", cl.risk_band],
+ ["expires", clockOf(cl.valid_until) || "no expiry set"],
+ ],
+ }
+ );
+ });
+ }
+
+ // Judgement has no instance rows at all, only its flat lanes.
+ function judgement() {}
+
+ var ZONE_BODY = {
+ execution: execution,
+ participants: participants,
+ governance: governance,
+ judgement: judgement,
+ };
+ ZONE_ORDER.forEach(function (key) {
+ zone(key);
+ ZONE_BODY[key]();
+ // At the END of each zone, once its tracks have claimed what they can,
+ // so a leftover sits under the heading it actually belongs to.
+ flatFor(key);
+ });
+
+ // Under no zone at all: see DOMAIN_ZONE.
+ flatFor(null);
+
+ return { lanes: lanes, dropped: dropped, tracks: trackCount, permits: permitRows };
+ }
+
+ function activityToTimelineDocument(buffer, domainFromIso, domainToIso) {
+ var built = buildRows(buffer, flowing.snapshot, domainToIso);
+ var runStarts = 0;
buffer.forEach(function (e) {
- var domain = STREAM_TYPE_TO_DOMAIN[e.stream_type] || "other";
- lanesByDomain[domain].points.push({ t: e.occurred_at, label: e.event_type });
+ if (e.event_type === "RunStarted") runStarts++;
});
+ var retainedHours = Math.round(FLOWING_WINDOW_MS / 3600000);
+ var subtitle =
+ buffer.length +
+ " event" +
+ (buffer.length === 1 ? "" : "s") +
+ " retained over " +
+ retainedHours +
+ "h";
+ // A "run started here" cue without the cost of a track per run. It counts
+ // only starts INSIDE the window: a run already under way when the window
+ // opened emitted its RunStarted before the buffer began and is invisible
+ // here, the same blind spot the flowing view has for anything older.
+ var note = retentionNote(buffer);
+ if (note) subtitle += " \u00b7 " + note;
+ if (runStarts > 0) {
+ subtitle += " · " + runStarts + " run" + (runStarts === 1 ? "" : "s") + " started";
+ }
+ // The tree is capped, and a cap that says nothing turns a trimmed picture
+ // into a complete-looking one.
+ if (built.dropped > 0) {
+ subtitle += " · " + built.dropped + " track" + (built.dropped === 1 ? "" : "s") + " not shown";
+ }
return {
// No lane here represents one subject's current status the way
// REWIND's Run lifecycle lane does, so this deliberately names a
@@ -565,14 +1453,37 @@
Rewind
// through the generic "last event" path in renderReadout.
subject_lane_id: "__no_subject__",
title: "Live activity",
- subtitle: buffer.length + " event" + (buffer.length === 1 ? "" : "s") + " in the last 15m",
+ subtitle: subtitle,
+ // The right edge of a flowing window is now, not the last event. Without
+ // this the scrubber cannot tell the two apart and would label a closed
+ // REWIND timeline LIVE as well.
+ live: true,
domain: { from: domainFromIso, to: domainToIso },
- lanes: DOMAIN_ORDER.map(function (key) {
- return lanesByDomain[key];
- }),
+ lanes: built.lanes,
};
}
+ // What the left edge of the buffer actually means. Panning into empty space
+ // looks exactly like a quiet beamline, so the page says which it is rather
+ // than letting the absence speak. Reported in the subtitle because that is
+ // the one line always on screen no matter where the chart is panned.
+ function retentionNote(buffer) {
+ if (flowing.replayTruncated) return "relay buffer capped, earlier events dropped";
+ if (buffer.length === 0) return "";
+ var oldest = Infinity;
+ buffer.forEach(function (e) {
+ var t = Date.parse(e.occurred_at);
+ if (t < oldest) oldest = t;
+ });
+ var ageMin = Math.round((Date.now() - oldest) / 60000);
+ // Comfortably short of the retention window means the relay has not been
+ // up long enough to fill it, so there is nothing older to pan to.
+ if (ageMin < FLOWING_WINDOW_MS / 60000 - 30) {
+ return "nothing retained before " + ageMin + "m ago";
+ }
+ return "";
+ }
+
function updateFlowingStatus() {
if (flowing.following) {
flowingStatusEl.textContent = "";
@@ -603,18 +1514,54 @@
Rewind
}
flowingResumeEl.addEventListener("click", resumeFollowing);
+ // Narrowing the window is a deliberate move to look closely, so it snaps
+ // back to the live edge rather than leaving the viewer somewhere arbitrary
+ // in the middle of the buffer.
+ var flowingWindowEl = document.getElementById("flowing-window");
+ function renderFlowingWindowPicker() {
+ flowingWindowEl.innerHTML = "";
+ FLOWING_WINDOWS.forEach(function (opt) {
+ var b = document.createElement("button");
+ b.type = "button";
+ b.textContent = opt[0];
+ b.setAttribute("aria-pressed", String(flowing.viewSpanSecs === opt[1]));
+ b.addEventListener("click", function () {
+ flowing.viewSpanSecs = opt[1];
+ renderFlowingWindowPicker();
+ resumeFollowing();
+ });
+ flowingWindowEl.appendChild(b);
+ });
+ }
+ renderFlowingWindowPicker();
+
+ // The domain starts at the oldest event actually held, not a flat 24 hours
+ // back. A relay that came up ten minutes ago holds ten minutes; letting the
+ // domain claim a day would let a viewer drag through 23 hours of blank
+ // chart, which is indistinguishable from a beamline that sat idle all
+ // night. Never later than the view span, or there is nothing to pan.
+ function flowingDomainStartMs(nowMs) {
+ var floor = nowMs - FLOWING_WINDOW_MS;
+ var oldest = nowMs - flowing.viewSpanSecs * 1000;
+ flowing.buffer.forEach(function (e) {
+ var t = Date.parse(e.occurred_at);
+ if (t >= floor && t < oldest) oldest = t;
+ });
+ return Math.max(floor, oldest);
+ }
+
function renderFlowing() {
var nowMs = Date.now();
pruneFlowingBuffer(nowMs);
var doc = activityToTimelineDocument(
flowing.buffer,
- new Date(nowMs - FLOWING_WINDOW_MS).toISOString(),
+ new Date(flowingDomainStartMs(nowMs)).toISOString(),
new Date(nowMs).toISOString()
);
window.CoraScrubber.mount(flowingStageEl, doc, {
chromeTitle: "Live activity",
subtitle: doc.subtitle,
- sliderLabel: "Fold cursor: time within the last 15 minutes",
+ viewSpanSecs: flowing.viewSpanSecs,
follow: true,
showPlay: false,
showJumpLast: false,
@@ -625,6 +1572,7 @@
Rewind
function handleActivity(msg) {
var events = msg.events || [];
+ if (msg.replay_truncated) flowing.replayTruncated = true;
if (events.length === 0) return;
flowing.buffer = flowing.buffer.concat(events);
pruneFlowingBuffer(Date.now());
@@ -636,24 +1584,54 @@
Rewind
}
// The window keeps sliding even with no NEW activity: an event that
- // scrolled out the left edge an hour ago should disappear from view on
- // its own, not wait for a fresh event to trigger the re-render that
- // would notice it.
+ // scrolled out the left edge should disappear from view on its own, not
+ // wait for a fresh event to trigger the re-render that would notice it.
+ //
+ // This used to re-slide once a MINUTE, and since every re-slide is a full
+ // mount() teardown and rebuild, a quiet page sat perfectly still and then
+ // jumped a sixtieth of its width in one frame. That is what made a feed
+ // arriving every 2s read as a page that updates once a minute. Data rate was
+ // never the problem; the absence of visible motion was. Re-sliding several
+ // times a second costs one rebuild of a few hundred SVG nodes and makes the
+ // right edge visibly creep, which is the whole signal that says "running".
+ // Re-sliding is a full mount() teardown, so anything the viewer is currently
+ // touching would be destroyed under them: a native tooltip needs its
+ // element to survive about a second before it ever appears, and keyboard
+ // focus on the slider would be thrown away twice a second. The rule is that
+ // the window slides only while nobody is interacting with it. A held pointer
+ // costs at most a few seconds of staleness, and the moment it leaves, the
+ // next tick catches the window up in one step.
+ var flowingHovered = false;
+ flowingStageEl.addEventListener("pointerenter", function () { flowingHovered = true; });
+ flowingStageEl.addEventListener("pointerleave", function () { flowingHovered = false; });
+
+ function flowingIsBusy() {
+ if (flowingHovered || document.hidden) return true;
+ var active = document.activeElement;
+ return !!active && active !== document.body && flowingStageEl.contains(active);
+ }
+
renderFlowing();
setInterval(function () {
- if (flowing.following) renderFlowing();
+ if (flowing.following && !flowingIsBusy()) renderFlowing();
}, FLOWING_RESLIDE_MS);
function handleSnapshot(msg) {
producerConnected = true;
generatedAt = Date.parse(msg.generated_at);
- renderRuns(msg.runs);
- renderSubjects(msg.subjects);
- renderCampaigns(msg.campaigns);
- renderDatasets(msg.datasets);
- renderClearances(msg.clearances);
- renderEnclosures(msg.enclosures);
- renderDecisions(msg.decisions);
+ // Kept, not just rendered into tables. The structure the chart lays out
+ // with -- which Run belongs to which Campaign, which Procedure is a phase
+ // of which Run -- arrives ONLY here; the activity stream can tell one
+ // instance from another by `stream_id` and nothing more.
+ // The message IS the snapshot, so it is kept whole rather than copied
+ // list by list. The copy that used to stand here named four lists, and
+ // when clearances started being drawn as ribbons the wire carried them,
+ // the tables rendered them and the chart still saw nothing: the field was
+ // dropped in transit by a list that had never been updated. Keeping the
+ // object cannot drop a field that is on it.
+ flowing.snapshot = msg;
+ if (flowing.following && !flowingIsBusy()) renderFlowing();
+ renderRewindPicker(null, msg.enclosures);
renderBanner();
}
@@ -685,7 +1663,7 @@
Rewind
handleSnapshot(msg);
break;
case "run_history_index":
- renderRewindPicker(msg.runs);
+ renderRewindPicker(msg.runs, null);
break;
case "enclosure_timeline":
handleEnclosureTimeline(msg);
@@ -703,19 +1681,41 @@
Rewind
var rewindStageEl = document.getElementById("rewind-stage");
var rewindEmptyEl = document.getElementById("rewind-empty");
- function renderRewindPicker(runs) {
+ // REWIND has TWO subjects and they arrive on different messages, so each
+ // call updates one side and keeps the other. An enclosure's timeline used
+ // to be reachable only by clicking its row in a table; with the tables gone
+ // it needs a way in, and the picker already is one.
+ var rewindRuns = [];
+ var rewindEnclosures = [];
+ function renderRewindPicker(runs, enclosures) {
+ if (runs) rewindRuns = runs;
+ if (enclosures) rewindEnclosures = enclosures;
var selected = rewindPickerEl.value;
while (rewindPickerEl.options.length > 1) {
rewindPickerEl.remove(1);
}
- runs.forEach(function (run) {
- var opt = document.createElement("option");
- opt.value = run.run_id;
+ var group = function (label, items, build) {
+ if (!items.length) return;
+ var g = document.createElement("optgroup");
+ g.label = label;
+ items.forEach(function (item) {
+ var opt = document.createElement("option");
+ build(opt, item);
+ g.appendChild(opt);
+ });
+ rewindPickerEl.appendChild(g);
+ };
+ group("Runs", rewindRuns, function (opt, run) {
+ opt.value = "run:" + run.run_id;
opt.textContent = run.name + " (" + run.status + (run.terminal ? ", finished" : "") + ")";
- rewindPickerEl.appendChild(opt);
+ });
+ group("Enclosures", rewindEnclosures, function (opt, enc) {
+ opt.value = "enclosure:" + enc.enclosure_id;
+ opt.textContent = enc.name + " (" + enc.permit_status + ")";
});
rewindPickerEl.value = selected;
- rewindEmptyEl.style.display = runs.length === 0 ? "block" : "none";
+ rewindEmptyEl.style.display =
+ rewindRuns.length + rewindEnclosures.length === 0 ? "block" : "none";
}
// Maps a RunHistoryEvent's event_type to the state it puts the Run into,
@@ -812,7 +1812,6 @@
Rewind
.then(function (history) {
mountTimelineInStage(runHistoryToTimelineDocument(history), {
chromeTitle: "Rewind",
- sliderLabel: "Fold cursor: time within the run",
});
})
.catch(function (err) {
@@ -833,7 +1832,10 @@
Rewind
}
function showEnclosureTimeline(enclosureId) {
- rewindPickerEl.value = "";
+ // The picker keeps its selection. It used to be blanked here because a
+ // table row was what opened this view and the picker had nothing to do
+ // with it; now the picker IS the control, and blanking it would say
+ // "nothing selected" over an enclosure timeline.
activeEnclosureId = enclosureId;
var doc = enclosureTimelines[enclosureId];
if (!doc) {
@@ -843,7 +1845,6 @@
Rewind
}
mountTimelineInStage(doc, {
chromeTitle: "Rewind",
- sliderLabel: "Fold cursor: time within the enclosure's history",
});
}
@@ -858,11 +1859,19 @@
Rewind
}
rewindPickerEl.addEventListener("change", function () {
- if (rewindPickerEl.value) {
- showRewind(rewindPickerEl.value);
- } else {
+ var v = rewindPickerEl.value;
+ if (!v) {
backToLive();
+ return;
}
+ // Prefixed rather than guessed from the id's shape: both are uuids, and
+ // deciding which of two views to open by inspecting an opaque identifier
+ // is the kind of thing that works until a run and an enclosure collide.
+ var at = v.indexOf(":");
+ var kind = v.slice(0, at);
+ var id = v.slice(at + 1);
+ if (kind === "enclosure") showEnclosureTimeline(id);
+ else showRewind(id);
});
rewindBackEl.addEventListener("click", backToLive);
@@ -871,7 +1880,7 @@
Rewind
return resp.json();
})
.then(function (body) {
- renderRewindPicker(body.runs || []);
+ renderRewindPicker(body.runs || [], null);
})
.catch(function () {
/* the /watch socket's own index frame will populate this shortly */
diff --git a/infra/status-relay/relay.py b/infra/status-relay/relay.py
index 34dd7242c6b..b40a9a6a094 100644
--- a/infra/status-relay/relay.py
+++ b/infra/status-relay/relay.py
@@ -131,7 +131,7 @@
Exceeding `open_timeout` aborts the TCP handshake with no HTTP response
at all, turning a legitimate 504 into an unexplained `Failed to fetch`."""
-_ACTIVITY_BUFFER_SECONDS = 15 * 60
+_ACTIVITY_BUFFER_SECONDS = 24 * 60 * 60
"""How long this relay backfills a freshly-connecting watcher with recent
`"activity"` events, mirroring `page.html`'s own `FLOWING_WINDOW_MS`: a
separate literal, not a shared constant, since this relay imports nothing
@@ -142,6 +142,22 @@
harmless since the browser prunes anything older than its own window on
receipt (`pruneFlowingBuffer`)."""
+_ACTIVITY_BUFFER_MAX_EVENTS = 40_000
+"""Hard ceiling on buffered events, independent of their age. At the measured
+2-BM rate (228 events in the busiest hour) a day is roughly 5,500 events, so
+this is about seven times the expected peak and exists for the case the
+measurement does not cover: a backfill, a migration, or any burst that would
+otherwise let one day of wall-clock consume unbounded memory on the jump host
+and arrive at a browser as one enormous replay message. When it bites, the
+OLDEST events are dropped and `_activity_buffer_truncated` says so, because a
+buffer that silently starts later than it claims makes a busy morning look
+like a quiet one."""
+
+_activity_buffer_truncated = False
+"""Whether the event cap has ever dropped anything this process. Reported to
+watchers in the replay message: absent data must not read as an absence of
+activity."""
+
_PAGE_PATH = Path(__file__).parent / "page.html"
_SCRUBBER_JS_PATH = Path(__file__).parent / "scrubber.js"
@@ -268,8 +284,13 @@ def _store_enclosure_timeline(message: dict[str, Any]) -> None:
def _prune_activity_buffer() -> None:
cutoff = datetime.now(UTC).timestamp() - _ACTIVITY_BUFFER_SECONDS
- global _activity_buffer # noqa: PLW0603
+ global _activity_buffer, _activity_buffer_truncated # noqa: PLW0603
_activity_buffer = [event for event in _activity_buffer if _event_epoch(event) >= cutoff]
+ if len(_activity_buffer) > _ACTIVITY_BUFFER_MAX_EVENTS:
+ dropped = len(_activity_buffer) - _ACTIVITY_BUFFER_MAX_EVENTS
+ _activity_buffer = _activity_buffer[-_ACTIVITY_BUFFER_MAX_EVENTS:]
+ _activity_buffer_truncated = True
+ _log.warning("activity_buffer.capped", extra={"dropped": dropped})
def _event_epoch(event: dict[str, Any]) -> float:
@@ -309,6 +330,11 @@ def _activity_replay_message() -> dict[str, Any] | None:
"producer_id": _producer_id,
"generated_at": datetime.now(UTC).isoformat(),
"events": list(_activity_buffer),
+ # Only ever sent on a replay, and only true when something was
+ # actually dropped: a watcher that panned to the left edge would
+ # otherwise read the start of this buffer as the start of the record.
+ "replay_truncated": _activity_buffer_truncated,
+ "retained_seconds": _ACTIVITY_BUFFER_SECONDS,
}
diff --git a/infra/status-relay/scrubber.js b/infra/status-relay/scrubber.js
index 887b3149e97..6a7c1964623 100644
--- a/infra/status-relay/scrubber.js
+++ b/infra/status-relay/scrubber.js
@@ -49,18 +49,75 @@
const SVGNS = "http://www.w3.org/2000/svg";
const VW = 920;
- const PAD_L = 84;
+ // Wide enough for a kind prefix, an indent and a name: a row is now often
+ // an INSTANCE ("RUN R-4471") rather than a domain ("Runs"), and an
+ // instance that cannot be told from its siblings is not worth a row.
+ const PAD_L = 168;
const PAD_R = 24;
- const LANE_START = 34;
- const LANE_HEIGHT = 40;
+ const LANE_START = 32;
+ const LANE_HEIGHT = 34;
+ // A row is sized for what it has to hold, not to a common pitch: a zone
+ // caption is a divider, a track names itself in the gutter and needs no
+ // room above its bar, and a markers lane has to seat a label over every
+ // burst without it touching the row above.
+ const ROW_H = { zone: 18, track: 20, markers: 28, series: 32 };
+ // The containment tree is only ever three deep, so the step stays a nudge
+ // rather than eating the gutter.
+ const INDENT = 10;
+ // Width of the kind column, which is a fixed 4-character mono word.
+ const KIND_W = 34;
+ // Advance width of the zone caption's own mono face, used to start its rule
+ // clear of the text rather than under it.
+ const ZONE_CH = 5.9;
+ // Thickness carries DEPTH: the campaign reads as the thing containing the
+ // runs, which contain the procedures, without another colour or rule.
+ const TRACK_H = [7, 5, 3];
const MAX_SERIES_LANES = 6;
- const MAX_MARKER_LABELS = 12;
const AXIS_MARGIN = 44;
+ // One event is one square; a group is those squares PACKED side by side, so
+ // a burst's length is how many happened. Each keeps its own severity colour
+ // and its own hit target, which is what makes one event inside a burst
+ // pickable: there is no merged mark to drill into, only neighbours that
+ // stopped overlapping.
+ const MARK_S = 6;
+ const MARK_GAP = 2;
+ const MARK_STEP = MARK_S + MARK_GAP;
+ // Past this many, packing would run a single burst across a third of the
+ // plot and shove its neighbours out of true. Beyond it the group draws as
+ // one bar and takes a count back, which is the one case a number is worth
+ // more than the shape. The bar is taller than a square so that it reads as
+ // a summary rather than as one very long event, and so the digits fit.
+ const PACK_MAX = 8;
+ const MANY_H = 9;
+ const MARK_H = MARK_S;
+ // Two marks closer than this cannot be drawn apart. Derived from the mark
+ // rather than chosen, and deliberately a WIDTH: what can be separated is a
+ // property of the canvas, so a duration constant would silently lie at
+ // every window size but the one it was measured at.
+ const COLLAPSE_GAP = MARK_STEP;
+ // Above the widest measured advance of the label face, not at it: the box
+ // is an estimate used to clamp a label inside the plot, and an estimate
+ // that runs under the truth clamps a label to an edge it then overhangs.
+ const LABEL_CH = 5.85;
+ const LABEL_PAD = 5;
+ const LANE_LABEL_CH = 6.2;
+ // Extra time rendered either side of the view, as a multiple of its span.
+ // A drag translates rather than rebuilds, and this buffer is what gives the
+ // translation something to reveal.
+ const OVERSCAN = 1;
+ // How far the pinned-group halo stands off its pack on every side.
+ const SEL_PAD = 3;
function parseT(iso) {
return Date.parse(iso) / 1000;
}
+ // A track's own `from`/`to` arrive as ISO strings on the document, the same
+ // as every point's `t`, and have to land on the same seconds axis.
+ function trackSecs(model, iso) {
+ return parseT(iso) - model.t0;
+ }
+
function svg(tag, attrs) {
const el = document.createElementNS(SVGNS, tag);
if (attrs) {
@@ -79,11 +136,39 @@
return PAD_L + (secs - scale.dmin) * scale.k;
}
- function buildScale(xmax) {
- const dmin = 0;
- const dmax = xmax > 0 ? xmax : 1;
+ // Ticks land on round wall-clock boundaries, so they stay put as the window
+ // slides instead of renumbering under a moving origin.
+ const TICK_STEPS = [5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200];
+ function pickTickStep(span) {
+ const target = span / 9;
+ for (const s of TICK_STEPS) {
+ if (s >= target) return s;
+ }
+ return TICK_STEPS[TICK_STEPS.length - 1];
+ }
+
+ // The scale now maps a VIEW onto the plot, not the whole domain. When the
+ // view equals the domain (REWIND's default) this is the old behaviour
+ // exactly; when it is narrower, the chart becomes a window that pans.
+ // `bmin`/`bmax` bound what is RENDERED; `dmin`/`dmax` bound what is VISIBLE.
+ // The buffer is clamped to the domain, so a view that already covers the
+ // whole domain (REWIND) renders exactly the domain and nothing more.
+ function buildScale(from, to, domainMax) {
+ const dmin = from;
+ const dmax = to > from ? to : from + 1;
const k = (VW - PAD_L - PAD_R) / (dmax - dmin);
- return { dmin, dmax, k };
+ const pad = (dmax - dmin) * OVERSCAN;
+ const cap = Math.max(dmax, domainMax || 0);
+ return { dmin, dmax, k, bmin: Math.max(0, dmin - pad), bmax: Math.min(cap, dmax + pad) };
+ }
+
+ // Keep the view inside the domain, and never let it grow past it: panning
+ // should run out at the ends rather than drift into blank space that reads
+ // like a quiet period.
+ function clampView(from, span, domainMax) {
+ const width = Math.min(span, domainMax);
+ const start = Math.max(0, Math.min(from, domainMax - width));
+ return { from: start, to: start + width };
}
// Fold every lane forward to time t: for the primary markers lane (if
@@ -135,12 +220,50 @@
lane_id: lane.lane_id,
label: lane.label,
render: lane.render,
+ noun: lane.noun,
+ // `zone` and `track` rows. All optional, all ignored by a document that
+ // does not use them, so the flat-lane shape this module started with
+ // still renders exactly as it did.
+ kind: lane.kind || null,
+ depth: lane.depth || 0,
+ from: lane.from || null,
+ to: lane.to || null,
+ segments: lane.segments || null,
+ tone: lane.tone || null,
+ // What this row IS, for the gutter card. Structured rather than one
+ // string, so it renders in the same card the events use: a heading, a
+ // sentence of what the row's shape means, then facts. The document
+ // supplies every word of it; this module knows what a row looks like
+ // and nothing about what one means.
+ about: lane.about || null,
+ // Rows this row is bound to, as `{ : "why" }`. The
+ // document supplies the graph and the reason; this module knows only
+ // that lanes can name kin, never what a run or a subject is. Both
+ // directions are the document's job to state: a relation drawn in one
+ // direction only would answer "what does this run measure" and stay
+ // silent on "what is measuring this sample".
+ kin: lane.kin || null,
points: (lane.points || [])
.map((p) => ({
secs: parseT(p.t) - t0,
label: p.label,
state: p.state || null,
tone: p.tone || null,
+ // Severity rank, 0 routine / 1 notable / 2 critical. Supplied by
+ // whoever built the document: which event types matter is domain
+ // vocabulary, and this module deliberately knows none.
+ tier: p.tier || 0,
+ // Relationships, all optional. A document that carries none renders
+ // exactly as it did before they existed.
+ id: p.id || null,
+ // Kinship can belong to the EVENT rather than to the row. A run has
+ // a row of its own and a dataset does not, so a dataset's binding
+ // rides on its one event instead of on the lane it shares with
+ // everything else in its domain.
+ kin: p.kin || null,
+ corr: p.corr || null,
+ cause: p.cause || null,
+ cause_at: p.cause_at || null,
value: p.value,
text: p.text != null ? p.text : null,
}))
@@ -156,8 +279,14 @@
const omittedSeries = seriesLanesAll.length - seriesLanes.length;
const orderedSeriesIds = new Set(seriesLanes.map((l) => l.lane_id));
+ // `series` is the only render kind that gets capped, because it is the
+ // only one whose row count is driven by how many numeric channels a
+ // producer happens to have. Everything else the document asked for is
+ // drawn: a zone caption, an instance track and a markers lane are all
+ // deliberate rows, and silently dropping one would leave a gap in a
+ // containment tree with nothing to say a level was missing.
const lanes = rawLanes.filter(
- (l) => l.render === "markers" || orderedSeriesIds.has(l.lane_id)
+ (l) => l.render !== "series" || orderedSeriesIds.has(l.lane_id)
);
const subjectLaneId = doc.subject_lane_id || (markerLanes[0] && markerLanes[0].lane_id);
@@ -168,61 +297,747 @@
for (const p of lane.points) xmax = Math.max(xmax, p.secs);
}
- return { t0, xmax, lanes, primaryLane, omittedSeries };
+ // Indexed once per mount, not per selection: a hover that traced the chain
+ // by scanning every lane would do it at pointer rate.
+ const byId = new Map();
+ const childrenOf = new Map();
+ // Which row an event is ON. Needed to answer "what is the pinned event
+ // bound to", which is a question about its ROW, not about the event.
+ const laneOf = new Map();
+ let hasCausation = false;
+ for (const lane of lanes) {
+ for (const p of lane.points) {
+ if (p.id) byId.set(p.id, p);
+ if (p.id || p.cause) hasCausation = true;
+ laneOf.set(p, lane);
+ }
+ }
+ for (const lane of lanes) {
+ for (const p of lane.points) {
+ if (!p.cause) continue;
+ const kids = childrenOf.get(p.cause);
+ if (kids) kids.push(p);
+ else childrenOf.set(p.cause, [p]);
+ }
+ }
+
+ return {
+ t0,
+ xmax,
+ lanes,
+ primaryLane,
+ omittedSeries,
+ // A FLOWING window and a CLOSED history want opposite things at the
+ // right edge, and the document already knows which it is. Live: a rule
+ // marking the present, and no roaming cursor -- there is no "state at a
+ // time you point at" to read, only what is current. Closed: a fold
+ // cursor the viewer drives, and no live rule, because nothing about that
+ // document is live. Deriving it here rather than from a caller option
+ // means every mount agrees, rather than each caller remembering to.
+ live: !!doc.live,
+ byId,
+ childrenOf,
+ laneOf,
+ // Whether this document carries causation AT ALL. A REWIND run history
+ // has none, so every point there looks causeless -- and calling that "an
+ // operator acted directly" would state a fact the document never
+ // supplied. Absent data must not read as a positive finding.
+ hasCausation,
+ };
+ }
+
+ // The lane already names the aggregate, so repeating it in every label costs
+ // a third of the axis for nothing: on a lane called "Procedures",
+ // `ProcedureIterationStarted` says no more than `IterationStarted`. Derived
+ // from the lane's own label rather than a table of domain nouns, so this
+ // module stays subject-neutral the way its header promises.
+ function stripLanePrefix(label, laneLabel, laneNoun) {
+ // The lane's own noun where the document supplies one, because a lane
+ // label is written to fit a gutter and a shortened one is not the
+ // aggregate's name: "Calibs" leaves the stem "Calib", which matches the
+ // front of "CalibrationRecorded" and cuts it to "rationRecorded".
+ const noun = (laneNoun || laneLabel || "").replace(/s$/, "");
+ if (noun.length < 3 || label.length <= noun.length) return label;
+ if (label.indexOf(noun) !== 0) return label;
+ // What is left has to be a WORD. These names are PascalCase, so the
+ // remainder starting lowercase means the cut landed inside one -- the
+ // failure above, and the only thing separating it from a correct strip.
+ const rest = label.slice(noun.length);
+ return /^[A-Z]/.test(rest) ? rest : label;
}
- function renderTimeline(model, scale) {
- const laneCount = Math.max(1, model.lanes.length);
- const axisY = LANE_START + laneCount * LANE_HEIGHT + 10;
+ // Grouping is a RENDERING concern only: `lane.points` keeps every point,
+ // because `foldTo` walks the primary lane for the last one carrying a
+ // `state` and a merged point would break REWIND's folded readout.
+ function clusterPoints(points, X) {
+ const out = [];
+ let cur = null;
+ for (const p of points) {
+ const x = X(p.secs);
+ if (cur && x - cur.xEnd < COLLAPSE_GAP) {
+ cur.items.push(p);
+ cur.xEnd = x;
+ } else {
+ if (cur) out.push(cur);
+ cur = { xStart: x, xEnd: x, items: [p] };
+ }
+ }
+ if (cur) out.push(cur);
+ return out;
+ }
+
+ // How wide a group draws once its members are packed, and where it starts.
+ // Centred on the time the group occupies, so the bar sits over its events
+ // rather than growing off one end of them.
+ function packWidth(count) {
+ return Math.min(count, PACK_MAX) * MARK_STEP - MARK_GAP;
+ }
+ function packStart(cluster) {
+ return (cluster.xStart + cluster.xEnd) / 2 - packWidth(cluster.items.length) / 2;
+ }
+
+ // Packing makes a group WIDER than the span that formed it, so two groups
+ // that did not overlap as points can overlap as bars. Merge until they do
+ // not: without this the packed bars collide and the thing this whole
+ // rendering exists to prevent comes back in a new shape.
+ function packClusters(points, X) {
+ let groups = clusterPoints(points, X);
+ for (let pass = 0; pass < 8; pass++) {
+ const merged = [];
+ let changed = false;
+ for (const g of groups) {
+ const prev = merged[merged.length - 1];
+ if (prev && packStart(prev) + packWidth(prev.items.length) + MARK_GAP > packStart(g)) {
+ prev.items = prev.items.concat(g.items);
+ prev.xEnd = g.xEnd;
+ changed = true;
+ } else {
+ merged.push({ xStart: g.xStart, xEnd: g.xEnd, items: g.items.slice() });
+ }
+ }
+ groups = merged;
+ if (!changed) break;
+ }
+ return groups;
+ }
+
+ // The point a cluster is named after: highest tier first, then the rarest
+ // name in it, so a lone RunAborted is never spoken for by the six routine
+ // events it happens to sit beside.
+ function clusterHead(cluster) {
+ const counts = {};
+ for (const p of cluster.items) counts[p.label] = (counts[p.label] || 0) + 1;
+ let best = cluster.items[0];
+ let tier = 0;
+ for (const p of cluster.items) {
+ const t = p.tier || 0;
+ if (t > tier) tier = t;
+ const bt = best.tier || 0;
+ if (t > bt || (t === bt && counts[p.label] < counts[best.label])) best = p;
+ }
+ return { point: best, tier, uniform: cluster.items.every((p) => p.label === best.label) };
+ }
+
+ // Seat labels by severity first, then left to right. A purely left-to-right
+ // greedy pass lets a flood of routine traffic take the slot a critical event
+ // needed, and that is the one label that must never be the one dropped.
+ //
+ // Candidates now span the whole rendered buffer, most of it off screen. Only
+ // a label whose mark is IN VIEW gets nudged off the canvas edge: doing it to
+ // the rest would stack the buffer's labels into a pile just outside the
+ // plot, which the next pan would slide into view as a solid block of text.
+ function seatLabels(candidates) {
+ const order = candidates.slice().sort((a, b) => b.tier - a.tier || a.cx - b.cx);
+ const placed = [];
+ for (const c of order) {
+ if (c.skip) continue;
+ let l = c.cx - c.w / 2;
+ let r = c.cx + c.w / 2;
+ // Clamp anything that OVERLAPS the plot, not just what is centred in
+ // it. Labels are laid out across the whole overscan buffer so a pan has
+ // them ready, and one straddling the edge used to be left where it fell
+ // and half eaten by the clip, which reads as a broken word rather than
+ // as a mark that is partly off screen. Wholly outside stays where it is
+ // and stays invisible.
+ if (r > PAD_L && l < VW - PAD_R) {
+ if (l < PAD_L) {
+ l = PAD_L;
+ r = l + c.w;
+ }
+ if (r > VW - PAD_R) {
+ r = VW - PAD_R;
+ l = r - c.w;
+ }
+ }
+ if (placed.some((q) => !(r <= q.l - LABEL_PAD || l >= q.r + LABEL_PAD))) continue;
+ placed.push({ l, r, c });
+ }
+ return placed;
+ }
+
+ function renderChainEdges(g, over, model, focus, pointPos, scale) {
+ const layer = svg("g", { class: "cs-edges" });
+ // Rings go ABOVE the marks while the edges stay below them. They are
+ // drawn in the same pass but they are not the same kind of thing: an
+ // arrow's tail must pass behind the mark it leaves, and a ring is a
+ // highlight ON a mark, so the two want opposite sides of it. Sharing the
+ // edge layer left every ring chopped by the squares packed either side.
+ const ringLayer = svg("g", { class: "cs-rings" });
+
+ // One marker per direction. Causation is a strict parent pointer in an
+ // append-only log, so the head always sits at the EFFECT and the arrow is
+ // always single: a double head would assert mutual causation, which cannot
+ // happen. Upstream and downstream answer different questions ("why did
+ // this happen" against "what did it set off") and differ in hue, never in
+ // direction.
+ const defs = svg("defs");
+ for (const [id, fill] of [["cs-arrow-up", "#f0644b"], ["cs-arrow-down", "#e6b24a"]]) {
+ const marker = svg("marker", {
+ id,
+ viewBox: "0 0 8 8",
+ refX: "6.5",
+ refY: "4",
+ markerWidth: "4.5",
+ markerHeight: "4.5",
+ orient: "auto",
+ });
+ marker.appendChild(svg("path", { d: "M0,4 L0,4 M0,0 L8,4 L0,8 z", fill }));
+ defs.appendChild(marker);
+ }
+ layer.appendChild(defs);
+
+ const edges = [];
+ for (const [point, hop] of focus.dist) {
+ if (!point.cause) continue;
+ const parent = model.byId.get(point.cause);
+ if (!parent || !focus.dist.has(parent)) continue;
+ edges.push({ from: parent, to: point, up: hop <= 0 });
+ }
+ fanEdges(edges, (p) => pointPos.get(p));
+
+ for (const e of edges) {
+ const a = pointPos.get(e.from);
+ const b = pointPos.get(e.to);
+ if (!a || !b) continue;
+ const hop = Math.abs(focus.dist.get(e.to));
+ const path = svg("path", {
+ d: edgePath(a, b, e.fan || 0),
+ class: `cs-edge cs-edge--${e.up ? "up" : "down"}`,
+ "marker-end": `url(#cs-arrow-${e.up ? "up" : "down"})`,
+ });
+ // Thickness carries distance: the immediate cause is heaviest and each
+ // further hop thinner, so the near story reads before the far one.
+ path.style.strokeWidth = String(Math.max(0.7, 2.1 - 0.42 * Math.max(0, hop - 1)));
+ path.classList.add(`cs-hop-${Math.min(hop, MAX_CHAIN_HOPS)}`);
+ layer.appendChild(path);
+ }
+
+ // A null causation_id means an operator acted directly. Ring it, so a root
+ // never reads as an orphan the trace merely failed to reach. Only where the
+ // document actually carries causation: in one that does not, everything is
+ // causeless and every mark would be ringed as an operator action.
+ if (model.hasCausation) {
+ for (const point of focus.dist.keys()) {
+ if (point.cause) continue;
+ const pt = pointPos.get(point);
+ if (pt) {
+ ringLayer.appendChild(svg("circle", { cx: pt.x, cy: pt.y, r: 6.5, class: "cs-root-ring" }));
+ }
+ }
+ }
+
+ // The cause fell out of the retained window. Drawing nothing would claim
+ // the event was uncaused; the stub says a cause exists and when it was,
+ // which is why `cause_occurred_at` rides the wire beside the id.
+ if (focus.unresolved) {
+ const pt = pointPos.get(focus.unresolved);
+ if (pt) {
+ // Anchored to the MARK, not to the plot's left edge. The edge layer
+ // is clipped to the plot now, so a stub pinned to that edge would be
+ // sliced in half the moment the view panned; hung off the mark it
+ // runs out toward the past and is clipped there, which is where its
+ // cause actually is. The readout carries the same timestamp in words,
+ // so nothing is lost when the note itself scrolls out.
+ layer.appendChild(
+ svg("path", {
+ d: `M${pt.x - 46},${pt.y} L${pt.x - 7},${pt.y}`,
+ class: "cs-edge cs-edge--up cs-edge--stub",
+ "marker-end": "url(#cs-arrow-up)",
+ })
+ );
+ const note = svg("text", {
+ x: pt.x - 48,
+ y: pt.y - 6,
+ class: "cs-edge-note",
+ "text-anchor": "end",
+ });
+ note.textContent = focus.unresolved.cause_at
+ ? fmtClock(model.t0, parseT(focus.unresolved.cause_at) - model.t0)
+ : "before this window";
+ layer.appendChild(note);
+ }
+ }
+
+ g.appendChild(layer);
+ over.appendChild(ringLayer);
+ }
+
+ // Clip paths are referenced by id, and two scrubbers can be mounted on one
+ // page (the flowing window and REWIND), so the id has to be unique per
+ // render or the second mount clips against the first one's rect.
+ let clipSeq = 0;
+
+ function renderTimeline(model, scale, focus) {
+ // Rows stack at their own heights rather than on a fixed pitch: a zone
+ // caption, a lifetime and a series of instants are not the same kind of
+ // row (see ROW_H).
+ const rowY = new Map();
+ const rowH = new Map();
+ let stackY = LANE_START;
+ for (const lane of model.lanes) {
+ const h = ROW_H[lane.render] || LANE_HEIGHT;
+ rowY.set(lane, stackY + h / 2);
+ rowH.set(lane, h);
+ stackY += h;
+ }
+ const axisY = (model.lanes.length ? stackY : LANE_START + LANE_HEIGHT) + 10;
const vh = axisY + AXIS_MARGIN;
const X = (secs) => xFor(scale, secs);
+ // Slider semantics only where there is a value to move. A live window has
+ // no cursor to announce, so claiming a slider role would promise a control
+ // that is not there.
const g = svg("svg", {
viewBox: `0 0 ${VW} ${vh}`,
class: "cora-scrubber__svg",
- role: "img",
- "aria-label": "Timeline. Drag the cursor to fold it to any instant.",
+ tabindex: "0",
+ ...(model.live
+ ? {
+ role: "group",
+ "aria-label":
+ "Live activity timeline. Comma and period step between events, arrows pan, " +
+ "Escape releases a pinned event.",
+ }
+ : {
+ role: "slider",
+ "aria-label":
+ "Timeline. Arrows move the fold cursor, comma and period step between events, " +
+ "shift with arrows pans, Enter pins the nearest event.",
+ "aria-valuemin": "0",
+ "aria-valuemax": String(Math.round(model.xmax)),
+ "aria-valuenow": "0",
+ }),
});
+ // SVG has no text-overflow, so a long label silently runs under the plot
+ // instead of being clipped. Measure in the label's own advance width and
+ // keep the full text on hover.
+ const fitted = (text, x, room, cls, anchor) => {
+ const t = svg("text", { x, y: 0, class: cls, "text-anchor": anchor || "end" });
+ const maxChars = Math.floor(room / LANE_LABEL_CH);
+ t.textContent =
+ text.length > maxChars ? `${text.slice(0, Math.max(1, maxChars - 1))}…` : text;
+ if (t.textContent !== text) {
+ const full = svg("title");
+ full.textContent = text;
+ t.appendChild(full);
+ }
+ return t;
+ };
+
+ // A SECOND channel, deliberately not the first. Dimming already means
+ // one thing -- "something on this row is in the chain you are tracing" --
+ // and lighting a row because the record binds it to the pinned event's
+ // row would make it mean two, with no way left to tell which. So a bound
+ // row keeps whatever brightness the chain gave it and gets a tick in the
+ // gutter instead, carrying the reason it is bound.
+ const kin = focus ? kinOf(model, focus.point) : null;
+
const laneY = new Map();
- model.lanes.forEach((lane, i) => {
- const y = LANE_START + i * LANE_HEIGHT;
+ model.lanes.forEach((lane) => {
+ const y = rowY.get(lane);
laneY.set(lane.lane_id, y);
- g.appendChild(svg("line", { x1: PAD_L, y1: y, x2: VW - PAD_R, y2: y, class: "cs-baseline" }));
- const t = svg("text", { x: PAD_L - 12, y: y + 4, class: "cs-lane-label", "text-anchor": "end" });
- t.textContent = lane.label;
+ // The gutter is the one part of the chart that names things without
+ // explaining them: KIND is a four-letter abbreviation, a zone caption is
+ // one word, and a flat lane's label says nothing about which stream
+ // types land on it. A hit target over the whole gutter cell answers all
+ // three, on the text AND on the space around it, because a 9px label is
+ // a poor thing to have to aim at.
+ if (lane.about) {
+ const hit = svg("rect", {
+ x: 0,
+ y: y - rowH.get(lane) / 2,
+ width: PAD_L - 4,
+ height: rowH.get(lane),
+ class: "cs-gutter-hit",
+ });
+ hit._csAbout = lane;
+ g.appendChild(hit);
+ }
+ if (lane.render === "zone") {
+ // A caption then a rule to the right of it. The rule has to START
+ // clear of the text: run from the plot edge it passes straight under
+ // a caption long enough to reach there, which every one of these is.
+ const zt = svg("text", { x: 8, y: y + 3, class: "cs-zone", "text-anchor": "start" });
+ zt.textContent = lane.label;
+ g.appendChild(zt);
+ const ruleFrom = Math.max(PAD_L, 8 + lane.label.length * ZONE_CH + 10);
+ g.appendChild(
+ svg("line", { x1: ruleFrom, y1: y, x2: VW - PAD_R, y2: y, class: "cs-zone-rule" })
+ );
+ return;
+ }
+ if (kin && kin[lane.lane_id]) {
+ // At the rail's start, pointing into the plot: the row's own left
+ // edge is where the eye already goes to read across it.
+ // No ``: decoration is not hit-testable on this chart, so one
+ // here could never open. The words live in the card, which is
+ // readable on hover and on a pin alike.
+ g.appendChild(
+ svg("polygon", {
+ points: `${PAD_L - 8},${y - 4} ${PAD_L - 8},${y + 4} ${PAD_L - 1},${y}`,
+ class: "cs-kin-tick",
+ })
+ );
+ }
+ // Every row that holds marks gets the same rail, tracks included. A
+ // track used to draw only its lifetime bar, so a row whose bar was
+ // short had nothing to read its marks along while every flat lane did,
+ // and the two halves of the chart looked like two charts.
+ g.appendChild(
+ svg("line", {
+ x1: PAD_L,
+ y1: y,
+ x2: VW - PAD_R,
+ y2: y,
+ class: "cs-baseline" + (lane.render === "track" ? " cs-baseline--track" : ""),
+ })
+ );
+ if (lane.render === "track") {
+ // Two-part gutter: the KIND, fixed width and indented by depth, then
+ // the instance's own name. Reading down the kind column alone gives
+ // the shape of the tree.
+ const indent = lane.depth * INDENT;
+ if (lane.kind) {
+ const kt = svg("text", { x: 8 + indent, y: y + 3, class: "cs-row-kind", "text-anchor": "start" });
+ kt.textContent = lane.kind;
+ g.appendChild(kt);
+ }
+ const labX = 8 + indent + KIND_W;
+ const lt = fitted(lane.label, labX, PAD_L - 12 - labX, `cs-track-label cs-track-label--d${lane.depth}`, "start");
+ lt.setAttribute("y", y + 3);
+ g.appendChild(lt);
+ return;
+ }
+ const t = fitted(lane.label, PAD_L - 12, PAD_L - 16, "cs-lane-label");
+ t.setAttribute("y", y + 4);
+ // A gutter label is two or three shortened words. Where the row's
+ // membership rule is not obvious from them, the document says so and
+ // it hangs off the label rather than off a legend nobody reads.
+ if (lane.hint) {
+ const existing = t.querySelector("title");
+ const ttl = existing || svg("title");
+ ttl.textContent = (existing ? existing.textContent + " -- " : "") + lane.hint;
+ if (!existing) t.appendChild(ttl);
+ }
g.appendChild(t);
});
+ // Everything positioned by TIME lives in one clipped group, so a pan can
+ // be a single translate on it. The clip is what makes that safe: the group
+ // holds a buffer wider than the view, and without it those extra marks
+ // would draw straight over the lane labels.
+ const seq = ++clipSeq;
+ const defs = svg("defs");
+ const clipRect = (id, box) => {
+ const c = svg("clipPath", { id });
+ c.appendChild(svg("rect", box));
+ defs.appendChild(c);
+ return `url(#${id})`;
+ };
+ const plotClip = clipRect(`cs-plot-${seq}`, {
+ x: PAD_L,
+ y: 0,
+ width: VW - PAD_R - PAD_L,
+ height: vh,
+ });
+ const axisClip = clipRect(`cs-axis-${seq}`, { x: 0, y: axisY, width: VW, height: vh - axisY });
+ g.appendChild(defs);
+ // The clip must sit OUTSIDE the transform. `clip-path` resolves in the
+ // element's own user space, so a clip on the group that carries the
+ // translate slides along with the content it is meant to be windowing:
+ // the marks move, the window moves with them, and the same slice stays on
+ // screen shifted sideways. Window first, then pan what is inside it.
+ const plotWindow = svg("g", { "clip-path": plotClip });
+ const axisWindow = svg("g", { "clip-path": axisClip });
+ const plot = svg("g", { class: "cs-pan cs-plot" });
+ const axisRow = svg("g", { class: "cs-pan cs-axis-row" });
+ // Filled later, appended first: an arrow leaving a solid mark has to pass
+ // BEHIND it, or its tail sits on top of the very thing it starts from.
+ const edgeLayer = svg("g", { class: "cs-edge-layer" });
+ // Behind the marks for the same reason as the edges: it is a backdrop for
+ // the group a pinned event belongs to, not a thing to read over it.
+ const selLayer = svg("g", { class: "cs-sel-layer" });
+ plot.appendChild(edgeLayer);
+ plot.appendChild(selLayer);
+ plotWindow.appendChild(plot);
+ axisWindow.appendChild(axisRow);
+ g.appendChild(plotWindow);
+ g.appendChild(axisWindow);
+ // One offset, applied to both strips: they are windowed differently but
+ // they show the same instant, so they can never be panned apart.
+ const setPan = (dx) => {
+ const t = `translate(${dx} 0)`;
+ plot.setAttribute("transform", t);
+ axisRow.setAttribute("transform", t);
+ };
+
const timed = [];
+ const selectable = [];
+ const pointPos = new Map();
+ const chain = focus ? focus.dist : null;
for (const lane of model.lanes) {
const y = laneY.get(lane.lane_id);
- if (lane.render === "markers") {
- // A REWIND run has a handful of lifecycle events at most, so a
- // text label above every marker reads fine. A flowing window's
- // domain lane can hold far more (a busy hour of Decisions,
- // say), where the same labels would overlap into noise; past
- // this count, keep the marks (still hoverable via the readout at
- // any folded instant) and drop only the always-on labels.
- const showLabels = lane.points.length <= MAX_MARKER_LABELS;
- lane.points.forEach((p) => {
- const x = X(p.secs);
- const m = svg("rect", {
- x: x - 4,
- y: y - 4,
- width: 8,
- height: 8,
- class: "cs-mark cs-mark--setpoint",
+ if (lane.render === "zone") continue;
+
+ // A track is a LIFETIME, so it is drawn as a bar over the time it
+ // covers rather than as a sample at each end. Segments say the bar
+ // changed state part-way through: a permit is a condition over a range,
+ // and drawn as dots you learn only that it was sampled, never whether
+ // it held.
+ if (lane.render === "track") {
+ // A bar recedes with the marks on it. Only the marks used to dim, so
+ // pinning an event dropped 250 squares to a whisper and left the
+ // ribbons -- a saturated green running the width of the chart --
+ // untouched, which made the loudest thing on screen the part that is
+ // not the story. A lane stays lit when it holds something in the
+ // traced chain or the pinned event's correlation, which is also what
+ // makes the tree answer "where did this happen".
+ const laneLit =
+ !focus ||
+ lane.points.some((p) => chain.has(p) || (focus.corr && p.corr === focus.corr));
+ const bars = lane.segments
+ ? lane.segments
+ : [{ from: lane.from, to: lane.to, tone: lane.tone, h: TRACK_H[Math.min(lane.depth, 2)] }];
+ for (const seg of bars) {
+ const h = seg.h || TRACK_H[Math.min(lane.depth, 2)];
+ const open = seg.to === null || seg.to === undefined;
+ const s0 = seg.from === null || seg.from === undefined ? scale.bmin : trackSecs(model, seg.from);
+ const s1 = open ? scale.bmax : trackSecs(model, seg.to);
+ const a = X(Math.max(s0, scale.bmin));
+ const b = X(Math.min(s1, scale.bmax));
+ if (b - a < 0.5) continue;
+ plot.appendChild(
+ svg("rect", {
+ x: a,
+ y: y - h / 2,
+ width: b - a,
+ height: h,
+ rx: Math.min(3, h / 2),
+ class:
+ `cs-track cs-track--d${lane.depth}` +
+ (seg.tone ? ` cs-track--${seg.tone}` : "") +
+ (open ? " cs-track--open" : "") +
+ // A standing bad or unsettled condition never recedes. Every
+ // other bar is context you can put down while you read
+ // something else; "the hutch is not permitted" is not, and it
+ // is the one dim that could cost something.
+ (!laneLit && seg.tone !== "bad" && seg.tone !== "warn" ? " cs-dim" : ""),
+ })
+ );
+ }
+ // A bar that began before the buffer must say so, or it reads as one
+ // that started exactly where the chart happens to begin.
+ if (lane.from !== null && trackSecs(model, lane.from) < scale.bmin - 1e-6) {
+ plot.appendChild(
+ svg("polygon", {
+ points: `${PAD_L + 6},${y - 5} ${PAD_L + 6},${y + 5} ${PAD_L},${y}`,
+ class: "cs-track-cap" + (laneLit ? "" : " cs-dim"),
+ })
+ );
+ }
+ }
+
+ if (lane.render === "markers" || lane.render === "track") {
+ // The whole buffer, not just the view: what a pan translates into
+ // sight has to have been drawn already, and a cluster straddling the
+ // view edge must merge the same way it would mid-view rather than
+ // splitting into a different shape at the boundary.
+ const visible = lane.points.filter((p) => p.secs >= scale.bmin && p.secs <= scale.bmax);
+ const clusters = packClusters(visible, X);
+ const candidates = [];
+ let prevBase = null;
+
+ clusters.forEach((c) => {
+ const head = clusterHead(c);
+ const n = c.items.length;
+ const base = stripLanePrefix(head.point.label, lane.label, lane.noun);
+ // `xN` only when every member really is that event: a burst of five
+ // Adjusted plus one Resumed is not six resumes, so a mixed cluster
+ // names the one it is titled after and counts the rest as `+N`.
+ const text = n === 1 ? base : base + (head.uniform ? ` ×${n}` : ` +${n - 1}`);
+
+ // Lit if it is in the traced chain, or shares the pinned event's
+ // correlation. Correlation is a SET, not a sequence, so its members
+ // are highlighted and never joined by a line: N-1 edges would assert
+ // an order the record does not claim.
+ const inChain = !!focus && c.items.some((p) => chain.has(p));
+ const inCorr =
+ !!focus && !!focus.corr && c.items.some((p) => p.corr === focus.corr);
+ const dim = !!focus && !inChain && !inCorr;
+
+ // A lane that is overwhelmingly one event type repeats that label
+ // forever and says nothing after the first. Only a real burst, or
+ // anything above routine tier, re-earns it -- and never a member of
+ // the chain being traced, which would otherwise strip the label off
+ // the very event the viewer just pinned.
+ const repeat = !inChain && head.tier === 0 && n < 3 && base === prevBase;
+ if (head.tier === 0) prevBase = base;
+
+ const x0 = packStart(c);
+ // Over the cap a group draws as ONE bar carrying a count: a hundred
+ // squares is a smear, and a smear that will not say how many is
+ // worse than a number.
+ const wide = n > PACK_MAX;
+ const cells = wide ? [{ point: c.items[0], span: c.items }] : c.items.map((q) => ({ point: q }));
+ cells.forEach((cell, i) => {
+ const q = cell.point;
+ const cw = wide ? packWidth(n) : MARK_S;
+ const ch = wide ? MANY_H : MARK_S;
+ const cx = wide ? x0 : x0 + i * MARK_STEP;
+ const tier = wide ? head.tier : q.tier || 0;
+ // Per CELL, not per cluster. A pack of six can hold one event of
+ // the chain and five bystanders, and lighting all six because one
+ // of them qualifies overstates the trace by five events.
+ const mine = wide ? c.items : [q];
+ let hop = null;
+ if (chain) {
+ for (const z of mine) {
+ if (!chain.has(z)) continue;
+ const d = Math.abs(chain.get(z));
+ if (hop === null || d < hop) hop = d;
+ }
+ }
+ // Correlation gets a FORM, never a place on the tone ramp. Both
+ // channels used to sit at full opacity, which was ambiguous but
+ // harmless; once tone means hop distance, a correlated bystander
+ // left at full would read as the pinned event itself.
+ const corrOnly =
+ !!focus && hop === null && !!focus.corr && mine.some((z) => z.corr === focus.corr);
+ const markEl = svg("rect", {
+ x: cx,
+ y: y - ch / 2,
+ width: cw,
+ height: ch,
+ rx: 1.5,
+ class:
+ `cs-mark cs-mark--${wide ? "many" : n > 1 ? "packed" : "single"} cs-tier--${tier}` +
+ // On a track a mark sits ON the lifetime bar, so it needs the
+ // bar held off it. On a flat lane there is nothing under it
+ // and a ring would only thicken the square.
+ (lane.render === "track" ? " cs-mark--on-track" : "") +
+ (hop !== null ? ` cs-hop-${Math.min(hop, MAX_CHAIN_HOPS)}` : "") +
+ (corrOnly ? " cs-mark--corr" : ""),
+ });
+ const title = svg("title");
+ title.textContent = wide
+ ? c.items.map((z) => `${z.label} @ ${fmtClock(model.t0, z.secs)}`).join("\n")
+ : `${q.label} @ ${fmtClock(model.t0, q.secs)}`;
+ markEl.appendChild(title);
+ markEl.classList.add("cs-mark--hit");
+ plot.appendChild(markEl);
+ timed.push({ el: markEl, t: q.secs });
+
+ // One cluster object per CELL, so hovering or pinning resolves to
+ // the one event under the pointer rather than to whatever the
+ // group is named after. `group` keeps the neighbours reachable so
+ // the card can still say which of how many this is.
+ // Below the cap a cell IS one event. Above it the bar is a
+ // single object standing for all of them, and handing it only the
+ // first would let a click on a bar of ninety report one event and
+ // silently drop the rest; carrying the group makes the card
+ // enumerate them instead.
+ const cell_c = {
+ xStart: cx,
+ xEnd: cx + cw,
+ items: wide ? c.items : [q],
+ group: c.items,
+ index: i,
+ };
+ markEl._csCluster = cell_c;
+ // The whole pack, so pinning one square can outline the run of
+ // them it came out of rather than just itself.
+ markEl._csGroup = n > 1 ? { x: x0, y, w: packWidth(n), h: ch } : null;
+ const hit = svg("rect", {
+ x: cx - MARK_GAP,
+ y: y - 9,
+ width: cw + MARK_GAP * 2,
+ height: 18,
+ class: "cs-hit",
+ });
+ hit._csCluster = cell_c;
+ plot.appendChild(hit);
+ selectable.push({ el: markEl, point: q });
+ if (focus && hop === null && !corrOnly) markEl.classList.add("cs-dim");
});
- g.appendChild(m);
- timed.push({ el: m, t: p.secs });
- if (showLabels) {
- const lab = svg("text", { x, y: y - 10, class: "cs-life-label", "text-anchor": "middle" });
- lab.textContent = p.label;
- g.appendChild(lab);
- timed.push({ el: lab, t: p.secs });
+
+ if (wide) {
+ const ct = svg("text", {
+ x: x0 + packWidth(n) / 2,
+ y: y + 2.6,
+ class: "cs-cluster-count",
+ "text-anchor": "middle",
+ });
+ ct.textContent = String(n);
+ plot.appendChild(ct);
+ timed.push({ el: ct, t: c.items[0].secs });
+ }
+
+ if (focus) {
+ // Edges land on the SQUARE, not on the event's true x: the pack
+ // moved it, and an arrow pointing at empty chart beside the mark
+ // it means would be worse than one pointing slightly off-time.
+ c.items.forEach((q, i) => {
+ if (!chain.has(q)) return;
+ const cx = wide ? x0 + packWidth(n) / 2 : x0 + i * MARK_STEP + MARK_S / 2;
+ pointPos.set(q, { x: cx, y });
+ });
}
+
+ candidates.push({
+ // Over the BAR, not over the span that formed it. Packing widens
+ // a group and shifts its centre, and a label anchored to the old
+ // centre sits off its own mark -- and near the plot edge is left
+ // unclamped and clipped, because the clamp only fires for a
+ // centre that is itself on screen.
+ cx: x0 + packWidth(n) / 2,
+ tier: head.tier,
+ text,
+ skip: repeat,
+ dim,
+ w: text.length * LABEL_CH + 4,
+ t: c.items[0].secs,
+ });
+ });
+
+ // A track names itself in the gutter, and its row is sized for a bar
+ // rather than for a caption above one. Seating labels there would put
+ // them through the row above.
+ const seated = lane.render === "track" ? [] : seatLabels(candidates);
+ seated.forEach((slot) => {
+ const lab = svg("text", {
+ x: (slot.l + slot.r) / 2,
+ y: y - 11,
+ // A label must recede with its own mark. Dimming one and not the
+ // other leaves the loudest thing on screen belonging to the part
+ // that is not the story.
+ class: `cs-life-label cs-tier--${slot.c.tier}${slot.c.dim ? " cs-dim" : ""}`,
+ "text-anchor": "middle",
+ });
+ lab.textContent = slot.c.text;
+ plot.appendChild(lab);
+ timed.push({ el: lab, t: slot.c.t });
});
} else {
const numeric = lane.points.filter((p) => p.value !== null && p.value !== undefined);
@@ -233,7 +1048,7 @@
const span = vmax - vmin || 1;
const yFor = (v) => y + 14 - ((v - vmin) / span) * 24;
const points = numeric.map((p) => `${X(p.secs)},${yFor(p.value)}`).join(" ");
- g.appendChild(svg("polyline", { points, class: "cs-run-line" }));
+ plot.appendChild(svg("polyline", { points, class: "cs-run-line" }));
}
lane.points.forEach((p) => {
const x = X(p.secs);
@@ -241,51 +1056,370 @@
p.text != null
? svg("circle", { cx: x, cy: y, r: 3.5, class: "cs-mark cs-mark--check" })
: svg("circle", { cx: x, cy: y, r: 2.5, class: "cs-mark cs-mark--acquire" });
- g.appendChild(mark);
+ plot.appendChild(mark);
timed.push({ el: mark, t: p.secs });
});
}
}
g.appendChild(svg("line", { x1: PAD_L, y1: axisY, x2: VW - PAD_R, y2: axisY, class: "cs-axis" }));
- const tickStep = model.xmax > 0 ? Math.max(1, Math.round(model.xmax / 12 / 5) * 5) : 1;
- for (let secs = 0; secs <= model.xmax; secs += tickStep) {
+ // Wall clock, not `0s..900s`. In a flowing window `t0` slides on every
+ // re-render, so a relative label renames the same physical event every
+ // time and the eye has nothing fixed to measure motion against. Ticks
+ // span the VIEW, so they stay put under the pointer while panning.
+ const tickStep = pickTickStep(scale.dmax - scale.dmin);
+ const firstTick = Math.ceil((model.t0 + scale.bmin) / tickStep) * tickStep - model.t0;
+ for (let secs = firstTick; secs <= scale.bmax; secs += tickStep) {
const x = X(secs);
- g.appendChild(svg("line", { x1: x, y1: axisY, x2: x, y2: axisY + 5, class: "cs-tick" }));
+ axisRow.appendChild(svg("line", { x1: x, y1: axisY, x2: x, y2: axisY + 5, class: "cs-tick" }));
const lab = svg("text", { x, y: axisY + 17, class: "cs-tick-label", "text-anchor": "middle" });
- lab.textContent = `${secs}s`;
- g.appendChild(lab);
+ lab.textContent = fmtClock(model.t0, secs).slice(0, tickStep < 60 ? 8 : 5);
+ axisRow.appendChild(lab);
}
- const cursorLine = svg("line", {
- x1: X(0),
- y1: LANE_START - 14,
- x2: X(0),
- y2: axisY,
- class: "cs-cursor",
- });
- g.appendChild(cursorLine);
- const handle = svg("polygon", { points: "0,-9 7,0 0,9 -7,0", class: "cs-cursor-handle" });
- handle.setAttribute("transform", `translate(${X(0)} ${axisY})`);
- g.appendChild(handle);
+ // Appended here, after every mark, so the rings land on top of them.
+ const ringWindow = svg("g");
+ plot.appendChild(ringWindow);
+ if (focus) renderChainEdges(edgeLayer, ringWindow, model, focus, pointPos, scale);
- return { g, X, axisY, timed, cursorLine, handle };
+ // Both mark an INSTANT, so both belong to the pannable group and travel
+ // with the events they sit between. Only one is ever drawn.
+ let cursorLine = null;
+ let handle = null;
+ if (model.live) {
+ // The present. Drawn only when the view actually reaches it: panned into
+ // the past the right edge is just wherever the viewer stopped, and
+ // labelling that LIVE would be a lie, so the rule leaves rather than
+ // following the edge.
+ if (model.xmax >= scale.bmin && model.xmax <= scale.bmax) {
+ const lx = X(model.xmax);
+ plot.appendChild(
+ svg("line", { x1: lx, y1: LANE_START - 18, x2: lx, y2: axisY, class: "cs-now" })
+ );
+ const lab = svg("text", {
+ x: lx - 5,
+ y: LANE_START - 22,
+ class: "cs-now-label",
+ "text-anchor": "end",
+ });
+ lab.textContent = "LIVE";
+ plot.appendChild(lab);
+ }
+ } else {
+ cursorLine = svg("line", {
+ x1: X(0),
+ y1: LANE_START - 14,
+ x2: X(0),
+ y2: axisY,
+ class: "cs-cursor",
+ });
+ plot.appendChild(cursorLine);
+ handle = svg("polygon", { points: "0,-9 7,0 0,9 -7,0", class: "cs-cursor-handle" });
+ handle.setAttribute("transform", `translate(${X(0)} ${axisY})`);
+ plot.appendChild(handle);
+ }
+
+ return { g, setPan, X, axisY, timed, cursorLine, handle, selectable, selLayer };
}
function applyFold(model, scene, cursor) {
const X = scene.X;
- scene.cursorLine.setAttribute("x1", X(cursor));
- scene.cursorLine.setAttribute("x2", X(cursor));
- scene.handle.setAttribute("transform", `translate(${X(cursor)} ${scene.axisY})`);
+ if (scene.cursorLine) {
+ scene.cursorLine.setAttribute("x1", X(cursor));
+ scene.cursorLine.setAttribute("x2", X(cursor));
+ scene.handle.setAttribute("transform", `translate(${X(cursor)} ${scene.axisY})`);
+ }
for (const { el, t } of scene.timed) {
el.classList.toggle("cs-future", t > cursor + 1e-6);
}
}
- function renderReadout(root, model, folded, t0, cursor) {
+ // The card that follows the pointer, and the whole of the interaction: what
+ // this event is, when, what caused it, what it set off. Over one square of a
+ // packed burst it names that square's own event and says which of how many;
+ // over a bar too big to pack it lists the contents, because the number on
+ // that bar is a promise they are recoverable.
+ // The event's own bindings where it has them, otherwise its row's. Never
+ // the two merged: a point that states its kin is stating it for itself, and
+ // adding the row's would attribute a neighbour's relations to it.
+ function kinOf(model, point) {
+ if (point.kin) return point.kin;
+ const lane = model.laneOf.get(point);
+ return (lane && lane.kin) || null;
+ }
+
+ // Same card as an event's, deliberately: a page with two tooltip designs
+ // has the reader learning two. What differs is that this one describes a
+ // ROW rather than an instant, so it leads with a sentence about the shape
+ // and never carries a clock.
+ function aboutHtml(model, lane) {
+ const esc = (v) =>
+ String(v).replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[ch]));
+ const about = lane.about;
+ const out = [`
${esc(about.head)}
`];
+ if (about.note) out.push(`
${esc(about.note)}
`);
+ // The instances behind the marks, where the document names them. Scrolls
+ // inside the card, the same way a burst's member list does, so a long one
+ // cannot push the facts under it off screen.
+ if (about.list && about.list.length) {
+ out.push(
+ '
' +
+ about.list
+ .map(
+ ([k, v]) =>
+ `
${esc(k)}` +
+ `${esc(v)}
`
+ )
+ .join("") +
+ "
"
+ );
+ }
+ for (const [k, v] of about.rows || []) {
+ if (v === null || v === undefined || v === "") continue;
+ out.push(
+ `
${esc(k)}` +
+ `${esc(v)}
`
+ );
+ }
+ // Kinship is a property of the ROW, so it belongs on the row's card as
+ // much as on an event's. Resolved here rather than in the document,
+ // because only the render knows which lanes are on screen to be named.
+ if (lane.kin) {
+ for (const other of model.lanes) {
+ if (!lane.kin[other.lane_id]) continue;
+ out.push(
+ `
`
+ );
+ }
+ }
+ return out.join("");
+ }
+
+ // How many chain rows a card will draw before it stops. The card is a hover
+ // surface, not the panel, and a fan of three at six hops is arithmetically
+ // able to reach several hundred descendants.
+ const TIP_CHAIN_ROWS = 12;
+
+ // The ancestors of the pinned event, oldest FIRST, so the card reads
+ // forwards the way the story happened rather than backwards from the end.
+ //
+ // A path, not a tree, and not by simplification: `causation_id` is one
+ // scalar column, so an event has exactly one cause and this walk cannot
+ // branch. Bounded by whatever the dial traced, because `focus.dist` is.
+ function ancestorPath(model, focus) {
+ const out = [];
+ let cur = focus.point;
+ while (cur && cur.cause) {
+ const parent = model.byId.get(cur.cause);
+ if (!parent || !focus.dist.has(parent)) break;
+ out.push(parent);
+ cur = parent;
+ }
+ return out.reverse();
+ }
+
+ // The descendants, depth-first, each carrying its hop so the card can indent
+ // it.
+ //
+ // Kept as a TREE. Flattening it to a list would put two siblings in an order
+ // the record does not state, which is the same claim already refused for the
+ // correlation set -- made in text instead of in edges, and no more true for
+ // being quieter. Two events woken by one cause are concurrent, and the only
+ // honest rendering of that is that neither is drawn under the other.
+ function descendantTree(model, focus, cap) {
+ const rows = [];
+ const seen = new Set();
+ const walk = (point, depth) => {
+ if (!point.id) return;
+ const kids = (model.childrenOf.get(point.id) || [])
+ .filter((k) => !seen.has(k) && focus.dist.has(k) && focus.dist.get(k) > 0)
+ .sort((a, b) => a.secs - b.secs);
+ for (const kid of kids) {
+ seen.add(kid);
+ rows.push({ point: kid, depth, siblings: kids.length });
+ walk(kid, depth + 1);
+ }
+ };
+ walk(focus.point, 0);
+ return { rows: rows.slice(0, cap), total: rows.length, dropped: Math.max(0, rows.length - cap) };
+ }
+
+ function tipHtml(model, cluster, focus) {
+ const esc = (v) =>
+ String(v).replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[ch]));
+ const row = (k, v, cls) =>
+ `
`);
+ out.push(row("occurred", fmtClock(model.t0, head.secs)));
+ if (listed) {
+ out.push(row("in a burst of", `${listed.length}, too many to separate`));
+ out.push(
+ '
"
+ );
+ } else if (group.length > 1) {
+ out.push(row("in a burst of", `${group.length}, this is #${(cluster.index || 0) + 1}`));
+ }
+
+ // What the RECORD binds this event's row to, as against what caused it.
+ // Two different questions with two different sources: causation comes off
+ // the event, kinship comes off the projection, and they are never merged
+ // into one line. Silent where the document states none, which is not the
+ // same as a row that is bound to nothing.
+ const kin = kinOf(model, head);
+ if (kin) {
+ const named = model.lanes.filter((l) => kin[l.lane_id]);
+ for (const l of named) {
+ out.push(row(kin[l.lane_id], l.label, "cs-tip-row--kin"));
+ }
+ }
+
+ // Silent where the document records no relations at all, rather than
+ // reporting their absence as an operator having acted directly.
+ if (model.hasCausation) {
+ let cause;
+ if (!head.cause) {
+ cause = "nothing, an operator acted directly";
+ } else {
+ const parent = model.byId.get(head.cause);
+ cause = parent
+ ? `${parent.label} @ ${fmtClock(model.t0, parent.secs)}`
+ : head.cause_at
+ ? `outside this window, @ ${fmtClock(model.t0, parseT(head.cause_at) - model.t0)}`
+ : "outside this window";
+ }
+ out.push(row("caused by", cause, head.cause ? "cs-tip-row--up" : ""));
+
+ // Above, the immediate cause in words. Here, everything between it and
+ // whatever the dial reached -- which the card used to draw on the chart
+ // and then decline to name, reporting one hop up and a bare count down.
+ // A count says a chain is long; it does not say what happened.
+ const item = (p, cls, pad) =>
+ `
`;
+
+ // Flat, because a path has no structure to show. The indentation the
+ // effects get below would be decoration here, and decoration that looks
+ // like the tree's real indentation is worse than none.
+ const up = ancestorPath(model, focus);
+ if (up.length > 1) {
+ out.push(
+ '
"
+ );
+ }
+
+ const down = descendantTree(model, focus, TIP_CHAIN_ROWS);
+ out.push(
+ row(
+ "set off",
+ down.total ? `${down.total} event${down.total === 1 ? "" : "s"}` : "nothing",
+ down.total ? "cs-tip-row--down" : ""
+ )
+ );
+ if (down.rows.length) {
+ // Indented by hop. Two rows at the same indent came out of one cause
+ // and are concurrent; the record states no order between them and
+ // neither does this.
+ out.push(
+ '
"
+ );
+ }
+ if (focus.corr) {
+ let n = 0;
+ for (const lane of model.lanes) for (const q of lane.points) if (q.corr === focus.corr) n += 1;
+ out.push(row("correlated with", `${n} event${n === 1 ? "" : "s"}`));
+ }
+ if (focus.truncatedUp || focus.truncatedDown) {
+ const cut = [];
+ if (focus.truncatedUp) cut.push("earlier causes");
+ if (focus.truncatedDown) cut.push("later effects");
+ out.push(row("not shown", `${cut.join(" and ")} beyond ${focus.hops} step${focus.hops === 1 ? "" : "s"}`));
+ }
+ }
+ return out.join("");
+ }
+
+ // What the trace found, in words, for the panel that has room for them.
+ function chainRows(model, focus) {
+ const rows = [];
+ const point = focus.point;
+ rows.push(["pinned", point.label, `tier${point.tier || 0}`]);
+
+ // Say nothing about causation where the document records none, rather than
+ // reporting its absence as an operator action.
+ if (!model.hasCausation) return rows;
+
+ let causeText;
+ if (!point.cause) {
+ causeText = "nothing, an operator acted directly";
+ } else {
+ const parent = model.byId.get(point.cause);
+ if (parent) {
+ causeText = `${parent.label} @ ${fmtClock(model.t0, parent.secs)}`;
+ } else if (point.cause_at) {
+ causeText = `outside this window, @ ${fmtClock(model.t0, parseT(point.cause_at) - model.t0)}`;
+ } else {
+ causeText = "outside this window";
+ }
+ }
+ rows.push(["caused by", causeText, point.cause ? "warn" : null]);
+
+ let effects = 0;
+ for (const hop of focus.dist.values()) if (hop > 0) effects += 1;
+ rows.push(["set off", effects ? `${effects} event${effects === 1 ? "" : "s"}` : "nothing", null]);
+
+ // The chain is walked a bounded number of hops. Say when it was cut, or a
+ // trimmed story reads as a complete one.
+ if (focus.truncatedUp || focus.truncatedDown) {
+ const cut = [];
+ if (focus.truncatedUp) cut.push("earlier causes");
+ if (focus.truncatedDown) cut.push("later effects");
+ rows.push([
+ "not shown",
+ `${cut.join(" and ")} beyond ${focus.hops} step${focus.hops === 1 ? "" : "s"}`,
+ "warn",
+ ]);
+ }
+ // Only where it actually happened. Saying "branches: no" on every pin
+ // would train the reader to skip the row on the one pin where a single
+ // event woke four subscribers, which is the whole reason to raise the dial.
+ if (focus.widest > 1) {
+ rows.push(["branches", `one event set off ${focus.widest} at once`, null]);
+ }
+ return rows;
+ }
+
+ function renderReadout(root, model, folded, t0, cursor, focus) {
const r = root.querySelector(".cs-readout-body");
r.innerHTML = "";
const rows = [["clock", fmtClock(t0, cursor), null]];
+ if (focus) rows.push(...chainRows(model, focus));
if (folded.primary) {
const state = folded.primary.state || "not started";
// A point-supplied `tone` (e.g. an enclosure's NotPermitted) wins;
@@ -301,14 +1435,19 @@
}
for (const lane of model.lanes) {
if (lane === model.primaryLane) continue;
+ // A zone caption is a heading over the rows beneath it, not a row. It
+ // has no points and never will, so listing it here only ever produced
+ // "Execution -- no reading yet".
+ if (lane.render === "zone") continue;
const reading = folded.readings[lane.lane_id];
let text;
if (!reading) {
text = "no reading yet";
- } else if (lane.render === "markers") {
- // A non-primary markers lane (every domain lane in a flowing
- // window with no single subject, see mount()'s `follow` option):
- // there is no value to show, only the most recent event's label.
+ } else if (lane.render === "markers" || lane.render === "track") {
+ // A markers lane or a track: neither carries a number, only events,
+ // so the most recent event's label IS the reading. A track used to
+ // fall through to the numeric branch below and report "undefined @"
+ // for every run, procedure, subject and ribbon on the chart.
text = `${reading.label} @ ${fmtClock(t0, reading.secs)}`;
} else {
text = `${reading.text != null ? reading.text : reading.value} @ ${fmtClock(t0, reading.secs)}`;
@@ -331,7 +1470,6 @@
function wireDrag(root, model, scene, state, opts) {
const svgEl = scene.g;
- const slider = root.querySelector(".cs-slider");
const onScrub = opts && opts.onScrub;
// Fired once per user-initiated move, never from a programmatic
// setCursor (mount()'s own initial positioning, or a caller re-
@@ -342,9 +1480,13 @@
if (onScrub) onScrub();
};
+ // `state.scale` describes what was RENDERED. Between rebuilds a pan only
+ // translates that content, so the pointer has to be moved back into
+ // rendered space before it can be read as a time -- without this the
+ // hover cursor drifts away from the pointer by exactly the pan distance.
const secsFromEvent = (clientX) => {
const rect = svgEl.getBoundingClientRect();
- const xUser = ((clientX - rect.left) / rect.width) * VW;
+ const xUser = ((clientX - rect.left) / rect.width) * VW - state.panDx;
return (xUser - PAD_L) / state.scale.k + state.scale.dmin;
};
@@ -352,13 +1494,11 @@
state.cursor = Math.max(0, Math.min(model.xmax, secs));
applyFold(model, scene, state.cursor);
const folded = foldTo(model, state.cursor);
- renderReadout(root, model, folded, model.t0, state.cursor);
- slider.setAttribute("aria-valuenow", String(Math.round(state.cursor)));
- const pct = model.xmax > 0 ? (state.cursor / model.xmax) * 100 : 0;
- const fill = root.querySelector(".cs-slider-fill");
- const thumb = root.querySelector(".cs-slider-thumb");
- if (fill) fill.style.width = `${pct}%`;
- if (thumb) thumb.style.left = `${pct}%`;
+ renderReadout(root, model, folded, model.t0, state.cursor, state.pinned);
+ if (!model.live) {
+ svgEl.setAttribute("aria-valuenow", String(Math.round(state.cursor)));
+ svgEl.setAttribute("aria-valuetext", fmtClock(model.t0, state.cursor));
+ }
};
state.setCursor = setCursor;
@@ -402,80 +1542,397 @@
};
state.startPlay = startPlay;
+ // ---- Three gestures, no overlap.
+ //
+ // Press on a MARK selects it. Press on EMPTY CHART pans the view. Hover
+ // moves the fold cursor. Previously a press anywhere started a scrub,
+ // which meant a mark could never receive one, so nothing on the chart was
+ // clickable and the only way to read a cluster's contents was the native
+ // tooltip. Selection is the prerequisite for pinning a causal chain.
+ const CLICK_SLOP = 4;
+
+ const setSelection = (cluster) => {
+ state.selected = cluster || null;
+ if (opts && opts.onSelect) opts.onSelect(cluster);
+ // Selecting rebuilds, because tracing a chain un-collapses its members
+ // and that changes what the clusters are. `state.refocus` is owned by
+ // mount(), which holds the view and the render loop.
+ state.refocus();
+ if (!cluster) return;
+ // A chain can be pinned from off screen, by keyboard or by a selection
+ // that survived a pan, and then every visible mark dims with nothing lit
+ // to show for it. Bring the pinned event in either way.
+ state.reveal(cluster.items[0].secs);
+ state.anchorTip();
+ // Where the cursor answers for the pointer, a pin fixes it at its own
+ // instant so the panel and the highlight cannot disagree. Where the
+ // cursor is parked (a flowing window), it stays parked: dropping a
+ // dashed rule across every lane is exactly what the pin is trying to
+ // avoid, and the card plus the panel's pinned rows already say which
+ // event it is. `state.setCursor` and not this closure's, because
+ // refocus() above replaced the scene the local one writes to.
+ if (!model.live) state.setCursor(cluster.items[0].secs);
+ };
+ state.setSelection = setSelection;
+
+ const clusterFor = (target) => {
+ let node = target;
+ while (node && node !== svgEl) {
+ if (node._csCluster) return node._csCluster;
+ node = node.parentNode;
+ }
+ return null;
+ };
+ const aboutFor = (target) => (target && target._csAbout) || null;
+
+ let pan = null;
const onDown = (e) => {
+ const cluster = clusterFor(e.target);
+ if (cluster) {
+ e.preventDefault();
+ stopPlay();
+ notifyScrub();
+ setSelection(cluster);
+ return;
+ }
+ if (!state.canPan) return;
e.preventDefault();
- slider.focus();
+ svgEl.focus();
stopPlay();
+ // Panning is leaving the live edge, so the caller has to stop following.
+ // Without this the next re-slide snaps the view forward again and the
+ // drag fights the clock.
notifyScrub();
- setCursor(secsFromEvent(e.clientX));
- const onMove = (ev) => setCursor(secsFromEvent(ev.clientX));
- const onUp = () => {
+ state.stopGlide();
+ pan = { x0: e.clientX, from0: state.view.from, moved: false, v: 0, x: e.clientX, at: e.timeStamp };
+ svgEl.classList.add("cs-grabbing");
+ const onMove = (ev) => {
+ if (!pan) return;
+ if (Math.abs(ev.clientX - pan.x0) > CLICK_SLOP) pan.moved = true;
+ const rect = svgEl.getBoundingClientRect();
+ const perPx = (state.view.to - state.view.from) / ((rect.width * (VW - PAD_L - PAD_R)) / VW);
+ // Velocity in seconds-of-record per millisecond, smoothed so one
+ // jittery sample cannot decide how far the release throws.
+ const dt = ev.timeStamp - pan.at;
+ if (dt > 0) {
+ const inst = (-(ev.clientX - pan.x) * perPx) / dt;
+ pan.v = pan.v * 0.7 + inst * 0.3;
+ pan.x = ev.clientX;
+ pan.at = ev.timeStamp;
+ }
+ state.panTo(pan.from0 - (ev.clientX - pan.x0) * perPx);
+ };
+ const onUp = (ev) => {
+ // A press that never moved is a click on empty space: clear any pinned
+ // selection rather than leaving it stranded with nothing highlighted.
+ if (pan && !pan.moved) setSelection(null);
+ // Stale velocity throws the view after the hand has already stopped,
+ // so a release that follows a pause coasts nowhere.
+ const idle = pan && ev && ev.timeStamp - pan.at > 90;
+ const v = pan && pan.moved && !idle ? pan.v : 0;
+ pan = null;
+ svgEl.classList.remove("cs-grabbing");
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
+ state.glide(v);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
svgEl.addEventListener("pointerdown", onDown);
- const track = slider.querySelector(".cs-slider-track");
- const secsFromSlider = (clientX) => {
- const rect = track.getBoundingClientRect();
- const f = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
- return f * model.xmax;
- };
- let sliderDragging = false;
- slider.addEventListener("pointerdown", (e) => {
- e.preventDefault();
- slider.focus();
- stopPlay();
- notifyScrub();
- sliderDragging = true;
- try {
- slider.setPointerCapture(e.pointerId);
- } catch (err) {
- /* capture is best-effort; the dragging flag drives the move */
+ // Hover is the primary act: moving onto an event shows its card, lights
+ // its causal chain and its correlation group, and draws the edges. No
+ // press, so it never competes with pan or select, and nothing has to be
+ // clicked to read a relation. A pinned selection wins and hover stops
+ // changing anything until it is released.
+ //
+ // `pointermove` and never `pointerover`: focusing rebuilds the scene,
+ // which recreates the element under the pointer and makes the browser
+ // fire a fresh `pointerover`. Reacting to that would focus the same event
+ // forever; a rebuild generates no `pointermove`, so this cannot loop.
+ svgEl.addEventListener("pointermove", (e) => {
+ if (pan) return;
+ // The gutter never traces or pins, so it never rebuilds the scene: it
+ // only opens the card. A pin still wins, the same way it does over a
+ // mark, because releasing someone's pin to explain a row heading would
+ // be the tooltip taking work away from them.
+ const lane = aboutFor(e.target);
+ if (lane) {
+ if (state.selected) return;
+ state.showAbout(lane, e.clientX, e.clientY);
+ return;
}
- setCursor(secsFromSlider(e.clientX));
- });
- slider.addEventListener("pointermove", (e) => {
- if (sliderDragging) setCursor(secsFromSlider(e.clientX));
- });
- slider.addEventListener("pointerup", () => {
- sliderDragging = false;
+ const cluster = clusterFor(e.target);
+ if (state.selected) return;
+ state.setHover(cluster, e.clientX, e.clientY);
+ // The fold cursor follows the pointer only where the caller wants it to.
+ // In a flowing window it stays parked at the live edge: a dashed rule
+ // roaming across every lane is one more thing between the pointer and
+ // the event it is trying to reach.
+ if (cluster || model.live) return;
+ setCursor(secsFromEvent(e.clientX));
});
- slider.addEventListener("pointercancel", () => {
- sliderDragging = false;
+ svgEl.addEventListener("pointerleave", () => {
+ if (pan || state.selected) return;
+ state.setHover(null);
});
- slider.addEventListener("keydown", (e) => {
- const big = model.xmax / 12;
- const map = {
- ArrowLeft: -2,
- ArrowRight: 2,
- ArrowDown: -2,
- ArrowUp: 2,
- PageDown: -big,
- PageUp: big,
- Home: -1e9,
- End: 1e9,
- };
+ // Keyboard parity: the chart itself is the focusable control, so every
+ // gesture below has a key.
+ svgEl.addEventListener("keydown", (e) => {
+ const span = state.view.to - state.view.from;
if (e.key === " " || e.key === "Spacebar") {
if (opts && opts.showPlay === false) return;
e.preventDefault();
state.playing ? stopPlay() : startPlay();
return;
}
- if (!(e.key in map)) return;
+ // Arrows move the VALUE, because the element carries slider semantics
+ // and announces the cursor's clock time; panning the viewport is a
+ // different act and takes Shift or the Page keys. `,` and `.` walk real
+ // events, which is the only way to reach a mark without hunting.
+ if (e.key === "," || e.key === ".") {
+ e.preventDefault();
+ const dir = e.key === "." ? 1 : -1;
+ if (model.live) {
+ // No cursor to walk, so step the SELECTION: the ring and the card
+ // are then what say where the keyboard is, which is more than an
+ // invisible caret ever said.
+ const from = state.selected ? state.selected.items[0].secs : dir > 0 ? -1 : model.xmax + 1;
+ const next = stepToAdjacentPoint(model, from, dir);
+ if (next === null) return;
+ notifyScrub();
+ state.reveal(next);
+ const near = nearestCluster(scene, next);
+ if (near) setSelection(near);
+ return;
+ }
+ const step = stepToAdjacentPoint(model, state.cursor, dir);
+ if (step !== null) {
+ notifyScrub();
+ setSelection(null);
+ setCursor(step);
+ state.revealCursor();
+ }
+ return;
+ }
+ if (e.key === "Enter") {
+ e.preventDefault();
+ if (model.live) return;
+ const near = nearestCluster(scene, state.cursor);
+ if (near) setSelection(near);
+ return;
+ }
+ if (e.key === "Escape") {
+ if (!state.selected) return;
+ e.preventDefault();
+ setSelection(null);
+ return;
+ }
+ const panBy = { PageDown: -span, PageUp: span };
+ if (model.live && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
+ e.preventDefault();
+ if (!state.canPan) return;
+ notifyScrub();
+ state.panTo(state.view.from + (e.key === "ArrowRight" ? span / 4 : -span / 4));
+ return;
+ }
+ if (model.live && (e.key === "Home" || e.key === "End")) {
+ e.preventDefault();
+ if (!state.canPan) return;
+ notifyScrub();
+ state.panTo(e.key === "Home" ? 0 : model.xmax);
+ return;
+ }
+ if (e.shiftKey && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
+ e.preventDefault();
+ if (!state.canPan) return;
+ notifyScrub();
+ state.panTo(state.view.from + (e.key === "ArrowRight" ? span / 4 : -span / 4));
+ return;
+ }
+ if (e.key in panBy) {
+ e.preventDefault();
+ if (!state.canPan) return;
+ notifyScrub();
+ state.panTo(state.view.from + panBy[e.key]);
+ return;
+ }
+ const nudge = { ArrowLeft: -1, ArrowRight: 1, ArrowDown: -1, ArrowUp: 1 };
+ if (e.key === "Home" || e.key === "End") {
+ e.preventDefault();
+ notifyScrub();
+ setSelection(null);
+ setCursor(e.key === "Home" ? 0 : model.xmax);
+ state.revealCursor();
+ return;
+ }
+ if (!(e.key in nudge)) return;
e.preventDefault();
stopPlay();
notifyScrub();
- if (e.key === "Home") setCursor(0);
- else if (e.key === "End") setCursor(model.xmax);
- else setCursor(state.cursor + map[e.key]);
+ setSelection(null);
+ setCursor(state.cursor + nudge[e.key] * ((state.view.to - state.view.from) / 100));
+ state.revealCursor();
});
- return { setCursor, stopPlay, startPlay };
+ return { setCursor, stopPlay, startPlay, setSelection };
+ }
+
+ // A quadratic with one far-offset control leaves the source at a sharp angle
+ // and whips back, which reads as a 90-degree kink rather than a curve. A
+ // cubic whose controls extend along the dominant axis leaves and enters
+ // smoothly, and the lateral offset rides on BOTH controls so the whole curve
+ // bows instead of bending.
+ function edgePath(a, b, fan) {
+ const dx = b.x - a.x;
+ const dy = b.y - a.y;
+ const shrink = 7;
+ if (Math.abs(dy) < 5) {
+ const dir = dx >= 0 ? 1 : -1;
+ const bx = b.x - dir * shrink;
+ const lift = 13 + Math.abs(fan) * 0.5;
+ return `M${a.x},${a.y} C${a.x + dx * 0.28},${a.y - lift} ${bx - dx * 0.28},${b.y - lift} ${bx},${b.y}`;
+ }
+ const by = b.y - (dy > 0 ? shrink : -shrink);
+ const k = (by - a.y) * 0.45;
+ return `M${a.x},${a.y} C${a.x + fan},${a.y + k} ${b.x + fan},${by - k} ${b.x},${by}`;
+ }
+
+ // Separate edges that share an x corridor.
+ //
+ // Fanning per SOURCE is not enough: a reacting subscriber fires within the
+ // same second as its cause, so two edges with DIFFERENT causes routinely
+ // occupy the same corridor and were each centred independently on it. Every
+ // offset is also stepped away from zero, because an offset of exactly zero
+ // draws a dead-straight vertical that collides with any other zero-offset
+ // edge and reads as a grid rule rather than an arrow.
+ function fanEdges(edges, posOf) {
+ const corridors = new Map();
+ for (const e of edges) {
+ const a = posOf(e.from);
+ const b = posOf(e.to);
+ if (!a || !b) continue;
+ const key = Math.round((a.x + b.x) / 2 / 14);
+ const bucket = corridors.get(key);
+ if (bucket) bucket.push(e);
+ else corridors.set(key, [e]);
+ }
+ for (const bucket of corridors.values()) {
+ bucket.sort((x, z) => posOf(x.to).y - posOf(z.to).y);
+ bucket.forEach((e, i) => {
+ let step = i - (bucket.length - 1) / 2;
+ step = step >= 0 ? step + 0.5 : step - 0.5;
+ const a = posOf(e.from);
+ const b = posOf(e.to);
+ e.fan = step * Math.max(13, Math.abs(b.y - a.y) * 0.07);
+ });
+ }
+ }
+
+ // How far a chain MAY be walked, and how far it is by default. The ceiling
+ // is a hairball guard; the default is a reading choice: one hop answers "why
+ // did this happen and what did it set off", which is the question a pin
+ // usually is, and everything past it is a follow-up the viewer can ask for.
+ //
+ // The dial is causation ONLY. Correlation is a flat SET -- every member is
+ // one originating command away from every other by construction -- so there
+ // is no depth to walk, and a control implying otherwise would put a shape on
+ // the record that the record does not have.
+ const MAX_CHAIN_HOPS = 6;
+ const DEFAULT_CHAIN_HOPS = 2;
+
+ // Ancestors and descendants of `point`, with hop distance from it.
+ //
+ // Both walks are bounded and both carry a seen-set. An append-only log cannot
+ // contain a causal cycle, but nothing in this module enforces that: the ids
+ // arrive over a socket, and a malformed or self-referencing causation_id
+ // would spin `while (cur.cause)` forever and hang the page. Trusting the
+ // shape of remote data is not a guarantee, it is a hope.
+ function traceChain(model, point, maxHops) {
+ const hops_max = Math.max(1, Math.min(MAX_CHAIN_HOPS, maxHops || DEFAULT_CHAIN_HOPS));
+ const dist = new Map([[point, 0]]);
+ let truncatedUp = false;
+ let truncatedDown = false;
+ let unresolved = null;
+
+ let cur = point;
+ let hops = 0;
+ while (cur && cur.cause) {
+ const parent = model.byId.get(cur.cause);
+ if (!parent) {
+ unresolved = cur;
+ break;
+ }
+ if (dist.has(parent)) break;
+ hops += 1;
+ if (hops > hops_max) {
+ truncatedUp = true;
+ break;
+ }
+ dist.set(parent, -hops);
+ cur = parent;
+ }
+
+ // Upstream above is a WALK, downstream here is a frontier, and the
+ // asymmetry is the record's, not a shortcut: `causation_id` is one scalar
+ // column, so an event has exactly one cause and upstream can only ever be
+ // a thread. Downstream is the inverted multimap, where one event waking
+ // three subscribers gives one node three children. Raising the dial
+ // therefore lengthens the story backwards and widens it forwards.
+ let widest = 0;
+ let frontier = [point];
+ for (let depth = 1; depth <= hops_max && frontier.length; depth += 1) {
+ const next = [];
+ for (const node of frontier) {
+ if (!node.id) continue;
+ const kids = model.childrenOf.get(node.id) || [];
+ let fanned = 0;
+ for (const kid of kids) {
+ if (dist.has(kid)) continue;
+ dist.set(kid, depth);
+ next.push(kid);
+ fanned += 1;
+ }
+ if (fanned > widest) widest = fanned;
+ }
+ frontier = next;
+ if (depth === hops_max && next.length) truncatedDown = true;
+ }
+
+ return { dist, unresolved, truncatedUp, truncatedDown, widest, hops: hops_max };
+ }
+
+ // The rendered cluster closest to the cursor, so Enter can pin what the
+ // readout is already describing without a pointer.
+ function nearestCluster(scene, cursor) {
+ let best = null;
+ let bestGap = Infinity;
+ for (const { el } of scene.selectable) {
+ const c = el._csCluster;
+ if (!c) continue;
+ const gap = Math.abs(c.items[0].secs - cursor);
+ if (gap < bestGap) {
+ bestGap = gap;
+ best = c;
+ }
+ }
+ return best;
+ }
+
+ // Nearest point in any lane strictly before or after `from`, so `,` and `.`
+ // walk real events rather than arbitrary time steps.
+ function stepToAdjacentPoint(model, from, dir) {
+ let best = null;
+ for (const lane of model.lanes) {
+ for (const p of lane.points) {
+ if (dir > 0 ? p.secs > from + 1e-6 : p.secs < from - 1e-6) {
+ if (best === null || (dir > 0 ? p.secs < best : p.secs > best)) best = p.secs;
+ }
+ }
+ }
+ return best;
}
function scaffold(root, model, opts) {
@@ -492,7 +1949,19 @@
// "Resume following" control instead.
const showPlay = opts.showPlay !== false;
const showJumpLast = opts.showJumpLast !== false;
+ // Named for what it walks. "Depth" alone would read as covering the
+ // correlation highlight too, and that set has no depth to walk.
+ const depthOpts = [1, 2, 3, 4, 6]
+ .map(
+ (n) =>
+ ``
+ )
+ .join("");
const controlsHtml =
+ `` +
(showPlay ? '' : "") +
(showJumpLast
? ``
@@ -506,19 +1975,22 @@
${controlsHtml}
-
-
-
-
-
-
-
+
+
${
+ opts.live
+ ? "Hover an event for its relations · drag to pan · click to pin · " +
+ "←→ pan, ,. step events, " +
+ "esc release"
+ : "Hover an event for its relations · drag to pan · click to pin · " +
+ "←→ cursor, shift to pan, " +
+ ",. step events, enter pin"
+ }
${note}
-
Folded state at cursor
+
${
+ opts.live ? "Current state" : "Folded state at cursor"
+ }
@@ -537,7 +2009,14 @@
opts = opts || {};
const model = buildModel(doc);
- const scale = buildScale(model.xmax);
+ // `opts.viewSpanSecs` narrower than the domain turns the chart into a
+ // window that pans. Absent, the view is the whole domain and panning is a
+ // no-op, which is REWIND's behaviour unchanged.
+ const domainMax = model.xmax > 0 ? model.xmax : 1;
+ const span = opts.viewSpanSecs && opts.viewSpanSecs > 0 ? opts.viewSpanSecs : domainMax;
+ const initialFrom = opts.follow ? domainMax - span : 0;
+ let view = clampView(initialFrom, span, domainMax);
+ const scale = buildScale(view.from, view.to, domainMax);
// Generic over WHAT truncated (`observations` for a run, `events` for
// an enclosure, ...): report every truthy key by name rather than
// hardcoding one domain's vocabulary.
@@ -552,16 +2031,262 @@
scaffold(root, model, {
chromeTitle: opts.chromeTitle || "Timeline",
subtitle,
- sliderLabel: opts.sliderLabel || "Fold cursor: time within the window",
+ live: model.live,
showPlay: opts.showPlay,
showJumpLast: opts.showJumpLast,
jumpLabel: opts.jumpLabel,
});
- const scene = renderTimeline(model, scale);
- root.querySelector(".cs-stage").appendChild(scene.g);
+ const stage = root.querySelector(".cs-stage");
+ // The card lives outside the SVG and survives every rebuild, so a hover
+ // that re-renders the scene underneath it does not make it flicker.
+ const tip = stage.querySelector(".cs-tip");
+ let scene = renderTimeline(model, scale);
+ stage.appendChild(scene.g);
+
+ const state = {
+ scale,
+ view,
+ cursor: 0,
+ playing: false,
+ rafId: 0,
+ selected: null,
+ depth: DEFAULT_CHAIN_HOPS,
+ canPan: span < domainMax - 1e-6,
+ };
+
+ // What the DOM holds, which after a translate-only pan is no longer what
+ // `state.view` says. Every pan decision is made against this.
+ let shown = { from: view.from, bmin: scale.bmin, bmax: scale.bmax, k: scale.k };
+ state.panDx = 0;
+
+ // A translate on the clipped group, never a rebuild. Rebuilding per frame
+ // reclusters and reseats every label, so marks and text hop between two
+ // equally valid layouts many times a second and the drag reads as
+ // stepped. Content outside the view is already drawn (see OVERSCAN), so
+ // sliding it in costs one attribute write.
+ state.panTo = (from) => {
+ const next = clampView(from, span, domainMax);
+ if (Math.abs(next.from - state.view.from) < 1e-9) return;
+ state.view = next;
+ // Past the buffer there is genuinely nothing drawn to reveal, so this
+ // is the one case that must rebuild.
+ if (next.from < shown.bmin - 1e-6 || next.to > shown.bmax + 1e-6) {
+ rerender();
+ return;
+ }
+ state.panDx = -(next.from - shown.from) * shown.k;
+ scene.setPan(state.panDx);
+ };
+
+ // Release carries on for a moment instead of stopping dead. Travel is
+ // bounded by the buffer so a throw can never outrun what is drawn, which
+ // is what keeps a mid-glide rebuild -- and the reseat that comes with it
+ // -- off the screen entirely.
+ let glideId = 0;
+ state.stopGlide = () => {
+ if (glideId) cancelAnimationFrame(glideId);
+ glideId = 0;
+ };
+ state.glide = (v0) => {
+ state.stopGlide();
+ const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ if (still || !state.canPan || Math.abs(v0) < 0.005) {
+ settle();
+ return;
+ }
+ const lo = shown.bmin;
+ const hi = shown.bmax - (state.view.to - state.view.from);
+ let v = v0;
+ let prev = null;
+ const step = (ts) => {
+ if (prev === null) prev = ts;
+ const dt = Math.min(50, ts - prev);
+ prev = ts;
+ v *= Math.pow(0.9, dt / 16.7);
+ const want = state.view.from + v * dt;
+ const to = Math.max(lo, Math.min(hi, want));
+ state.panTo(to);
+ if (Math.abs(v) < 0.005 || to !== want) {
+ glideId = 0;
+ settle();
+ return;
+ }
+ glideId = requestAnimationFrame(step);
+ };
+ glideId = requestAnimationFrame(step);
+ };
+
+ // Rebuild where the motion stopped. Unconditional because labels are
+ // seated for where they were DRAWN, so one that panned in from the buffer
+ // sits centred on its mark and can hang outside the plot; only a rebuild
+ // puts it right. One frame after the hand lets go, a reseat is invisible.
+ function settle() {
+ if (Math.abs(state.view.from - shown.from) > 1e-6) rerender();
+ }
+ // Bring an instant into view: after a keyboard step walked the cursor past
+ // an edge, so `,` and `.` can cross the whole domain without a manual pan,
+ // or after a pin landed on something off screen. Takes the instant rather
+ // than reading the cursor, because a flowing window parks the cursor and a
+ // pin there has nothing to do with where it sits.
+ state.reveal = (secs) => {
+ if (!state.canPan) return;
+ const margin = (state.view.to - state.view.from) * 0.1;
+ if (secs < state.view.from + margin) state.panTo(secs - margin);
+ else if (secs > state.view.to - margin) {
+ state.panTo(secs - (state.view.to - state.view.from) + margin);
+ }
+ };
+ state.revealCursor = () => state.reveal(state.cursor);
+
+ // The selection anchors on a POINT, not a cluster: clusters are rebuilt on
+ // every render and tracing a chain changes which ones exist at all, so a
+ // cluster object cannot survive its own selection. `hover` is the same
+ // thing with a shorter life; a pin outranks it.
+ let anchor = null;
+ let hover = null;
+
+ function traceFor(point) {
+ if (!point) return null;
+ const traced = traceChain(model, point, state.depth);
+ return {
+ point,
+ corr: point.corr || null,
+ dist: traced.dist,
+ unresolved: traced.unresolved,
+ truncatedUp: traced.truncatedUp,
+ truncatedDown: traced.truncatedDown,
+ widest: traced.widest,
+ hops: traced.hops,
+ };
+ }
+ const focusFor = () => traceFor(anchor || hover);
+
+ // Hovering rebuilds, because tracing a chain un-collapses its members and
+ // that changes which clusters exist. Guarded on the anchor POINT so
+ // sweeping within one mark costs nothing, and so the rebuild's own
+ // re-entry cannot recurse.
+ // No rebuild and no focus: a row card describes what is already drawn.
+ state.showAbout = (lane, clientX, clientY) => {
+ if (hover !== null) {
+ hover = null;
+ rerender();
+ }
+ tip.innerHTML = aboutHtml(model, lane);
+ tip.setAttribute("data-on", "1");
+ placeTip(clientX, clientY);
+ };
+
+ state.setHover = (cluster, clientX, clientY) => {
+ const point = cluster ? cluster.items[0] : null;
+ if (point !== hover) {
+ hover = point;
+ rerender();
+ }
+ if (!cluster) {
+ tip.setAttribute("data-on", "0");
+ return;
+ }
+ tip.innerHTML = tipHtml(model, cluster, traceFor(point));
+ tip.setAttribute("data-on", "1");
+ placeTip(clientX, clientY);
+ };
+
+ // Kept inside the stage and flipped to the other side of the pointer near
+ // an edge, so the card never leaves the panel or covers the mark it
+ // describes.
+ function placeTip(clientX, clientY) {
+ if (clientX === undefined) return;
+ const sb = stage.getBoundingClientRect();
+ const tb = tip.getBoundingClientRect();
+ let x = clientX - sb.left + 16;
+ let y = clientY - sb.top - tb.height - 12;
+ if (x + tb.width > sb.width - 6) x = clientX - sb.left - tb.width - 16;
+ if (y < 4) y = clientY - sb.top + 20;
+ tip.style.left = `${Math.max(4, x)}px`;
+ tip.style.top = `${Math.max(4, y)}px`;
+ }
+
+ let controls;
+ function rerender() {
+ state.scale = buildScale(state.view.from, state.view.to, domainMax);
+ const focus = focusFor();
+ const next = renderTimeline(model, state.scale, focus);
+ stage.replaceChildren(tip, next.g);
+ scene = next;
+ shown = {
+ from: state.view.from,
+ bmin: state.scale.bmin,
+ bmax: state.scale.bmax,
+ k: state.scale.k,
+ };
+ state.panDx = 0;
+ state.pinned = anchor ? focus : null;
+ controls = wireDrag(root, model, scene, state, opts);
+ if (anchor) {
+ const match = scene.selectable.find((s) => s.point === anchor);
+ if (match) {
+ match.el.classList.add("cs-selected");
+ // Two marks, two questions. The ring on the square answers "which
+ // event", and a square is 6 units wide, so on its own it is a
+ // speck in the middle of a bar and says nothing about the run of
+ // events it came out of. The halo answers "out of what", and it
+ // covers the whole pack.
+ const box = match.el._csGroup;
+ if (box) {
+ scene.selLayer.appendChild(
+ svg("rect", {
+ x: box.x - SEL_PAD,
+ y: box.y - box.h / 2 - SEL_PAD,
+ width: box.w + SEL_PAD * 2,
+ height: box.h + SEL_PAD * 2,
+ rx: SEL_PAD + 1.5,
+ class: "cs-sel-group",
+ })
+ );
+ }
+ }
+ }
+ controls.setCursor(state.cursor);
+ }
- const state = { scale, cursor: 0, playing: false, rafId: 0 };
- const controls = wireDrag(root, model, scene, state, opts);
+ state.refocus = () => {
+ anchor = state.selected ? state.selected.items[0] : null;
+ // A pin supersedes whatever was hovered; releasing one leaves the chart
+ // clear rather than snapping back to whatever the pointer is over.
+ hover = null;
+ if (!anchor) {
+ tip.setAttribute("data-on", "0");
+ rerender();
+ return;
+ }
+ rerender();
+ };
+
+ // Anchored to the pinned MARK, not to wherever the pointer happened to
+ // be: a pin outlives the pointer. Called LAST because pinning can also
+ // pan (`reveal`), and a card placed before that pan points at open chart.
+ state.anchorTip = () => {
+ if (!anchor || !state.selected) return;
+ const seat = scene.selectable.find((x) => x.point === anchor);
+ if (!seat) return;
+ const r = seat.el.getBoundingClientRect();
+ tip.innerHTML = tipHtml(model, state.selected, focusFor());
+ tip.setAttribute("data-on", "1");
+ placeTip(r.left + r.width / 2, r.top);
+ };
+
+ controls = wireDrag(root, model, scene, state, opts);
+
+ // Rebuild, not restyle: a deeper trace un-collapses clusters the pack had
+ // merged and adds edges, so which marks exist at all changes with the dial.
+ const depthPick = root.querySelector(".cs-depth-pick");
+ if (depthPick) {
+ depthPick.addEventListener("change", () => {
+ state.depth = Number(depthPick.value) || DEFAULT_CHAIN_HOPS;
+ rerender();
+ if (anchor) state.anchorTip();
+ });
+ }
const playBtn = root.querySelector(".cs-play");
if (playBtn) {
@@ -584,6 +2309,7 @@
root._coraScrubberCleanup = () => {
controls.stopPlay();
+ state.stopGlide();
};
}