From f227f087c7955dd966e40930e7ad9ae7746c29fd Mon Sep 17 00:00:00 2001 From: xmap <16776958+xmap@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:11:16 -0500 Subject: [PATCH] Drop the causal trace's arrowheads, keep one only on the stub A marker's size scales with the edge's own stroke width, so the heaviest hop drew a head at 4.5 * 2.1 = 9.5px, wider than the 5-7px mark it pointed at: fattest exactly where edges crowd hardest, undoing the thinning that lets the near story read before the far one. The time axis already states direction, since a cause is always earlier than its effect, so the head was answering a question nothing needed answered. Hue now carries the whole split the head used to gesture at, "why did this happen" against "what did it set off", in both the edges and the tooltip card's rows. The stub keeps its head: it hangs into empty space with no second mark to orient against, so without one it would read as an error bar rather than a pointer at an off-window cause. Also, in the same pass: - edgePath no longer stops short of the target mark to leave room for a head. Paths now run centre to centre and tuck under the marks, which the edge layer's paint order already allows for free. - the pinned tooltip card is the one card a reader can scroll into now; pointer-events toggles off for the hovering and released states so a following or hidden card never swallows pointer events over the chart. - the preview fixture moves from picking random stream/event pairs to a scripted "one shift at 2-BM" narrative, timed against the same snapshot numbers the tables already show, so a causal chain reads as a real sequence rather than nonsense (a run completing before it started, a mount dismounted that was never mounted) when eyeballing this change. Co-Authored-By: Claude Opus 5 (1M context) --- infra/status-relay/design/page-preview.html | 547 ++++++++++++++------ infra/status-relay/page.html | 17 +- infra/status-relay/scrubber.js | 89 ++-- 3 files changed, 450 insertions(+), 203 deletions(-) diff --git a/infra/status-relay/design/page-preview.html b/infra/status-relay/design/page-preview.html index 63c18b25f15..6daeb283edd 100644 --- a/infra/status-relay/design/page-preview.html +++ b/infra/status-relay/design/page-preview.html @@ -22,28 +22,32 @@ // Feed the page a synthetic producer before its own script runs. const SRC = "../page.html"; const NOW = Date.now(); - const LANES = [ - ["Run", ["RunStarted", "RunAdjusted", "RunAborted", "RunCompleted", "RunResumed"]], - ["Decision", ["DecisionRegistered", "DecisionRated", "DecisionDebriefRequested"]], - ["Procedure", ["ProcedureStarted", "ProcedureIterationStarted", "ProcedureCompleted"]], - ["Caution", ["CautionRegistered"]], - ["Enclosure", ["EnclosurePermitObserved"]], - ["Dataset", ["DatasetRegistered", "DatasetPromoted"]], - // Zones other than Execution and Judgement were exercised by a permit - // ribbon and nothing else, so every lane the page can now file -- an - // equipment change, a clearance review, a budget draw -- went undrawn - // here while the fixture looked complete. - ["Clearance", ["ClearanceReviewed", "ClearanceExtended"]], - ["Mount", ["MountRegistered", "MountDismounted"]], - ["Supply", ["SupplyLevelObserved"]], - ["Visit", ["VisitStarted"]], - ["Allocation", ["AllocationDrawnDown"]], - ["Calibration", ["CalibrationRecorded"]], - // Deliberately not in the page's domain table: the "Unfiled" lane has to - // be reachable, or the one row that catches an unknown stream type is - // the one row nothing ever tests. - ["Sprocket", ["SprocketWidened"]], - ]; + + // ONE SHIFT AT 2-BM, told in order. + // + // This fixture used to pick a random stream and a random event type off it + // every few seconds. That produced the right SHAPES (density, causation, + // every lane) and nonsense CONTENT: runs that completed before they + // started, a mount dismounted that was never mounted, four permit + // observations in a row saying nothing. Fine for exercising the renderer, + // useless for reading, and actively misleading in a demo, because the one + // thing the page exists to show is whether a sequence makes sense. + // + // So the live hour is now SCRIPTED, beat by beat, against the same session + // the snapshot below already describes: R-4471 aligns, takes flat fields + // and starts a tomography; the hutch drops at 26 minutes and takes the run + // with it; R-4472 starts and holds on a cooling-water trip; R-4480 begins + // alignment. The snapshot's own numbers (started_at, iteration_count, + // last_status_changed_at) are what the beats are timed against, so the + // tables and the timeline now tell the same story instead of two. + // + // Every property the random version was built to guarantee is preserved + // and each is called out at its own site below: the Unfiled lane stays + // reachable, causation still forks rather than threads, the wide fan is + // still deterministic and still lands in the live window, events still + // exist for instances with no snapshot row, and the density still matches + // the measured 2-BM hour. What changed is that all of it now happens for a + // reason a beamline scientist would recognise. let seed = 11; function rnd() { @@ -59,56 +63,30 @@ let evSeq = 0; const uuid = (n) => "00000000-0000-4000-8000-" + String(n).padStart(12, "0"); - // Events have to carry the SAME instance ids the snapshot does, or every - // track renders as an empty bar with its events piled into a catch-all lane + // Events carry the SAME instance ids the snapshot does, or every track + // renders as an empty bar with its events piled into a catch-all lane // instead. A stream_id pool that matched nothing was the whole reason to - // check this rather than assume it. - // Ids AND lifetimes. An event has to belong to an instance that was alive - // when it happened, or marks land outside their own track's bar and the - // preview shows a geometry the real feed can never produce -- which is - // exactly the kind of plausible-looking fixture that has already sent this - // work down two wrong turns. + // check this rather than assume it. Scripting the beats makes this + // structural instead of a filter that has to be kept honest: a beat names + // the instance it is about, so it cannot land outside that instance's bar. + // + // Kinship for a dataset hangs off the EVENT, so Dataset events name ds1 / + // ds2 / ds3 from the snapshot. A random id per event would walk the + // flat-lane path and never the bound one, and the preview would look right + // while testing nothing. // // "r0" and "p0" have no snapshot row at all: a run that finished before the // window opened still has events inside it, and they must reach the - // "elsewhere" lane rather than vanish. - const LIVES = { - Run: [ - ["r1", 52, 0], ["r2", 21, 0], ["r3", 9, 0], ["r0", 24 * 60, 55], - ], - Procedure: [ - ["p1", 50, 44], ["p2", 43, 30], ["p3", 29, 0], - ["p4", 19, 12], ["p5", 8, 0], ["p0", 24 * 60, 58], - ], - Subject: [["s1", 55, 0], ["s2", 24, 0]], - Campaign: [["c1", 52, 0]], - Enclosure: [["enc1", 60, 0]], - Clearance: [["cl1", 24 * 60, 0], ["cl2", 24 * 60, 0]], - // Kinship for a dataset hangs off the EVENT, so a Dataset event has to - // carry a dataset_id the snapshot names. A random id per event would walk - // the flat-lane path and never the bound one, and the preview would look - // right while testing nothing. - Dataset: [["ds1", 24 * 60, 0], ["ds2", 24 * 60, 0], ["ds3", 24 * 60, 0]], - }; - function streamIdFor(stream, ms) { - const lives = LIVES[stream]; - if (!lives) { - // Judgement domains have no instance track, so their ids only ever have - // to tell one stream from another. - return uuid(100 + Math.floor(rnd() * 12)); - } - const minsAgo = (NOW - ms) / 60000; - const alive = lives.filter((l) => minsAgo <= l[1] && minsAgo >= l[2]); - const pool = alive.length ? alive : lives; - return pool[Math.floor(rnd() * pool.length)][0]; - } + // "elsewhere" lane rather than vanish. The prior shifts in the backlog are + // where they come from. + const mins = (m) => NOW - m * 60000; - function event(stream, types, ms) { + function event(stream, id, type, ms) { return { event_id: uuid(900000 + evSeq++), stream_type: stream, - stream_id: streamIdFor(stream, ms), - event_type: types[Math.floor(rnd() * types.length)], + stream_id: id, + event_type: type, occurred_at: new Date(ms).toISOString(), recorded_at: new Date(ms).toISOString(), // One per command, near enough: a correlation group is the handful of @@ -121,118 +99,319 @@ }; } - function burst(atMs) { - const [stream, types] = LANES[Math.floor(rnd() * LANES.length)]; - const n = rnd() < 0.45 ? 1 + Math.floor(rnd() * 5) : 1; + // A reaction: a different stream, moments later, carrying the cause's + // correlation id. Only a subscriber sets causation_id in production, which + // is exactly this shape. + function caused(child, cause) { + child.causation_id = cause.event_id; + child.cause_occurred_at = cause.occurred_at; + child.correlation_id = cause.correlation_id; + return child; + } + + // The live hour, beat by beat. Each entry is [minutesAgo, stream, id, + // event_type, tag?, causedByTag?] and reads top to bottom as the shift + // actually ran. Timings are taken FROM the snapshot below (a run's + // started_at, a procedure's registered_at and last_status_changed_at), so + // the two cannot drift into telling different stories. + const SHIFT = [ + // Beamtime opens: the user arrives, the hutch is searched, the ESAF is + // checked, the sample goes on the stage. + [60, "Enclosure", "enc1", "EnclosurePermitObserved"], + [59, "Actor", "act1", "ActorRegistered", "actor"], + [58.6, "Visit", "v1", "VisitArrived", "visit", "actor"], + [58.2, "Visit", "v1", "VisitCheckedIn", null, "visit"], + [58, "Visit", "v1", "VisitStarted", null, "visit"], + [57.4, "Clearance", "cl1", "ClearanceApproved", "cleared"], + [57.2, "Clearance", "cl1", "ClearanceActivated", null, "cleared"], + [57, "Enclosure", "enc1", "EnclosurePermitObserved", "searched"], + [55.8, "Subject", "s1", "SubjectRegistered", "s1reg"], + [55.4, "Mount", "m1", "MountAssetInstalled", null, "s1reg"], + [55, "Subject", "s1", "SubjectMounted", "mounted"], + [53.4, "Campaign", "c1", "CampaignRegistered"], + [53, "Campaign", "c1", "CampaignStarted"], + [52.6, "Allocation", "a1", "AllocationActivated"], + + // R-4471 begins under the campaign, on the mounted sample. + [52, "Run", "r1", "RunStarted", "r1start", "mounted"], + [51.8, "Run", "r1", "RunAddedToCampaign", null, "r1start"], + [51.4, "Run", "r1", "RunObservationLogbookOpened", null, "r1start"], + + // Alignment, then flat fields, then the tomography itself. This is the + // ordinary 2-BM ladder and the whole reason `iteration_count` exists on + // the procedure rows. + [50.4, "Procedure", "p1", "ProcedureRegistered", "p1reg"], + [50, "Procedure", "p1", "ProcedureStarted", null, "p1reg"], + [49, "Procedure", "p1", "ProcedureIterationStarted"], + [48.4, "Procedure", "p1", "ProcedureIterationEnded"], + [47, "Procedure", "p1", "ProcedureIterationStarted"], + [45.2, "Procedure", "p1", "ProcedureIterationEnded"], + [44, "Procedure", "p1", "ProcedureCompleted", "aligned"], + [43.4, "Procedure", "p2", "ProcedureRegistered", null, "aligned"], + [43, "Procedure", "p2", "ProcedureStarted", "p2start"], + [41, "Calibration", "cal1", "CalibrationRevisionAppended", "calrev"], + [40.4, "Calibration", "cal1", "CalibrationRevisionPublished", null, "calrev"], + [30, "Procedure", "p2", "ProcedureCompleted", "flats"], + [29.4, "Procedure", "p3", "ProcedureRegistered", null, "flats"], + [29, "Procedure", "p3", "ProcedureStarted"], + + // The hutch drops mid-scan. A permit loss is the one event at this + // beamline that reliably takes a run with it, and the caution and both + // holds hang off that single observation. + [26, "Enclosure", "enc1", "EnclosurePermitObserved", "permitlost"], + [25.7, "Caution", "ct1", "CautionRegistered", null, "permitlost"], + [25.4, "Run", "r1", "RunHeld", null, "permitlost"], + [25.1, "Procedure", "p3", "ProcedureHeld", null, "permitlost"], + [24, "Subject", "s2", "SubjectRegistered"], + [22, "Enclosure", "enc1", "EnclosurePermitObserved", "permitback"], + [21.6, "Run", "r1", "RunResumed", null, "permitback"], + [21.4, "Run", "r1", "HoldClaimReleased", null, "permitback"], + [21.3, "Procedure", "p3", "ProcedureResumed", null, "permitback"], + + // R-4472 starts on the second sample while R-4471 keeps scanning. + [21, "Run", "r2", "RunStarted", "r2start"], + [20.6, "Subject", "s2", "SubjectMounted", null, "r2start"], + [20.2, "Run", "r2", "RunAddedToCampaign", null, "r2start"], + [19.4, "Procedure", "p4", "ProcedureRegistered"], + [19, "Procedure", "p4", "ProcedureStarted", null, "r2start"], + [18, "Procedure", "p4", "ProcedureIterationStarted"], + + // An operator judgement, recorded and then rated. + [14, "Decision", "d1", "DecisionRegistered", "d1made"], + [13.4, "Decision", "d1", "DecisionRated", null, "d1made"], + + // Cooling water starts to fall off before it trips. The degradation is + // the WARNING and has to precede the trip, or the fan below draws an + // effect ahead of its cause. + [13.2, "Supply", "sup1", "SupplyDegraded"], + // The trip itself lands at 12.9 and is the hub of the wide fan below, + // which is where R-4472's hold comes from. + + // R-4480 begins, unbound to any campaign, which is the row that proves a + // standalone run still renders. + [9, "Run", "r3", "RunStarted", "r3start"], + [8.4, "Procedure", "p5", "ProcedureRegistered"], + [8, "Procedure", "p5", "ProcedureStarted", null, "r3start"], + [7, "Procedure", "p5", "ProcedureIterationStarted"], + [6.4, "Decision", "d2", "DecisionRegistered"], + [5.2, "Procedure", "p5", "ProcedureIterationStarted"], + [4.6, "Procedure", "p5", "ProcedureIterationEnded"], + + // The product of the shift so far, and the one capture that fell short. + [2.6, "Dataset", "ds1", "DatasetRegistered", "ds1reg"], + [2.2, "Acquisition", "ds1", "AcquisitionRecorded", null, "ds1reg"], + [1.9, "Attestation", "ds1", "AttestationRecorded", null, "ds1reg"], + [1.5, "Dataset", "ds2", "DatasetPromoted"], + // Shipped today: a capture whose Run ended before the file stopped + // changing, holding 1 projection of a commanded 1541. Worth having in + // the demo precisely because it is the case the record could not state + // at all until now. NOTE: `Shortfall` is not yet in page.html's + // STREAM_TYPE_TO_DOMAIN, so it files under Unfiled until that lands. + [1.2, "Shortfall", "sf1", "ShortfallRecorded"], + [0.9, "Procedure", "p5", "ProcedureIterationStarted"], + // Deliberately not a real aggregate: the "Unfiled" lane has to be + // reachable on purpose, or the one row that catches an unknown stream + // type is the one row nothing ever tests. + [0.4, "Sprocket", "sp1", "SprocketWidened"], + ]; + + // The tomography scan itself, which is where a real 2-BM hour gets its + // density: 41 iterations across the 29 minutes p3 has been running, minus + // the four it sat held. Generated rather than typed out, because the count + // has to match the snapshot's `iteration_count` and a hand-typed list + // would drift from it on the first edit. + function tomographyIterations() { const out = []; - for (let i = 0; i < n; i++) out.push(event(stream, types, atMs + i * 400)); - - // A REACTION tail. Only a subscriber sets causation_id in production, and - // it fires within a couple of seconds of its cause and on a different - // stream, so the chain has to be built that way here or the fixture keeps - // producing roots forever. An earlier version tried to chain within a - // burst, which cannot work: a burst is one stream and the next is eight - // seconds away, so no candidate was ever both foreign and recent. - // The tail must FORK, not just run. It used to reassign `cause = re` on - // every hop, which can only ever produce a thread -- so no node in this - // fixture ever had two children, and the renderer's branching walk (the - // whole reason downstream is a frontier and not a pointer chase) had never - // once been exercised by the fixture written to exercise it. Production - // branches freely: one event wakes a projector, a policy watcher and an - // expirer, and all three append against the same causation_id. - if (rnd() < 0.55) { - const root = out[Math.floor(rnd() * out.length)]; - let cause = root; - let ms = atMs + n * 400; - const hops = 2 + Math.floor(rnd() * 4); - for (let h = 0; h < hops; h++) { - const other = LANES.filter((l) => l[0] !== cause.stream_type); - const [rStream, rTypes] = other[Math.floor(rnd() * other.length)]; - ms += 300 + rnd() * 1500; - const re = event(rStream, rTypes, ms); - re.causation_id = cause.event_id; - re.cause_occurred_at = cause.occurred_at; - re.correlation_id = cause.correlation_id; - out.push(re); - // Deepen, or hang a sibling off the same cause. Both shapes have to - // occur: a fixture that only forks tests depth no better than one - // that only threads tested branching. - cause = rnd() < 0.42 ? cause : re; - } + let n = 0; + for (let m = 28.5; m > 0.2 && n < 41; m -= 0.62) { + if (m < 25.4 && m > 21.3) continue; // held: the scan is not stepping + const started = event("Procedure", "p3", "ProcedureIterationStarted", mins(m)); + out.push(started); + // Each step ends before the next begins. The pair is what a scan + // actually emits, and it is also most of where a busy 2-BM hour's + // event count comes from. + out.push(caused(event("Procedure", "p3", "ProcedureIterationEnded", mins(m - 0.28)), started)); + n++; } - - if (rnd() < 0.1) fanOut(out, out[Math.floor(rnd() * out.length)], atMs + n * 400 + 900); return out; } // A WIDE fan: one event waking a whole rank of subscribers at once, each of - // which wakes one or two more. The reaction tail cannot reach this shape -- - // a 42% sibling rate mostly deepens, so a long cascade is still a - // near-thread, and the card's row cap of twelve stayed unreachable no matter - // how many hops were added. A cap that can never be hit makes every - // assertion about how it reports itself reconcile trivially. - function fanOut(out, hub, atMs) { - let fms = atMs; - const width = 5 + Math.floor(rnd() * 4); - for (let w = 0; w < width; w++) { - const pool = LANES.filter((l) => l[0] !== hub.stream_type); - const [wStream, wTypes] = pool[Math.floor(rnd() * pool.length)]; - fms += 120 + rnd() * 400; - const kid = event(wStream, wTypes, fms); - kid.causation_id = hub.event_id; - kid.cause_occurred_at = hub.occurred_at; - kid.correlation_id = hub.correlation_id; + // which wakes one or two more. A reaction chain cannot reach this shape, and + // the card's row cap of twelve stayed unreachable no matter how many hops + // were added; a cap that can never be hit makes every assertion about how it + // reports itself reconcile trivially. + // + // The hub is the cooling-water supply going unavailable, the same BLEPS + // Flow4 shape the deployment actually saw. That is what this fan genuinely + // looks like in production: one observation wakes the run supervisor, the + // procedure watcher, the caution drafter and the debriefer, and several of + // those wake something in turn. + // + // Fixed at 12.9 minutes rather than left to a dice roll, because whether a + // fan lands where the chart is looking must not depend on the wall clock. + // It sits there rather than nearer the edge because THIS fan is where + // R-4472's hold comes from, and the snapshot puts that hold at 12 minutes: + // a hub placed later would have the effect preceding its cause on screen. + // Every arm has to make sense on its own, not merely be a distinct stream. + // Earlier drafts hung `SupplyDegraded` off the trip (degradation PRECEDES + // unavailability, so the fan drew the cause after its effect) and paired + // `CautionRegistered` with a `CautionSuperseded` two seconds later, which + // reads as CORA retracting a warning it has just issued. A fan is the one + // place a reader looks to ask "what did this cause", so a nonsense arm + // there is worse than no arm at all. + const FAN = [ + ["Caution", "ct2", "CautionRegistered", ["Decision", "d3", "DecisionRegistered"]], + // The operator acknowledging a caution is a Run-stream fact, which is + // also what takes the cascade to exactly twelve: the card's row cap has + // to be REACHED by this fixture, or every assertion about how the card + // reports hitting it reconciles trivially. + // + // It lands on R-4471, the run that is actually up at this point. R-4480 + // does not start for another four minutes, and hanging it there put an + // acknowledgement ahead of its own run's genesis: the precise shape this + // rewrite exists to remove, so it is worth naming rather than just + // fixing. + ["Caution", "ct3", "CautionRegistered", ["Run", "r1", "CautionAcknowledgement"]], + ["Procedure", "p4", "ProcedureHeld", ["Procedure", "p4", "ProcedureIterationEnded"]], + ["Run", "r2", "RunHeld", ["Run", "r2", "DecisionDebriefRequested"]], + ["Decision", "d4", "DecisionRegistered", ["Decision", "d4", "DecisionRated"]], + ["Clearance", "cl1", "ClearanceReviewStepAppended", null], + ["Attestation", "ds2", "AttestationRecorded", null], + ]; + + function supplyTripFan() { + const out = []; + const hubAt = mins(12.9); + const hub = event("Supply", "sup1", "SupplyMarkedUnavailable", hubAt); + out.push(hub); + let t = hubAt + 900; + FAN.forEach(([s, id, type, grand]) => { + t += 3000 + rnd() * 4000; + const kid = caused(event(s, id, type, t), hub); out.push(kid); - for (let g = 0, gn = 1 + Math.floor(rnd() * 2); g < gn; g++) { - const gpool = LANES.filter((l) => l[0] !== kid.stream_type); - const [gStream, gTypes] = gpool[Math.floor(rnd() * gpool.length)]; - fms += 100 + rnd() * 300; - const gk = event(gStream, gTypes, fms); - gk.causation_id = kid.event_id; - gk.cause_occurred_at = kid.occurred_at; - gk.correlation_id = kid.correlation_id; - out.push(gk); + // Siblings hang off the hub AND children hang off the siblings, so the + // walk has both shapes to follow. A fixture that only forks tests depth + // no better than one that only threads tested branching. + if (grand) { + t += 1500 + rnd() * 2500; + out.push(caused(event(grand[0], grand[1], grand[2], t), kid)); } - } + }); + // The signal clears, but a person still has to accept the supply back, so + // Recovering is where it rests. That is exactly where 2-BM's own Flow4 + // supply sat for weeks, which is the state worth showing. + out.push(event("Supply", "sup1", "SupplyMarkedRecovering", mins(3))); + return out; + } + + // Prior shifts, filling the retention window behind the live hour. These + // run on r0 / p0, which have NO snapshot row: a run that finished before + // the window opened still has events inside it, and they must reach the + // "elsewhere" lane rather than vanish. + // + // Each is a complete cycle rather than a scatter of types, so scrolling + // back reads as previous shifts and not as noise: start, align, scan, + // finish, register the dataset, debrief. + const CYCLE = [ + [0, "Run", "r0", "RunStarted"], + [0.6, "Run", "r0", "RunObservationLogbookOpened"], + [1.5, "Procedure", "p0", "ProcedureRegistered"], + [2, "Procedure", "p0", "ProcedureStarted"], + [3, "Procedure", "p0", "ProcedureIterationStarted"], + [4, "Procedure", "p0", "ProcedureIterationEnded"], + [5, "Procedure", "p0", "ProcedureIterationStarted"], + [6, "Procedure", "p0", "ProcedureIterationEnded"], + [7, "Procedure", "p0", "ProcedureCompleted"], + [9, "Calibration", "cal0", "CalibrationRevisionAppended"], + [12, "Run", "r0", "RunAdjusted"], + [16, "Run", "r0", "RunCompleted"], + [17, "Dataset", "ds3", "DatasetRegistered"], + [17.5, "Acquisition", "ds3", "AcquisitionRecorded"], + [18, "Run", "r0", "DecisionDebriefRequested"], + [19, "Attestation", "ds3", "AttestationRecorded"], + ]; + + function priorShift(startMinsAgo) { + const out = []; + let prev = null; + CYCLE.forEach(([off, s, id, type]) => { + const ev = event(s, id, type, mins(startMinsAgo - off)); + // A short causal thread through the cycle, so the deep backlog is not + // uniformly root-only. + if (prev && rnd() < 0.5) caused(ev, prev); + out.push(ev); + prev = ev; + }); return out; } - // A full retention window, at the measured 2-BM shape: 228 events in the - // busiest hour, quieter overnight. This is the load the relay now replays - // on connect and the page then holds, so the preview has to carry it or the + // A full retention window: roughly 780 events over the day, about 150 in + // the live hour, quieter overnight. This is the load the relay replays on + // connect and the page then holds, so the preview has to carry it or the // only thing ever exercised is the fifteen minutes the page used to keep. + // + // The live hour is below the 228 the busiest measured 2-BM hour reached. + // That gap is deliberate rather than unfinished: the old fixture hit 228 by + // emitting random types at a tuned interval, and this one only emits events + // a real session would produce, so the count is whatever the session's own + // beats add up to. Padding it back to 228 would mean inventing traffic, + // which is the exact thing this rewrite removed. If a check ever needs the + // busiest hour specifically, give it a denser SCAN rather than more noise: + // the iteration pairs are where a real hour's volume comes from. function backlog() { const events = []; - const span = 24 * 60 * 60 * 1000; - for (let t = NOW - span; t < NOW; ) { - // Hour of day drives the rate over the deep backlog: a beamline runs hot - // in shift hours and ticks over between them. The most recent hour is - // always busy regardless, because everything that reads this fixture -- - // the eye and every check -- looks at the live window, and a fixture - // that goes quiet at 3am makes those checks pass or fail by the clock - // rather than by the code. - const hour = new Date(t).getHours(); - const recent = NOW - t < 60 * 60 * 1000; - const busy = recent || (hour >= 8 && hour < 22); - t += (busy ? 8000 : 40000) + rnd() * (busy ? 25000 : 90000); - if (t < NOW) events.push(...burst(t)); + + // Behind the live hour: a cycle every ~25 minutes through shift hours, + // every ~75 overnight. The most recent hour is the scripted shift and is + // always busy regardless, because everything that reads this fixture + // looks at the live window, and a fixture that goes quiet at 3am makes + // checks pass or fail by the clock rather than by the code. + for (let m = 24 * 60; m > 62; ) { + const hour = new Date(mins(m)).getHours(); + const busy = hour >= 8 && hour < 22; + events.push(...priorShift(m)); + m -= (busy ? 22 : 68) + rnd() * (busy ? 9 : 30); } - // One guaranteed wide fan, placed in the live window. The same reasoning - // as the `recent` rate above: at a 10% chance per burst, whether a fan - // lands where the flowing chart is looking depends on the wall clock, so - // the check that needs one passed in the afternoon and reported "none - // found" an hour later. A shape a check requires cannot be left to a dice - // roll whose landing spot moves with the time of day. - const hubAt = NOW - 4 * 60 * 1000; - const [hStream, hTypes] = LANES[Math.floor(rnd() * LANES.length)]; - const hub = event(hStream, hTypes, hubAt); - events.push(hub); - fanOut(events, hub, hubAt + 800); + SHIFT.forEach(([m, s, id, type]) => events.push(event(s, id, type, mins(m)))); + // Second pass for the causal links, so a beat can name a tag defined + // anywhere in the script rather than only above it. + const tagged = {}; + let i = events.length - SHIFT.length; + SHIFT.forEach(([, , , , tag], k) => { if (tag) tagged[tag] = events[i + k]; }); + SHIFT.forEach(([, , , , , cause], k) => { + if (cause && tagged[cause]) caused(events[i + k], tagged[cause]); + }); + + events.push(...tomographyIterations()); + events.push(...supplyTripFan()); events.sort((a, b) => (a.occurred_at < b.occurred_at ? -1 : a.occurred_at > b.occurred_at ? 1 : 0)); return events; } + // What the page receives while you watch it. The scan is still stepping, + // so the live tail is the tomography ticking over plus the occasional + // observation, which is what a 2-BM feed actually looks like between + // operator actions rather than a fresh random domain every two seconds. + let liveIter = 0; + function liveTick() { + const now = Date.now(); + const started = event("Procedure", "p3", "ProcedureIterationStarted", now); + const out = [started, caused(event("Procedure", "p3", "ProcedureIterationEnded", now + 700), started)]; + liveIter++; + if (liveIter % 4 === 0) { + out.push(event("Procedure", "p5", "ProcedureIterationStarted", now + 200)); + } + if (liveIter % 9 === 0) { + const reg = event("Dataset", "ds1", "DatasetRegistered", now + 500); + out.push(reg); + out.push(caused(event("Acquisition", "ds1", "AcquisitionRecorded", now + 900), reg)); + out.push(caused(event("Attestation", "ds1", "AttestationRecorded", now + 1200), reg)); + } + return out; + } + // A shape the LAYOUT can be built from, not just tables: two runs under one // campaign and one standalone, procedures under each, and subjects the runs // point at. Without `campaign_id` / `subject_id` / `parent_run_id` the @@ -434,7 +613,7 @@ this._send({ kind: "activity", events: backlog() }); this._tick = setInterval(() => { this._send(snapshot()); - if (rnd() < 0.7) this._send({ kind: "activity", events: burst(Date.now()) }); + this._send({ kind: "activity", events: liveTick() }); }, 2000); }, 80); } @@ -445,22 +624,56 @@ const frame = document.createElement("iframe"); document.body.appendChild(frame); - fetch(SRC) + // `no-store`, plus a cache-buster on the script the page pulls in. + // + // Without both, the harness happily shows a page.html and a scrubber.js + // from some earlier edit while the server already has the current ones. + // That is a uniquely expensive failure HERE, because this page's whole job + // is to show what a change looks like: the edit appears to have done + // nothing, and the natural next move is to go and "fix" code that was + // already right. It also makes the harness flaky in a way that looks like + // a fixture bug, since a stale page.html against a current scrubber.js + // fails in whatever way those two versions happen to disagree. + fetch(SRC, { cache: "no-store" }) .then((r) => r.text()) .then((html) => { - const doc = frame.contentDocument; - doc.open(); - doc.write(html); - doc.close(); - // Replace before page.html's own connect() runs on DOMContentLoaded-ish - // timing; its script is inline at the end of body, so patching the - // iframe's global here beats it to the first connect attempt. - frame.contentWindow.WebSocket = StubSocket; - frame.contentWindow.fetch = (url) => { + // Published on THIS window so the injected shim below can reach them + // through `window.parent`. + window.__coraStubSocket = StubSocket; + window.__coraStubFetch = (url) => { const m = String(url).match(/^\/run-history\/(.+)$/); const body = m ? runHistory(decodeURIComponent(m[1])) : runHistoryIndex(); return Promise.resolve({ ok: true, json: () => Promise.resolve(body) }); }; + + // Inject the swap INTO the document rather than assigning onto the + // iframe's window from out here. + // + // Assigning from outside is a race the preview loses at random, and + // loses invisibly: `document.open()` resets the frame's globals, so a + // patch applied before `write` is wiped, and one applied after `close` + // can arrive after page.html's own script has already opened a REAL + // WebSocket. That 404s against the static server and the page renders + // DISCONNECTED with zero events, which reads as a broken FIXTURE rather + // than a broken harness. Diagnosing it the slow way, the frame reports + // `WebSocket === StubSocket` while the page sits disconnected, because + // by then the patch has landed and the damage is done. + // + // A script tag at the top of has no such ordering question: it + // is parsed and run before the page's own inline script at the end of + // body, every time. + const shim = "window.WebSocket=window.parent.__coraStubSocket;" + + "window.fetch=window.parent.__coraStubFetch;"; + const bust = "?preview=" + Date.now(); + const fresh = html.replace(/(]+src=")([^"]+)(")/g, "$1$2" + bust + "$3"); + const wired = fresh.includes("") + ? fresh.replace("", "" + shim) + : shim + fresh; + + const doc = frame.contentDocument; + doc.open(); + doc.write(wired); + doc.close(); }); diff --git a/infra/status-relay/page.html b/infra/status-relay/page.html index 505d9ac0f18..f2d23e1a2b5 100644 --- a/infra/status-relay/page.html +++ b/infra/status-relay/page.html @@ -142,6 +142,15 @@ box-shadow: 0 6px 22px rgb(0 0 0 / 45%); } .cs-tip[data-on="1"] { opacity: 1; } + /* A PINNED card is a different object from a hovering one. It is anchored + to its mark rather than to the pointer, it outlives the pointer, and its + `.cs-tip-list` / `.cs-tip-chain` are capped and scrollable. Left + click-through it cannot be scrolled at all: the wheel falls past it to + the timeline underneath, so a deep causal chain has rows the reader can + see and cannot reach. The pan and hover handlers are bound to the SVG, + not to the stage, so a card that swallows its own events takes nothing + away from them. */ + .cs-tip[data-pinned="1"] { pointer-events: auto; } .cs-tip-head { font-size: 0.78rem; font-weight: 600; color: var(--cs-ink); margin-bottom: 0.35rem; @@ -186,8 +195,10 @@ .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 + /* The same two hues the edges use, so a row in the card and the edge it + stands for are recognisably the same relation. Carrying real weight now + that the edges have no heads: hue is what says whether an edge runs to a + cause or to an effect. 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)); } @@ -1429,7 +1440,7 @@

Live activity

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.", + "describe, and a causal link into a permit drop has to land on one of them.", rows: [ ["records", "Enclosure"], ["events on this lane", String((byStream[encId] || []).length)], diff --git a/infra/status-relay/scrubber.js b/infra/status-relay/scrubber.js index 6a7c1964623..fafe5832a77 100644 --- a/infra/status-relay/scrubber.js +++ b/infra/status-relay/scrubber.js @@ -479,31 +479,39 @@ 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 + // edge'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. + // One marker, and the stub is the only thing that uses it. + // + // The chain edges carry no head. A cause is always earlier than its + // effect, so the time axis states the direction already, and hue, + // thickness and opacity all state the more useful thing: which side of + // the focus an edge falls on, which is the "why did this happen" against + // "what did it set off" split the card is built around. A head answered + // neither question, and because markerUnits defaults to strokeWidth it + // was drawn at 4.5 * 2.1 = 9.5px on the nearest hop, wider than the 5-7px + // mark it pointed at, fattening exactly where edges crowd hardest and + // undoing the thinning that lets the near story read first. + // + // The stub keeps its head because nothing else can orient it: it hangs + // into empty space with no second mark at the far end, so a bare dashed + // whisker off the left of a mark reads as an error bar or a duration. At + // the stub's default stroke width of 1 the head is 4.5px, mark-sized. 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); - } + const stubHead = svg("marker", { + id: "cs-arrow-stub", + viewBox: "0 0 8 8", + refX: "6.5", + refY: "4", + markerWidth: "4.5", + markerHeight: "4.5", + orient: "auto", + }); + stubHead.appendChild(svg("path", { d: "M0,4 L0,4 M0,0 L8,4 L0,8 z", fill: "#f0644b" })); + defs.appendChild(stubHead); layer.appendChild(defs); const edges = []; @@ -523,7 +531,6 @@ 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. @@ -562,7 +569,7 @@ 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)", + "marker-end": "url(#cs-arrow-stub)", }) ); const note = svg("text", { @@ -772,7 +779,7 @@ 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 + // Filled later, appended first: an edge 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 @@ -996,8 +1003,8 @@ 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. + // moved it, and an edge landing on empty chart beside the mark + // it means would be worse than one landing 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; @@ -1785,19 +1792,22 @@ // 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. + // + // Centre to centre, both ends. The path used to stop 7px short of the + // target so an arrowhead had clear air to sit in; with the heads gone that + // gap was just a line pointing NEAR a mark instead of at it, and at the + // distances events actually sit apart the stop-short read as a miss. The + // ends tuck UNDER the marks, which is free: `edgeLayer` is appended to the + // plot before any mark, so every mark already paints over it. 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}`; + return `M${a.x},${a.y} C${a.x + dx * 0.28},${a.y - lift} ${b.x - dx * 0.28},${b.y - lift} ${b.x},${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}`; + const k = dy * 0.45; + return `M${a.x},${a.y} C${a.x + fan},${a.y + k} ${b.x + fan},${b.y - k} ${b.x},${b.y}`; } // Separate edges that share an x corridor. @@ -1807,7 +1817,9 @@ // 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. + // edge and reads as a grid rule rather than a link. Load-bearing now that + // the edges carry no head: the head was the last thing telling a vertical + // edge apart from a rule, so the bow is the only cue left. function fanEdges(edges, posOf) { const corridors = new Map(); for (const e of edges) { @@ -2173,6 +2185,7 @@ } tip.innerHTML = aboutHtml(model, lane); tip.setAttribute("data-on", "1"); + tip.setAttribute("data-pinned", "0"); placeTip(clientX, clientY); }; @@ -2184,10 +2197,14 @@ } if (!cluster) { tip.setAttribute("data-on", "0"); + tip.setAttribute("data-pinned", "0"); return; } tip.innerHTML = tipHtml(model, cluster, traceFor(point)); tip.setAttribute("data-on", "1"); + // A hover card FOLLOWS the pointer, so it must stay click-through or it + // would sit under the cursor and fight the mark it describes. + tip.setAttribute("data-pinned", "0"); placeTip(clientX, clientY); }; @@ -2256,6 +2273,9 @@ hover = null; if (!anchor) { tip.setAttribute("data-on", "0"); + // Releasing a pin returns the card to click-through, so a hidden + // card can never keep swallowing pointer events over the chart. + tip.setAttribute("data-pinned", "0"); rerender(); return; } @@ -2272,6 +2292,9 @@ const r = seat.el.getBoundingClientRect(); tip.innerHTML = tipHtml(model, state.selected, focusFor()); tip.setAttribute("data-on", "1"); + // Anchored, not following: this is the one card the reader can put a + // pointer INTO, which is what makes its capped chain scrollable. + tip.setAttribute("data-pinned", "1"); placeTip(r.left + r.width / 2, r.top); };