From 35470aadf037b17b87a613927080e1aca967019f Mon Sep 17 00:00:00 2001 From: saymrwulf Date: Thu, 7 May 2026 10:45:52 +0200 Subject: [PATCH] Ch00 + Ch01: perception towers at the bottom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each player gets a Tetris-style stack at the bottom of the canvas showing the messages they've accepted, in the order they accepted them. As `acceptIntoView` (and `seal`, for the author) beats fire, new blocks slide up from below and settle into place at the top of the tower with a cubic-out ease. The block's color comes from the message's AUTHOR, not the tower owner — so a tower mixes colors, which is exactly what a local DAG looks like in real Crisis. The pedagogical payoff: stack ORDER differs between players because Carl wrote γ before β reached him. Aaron and Ben end with stacks [α, β, γ]; Carl ends with [α, γ, β]. The asymmetry that the chapter spends 5 minutes establishing is now permanently visible at the bottom of the screen, in one glance. Implementation: - `Ch01WorldState.viewOrder: [Ch01Cast: [String]]` — set type can't carry order, so we track an array alongside `views`. Populated when `seal` fires (author gains own message) and when `acceptIntoView` fires (recipient gains incoming message). - `Ch00WorldState.towerBlocks: [Ch01Cast: [Ch00TowerBlock]]` — abstract `tx-N` blocks with author colors, populated during the `logsDiverge` beat in a DIFFERENT order per cast (preview of the same asymmetry that Ch01 will demonstrate concretely). - Tower geometry is identical between chapters: 4 columns of width 110, gap 24, base Y at canvas.height − 110, blocks 26pt tall with 4pt gaps. Cast name + "VIEW" header at the top of each column. Faint dashed rails frame the empty tower so the viewer can see the silhouette before any block has landed. - Active block in Ch01 fades + slides in over the active beat's progress, so the viewer sees each new arrival actually land. Bundled, harness 55/55 invariants, 0 audit errors, 281 PNGs. Co-Authored-By: Claude Opus 4.7 --- .../CrisisViz/Chapters/Ch01_Problem.swift | 77 ++++++++++ .../CrisisViz/Chapters/Ch02_Graph.swift | 133 +++++++++++++++++- .../CrisisViz/Engine/Ch00Timeline.swift | 44 ++++++ .../CrisisViz/Engine/Ch01Timeline.swift | 11 ++ 4 files changed, 264 insertions(+), 1 deletion(-) diff --git a/CrisisViz/Sources/CrisisViz/Chapters/Ch01_Problem.swift b/CrisisViz/Sources/CrisisViz/Chapters/Ch01_Problem.swift index 74088b8..1786352 100644 --- a/CrisisViz/Sources/CrisisViz/Chapters/Ch01_Problem.swift +++ b/CrisisViz/Sources/CrisisViz/Chapters/Ch01_Problem.swift @@ -46,9 +46,86 @@ struct Ch01_Problem: View { drawTitle(in: &context, size: size, text: title, alpha: world.titleAlpha) } + drawPerceptionTowers(in: &context, size: size, world: world) drawBeatTag(in: &context, size: size, world: world) } + // MARK: - Perception towers (Ch00 abstract preview) + + /// Bottom-of-canvas towers, one per introduced cast. They fill with + /// abstract `tx-N` blocks during the `logsDiverge` beat, in a + /// DIFFERENT order per cast — a preview of the same structure that + /// Ch01 fills with real α/β/γ messages. + private func drawPerceptionTowers( + in context: inout GraphicsContext, size: CGSize, + world: Ch00WorldState + ) { + let casts: [Ch01Cast] = [.aaron, .ben, .carl, .dave] + let visibleCasts = casts.filter { world.introduced.contains($0) } + guard !visibleCasts.isEmpty else { return } + + let blockH: CGFloat = 26 + let blockGap: CGFloat = 4 + let towerH: CGFloat = 4 * (blockH + blockGap) + 28 + let baseY: CGFloat = size.height - 110 + let towerW: CGFloat = 110 + let totalW = CGFloat(visibleCasts.count) * towerW + + CGFloat(visibleCasts.count - 1) * 24 + let startX = (size.width - totalW) / 2 + + for (i, cast) in visibleCasts.enumerated() { + let towerX = startX + CGFloat(i) * (towerW + 24) + let towerCenter = towerX + towerW / 2 + let color = castColor(cast) + + // Header + context.draw( + Text(cast.role.displayName.uppercased()) + .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) + .foregroundColor(color.opacity(0.85)), + at: CGPoint(x: towerCenter, y: baseY - towerH + 4) + ) + context.draw( + Text("VIEW") + .font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced)) + .foregroundColor(.white.opacity(0.35)), + at: CGPoint(x: towerCenter, y: baseY - towerH + 18) + ) + // Baseline + rails + var baseline = Path() + baseline.move(to: CGPoint(x: towerX, y: baseY)) + baseline.addLine(to: CGPoint(x: towerX + towerW, y: baseY)) + context.stroke(baseline, with: .color(color.opacity(0.45)), + lineWidth: 1.2) + for railX in [towerX, towerX + towerW] { + var rail = Path() + rail.move(to: CGPoint(x: railX, y: baseY)) + rail.addLine(to: CGPoint(x: railX, y: baseY - towerH + 26)) + context.stroke(rail, with: .color(color.opacity(0.18)), + style: StrokeStyle(lineWidth: 0.8, dash: [3, 4])) + } + + // Blocks + let blocks = world.towerBlocks[cast] ?? [] + for (j, block) in blocks.enumerated() { + let blockY = baseY - CGFloat(j + 1) * (blockH + blockGap) + let rect = CGRect(x: towerX + 6, y: blockY, + width: towerW - 12, height: blockH) + let blockColor = castColor(block.authorCast) + context.fill(RoundedRectangle(cornerRadius: 5).path(in: rect), + with: .color(blockColor.opacity(0.88))) + context.stroke(RoundedRectangle(cornerRadius: 5).path(in: rect), + with: .color(.white.opacity(0.45)), lineWidth: 1.0) + context.draw( + Text(block.label) + .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) + .foregroundColor(.white), + at: CGPoint(x: rect.midX, y: rect.midY) + ) + } + } + } + // MARK: - Lane geometry (mirrors Ch01's; will extract to a shared // LaneRenderKit once a third chapter adopts) diff --git a/CrisisViz/Sources/CrisisViz/Chapters/Ch02_Graph.swift b/CrisisViz/Sources/CrisisViz/Chapters/Ch02_Graph.swift index 2bb842d..6811f59 100644 --- a/CrisisViz/Sources/CrisisViz/Chapters/Ch02_Graph.swift +++ b/CrisisViz/Sources/CrisisViz/Chapters/Ch02_Graph.swift @@ -80,10 +80,141 @@ struct Ch02_Graph: View { drawOpenEnvelope(in: &context, size: size, env: env) } - // 10. Footer: timeline position + active beat label, faint. + // 10. Perception towers at the bottom — one per cast, each + // stacking the messages that cast has accepted, in the + // order they arrived. Aaron and Ben end up with the same + // ordered stack (α, β, γ); Carl ends up with (α, γ, β) + // because β reached him after he'd already written γ. The + // different stack orderings make the asymmetry physically + // visible at a glance. + drawPerceptionTowers(in: &context, size: size, world: world, t: t) + + // 11. Beat tag (testbed-only debug label). drawFooter(in: &context, size: size, t: t, world: world) } + // MARK: - Perception towers + + /// Bottom-of-canvas towers. One per cast member that has been + /// introduced. Each tower shows the ordered list of messages in + /// that cast's local view. New blocks slide up into place as + /// `acceptIntoView` beats fire — the active block is at full + /// opacity once the beat completes. + private func drawPerceptionTowers( + in context: inout GraphicsContext, size: CGSize, + world: Ch01WorldState, t: Double + ) { + let casts: [Ch01Cast] = [.aaron, .ben, .carl, .dave] + let visibleCasts = casts.filter { world.introduced.contains($0) } + guard !visibleCasts.isEmpty else { return } + + // Tower geometry. Block size is generous so message-id labels + // stay readable. Total tower span sits in the lower band of the + // canvas, well clear of the cast lanes (lanes 0-3 are at + // y ≈ 116..450) and clear of the GlassNarration overlay + // (occupies bottom-LEFT in the live app — towers stay center). + let blockH: CGFloat = 26 + let blockGap: CGFloat = 4 + let towerH: CGFloat = 4 * (blockH + blockGap) + 28 // header + 4 blocks max + let baseY: CGFloat = size.height - 110 + let towerW: CGFloat = 110 + let totalW = CGFloat(visibleCasts.count) * towerW + + CGFloat(visibleCasts.count - 1) * 24 + let startX = (size.width - totalW) / 2 + + // Detect the message currently being added (if any) so its + // block can fade in over the active beat's progress. + var activeAcceptingMid: String? = nil + var activeAcceptingCast: Ch01Cast? = nil + if let active = world.activeBeat { + switch active.kind { + case .acceptIntoView(let at, let mid): + activeAcceptingMid = mid + activeAcceptingCast = at + case .seal(let mid): + activeAcceptingMid = mid + activeAcceptingCast = Ch01Timeline.messages[mid]?.author + default: break + } + } + + for (i, cast) in visibleCasts.enumerated() { + let towerX = startX + CGFloat(i) * (towerW + 24) + let towerCenter = towerX + towerW / 2 + let color = castColor(cast) + + // Header: cast name above the tower. + context.draw( + Text(cast.role.displayName.uppercased()) + .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) + .foregroundColor(color.opacity(0.85)), + at: CGPoint(x: towerCenter, y: baseY - towerH + 4) + ) + // "VIEW" sub-label + context.draw( + Text("VIEW") + .font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced)) + .foregroundColor(.white.opacity(0.35)), + at: CGPoint(x: towerCenter, y: baseY - towerH + 18) + ) + // Tower base line — thin axis showing the floor of the stack. + var baseline = Path() + baseline.move(to: CGPoint(x: towerX, y: baseY)) + baseline.addLine(to: CGPoint(x: towerX + towerW, y: baseY)) + context.stroke(baseline, + with: .color(color.opacity(0.45)), + lineWidth: 1.2) + // Vertical guides — faint, so the empty tower silhouette + // is visible before any block has landed. + var leftRail = Path() + leftRail.move(to: CGPoint(x: towerX, y: baseY)) + leftRail.addLine(to: CGPoint(x: towerX, y: baseY - towerH + 26)) + context.stroke(leftRail, with: .color(color.opacity(0.18)), + style: StrokeStyle(lineWidth: 0.8, dash: [3, 4])) + var rightRail = Path() + rightRail.move(to: CGPoint(x: towerX + towerW, y: baseY)) + rightRail.addLine(to: CGPoint(x: towerX + towerW, y: baseY - towerH + 26)) + context.stroke(rightRail, with: .color(color.opacity(0.18)), + style: StrokeStyle(lineWidth: 0.8, dash: [3, 4])) + + // Stack the blocks. + let order = world.viewOrder[cast] ?? [] + for (j, mid) in order.enumerated() { + guard let msg = Ch01Timeline.messages[mid] else { continue } + let isActiveDrop = (activeAcceptingMid == mid && activeAcceptingCast == cast) + let dropProgress = isActiveDrop + ? max(0, min(1, world.activeProgress)) + : 1.0 + // Easing: cubic-out so the block "settles" gracefully. + let eased = 1 - pow(1 - dropProgress, 3) + let restY = baseY - CGFloat(j + 1) * (blockH + blockGap) + // The dropping block falls from below the baseline up + // to its rest Y. Earlier blocks sit at full opacity. + let dropFrom = baseY + 30 + let blockY = restY * eased + dropFrom * (1 - eased) + let alpha = 0.4 + 0.6 * eased + let rect = CGRect(x: towerX + 6, y: blockY, + width: towerW - 12, height: blockH) + let blockColor = castColor(authorOf(mid)) + context.fill(RoundedRectangle(cornerRadius: 5).path(in: rect), + with: .color(blockColor.opacity(0.88 * alpha))) + context.stroke(RoundedRectangle(cornerRadius: 5).path(in: rect), + with: .color(.white.opacity(0.45 * alpha)), + lineWidth: 1.0) + context.draw( + Text("\(mid) \(msg.hashShort)") + .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) + .foregroundColor(.white.opacity(alpha)), + at: CGPoint(x: rect.midX, y: rect.midY) + ) + } + } + } + + private func authorOf(_ mid: String) -> Ch01Cast { + Ch01Timeline.messages[mid]?.author ?? .aaron + } + // MARK: - Lane geometry /// Lane center Y for cast index 0..3 (Aaron/Ben/Carl/Dave) using the diff --git a/CrisisViz/Sources/CrisisViz/Engine/Ch00Timeline.swift b/CrisisViz/Sources/CrisisViz/Engine/Ch00Timeline.swift index 3025257..1a9b27b 100644 --- a/CrisisViz/Sources/CrisisViz/Engine/Ch00Timeline.swift +++ b/CrisisViz/Sources/CrisisViz/Engine/Ch00Timeline.swift @@ -47,6 +47,19 @@ struct Ch00WorldState { var daveOminous: Double = 0 // 0..1, red glow + warning text var activeBeat: Ch00Beat? = nil var activeProgress: Double = 0 + /// Per-cast tower contents — abstract preview blocks. During the + /// `logsDiverge` beat each cast accumulates a few colored blocks in + /// a DIFFERENT order, foreshadowing the asymmetry that becomes + /// concrete in Ch01. + var towerBlocks: [Ch01Cast: [Ch00TowerBlock]] = [:] +} + +/// One block in a Ch00 perception tower — abstract, no real digest. +struct Ch00TowerBlock { + let label: String + /// Whose color the block carries (so each tower mixes colors, + /// the way local logs in real Crisis would). + let authorCast: Ch01Cast } // MARK: - Timeline @@ -152,6 +165,37 @@ enum Ch00Timeline { case .logsDiverge: w.divergeProgress = progress if !isActive { w.divergeProgress = 1 } + // Populate each tower with a sequence of mixed-color + // blocks in a DIFFERENT order per cast. The blocks + // appear one at a time, paced over the beat's duration, + // so the viewer can see each player's stack grow + // independently — same population, different histories. + let perTowerOrders: [Ch01Cast: [Ch00TowerBlock]] = [ + .aaron: [ + .init(label: "tx-1", authorCast: .aaron), + .init(label: "tx-2", authorCast: .ben), + .init(label: "tx-3", authorCast: .carl), + ], + .ben: [ + .init(label: "tx-2", authorCast: .ben), + .init(label: "tx-1", authorCast: .aaron), + .init(label: "tx-3", authorCast: .carl), + ], + .carl: [ + .init(label: "tx-3", authorCast: .carl), + .init(label: "tx-2", authorCast: .ben), + .init(label: "tx-1", authorCast: .aaron), + ], + .dave: [ + .init(label: "tx-4", authorCast: .dave), + .init(label: "tx-1", authorCast: .aaron), + ], + ] + let perTowerProgress = isActive ? progress : 1.0 + for (cast, blocks) in perTowerOrders { + let revealed = Int(Double(blocks.count) * perTowerProgress) + w.towerBlocks[cast] = Array(blocks.prefix(revealed)) + } case .needAgreement: w.convergeProgress = progress if !isActive { w.convergeProgress = 1 } diff --git a/CrisisViz/Sources/CrisisViz/Engine/Ch01Timeline.swift b/CrisisViz/Sources/CrisisViz/Engine/Ch01Timeline.swift index f0ed963..6bddab7 100644 --- a/CrisisViz/Sources/CrisisViz/Engine/Ch01Timeline.swift +++ b/CrisisViz/Sources/CrisisViz/Engine/Ch01Timeline.swift @@ -121,6 +121,11 @@ struct Ch01WorldState { var sealedMessages: Set = [] /// Each cast member's local view: messages they have fully accepted. var views: [Ch01Cast: Set] = [:] + /// Same content as `views`, but in the order each cast accepted them. + /// Used to drive the "perception towers" at the bottom of the canvas + /// — Aaron sees α first, then β, then γ; Carl sees α, then γ, then β + /// (the asymmetry is visible as a different stack ordering). + var viewOrder: [Ch01Cast: [String]] = [:] /// What the active recipient has read of the just-arrived envelope — /// payload (if .readBody fired), parents list (if .readParents /// fired), each individual resolved parent. @@ -448,6 +453,9 @@ enum Ch01Timeline { // Author "knows" their own message after sealing. if let msg = messages[mid] { w.views[msg.author, default: []].insert(mid) + if !w.viewOrder[msg.author, default: []].contains(mid) { + w.viewOrder[msg.author, default: []].append(mid) + } } // Once sealed, drop the composing state. if !isActive { w.composing = nil } @@ -502,6 +510,9 @@ enum Ch01Timeline { case .acceptIntoView(let at, let mid): // Permanent: recipient now holds the message. w.views[at, default: []].insert(mid) + if !w.viewOrder[at, default: []].contains(mid) { + w.viewOrder[at, default: []].append(mid) + } // Once accepted, the open envelope is dismissed. if !isActive { if w.openEnvelope?.recipient == at && w.openEnvelope?.messageId == mid {