mirror of
https://github.com/saymrwulf/crisis.git
synced 2026-05-14 20:37:54 +00:00
Ch06 total order: migrate to serial timeline + ordering snake
The chapter walks the leader chain from Ch05 and adds each leader's round-N ancestor closure to a single linear sequence at the bottom of the canvas — the "total order snake". Dashed placeholder slots #1 through #5 sit empty; as `appendToOrder` beats fire, blocks slide up from below into their position with cubic-out easing and the snake's spine arrows draw between consecutive slots. Sequence built across the chapter: Round 0: α (#1) → β (#2) → γ (#3) → δ (#4) Round 1: ε (#5) Round-N-ordered badges (green check + "ROUND 0 ORDERED" / "ROUND 1 ORDERED") fire as each round completes; final-convergence badge ("ALL VALIDATORS COMPUTE THE SAME TOTAL ORDER — CONVERGENCE") fires at the top once the snake is full. `Ch06Timeline.swift` (new) — 11 beats over ~51.5s. Beat kinds: `appendToOrder`, `roundOrderedBadge`, `finalConvergence`. `Ch07_Order.swift` rewritten to render from Ch06Timeline. Lanes fade slightly when a message goes into the order so the focus shifts to the snake. Slot connectors are arrowed. `SceneEngine` and `ImmersiveView` extended for chapter 6. Bundled, harness 55/55 invariants, 0 audit errors, 281 PNGs, 36/36 MP4 written. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
598bee5b81
commit
6345ae94f5
4 changed files with 398 additions and 321 deletions
|
|
@ -1,25 +1,7 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Ch06 (file Ch07_Order, user-facing chapter index 6): "Spokespersons line
|
||||
/// up. Everyone else falls in behind."
|
||||
///
|
||||
/// This is the masterclass scene — the visible *emergence* of total order
|
||||
/// from the DAG. Three pedagogical beats:
|
||||
///
|
||||
/// - Scene 0 ("Sorting the DAG into a line."): the empty timeline appears
|
||||
/// across the bottom, a leader-position cursor sweeps left to right, and
|
||||
/// the FIRST few ordered vertices peel off the DAG into the line so the
|
||||
/// mechanism is unambiguous. Aaron's, Ben's and Carl's earliest contributions
|
||||
/// are named on landing.
|
||||
///
|
||||
/// - Scene 1 ("Vertices slide into their place."): wave-pull. Each round's
|
||||
/// ordered vertices fly to their slots in succession, with cast-colored
|
||||
/// tracer lines so the viewer SEES who went where. Round zones appear on
|
||||
/// the strip as vertices land in them.
|
||||
///
|
||||
/// - Scene 2 ("Everyone produces the same line."): the whole ordered prefix
|
||||
/// is on the strip; a verification badge confirms Aaron's line equals
|
||||
/// Ben's line equals Carl's line — this is convergence, made visible.
|
||||
/// Ch06 (chapter index 6, file Ch07_Order.swift):
|
||||
/// "Spokespersons line up. Everyone else falls in behind." — total order.
|
||||
struct Ch07_Order: View {
|
||||
let sceneIndex: Int
|
||||
let localTime: Double
|
||||
|
|
@ -27,343 +9,267 @@ struct Ch07_Order: View {
|
|||
let dm: DataManager
|
||||
@Environment(AppSettings.self) private var settings
|
||||
|
||||
// Post-convergence step. The 80-step simulation first produces ordered
|
||||
// vertices at step 38 and reaches a stable converged prefix from step 40
|
||||
// onwards; we pick step 60 so a substantial ordered prefix is available
|
||||
// to slide into the "snake" line.
|
||||
private let dataStep = 60
|
||||
|
||||
/// Cap the visible-on-strip count so the line stays readable. Beyond
|
||||
/// ~40 vertices the strip gets too dense for a teaching frame.
|
||||
private let maxStripCount = 40
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
render(context: &context, size: size, time: localTime)
|
||||
let t = Ch06Scenes.timelineT(sceneIndex: sceneIndex,
|
||||
localTime: localTime)
|
||||
render(in: &context, size: size, t: t)
|
||||
}
|
||||
}
|
||||
|
||||
private func render(context: inout GraphicsContext, size: CGSize, time: Double) {
|
||||
guard dm.sim != nil,
|
||||
let snap = dm.honestData(step: dataStep) else { return }
|
||||
|
||||
let lanes = dm.castOrderedNodes()
|
||||
let allVertices = snap.vertices
|
||||
let allEdges = snap.edges
|
||||
|
||||
// First N ordered vertices, sorted by totalPosition.
|
||||
let orderedAll = allVertices.filter { $0.totalPosition != nil }
|
||||
.sorted { ($0.totalPosition ?? 0) < ($1.totalPosition ?? 0) }
|
||||
let ordered = Array(orderedAll.prefix(maxStripCount))
|
||||
let orderedSet = Set(ordered.map(\.digestHex))
|
||||
let unordered = allVertices.filter { !orderedSet.contains($0.digestHex) }
|
||||
|
||||
// DAG layout is the *source* (vertex pre-slide positions on their lanes).
|
||||
let layout = DAGLayout.compute(
|
||||
vertices: allVertices, edges: allEdges, nodes: lanes,
|
||||
canvasSize: size, margin: 60
|
||||
)
|
||||
|
||||
// Strip geometry — single row of slots above the bottom narration band.
|
||||
let stripY: CGFloat = size.height - 220
|
||||
let stripMargin: CGFloat = 40
|
||||
let stripWidth = size.width - stripMargin * 2
|
||||
let slotSpacing = stripWidth / CGFloat(max(ordered.count, 1))
|
||||
|
||||
// Per-vertex slide progress. Wave animation: vertex i begins sliding
|
||||
// at staggerStart_i, taking `pullDuration` to land. Scene 0 reveals
|
||||
// only the first few; Scene 1 floods; Scene 2 holds the final state.
|
||||
let pullDuration: Double = 0.9
|
||||
let stagger: Double = sceneIndex == 0 ? 0.4 : 0.18
|
||||
let revealLimit: Int
|
||||
switch sceneIndex {
|
||||
case 0: revealLimit = min(ordered.count, max(3, Int(time / 1.6) + 3))
|
||||
case 1: revealLimit = ordered.count
|
||||
default: revealLimit = ordered.count
|
||||
private func render(in context: inout GraphicsContext, size: CGSize, t: Double) {
|
||||
let world = Ch06Timeline.state(at: t)
|
||||
drawLanes(in: &context, size: size)
|
||||
drawCastFigures(in: &context, size: size)
|
||||
drawAcceptedVertices(in: &context, size: size, world: world)
|
||||
drawOrderingSnake(in: &context, size: size, world: world, t: t)
|
||||
drawRoundOrderedBadges(in: &context, size: size, world: world)
|
||||
if world.finalConvergenceAlpha > 0 {
|
||||
drawFinalConvergence(in: &context, size: size,
|
||||
alpha: world.finalConvergenceAlpha)
|
||||
}
|
||||
let timeOffsetForLandingAll: Double = sceneIndex == 2 ? 0.0 : Double(ordered.count) * stagger + pullDuration
|
||||
drawBeatTag(in: &context, size: size, world: world)
|
||||
}
|
||||
|
||||
// Background DAG (dimmed as the snake assembles).
|
||||
let bgFade = sceneIndex == 0
|
||||
? 0.55
|
||||
: max(0.10, 0.55 - 0.45 * min(1.0, time / 4.0))
|
||||
layout.drawNodeLanes(in: &context, nodes: lanes, canvasSize: size, dm: dm,
|
||||
textScale: settings.textScale)
|
||||
layout.drawRoundSeparators(in: &context, canvasSize: size, minRound: 0,
|
||||
alpha: 0.18 * bgFade, textScale: settings.textScale)
|
||||
layout.drawEdges(in: &context, edges: allEdges,
|
||||
alpha: max(0.04, 0.22 * bgFade))
|
||||
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
|
||||
}
|
||||
|
||||
// Unordered vertices stay in their lanes, dimmed.
|
||||
for v in unordered {
|
||||
guard let pos = layout.positions[v.digestHex] else { continue }
|
||||
let r: CGFloat = 5 + CGFloat(min(v.weight, 8)) * 0.6
|
||||
let rect = CGRect(x: pos.x - r, y: pos.y - r, width: r * 2, height: r * 2)
|
||||
let baseColor = dm.castColor(for: v.processIdHex)
|
||||
context.fill(Circle().path(in: rect), with: .color(baseColor.opacity(0.30 * bgFade)))
|
||||
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.20, y: castLaneY(laneIdx, size: size))
|
||||
}
|
||||
|
||||
// The strip itself — empty slot row that holds positions even before
|
||||
// anything has landed. Visible from t=0 in every scene so the viewer
|
||||
// sees the "destination" before the first vertex flies.
|
||||
drawStripBackdrop(in: &context, size: size, stripY: stripY,
|
||||
stripMargin: stripMargin, slotSpacing: slotSpacing,
|
||||
slotCount: ordered.count, time: time)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Per-round zones on the strip. We compute them on the fly from the
|
||||
// ordered list itself — no parallel array needed.
|
||||
drawRoundZonesOnStrip(in: &context, ordered: ordered,
|
||||
stripY: stripY, stripMargin: stripMargin,
|
||||
slotSpacing: slotSpacing,
|
||||
revealLimit: revealLimit, sceneIndex: sceneIndex,
|
||||
time: time, settings: settings)
|
||||
private func authorOf(_ mid: String) -> Ch01Cast {
|
||||
if let m = Ch01Timeline.messages[mid] { return m.author }
|
||||
if let m = Ch02Timeline.messages[mid] { return m.author }
|
||||
return .aaron
|
||||
}
|
||||
|
||||
// ─── Slide each ordered vertex from its lane to its slot ────────
|
||||
for (i, vertex) in ordered.enumerated() {
|
||||
guard let dagPos = layout.positions[vertex.digestHex] else { continue }
|
||||
let targetX = stripMargin + (CGFloat(i) + 0.5) * slotSpacing
|
||||
let targetY = stripY
|
||||
private static let allMessages: [String] = ["α", "β", "γ", "δ", "ε"]
|
||||
private static let castLanes: [(Ch01Cast, Int)] = [(.aaron, 0), (.ben, 1), (.carl, 2), (.dave, 3)]
|
||||
|
||||
// Per-vertex animation phase
|
||||
let startAt = Double(i) * stagger
|
||||
let progress: Double
|
||||
if sceneIndex == 2 {
|
||||
// In scene 2 the snake is fully formed.
|
||||
progress = 1.0
|
||||
} else if i >= revealLimit {
|
||||
progress = 0.0
|
||||
} else {
|
||||
progress = max(0, min(1, (time - startAt) / pullDuration))
|
||||
}
|
||||
private func vertexPosition(cast: Ch01Cast, mid: String, size: CGSize) -> CGPoint? {
|
||||
guard let i = Self.allMessages.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) * 56, y: lane)
|
||||
}
|
||||
|
||||
// Cubic ease-out for a bit of motion personality without overdoing.
|
||||
let eased = 1 - pow(1 - progress, 3)
|
||||
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 - 24, y: y))
|
||||
context.stroke(path, with: .color(castColor(cast).opacity(0.18)),
|
||||
style: StrokeStyle(lineWidth: 0.8, dash: [4, 6]))
|
||||
context.draw(
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let x = dagPos.x + (targetX - dagPos.x) * CGFloat(eased)
|
||||
let y = dagPos.y + (targetY - dagPos.y) * CGFloat(eased)
|
||||
let pos = CGPoint(x: x, y: y)
|
||||
let castColor = dm.castColor(for: vertex.processIdHex)
|
||||
let role = dm.castRole(for: vertex.processIdHex)
|
||||
private func drawCastFigures(in context: inout GraphicsContext, size: CGSize) {
|
||||
for cast in Ch01Cast.allCases {
|
||||
let pos = castPosition(cast: cast, size: size)
|
||||
let r: CGFloat = 24
|
||||
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(16), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white),
|
||||
at: pos
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Tracer line during slide — fades as the vertex settles.
|
||||
if eased > 0.05 && eased < 0.97 {
|
||||
var path = Path()
|
||||
path.move(to: dagPos)
|
||||
path.addLine(to: pos)
|
||||
let traceAlpha = (1 - eased) * 0.5 + 0.1
|
||||
context.stroke(path,
|
||||
with: .color(castColor.opacity(traceAlpha)),
|
||||
style: StrokeStyle(lineWidth: 1.6,
|
||||
dash: [3, 4]))
|
||||
}
|
||||
|
||||
// Vertex itself.
|
||||
let radius: CGFloat = 7 + CGFloat(min(vertex.weight, 8)) * 0.6
|
||||
let rect = CGRect(x: pos.x - radius, y: pos.y - radius,
|
||||
width: radius * 2, height: radius * 2)
|
||||
// A small landing flash for one beat after settling.
|
||||
let timeSinceLand = time - (startAt + pullDuration)
|
||||
let flashAmt: Double = (timeSinceLand > 0 && timeSinceLand < 0.35)
|
||||
? max(0, 1 - timeSinceLand / 0.35) : 0
|
||||
if flashAmt > 0.05 {
|
||||
let flashR = radius * (1 + 1.4 * CGFloat(flashAmt))
|
||||
let flashRect = CGRect(x: pos.x - flashR, y: pos.y - flashR,
|
||||
width: flashR * 2, height: flashR * 2)
|
||||
context.fill(Circle().path(in: flashRect),
|
||||
with: .color(castColor.opacity(0.25 * flashAmt)))
|
||||
}
|
||||
context.fill(Circle().path(in: rect),
|
||||
with: .color(castColor.opacity(0.55 + 0.4 * eased)))
|
||||
// Yellow ring for round-boundary (`isLast`) vertices —
|
||||
// the same convention used in Ch03_Rounds, kept consistent here
|
||||
// so the viewer sees that round-marking carries over to ordering.
|
||||
if vertex.isLast && eased > 0.6 {
|
||||
context.stroke(Circle().path(in: rect.insetBy(dx: -2, dy: -2)),
|
||||
with: .color(.yellow.opacity(0.5 * eased)),
|
||||
lineWidth: 1.5)
|
||||
}
|
||||
|
||||
// Position number after landing.
|
||||
if eased > 0.85 {
|
||||
private func drawAcceptedVertices(
|
||||
in context: inout GraphicsContext, size: CGSize, world: Ch06WorldState
|
||||
) {
|
||||
for (cast, _) in Self.castLanes {
|
||||
for mid in Self.allMessages {
|
||||
guard let pos = vertexPosition(cast: cast, mid: mid, size: size) else { continue }
|
||||
let r: CGFloat = 11
|
||||
let color = castColor(authorOf(mid))
|
||||
let inOrder = world.order.contains(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(inOrder ? 0.6 : 0.85)))
|
||||
context.stroke(Circle().path(in: rect),
|
||||
with: .color(.white.opacity(0.45)), lineWidth: 0.8)
|
||||
context.draw(
|
||||
Text("\(i + 1)")
|
||||
.font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.85)),
|
||||
at: CGPoint(x: pos.x, y: pos.y + radius + 11)
|
||||
)
|
||||
}
|
||||
|
||||
// Cast-name callout for the FIRST vertex of each named lead.
|
||||
// Only label the first appearance to keep the strip readable.
|
||||
if eased > 0.85 && role.isNamedCast,
|
||||
isFirstAppearance(of: role, in: ordered, atIndex: i) {
|
||||
context.draw(
|
||||
Text(role.displayName.uppercased())
|
||||
Text(mid)
|
||||
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(castColor.opacity(0.9)),
|
||||
at: CGPoint(x: pos.x, y: pos.y - radius - 12)
|
||||
.foregroundColor(.white.opacity(inOrder ? 0.7 : 1.0)),
|
||||
at: pos
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func drawOrderingSnake(
|
||||
in context: inout GraphicsContext, size: CGSize,
|
||||
world: Ch06WorldState, t: Double
|
||||
) {
|
||||
let trackY: CGFloat = size.height - 100
|
||||
let blockW: CGFloat = 76
|
||||
let blockH: CGFloat = 38
|
||||
let blockGap: CGFloat = 16
|
||||
let totalW = CGFloat(Self.allMessages.count) * blockW
|
||||
+ CGFloat(Self.allMessages.count - 1) * blockGap
|
||||
let startX = (size.width - totalW) / 2
|
||||
|
||||
context.draw(
|
||||
Text("TOTAL ORDER")
|
||||
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.65)),
|
||||
at: CGPoint(x: size.width / 2, y: trackY - 32)
|
||||
)
|
||||
|
||||
for i in 0..<Self.allMessages.count {
|
||||
let x = startX + CGFloat(i) * (blockW + blockGap)
|
||||
let rect = CGRect(x: x, y: trackY - blockH / 2,
|
||||
width: blockW, height: blockH)
|
||||
if i >= world.order.count {
|
||||
context.stroke(RoundedRectangle(cornerRadius: 6).path(in: rect),
|
||||
with: .color(.white.opacity(0.18)),
|
||||
style: StrokeStyle(lineWidth: 1.0, dash: [3, 4]))
|
||||
context.draw(
|
||||
Text("#\(i + 1)")
|
||||
.font(.system(size: settings.scaled(9), weight: .regular, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.30)),
|
||||
at: CGPoint(x: rect.midX, y: rect.midY)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Subtitle ────────────────────────────────────────────────────
|
||||
let subtitle: String = switch sceneIndex {
|
||||
case 0: "AARON, BEN, CARL — THEIR VERTICES WALK ONTO THE LINE"
|
||||
case 1: "EACH ROUND'S VERTICES FLY INTO POSITION"
|
||||
default: "EVERY HONEST NODE PRODUCES THIS EXACT LINE — CONVERGENCE"
|
||||
}
|
||||
context.draw(
|
||||
Text(subtitle)
|
||||
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.cyan.opacity(0.55)),
|
||||
at: CGPoint(x: size.width / 2, y: stripY + 80)
|
||||
)
|
||||
|
||||
// ─── Final-scene convergence badge ───────────────────────────────
|
||||
if sceneIndex == 2 {
|
||||
drawConvergenceBadge(in: &context, size: size, ordered: ordered, time: time)
|
||||
var activeSlideIndex = -1
|
||||
if case .appendToOrder = world.activeBeat?.kind {
|
||||
activeSlideIndex = world.order.count - 1
|
||||
}
|
||||
|
||||
// Top-right: ordered count.
|
||||
context.draw(
|
||||
Text("\(min(ordered.count, revealLimit))/\(orderedAll.count) ORDERED · \(allVertices.count) TOTAL DAG")
|
||||
.font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.35)),
|
||||
at: CGPoint(x: size.width / 2, y: 16)
|
||||
)
|
||||
for (i, mid) in world.order.enumerated() {
|
||||
let x = startX + CGFloat(i) * (blockW + blockGap)
|
||||
let restY = trackY
|
||||
let dropFrom = trackY + 60
|
||||
let isSliding = (i == activeSlideIndex)
|
||||
let p = isSliding ? max(0, min(1, world.activeProgress)) : 1.0
|
||||
let eased = 1 - pow(1 - p, 3)
|
||||
let y = restY * eased + dropFrom * (1 - eased)
|
||||
let rect = CGRect(x: x, y: y - blockH / 2,
|
||||
width: blockW, height: blockH)
|
||||
let color = castColor(authorOf(mid))
|
||||
context.fill(RoundedRectangle(cornerRadius: 6).path(in: rect),
|
||||
with: .color(color.opacity(0.92 * eased)))
|
||||
context.stroke(RoundedRectangle(cornerRadius: 6).path(in: rect),
|
||||
with: .color(.white.opacity(0.55 * eased)), lineWidth: 1.2)
|
||||
context.draw(
|
||||
Text("\(i + 1)")
|
||||
.font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.55 * eased)),
|
||||
at: CGPoint(x: rect.midX, y: rect.minY + 8)
|
||||
)
|
||||
context.draw(
|
||||
Text(mid)
|
||||
.font(.system(size: settings.scaled(16), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(eased)),
|
||||
at: CGPoint(x: rect.midX, y: rect.midY + 4)
|
||||
)
|
||||
}
|
||||
|
||||
_ = timeOffsetForLandingAll // referenced for clarity in pacing comments
|
||||
// Snake spine arrows between filled slots
|
||||
for i in 0..<max(0, world.order.count - 1) {
|
||||
let x1 = startX + CGFloat(i) * (blockW + blockGap) + blockW
|
||||
let x2 = startX + CGFloat(i + 1) * (blockW + blockGap)
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: x1, y: trackY))
|
||||
path.addLine(to: CGPoint(x: x2, y: trackY))
|
||||
context.stroke(path, with: .color(.white.opacity(0.35)),
|
||||
lineWidth: 1.2)
|
||||
var head = Path()
|
||||
head.move(to: CGPoint(x: x2, y: trackY))
|
||||
head.addLine(to: CGPoint(x: x2 - 5, y: trackY - 3))
|
||||
head.move(to: CGPoint(x: x2, y: trackY))
|
||||
head.addLine(to: CGPoint(x: x2 - 5, y: trackY + 3))
|
||||
context.stroke(head, with: .color(.white.opacity(0.35)),
|
||||
lineWidth: 1.2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pieces
|
||||
|
||||
/// Empty strip slots, drawn as a thin rounded rect with tick marks at
|
||||
/// each slot. Visible from t=0 so the viewer knows where vertices are
|
||||
/// going before they fly.
|
||||
private func drawStripBackdrop(
|
||||
in context: inout GraphicsContext, size: CGSize,
|
||||
stripY: CGFloat, stripMargin: CGFloat,
|
||||
slotSpacing: CGFloat, slotCount: Int, time: Double
|
||||
private func drawRoundOrderedBadges(
|
||||
in context: inout GraphicsContext, size: CGSize, world: Ch06WorldState
|
||||
) {
|
||||
let stripHeight: CGFloat = 4
|
||||
let stripRect = CGRect(
|
||||
x: stripMargin, y: stripY + stripHeight * 4,
|
||||
width: size.width - stripMargin * 2, height: stripHeight
|
||||
)
|
||||
context.fill(RoundedRectangle(cornerRadius: 2).path(in: stripRect),
|
||||
with: .color(.white.opacity(0.12)))
|
||||
// Tick at each slot.
|
||||
for i in 0..<slotCount {
|
||||
let x = stripMargin + (CGFloat(i) + 0.5) * slotSpacing
|
||||
var tick = Path()
|
||||
tick.move(to: CGPoint(x: x, y: stripRect.minY - 3))
|
||||
tick.addLine(to: CGPoint(x: x, y: stripRect.maxY + 3))
|
||||
context.stroke(tick, with: .color(.white.opacity(0.10)), lineWidth: 0.6)
|
||||
for (round, alpha) in world.roundOrderedAlpha where alpha > 0 {
|
||||
let label = "✓ ROUND \(round) ORDERED"
|
||||
context.draw(
|
||||
Text(label)
|
||||
.font(.system(size: settings.scaled(11), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.green.opacity(0.95 * alpha)),
|
||||
at: CGPoint(x: size.width / 2,
|
||||
y: size.height - 50 - CGFloat(round) * 18)
|
||||
)
|
||||
}
|
||||
// Direction arrow under the strip.
|
||||
let arrowY = stripRect.maxY + 28
|
||||
var arrow = Path()
|
||||
arrow.move(to: CGPoint(x: stripMargin, y: arrowY))
|
||||
arrow.addLine(to: CGPoint(x: size.width - stripMargin, y: arrowY))
|
||||
context.stroke(arrow, with: .color(.white.opacity(0.15)), lineWidth: 1)
|
||||
context.draw(
|
||||
Text("→ TOTAL ORDER (POSITION 1, 2, 3, …)")
|
||||
.font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.30 + 0.05 * sin(time))),
|
||||
at: CGPoint(x: size.width / 2, y: arrowY + 14)
|
||||
)
|
||||
}
|
||||
|
||||
/// Per-round shaded zones above the strip. Each round is a translucent
|
||||
/// band sized to the contiguous run of vertices with that round number.
|
||||
private func drawRoundZonesOnStrip(
|
||||
in context: inout GraphicsContext, ordered: [VertexData],
|
||||
stripY: CGFloat, stripMargin: CGFloat, slotSpacing: CGFloat,
|
||||
revealLimit: Int, sceneIndex: Int, time: Double,
|
||||
settings: AppSettings
|
||||
private func drawFinalConvergence(
|
||||
in context: inout GraphicsContext, size: CGSize, alpha: Double
|
||||
) {
|
||||
guard !ordered.isEmpty else { return }
|
||||
// Group consecutive same-round runs.
|
||||
var runStart = 0
|
||||
var i = 1
|
||||
let zoneTop = stripY - 16
|
||||
let zoneHeight: CGFloat = 28
|
||||
while i <= ordered.count {
|
||||
let endThisRun = (i == ordered.count) || (ordered[i].round != ordered[runStart].round)
|
||||
if endThisRun {
|
||||
// Only draw zones whose first vertex has been revealed.
|
||||
if runStart < revealLimit {
|
||||
let endIdx = min(i, revealLimit) - 1
|
||||
let runRound = ordered[runStart].round
|
||||
let xStart = stripMargin + CGFloat(runStart) * slotSpacing
|
||||
let xEnd = stripMargin + CGFloat(endIdx + 1) * slotSpacing
|
||||
let rect = CGRect(x: xStart, y: zoneTop,
|
||||
width: xEnd - xStart, height: zoneHeight)
|
||||
let alpha: Double = sceneIndex == 2 ? 0.18 : 0.12
|
||||
context.fill(RoundedRectangle(cornerRadius: 5).path(in: rect),
|
||||
with: .color(.cyan.opacity(alpha)))
|
||||
context.draw(
|
||||
Text("R\(runRound)")
|
||||
.font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.cyan.opacity(0.7)),
|
||||
at: CGPoint(x: rect.midX, y: rect.midY)
|
||||
)
|
||||
}
|
||||
runStart = i
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
_ = time
|
||||
}
|
||||
|
||||
/// Final-scene "convergence" badge confirming Aaron's line equals Ben's
|
||||
/// equals Carl's. The Crisis paper's central guarantee, rendered as a
|
||||
/// stamp.
|
||||
private func drawConvergenceBadge(
|
||||
in context: inout GraphicsContext, size: CGSize,
|
||||
ordered: [VertexData], time: Double
|
||||
) {
|
||||
// Emerge after a beat; pulse subtly.
|
||||
let appear = max(0, min(1, (time - 1.5) / 1.0))
|
||||
if appear < 0.05 { return }
|
||||
|
||||
let pulse = 0.5 + 0.5 * sin(time * 1.6)
|
||||
let badgeW: CGFloat = 460
|
||||
let badgeH: CGFloat = 64
|
||||
let badgeRect = CGRect(
|
||||
x: size.width / 2 - badgeW / 2,
|
||||
y: 70,
|
||||
width: badgeW, height: badgeH
|
||||
)
|
||||
|
||||
context.fill(RoundedRectangle(cornerRadius: 14).path(in: badgeRect),
|
||||
with: .color(.black.opacity(0.7 * appear)))
|
||||
context.stroke(RoundedRectangle(cornerRadius: 14).path(in: badgeRect),
|
||||
with: .color(.green.opacity(0.6 * appear * (0.7 + 0.3 * pulse))),
|
||||
lineWidth: 2)
|
||||
context.draw(
|
||||
Text("AARON'S LINE = BEN'S LINE = CARL'S LINE")
|
||||
Text("✓ ALL VALIDATORS COMPUTE THE SAME TOTAL ORDER — CONVERGENCE")
|
||||
.font(.system(size: settings.scaled(13), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.green.opacity(0.95 * appear)),
|
||||
at: CGPoint(x: badgeRect.midX, y: badgeRect.midY - 8)
|
||||
)
|
||||
context.draw(
|
||||
Text("\(ordered.count) POSITIONS · IDENTICAL · DETERMINISTIC")
|
||||
.font(.system(size: settings.scaled(10), weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.65 * appear)),
|
||||
at: CGPoint(x: badgeRect.midX, y: badgeRect.midY + 14)
|
||||
.foregroundColor(.green.opacity(0.95 * alpha)),
|
||||
at: CGPoint(x: size.width / 2, y: 40)
|
||||
)
|
||||
}
|
||||
|
||||
/// True iff `ordered[index]` is the FIRST occurrence of `role`'s
|
||||
/// process id in the ordered list. Used so the cast name labels only
|
||||
/// land on the lead's earliest entry, not every entry.
|
||||
private func isFirstAppearance(of role: CastRole, in ordered: [VertexData], atIndex index: Int) -> Bool {
|
||||
guard let pid = dm.castByPid.first(where: { $0.value.id == role.id })?.key else { return false }
|
||||
guard ordered[index].processIdHex == pid else { return false }
|
||||
for j in 0..<index where ordered[j].processIdHex == pid {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
private func drawBeatTag(
|
||||
in context: inout GraphicsContext, size: CGSize, world: Ch06WorldState
|
||||
) {
|
||||
guard let beatId = world.activeBeat?.id else { return }
|
||||
context.draw(
|
||||
Text(beatId)
|
||||
.font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.20)),
|
||||
at: CGPoint(x: size.width - 14, y: 10), anchor: .trailing
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
165
CrisisViz/Sources/CrisisViz/Engine/Ch06Timeline.swift
Normal file
165
CrisisViz/Sources/CrisisViz/Engine/Ch06Timeline.swift
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Ch06 — "Spokespersons line up. Everyone else falls in behind." (Total order.)
|
||||
///
|
||||
/// With round leaders chosen in Ch05, the chapter walks the chain of
|
||||
/// leaders and adds each leader's round-N ancestor closure to a single
|
||||
/// linear sequence — the total order. Visualized as an "ordering snake"
|
||||
/// at the bottom of the canvas: blocks slide into positions 1, 2, 3, …
|
||||
/// in topological order.
|
||||
|
||||
enum Ch06BeatKind {
|
||||
case settle(label: String)
|
||||
case carryForward
|
||||
/// Slide a single message into the next position of the ordering snake.
|
||||
case appendToOrder(messageId: String)
|
||||
/// "ROUND N ORDERED" badge on the snake.
|
||||
case roundOrderedBadge(round: Int)
|
||||
/// Final convergence emphasis.
|
||||
case finalConvergence
|
||||
}
|
||||
|
||||
struct Ch06Beat: Identifiable {
|
||||
let id: String
|
||||
let kind: Ch06BeatKind
|
||||
let durationSeconds: Double
|
||||
let narration: String
|
||||
var startTime: Double = 0
|
||||
var endTime: Double { startTime + durationSeconds }
|
||||
}
|
||||
|
||||
struct Ch06WorldState {
|
||||
/// Total-order snake: messages in the order they've been ordered.
|
||||
var order: [String] = []
|
||||
/// "Round N ordered" badge alpha (0..1) per round.
|
||||
var roundOrderedAlpha: [Int: Double] = [:]
|
||||
var finalConvergenceAlpha: Double = 0
|
||||
var activeBeat: Ch06Beat? = nil
|
||||
var activeProgress: Double = 0
|
||||
}
|
||||
|
||||
enum Ch06Timeline {
|
||||
static let beats: [Ch06Beat] = {
|
||||
let raw: [Ch06Beat] = [
|
||||
.init(id: "carry-forward", kind: .carryForward, durationSeconds: 4.0,
|
||||
narration: "From Ch05: we have round-0 leader α and round-1 leader ε. Now we use those leaders to build a single canonical sequence — the total order — that every honest validator agrees on."),
|
||||
|
||||
.init(id: "intro-totalorder", kind: .settle(label: "Total order via leaders"),
|
||||
durationSeconds: 5.0,
|
||||
narration: "Algorithm: walk the chain of round leaders. For each leader, add the leader's round-N ancestor closure to the order in topological sequence. Look at the bottom of the canvas — the ordering snake will fill in left to right."),
|
||||
|
||||
// Round 0: α, β, γ, δ — topological order based on parent edges
|
||||
.init(id: "append-alpha", kind: .appendToOrder(messageId: "α"),
|
||||
durationSeconds: 4.5,
|
||||
narration: "α has no parents — it goes into position 1 of the snake."),
|
||||
.init(id: "append-beta", kind: .appendToOrder(messageId: "β"),
|
||||
durationSeconds: 4.5,
|
||||
narration: "β references α as parent. α is already in the snake, so β goes into position 2."),
|
||||
.init(id: "append-gamma", kind: .appendToOrder(messageId: "γ"),
|
||||
durationSeconds: 4.5,
|
||||
narration: "γ also references α. β and γ are siblings — they go in lexicographic order, so γ takes position 3."),
|
||||
.init(id: "append-delta", kind: .appendToOrder(messageId: "δ"),
|
||||
durationSeconds: 4.5,
|
||||
narration: "δ references γ. γ is already in the snake at position 3, so δ goes into position 4."),
|
||||
|
||||
.init(id: "round-0-ordered", kind: .roundOrderedBadge(round: 0),
|
||||
durationSeconds: 5.0,
|
||||
narration: "Round 0 ordered. The snake holds α, β, γ, δ in positions 1 through 4. Crucially, every honest validator who has the same DAG runs the same sort and produces the same four-element prefix."),
|
||||
|
||||
// Round 1: ε
|
||||
.init(id: "append-eps", kind: .appendToOrder(messageId: "ε"),
|
||||
durationSeconds: 4.5,
|
||||
narration: "Round 1 leader ε — no other round-1 messages. ε goes into position 5."),
|
||||
|
||||
.init(id: "round-1-ordered", kind: .roundOrderedBadge(round: 1),
|
||||
durationSeconds: 4.5,
|
||||
narration: "Round 1 ordered. The snake now holds the full total order: α → β → γ → δ → ε."),
|
||||
|
||||
.init(id: "final-convergence", kind: .finalConvergence,
|
||||
durationSeconds: 6.5,
|
||||
narration: "Every honest validator's snake is byte-for-byte identical. Aaron's line, Ben's line, Carl's line, Dave's line — same sequence. This is total order: convergence on the SHAPE and the SEQUENCE of history."),
|
||||
|
||||
.init(id: "outro", kind: .settle(label: "Ordered"),
|
||||
durationSeconds: 4.0,
|
||||
narration: "Crisis has converged on a single canonical sequence. Next chapters: data availability — what happens when a leader knows something but doesn't share it."),
|
||||
]
|
||||
var t: Double = 0
|
||||
var assigned: [Ch06Beat] = []
|
||||
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) -> Ch06Beat? {
|
||||
let clamped = max(0, min(t, totalDuration))
|
||||
return beats.first { $0.startTime <= clamped && clamped < $0.endTime }
|
||||
?? beats.last
|
||||
}
|
||||
|
||||
static func state(at t: Double) -> Ch06WorldState {
|
||||
var w = Ch06WorldState()
|
||||
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: Ch06Beat, progress: Double, isActive: Bool,
|
||||
into w: inout Ch06WorldState
|
||||
) {
|
||||
switch beat.kind {
|
||||
case .settle, .carryForward:
|
||||
break
|
||||
case .appendToOrder(let mid):
|
||||
// Permanent once the beat starts: the message is added.
|
||||
// The renderer animates the slide-in based on activeProgress.
|
||||
if !w.order.contains(mid) {
|
||||
w.order.append(mid)
|
||||
}
|
||||
case .roundOrderedBadge(let r):
|
||||
w.roundOrderedAlpha[r] = isActive ? progress : 1.0
|
||||
case .finalConvergence:
|
||||
w.finalConvergenceAlpha = isActive ? progress : 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Ch06Scenes {
|
||||
/// 3 scenes mapping to ~51.5s of timeline at 1×.
|
||||
static let sceneStarts: [Double] = [0, 22.5, 36.5]
|
||||
static let sceneDurations: [Double] = [22.5, 14, 15]
|
||||
|
||||
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 Ch06Timeline.activeBeat(at: t)?.narration ?? ""
|
||||
}
|
||||
}
|
||||
|
|
@ -76,6 +76,10 @@ final class SceneEngine {
|
|||
// Ch05 — leader (2 scenes)
|
||||
SceneAddress(chapter: 5, scene: 0): 29.0,
|
||||
SceneAddress(chapter: 5, scene: 1): 18.5,
|
||||
// Ch06 — total order (3 scenes)
|
||||
SceneAddress(chapter: 6, scene: 0): 22.5,
|
||||
SceneAddress(chapter: 6, scene: 1): 14.0,
|
||||
SceneAddress(chapter: 6, scene: 2): 15.0,
|
||||
// Ch09 — Byzantine (2 scenes mapping to Ch09Timeline windows)
|
||||
SceneAddress(chapter: 9, scene: 0): 47.5,
|
||||
SceneAddress(chapter: 9, scene: 1): 32.0,
|
||||
|
|
|
|||
|
|
@ -168,6 +168,8 @@ struct ImmersiveView: View {
|
|||
return Ch04Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
|
||||
case 5:
|
||||
return Ch05Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
|
||||
case 6:
|
||||
return Ch06Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
|
||||
case 9:
|
||||
return Ch09Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
|
||||
default:
|
||||
|
|
|
|||
Loading…
Reference in a new issue