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

+ + +
+

Live activity

-
- -
- -

Runs

- - - -
RunStatusProgress
- - -

Subjects

- - - -
SubjectStatus
- - -

Campaigns

- - - -
CampaignIntentStatusRuns
- - -

Datasets from runs on screen

- - - -
DatasetStatusRun
- - -

Active clearances

- - - -
TemplateRiskValid until
- - -

Enclosures

- - - -
EnclosurePermit status
- - -

Recent decisions

- - - -
ChoiceConfidenceDecided byWhen
- @@ -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 @@ <h2>Rewind</h2> handleSnapshot(msg); break; case "run_history_index": - renderRewindPicker(msg.runs); + renderRewindPicker(msg.runs, null); break; case "enclosure_timeline": handleEnclosureTimeline(msg); @@ -703,19 +1681,41 @@ <h2>Rewind</h2> 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 @@ <h2>Rewind</h2> .then(function (history) { mountTimelineInStage(runHistoryToTimelineDocument(history), { chromeTitle: "Rewind", - sliderLabel: "Fold cursor: time within the run", }); }) .catch(function (err) { @@ -833,7 +1832,10 @@ <h2>Rewind</h2> } 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 @@ <h2>Rewind</h2> } mountTimelineInStage(doc, { chromeTitle: "Rewind", - sliderLabel: "Fold cursor: time within the enclosure's history", }); } @@ -858,11 +1859,19 @@ <h2>Rewind</h2> } 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 @@ <h2>Rewind</h2> 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 `{ <other lane_id>: "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 `<title>`: 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 = [`<div class="cs-tip-head">${esc(about.head)}</div>`]; + if (about.note) out.push(`<div class="cs-tip-note">${esc(about.note)}</div>`); + // 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( + '<div class="cs-tip-list">' + + about.list + .map( + ([k, v]) => + `<div class="cs-tip-item"><span class="cs-tip-k">${esc(k)}</span>` + + `<span class="cs-tip-v">${esc(v)}</span></div>` + ) + .join("") + + "</div>" + ); + } + for (const [k, v] of about.rows || []) { + if (v === null || v === undefined || v === "") continue; + out.push( + `<div class="cs-tip-row"><span class="cs-tip-k">${esc(k)}</span>` + + `<span class="cs-tip-v">${esc(v)}</span></div>` + ); + } + // 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( + `<div class="cs-tip-row cs-tip-row--kin"><span class="cs-tip-k">${esc(lane.kin[other.lane_id])}</span>` + + `<span class="cs-tip-v">${esc(other.label)}</span></div>` + ); + } + } + 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) => + `<div class="cs-tip-row${cls ? ` ${cls}` : ""}"><span class="cs-tip-k">${esc(k)}</span>` + + `<span class="cs-tip-v">${esc(v)}</span></div>`; + + const head = focus.point; + const group = cluster.group || cluster.items; + const listed = cluster.items.length > 1 ? cluster.items : null; + const out = []; + out.push(`<div class="cs-tip-head">${esc(head.label)}</div>`); + 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( + '<div class="cs-tip-list">' + + listed + .map( + (p) => + `<div class="cs-tip-item${p === head ? " cs-tip-item--head" : ""}">` + + `<span class="cs-tip-k">${esc(p.label)}</span>` + + `<span class="cs-tip-v">${esc(fmtClock(model.t0, p.secs))}</span></div>` + ) + .join("") + + "</div>" + ); + } 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) => + `<div class="cs-tip-item${cls ? ` ${cls}` : ""}"` + + `${pad ? ` style="padding-left: ${pad}px"` : ""}>` + + `<span class="cs-tip-k">${esc(p.label)}</span>` + + `<span class="cs-tip-v">${esc(fmtClock(model.t0, p.secs))}</span></div>`; + + // 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( + '<div class="cs-tip-chain">' + + up.map((p) => item(p, "cs-tip-item--up")).join("") + + item(head, "cs-tip-item--head") + + "</div>" + ); + } + + 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( + '<div class="cs-tip-chain">' + + down.rows.map((r) => item(r.point, "cs-tip-item--down", 8 + r.depth * 10)).join("") + + (down.dropped + ? `<div class="cs-tip-item cs-tip-item--cut"><span class="cs-tip-k">` + + `+${down.dropped} more not listed</span><span class="cs-tip-v"></span></div>` + : "") + + "</div>" + ); + } + 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) => + `<option value="${n}"${n === DEFAULT_CHAIN_HOPS ? " selected" : ""}>` + + `${n} hop${n === 1 ? "" : "s"}</option>` + ) + .join(""); const controlsHtml = + `<label class="cs-depth"><span class="cs-depth-label">Causation</span>` + + `<select class="cs-depth-pick" aria-label="How many causal hops to trace">${depthOpts}</select>` + + `</label>` + (showPlay ? '<button type="button" class="cs-btn cs-play" aria-pressed="false">Play</button>' : "") + (showJumpLast ? `<button type="button" class="cs-btn cs-jump-last">${opts.jumpLabel || "Jump to last event"}</button>` @@ -506,19 +1975,22 @@ <span class="cs-subtitle"></span> <div class="cs-controls">${controlsHtml}</div> </div> - <div class="cs-stage"></div> - <div class="cs-slider" tabindex="0" role="slider" - aria-label="${opts.sliderLabel}" - aria-valuemin="0" aria-valuenow="0" aria-valuemax="${Math.round(model.xmax)}"> - <div class="cs-slider-track"> - <div class="cs-slider-fill"></div> - <div class="cs-slider-thumb"></div> - </div> - </div> + <div class="cs-stage"><div class="cs-tip" data-on="0" aria-hidden="true"></div></div> + <div class="cs-hint">${ + opts.live + ? "Hover an event for its relations · drag to pan · click to pin · " + + "<kbd>←</kbd><kbd>→</kbd> pan, <kbd>,</kbd><kbd>.</kbd> step events, " + + "<kbd>esc</kbd> release" + : "Hover an event for its relations · drag to pan · click to pin · " + + "<kbd>←</kbd><kbd>→</kbd> cursor, <kbd>shift</kbd> to pan, " + + "<kbd>,</kbd><kbd>.</kbd> step events, <kbd>enter</kbd> pin" + }</div> ${note} <div class="cs-panels"> <div class="cs-readout"> - <div class="cs-readout-head">Folded state at cursor</div> + <div class="cs-readout-head">${ + opts.live ? "Current state" : "Folded state at cursor" + }</div> <div class="cs-readout-body"></div> </div> </div> @@ -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(); }; }