diff --git a/CrisisViz/Sources/CrisisViz/Chapters/Ch08_DA_Problem.swift b/CrisisViz/Sources/CrisisViz/Chapters/Ch08_DA_Problem.swift index 0e9ee05..43afd4c 100644 --- a/CrisisViz/Sources/CrisisViz/Chapters/Ch08_DA_Problem.swift +++ b/CrisisViz/Sources/CrisisViz/Chapters/Ch08_DA_Problem.swift @@ -1,6 +1,7 @@ import SwiftUI -/// Ch08: "Data Availability — The Problem" — 4 scenes: gossip≠storage, bootstrapping, sybil, separation. +/// Ch07 (chapter index 7, file Ch08_DA_Problem.swift): +/// "The leader knows. Did the leader tell anyone?" — DA problem. struct Ch08_DA_Problem: View { let sceneIndex: Int let localTime: Double @@ -8,509 +9,363 @@ struct Ch08_DA_Problem: View { let dm: DataManager @Environment(AppSettings.self) private var settings - /// Synthetic 8-node ring topology: cast colors first, then a few muted - /// peer tones so the chapter visually carries over from the cast scenes. - /// Avoids the legacy `DataManager.palette[i]` rainbow that broke cast - /// continuity here. - private static let castPalette: [Color] = [ - Cast.coral, Cast.teal, Cast.amber, Cast.violet, - Cast.muted, Cast.muted.opacity(0.85), - Cast.muted.opacity(0.7), Cast.muted.opacity(0.55) - ] - var body: some View { Canvas { context, size in - render(context: &context, size: size, time: localTime) + let t = Ch07Scenes.timelineT(sceneIndex: sceneIndex, + localTime: localTime) + render(in: &context, size: size, t: t) } } - private func render(context: inout GraphicsContext, size: CGSize, time: Double) { - switch sceneIndex { - case 0: renderGossipNotStorage(context: &context, size: size, time: time) - case 1: renderBootstrapping(context: &context, size: size, time: time) - case 2: renderSybilAttack(context: &context, size: size, time: time) - case 3: renderSeparation(context: &context, size: size, time: time) - default: break + private func render(in context: inout GraphicsContext, size: CGSize, t: Double) { + let world = Ch07Timeline.state(at: t) + drawLanes(in: &context, size: size) + drawCastFigures(in: &context, size: size) + drawAcceptedVertices(in: &context, size: size, world: world) + drawAaronVault(in: &context, size: size, world: world, t: t) + if let flight = world.hashFlight { + drawHashFlight(in: &context, size: size, flight: flight) + } + if let ask = world.askArrow { + drawAskArrow(in: &context, size: size, ask: ask) + } + if let asker = world.timeoutFlash { + drawTimeoutFlash(in: &context, size: size, asker: asker, t: t) + } + if world.stuckAlpha > 0 { + drawStuckBadge(in: &context, size: size, alpha: world.stuckAlpha) + } + drawBeatTag(in: &context, size: size, world: world) + } + + private func castLaneY(_ laneIdx: Int, size: CGSize) -> CGFloat { + let margin: CGFloat = 60 + let nodeCount: CGFloat = 7 + let laneHeight = (size.height - 2 * margin) / nodeCount + return margin + (CGFloat(laneIdx) + 0.5) * laneHeight + } + + private func castPosition(cast: Ch01Cast, size: CGSize) -> CGPoint { + let laneIdx: Int + switch cast { + case .aaron: laneIdx = 0 + case .ben: laneIdx = 1 + case .carl: laneIdx = 2 + case .dave: laneIdx = 3 + } + return CGPoint(x: size.width * 0.18, y: castLaneY(laneIdx, size: size)) + } + + private func castColor(_ cast: Ch01Cast) -> Color { + switch cast { + case .aaron: return Cast.coral + case .ben: return Cast.teal + case .carl: return Cast.amber + case .dave: return Cast.violet } } - // MARK: - Scene 0: Gossip ≠ Storage - - private func renderGossipNotStorage(context: inout GraphicsContext, size: CGSize, time: Double) { - let cx = size.width / 2 - let cy = size.height / 2 - let nodeCount = 8 - let radius: CGFloat = min(size.width, size.height) * 0.25 - - // Draw nodes - var positions: [CGPoint] = [] - for i in 0.. 0.01 { - let pulseRect = CGRect(x: pos.x - pulseR, y: pos.y - pulseR, - width: pulseR * 2, height: pulseR * 2) - let color = Self.castPalette[i % Self.castPalette.count] - context.stroke(Circle().path(in: pulseRect), - with: .color(color.opacity(alpha)), lineWidth: 1) - } - } - } - - // Gossip particles between nodes - for p in 0..<30 { - let seed = Double(p * 7919) - let fromIdx = Int(seed) % nodeCount - let toIdx = (fromIdx + 1 + Int(seed * 0.3) % (nodeCount - 1)) % nodeCount - let progress = ((time * 0.5 + seed * 0.05).truncatingRemainder(dividingBy: 1.0)) - - let from = positions[fromIdx] - let to = positions[toIdx] - let px = from.x + (to.x - from.x) * progress - let py = from.y + (to.y - from.y) * progress - let particleR: CGFloat = 2.5 - let particleRect = CGRect(x: px - particleR, y: py - particleR, - width: particleR * 2, height: particleR * 2) - let color = Self.castPalette[fromIdx % Self.castPalette.count] - context.fill(Circle().path(in: particleRect), with: .color(color.opacity(0.4 * (1 - progress)))) - } - - // Labels - context.draw( - Text("PUSH-BASED GOSSIP") - .font(.system(size: settings.scaled(18), weight: .heavy, design: .monospaced)) - .foregroundColor(.white.opacity(0.3)), - at: CGPoint(x: cx, y: 50) - ) - - // Bottom: NOT vs IS - let boxW: CGFloat = 250 - let boxH: CGFloat = 50 - let leftBox = CGRect(x: cx - boxW - 20, y: size.height - 100, width: boxW, height: boxH) - context.fill(RoundedRectangle(cornerRadius: 8).path(in: leftBox), - with: .color(.green.opacity(0.1))) - context.stroke(RoundedRectangle(cornerRadius: 8).path(in: leftBox), - with: .color(.green.opacity(0.3)), lineWidth: 1) - context.draw( - Text("✓ FIREHOSE FOR THE PRESENT") - .font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced)) - .foregroundColor(.green.opacity(0.7)), - at: CGPoint(x: leftBox.midX, y: leftBox.midY) - ) - - let rightBox = CGRect(x: cx + 20, y: size.height - 100, width: boxW, height: boxH) - context.fill(RoundedRectangle(cornerRadius: 8).path(in: rightBox), - with: .color(.red.opacity(0.1))) - context.stroke(RoundedRectangle(cornerRadius: 8).path(in: rightBox), - with: .color(.red.opacity(0.3)), lineWidth: 1) - context.draw( - Text("✕ NOT A DATABASE FOR THE PAST") - .font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced)) - .foregroundColor(.red.opacity(0.7)), - at: CGPoint(x: rightBox.midX, y: rightBox.midY) - ) + private func authorOf(_ mid: String) -> Ch01Cast { + if mid == "ξ" { return .aaron } + if let m = Ch01Timeline.messages[mid] { return m.author } + if let m = Ch02Timeline.messages[mid] { return m.author } + return .aaron } - // MARK: - Scene 1: Bootstrapping Problem + private static let initialMessages: [String] = ["α", "β", "γ", "δ", "ε"] + private static let castLanes: [(Ch01Cast, Int)] = [(.aaron, 0), (.ben, 1), (.carl, 2), (.dave, 3)] - private func renderBootstrapping(context: inout GraphicsContext, size: CGSize, time: Double) { - let cx = size.width / 2 - let cy = size.height * 0.48 - - // Existing network (left cluster) — 8 real nodes - let netRadius: CGFloat = min(size.width, size.height) * 0.18 - let netCenter = CGPoint(x: cx * 0.45, y: cy) - var netPositions: [CGPoint] = [] - let nodeCount = 8 - let stress = min(1.0, time * 0.1) - - // Draw inter-node connections first - for i in 0.. CGPoint { + let laneIdx: Int + switch cast { + case .aaron: laneIdx = 0 + case .ben: laneIdx = 1 + case .carl: laneIdx = 2 + case .dave: laneIdx = 3 } - for i in 0.. CGPoint? { + if mid == "ξ" { return xiPosition(cast: cast, size: size) } + guard let i = Self.initialMessages.firstIndex(of: mid) else { return nil } + let laneIdx: Int + switch cast { + case .aaron: laneIdx = 0 + case .ben: laneIdx = 1 + case .carl: laneIdx = 2 + case .dave: laneIdx = 3 } + let lane = castLaneY(laneIdx, size: size) + let castX = castPosition(cast: cast, size: size).x + return CGPoint(x: castX + 70 + CGFloat(i) * 50, y: lane) + } - for i in 0.. 0.5 ? stressColor : color - let r: CGFloat = 18 - let rect = CGRect(x: pos.x - r, y: pos.y - r, width: r * 2, height: r * 2) - context.fill(Circle().path(in: rect), with: .color(blendedColor.opacity(0.7))) + // MARK: - Lanes / cast / vertices - // Node label + private func drawLanes(in context: inout GraphicsContext, size: CGSize) { + for (cast, idx) in Self.castLanes { + let y = castLaneY(idx, size: size) + var path = Path() + path.move(to: CGPoint(x: 36, y: y)) + path.addLine(to: CGPoint(x: size.width - 200, y: y)) // leave room for vault + context.stroke(path, with: .color(castColor(cast).opacity(0.18)), + style: StrokeStyle(lineWidth: 0.8, dash: [4, 6])) context.draw( - Text("N\(i)") - .font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced)) - .foregroundColor(.white.opacity(0.6)), + Text(cast.role.displayName.capitalized) + .font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced)) + .foregroundColor(castColor(cast).opacity(0.75)), + at: CGPoint(x: 24, y: y), anchor: .leading + ) + } + } + + private func drawCastFigures(in context: inout GraphicsContext, size: CGSize) { + for cast in Ch01Cast.allCases { + let pos = castPosition(cast: cast, size: size) + let r: CGFloat = 22 + let color = castColor(cast) + context.fill( + Circle().path(in: CGRect(x: pos.x - r * 1.5, y: pos.y - r * 1.5, + width: r * 3, height: r * 3)), + with: .color(color.opacity(0.10)) + ) + context.fill( + Circle().path(in: CGRect(x: pos.x - r, y: pos.y - r, + width: r * 2, height: r * 2)), + with: .color(color.opacity(0.95)) + ) + context.draw( + Text(String(cast.role.displayName.prefix(1))) + .font(.system(size: settings.scaled(15), weight: .heavy, design: .monospaced)) + .foregroundColor(.white), at: pos ) + } + } - // Stress radiating lines that grow with time - if stress > 0.3 { - for s in 0..<4 { - let sAngle = Double(s) * (.pi / 2.0) + time * 0.5 + Double(i) * 0.4 - let sLen: CGFloat = 8 + 18 * stress - var stressLine = Path() - stressLine.move(to: CGPoint(x: pos.x + r * cos(sAngle), y: pos.y + r * sin(sAngle))) - stressLine.addLine(to: CGPoint(x: pos.x + (r + sLen) * cos(sAngle), - y: pos.y + (r + sLen) * sin(sAngle))) - context.stroke(stressLine, with: .color(.red.opacity(0.35 * stress)), lineWidth: 1.5) - } - } - - // Overload indicators (small "!" marks appearing with stress) - if stress > 0.6 { - let flash = 0.5 + 0.5 * sin(time * 5 + Double(i)) + private func drawAcceptedVertices( + in context: inout GraphicsContext, size: CGSize, world: Ch07WorldState + ) { + for (cast, _) in Self.castLanes { + // Carry-forward α-ε on every lane + for mid in Self.initialMessages { + guard let pos = vertexPosition(cast: cast, mid: mid, size: size) else { continue } + let r: CGFloat = 11 + let color = castColor(authorOf(mid)) + let rect = CGRect(x: pos.x - r, y: pos.y - r, width: r * 2, height: r * 2) + context.fill(Circle().path(in: rect), with: .color(color.opacity(0.85))) context.draw( - Text("!") + Text(mid) .font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced)) - .foregroundColor(.red.opacity(0.6 * flash)), - at: CGPoint(x: pos.x + r + 4, y: pos.y - r - 4) + .foregroundColor(.white), + at: pos ) } - } - - // "EXISTING NETWORK" label - context.draw( - Text("EXISTING NETWORK") - .font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced)) - .foregroundColor(.white.opacity(0.25)), - at: CGPoint(x: netCenter.x, y: netCenter.y + netRadius + 36) - ) - - // Multiple new nodes joining (right side) — the problem scales - let joiners = min(5, Int(time * 0.4) + 1) - var newNodePositions: [CGPoint] = [] - for j in 0.. 1 ? "S" : "")") - .font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced)) - .foregroundColor(.yellow.opacity(0.4)), - at: CGPoint(x: size.width * 0.8, y: cy + CGFloat(joiners) * 40 + 30) - ) - - // Request flood — each joiner sends requests to every network node - for j in 0.. 0 { - let annotBox = CGRect(x: cx - 130, y: size.height * 0.78, width: 260, height: 50) - context.fill(RoundedRectangle(cornerRadius: 8).path(in: annotBox), - with: .color(.orange.opacity(0.06 * annotAppear))) - context.stroke(RoundedRectangle(cornerRadius: 8).path(in: annotBox), - with: .color(.orange.opacity(0.25 * annotAppear)), lineWidth: 1) - context.draw( - Text("COST = O(HISTORY) × JOINERS") - .font(.system(size: settings.scaled(12), weight: .heavy, design: .monospaced)) - .foregroundColor(.orange.opacity(0.7 * annotAppear)), - at: CGPoint(x: annotBox.midX, y: annotBox.midY - 8) - ) - context.draw( - Text("each joiner replays entire DAG via gossip") - .font(.system(size: settings.scaled(9), weight: .medium, design: .monospaced)) - .foregroundColor(.orange.opacity(0.4 * annotAppear)), - at: CGPoint(x: annotBox.midX, y: annotBox.midY + 10) - ) - } - - // Bandwidth meter (full width at top) - let barX: CGFloat = 40 - let barY: CGFloat = 40 - let barW = size.width - 80 - let barH: CGFloat = 22 - let bgRect = CGRect(x: barX, y: barY, width: barW, height: barH) - context.fill(RoundedRectangle(cornerRadius: 4).path(in: bgRect), - with: .color(.white.opacity(0.05))) - context.stroke(RoundedRectangle(cornerRadius: 4).path(in: bgRect), - with: .color(.white.opacity(0.15)), lineWidth: 1) - - let fillPct = min(1.0, time * 0.06 * Double(joiners)) - let fillRect = CGRect(x: barX, y: barY, width: barW * fillPct, height: barH) - let barColor: Color = fillPct > 0.7 ? .red : fillPct > 0.4 ? .orange : .green - context.fill(RoundedRectangle(cornerRadius: 4).path(in: fillRect), - with: .color(barColor.opacity(0.6))) - - // Tick marks on bandwidth bar - for tick in stride(from: 0.25, through: 0.75, by: 0.25) { - let tickX = barX + barW * tick - var tickPath = Path() - tickPath.move(to: CGPoint(x: tickX, y: barY)) - tickPath.addLine(to: CGPoint(x: tickX, y: barY + barH)) - context.stroke(tickPath, with: .color(.white.opacity(0.1)), lineWidth: 0.5) - } - - context.draw( - Text("NETWORK BANDWIDTH: \(Int(fillPct * 100))%") - .font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced)) - .foregroundColor(.white.opacity(0.5)), - at: CGPoint(x: cx, y: barY + barH + 14) - ) - - context.draw( - Text("THE BOOTSTRAPPING PROBLEM — GOSSIP DOESN'T SCALE FOR HISTORY REPLAY") - .font(.system(size: settings.scaled(11), weight: .bold, design: .monospaced)) - .foregroundColor(.orange.opacity(0.5)), - at: CGPoint(x: cx, y: size.height - 30) - ) } - // MARK: - Scene 2: Sybil Attack + // MARK: - Aaron's vault (storage column on the right) - private func renderSybilAttack(context: inout GraphicsContext, size: CGSize, time: Double) { - let cx = size.width / 2 - let cy = size.height / 2 + private func drawAaronVault( + in context: inout GraphicsContext, size: CGSize, + world: Ch07WorldState, t: Double + ) { + // Vault sits in the right margin, vertically aligned roughly + // with Aaron's lane. + let vaultW: CGFloat = 160 + let vaultH: CGFloat = 130 + let vaultX = size.width - vaultW - 24 + let vaultY = castLaneY(0, size: size) - vaultH / 2 + let rect = CGRect(x: vaultX, y: vaultY, width: vaultW, height: vaultH) - // Honest nodes (left cluster) - let honestCenter = CGPoint(x: cx * 0.4, y: cy) - for i in 0..<8 { - let angle = Double(i) * (2.0 * .pi / 8.0) - let pos = CGPoint(x: honestCenter.x + 90 * cos(angle), - y: honestCenter.y + 70 * sin(angle)) - let r: CGFloat = 14 - let rect = CGRect(x: pos.x - r, y: pos.y - r, width: r * 2, height: r * 2) - let color = Self.castPalette[i % Self.castPalette.count] - context.fill(Circle().path(in: rect), with: .color(color.opacity(0.7))) - } - - // Sybil swarm (flooding in from the right) - let sybilCount = Int(min(200, time * 15)) - for i in 0.. 0.05 { - var arrow = Path() - arrow.move(to: CGPoint(x: leftRect.maxX + 5, y: cy)) - arrow.addLine(to: CGPoint(x: rightRect.minX - 5, y: cy)) - context.stroke(arrow, with: .color(.white.opacity(0.5 * arrowAppear)), lineWidth: 2) - - // Arrowhead - var head = Path() - head.move(to: CGPoint(x: rightRect.minX - 5, y: cy)) - head.addLine(to: CGPoint(x: rightRect.minX - 15, y: cy - 6)) - head.addLine(to: CGPoint(x: rightRect.minX - 15, y: cy + 6)) - head.closeSubpath() - context.fill(head, with: .color(.white.opacity(0.5 * arrowAppear))) - + // Body chunks visible as small filled rectangles inside the vault + if world.xiBodyInAaronVault { + let chunksRows = 4 + let chunksCols = 6 + let chunkW: CGFloat = 18 + let chunkH: CGFloat = 12 + let gridX = rect.minX + (rect.width - CGFloat(chunksCols) * (chunkW + 2)) / 2 + let gridY = rect.minY + 30 + for row in 0.. = [] // Ben / Carl receive the HASH only + var bodyMissingAt: Set = [] // ⚠ flag on Ben / Carl + var hashFlight: HashFlight? = nil + var askArrow: AskArrow? = nil + var timeoutFlash: Ch01Cast? = nil + var stuckAlpha: Double = 0 + var activeBeat: Ch07Beat? = nil + var activeProgress: Double = 0 + + struct HashFlight { + let to: Ch01Cast + let progress: Double + } + struct AskArrow { + let asker: Ch01Cast + let progress: Double + let willTimeout: Bool + } +} + +enum Ch07Timeline { + static let beats: [Ch07Beat] = { + let raw: [Ch07Beat] = [ + .init(id: "carry-forward", kind: .carryForward, durationSeconds: 4.0, + narration: "Coming out of Ch06 with the total order in hand. Now Aaron, as round leader, is about to produce a NEW message — call it ξ. ξ carries a large payload, a 'blob'. What happens when only the leader has the body?"), + + .init(id: "compose-xi", kind: .composeXi, durationSeconds: 6.0, + narration: "Aaron composes ξ. Payload is heavy — let's say a megabyte of transaction data. The body sits in Aaron's local vault on the right side of the canvas."), + + .init(id: "seal-xi", kind: .sealXi, durationSeconds: 3.5, + narration: "ξ is sealed. Aaron now holds the body locally and can compute the hash."), + + .init(id: "vault-settle", kind: .settle(label: "Aaron's vault"), + durationSeconds: 4.0, + narration: "Look at the vault on the right: ξ's body lives there, in Aaron's storage. Other validators do not have it. The hash is small; the body is large."), + + // Phase 2: send hash only + .init(id: "hash-to-ben", kind: .sendHashOnly(to: .ben), durationSeconds: 5.0, + narration: "Aaron sends just the HASH of ξ to Ben — a small envelope, no body inside. Bandwidth-cheap. But it carries a problem with it."), + .init(id: "ben-body-missing", kind: .markBodyMissing(at: .ben), + durationSeconds: 5.0, + narration: "Ben receives ξ's hash. He knows ξ exists. He cannot verify or use it without the body. A ⚠ BODY MISSING flag appears on Ben's copy of ξ."), + .init(id: "hash-to-carl", kind: .sendHashOnly(to: .carl), durationSeconds: 5.0, + narration: "Aaron sends ξ's hash to Carl, also without the body."), + .init(id: "carl-body-missing", kind: .markBodyMissing(at: .carl), + durationSeconds: 5.0, + narration: "Carl also has ξ's hash with no body. Same ⚠ flag. The problem is now on two lanes."), + + // Phase 3: requests + silence + .init(id: "ben-asks", kind: .askForBody(asker: .ben), durationSeconds: 5.0, + narration: "Ben asks Aaron: 'Send me ξ's body.' A request arrow shoots from Ben's lane to Aaron's."), + .init(id: "aaron-silent-ben", kind: .aaronSilent(asker: .ben), + durationSeconds: 5.5, + narration: "Aaron does not respond. Maybe he's offline. Maybe he's malicious. Maybe he's overwhelmed. Regardless: Ben's request times out with a red ✗. The protocol cannot force Aaron to share."), + .init(id: "carl-asks", kind: .askForBody(asker: .carl), durationSeconds: 5.0, + narration: "Carl asks Aaron next."), + .init(id: "aaron-silent-carl", kind: .aaronSilent(asker: .carl), + durationSeconds: 5.5, + narration: "Same outcome. Silence. Carl's request also times out."), + + // Phase 4: stuck + .init(id: "stuck", kind: .stuckBadge, durationSeconds: 6.0, + narration: "DA PROBLEM. ξ is committed to the ledger — its hash is referenced — but its body is unavailable. Without the body, Ben and Carl cannot verify, cannot use, cannot replay. The chain is alive but the data behind it isn't."), + + .init(id: "outro", kind: .settle(label: "Need a fix"), + durationSeconds: 4.0, + narration: "Crisis needs a way to make data un-loseable, even if the leader stays silent. That fix — erasure coding distributed across storage nodes — is the next chapter."), + ] + var t: Double = 0 + var assigned: [Ch07Beat] = [] + for var b in raw { + b.startTime = t + assigned.append(b) + t += b.durationSeconds + } + return assigned + }() + + static var totalDuration: Double { + beats.last.map { $0.endTime } ?? 0 + } + + static func activeBeat(at t: Double) -> Ch07Beat? { + let clamped = max(0, min(t, totalDuration)) + return beats.first { $0.startTime <= clamped && clamped < $0.endTime } + ?? beats.last + } + + static func state(at t: Double) -> Ch07WorldState { + var w = Ch07WorldState() + let clamped = max(0, min(t, totalDuration)) + + for beat in beats { + if clamped < beat.startTime { break } + let isActive = clamped < beat.endTime + let progress = isActive + ? max(0, min(1, (clamped - beat.startTime) / beat.durationSeconds)) + : 1.0 + apply(beat, progress: progress, isActive: isActive, into: &w) + if isActive { + w.activeBeat = beat + w.activeProgress = progress + } + } + return w + } + + private static func apply( + _ beat: Ch07Beat, progress: Double, isActive: Bool, + into w: inout Ch07WorldState + ) { + switch beat.kind { + case .settle, .carryForward: + break + case .composeXi: + w.xiComposed = true + case .sealXi: + w.xiSealed = true + w.xiBodyInAaronVault = true + case .sendHashOnly(let to): + if isActive { + w.hashFlight = .init(to: to, progress: progress) + } + // Permanent: recipient now has ξ's hash in their view. + w.xiInView.insert(to) + case .markBodyMissing(let at): + w.bodyMissingAt.insert(at) + case .askForBody(let asker): + if isActive { + w.askArrow = .init(asker: asker, progress: progress, willTimeout: true) + } + case .aaronSilent(let asker): + if isActive { + w.timeoutFlash = asker + } + case .stuckBadge: + w.stuckAlpha = isActive ? progress : 1.0 + } + } +} + +enum Ch07Scenes { + /// 4 scenes mapping to ~74s of timeline at 1×. + static let sceneStarts: [Double] = [0, 17.5, 37.5, 58.5] + static let sceneDurations: [Double] = [17.5, 20.0, 21.0, 10.0] + + static func timelineT(sceneIndex: Int, localTime: Double) -> Double { + let idx = max(0, min(sceneIndex, sceneStarts.count - 1)) + return sceneStarts[idx] + localTime + } + + static func durationFor(scene: Int) -> Double { + let idx = max(0, min(scene, sceneDurations.count - 1)) + return sceneDurations[idx] + } + + static func narrationAt(sceneIndex: Int, localTime: Double) -> String { + let t = timelineT(sceneIndex: sceneIndex, localTime: localTime) + return Ch07Timeline.activeBeat(at: t)?.narration ?? "" + } +} diff --git a/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift b/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift index a89da21..3a47e1a 100644 --- a/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift +++ b/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift @@ -80,6 +80,11 @@ final class SceneEngine { SceneAddress(chapter: 6, scene: 0): 22.5, SceneAddress(chapter: 6, scene: 1): 14.0, SceneAddress(chapter: 6, scene: 2): 15.0, + // Ch07 — DA problem (4 scenes) + SceneAddress(chapter: 7, scene: 0): 17.5, + SceneAddress(chapter: 7, scene: 1): 20.0, + SceneAddress(chapter: 7, scene: 2): 21.0, + SceneAddress(chapter: 7, scene: 3): 10.0, // Ch09 — Byzantine (2 scenes mapping to Ch09Timeline windows) SceneAddress(chapter: 9, scene: 0): 47.5, SceneAddress(chapter: 9, scene: 1): 32.0, diff --git a/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift b/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift index 9b66983..71d2aad 100644 --- a/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift +++ b/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift @@ -170,6 +170,8 @@ struct ImmersiveView: View { return Ch05Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime) case 6: return Ch06Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime) + case 7: + return Ch07Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime) case 9: return Ch09Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime) default: