mirror of
https://github.com/saymrwulf/crisis.git
synced 2026-05-14 20:37:54 +00:00
Ch08 erasure shards: migrate to serial timeline + per-cast vaults
The closing chapter. Aaron splits ξ's body into 4 erasure-coded shards (s1, s2, s3, s4) where any 2 reconstruct the body. Aaron's vault visually morphs from "1 MB body grid" to "4 labeled shards on a row". One shard each is then sent to Ben (s1), Carl (s2), Dave (s3); s4 stays with Aaron. Each non-Aaron cast gets a small vault next to their cast circle that shows whichever shards they currently hold. Empty vaults read "(empty)"; once shards arrive they appear as small filled chips labeled s1/s2/s3. Then Aaron goes offline (dimmed cast circle + red ⚠ OFFLINE label). Carl asks Ben for s1; Ben sends; Carl now holds s1+s2 — k=2 reached. A green halo pulses on Carl's vault as he runs the decoder, then a "✓ ξ body reconstructed" caption pins under his vault and a big banner reads "✓ ξ BODY RECONSTRUCTED — k=2 of 4 shards was enough — Aaron NOT NEEDED". Closes with a final-summary banner: "CRISIS COMPLETE · consensus + DA + Byzantine resilience" and an outro inviting the viewer to scrub. `Ch08Timeline.swift` (new) — 19 beats over ~85s. Beat kinds: `splitIntoShards`, `sendShard`, `stowShard`, `aaronOffline`, `askForShard`, `shardArrives`, `reconstructBody`, `reconstructed`, `finalSummary`. `Ch09_DA_Design.swift` rewritten end-to-end to render from Ch08Timeline. Aaron's vault adapts to "shards" mode after the split beat. Per-cast vaults sit just to the right of each cast circle (small, dashed, expand to fit chips). Reconstruction halo pulses while running, then settles into a steady green border once the body is back. `SceneEngine` and `ImmersiveView` extended for chapter 8 — every chapter (0–9) now has a timeline-backed renderer + beat-bound narration. Bundled, harness 55/55 invariants, 0 audit errors, 281 PNGs, 36/36 MP4 written. All 10 chapters migrated. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
32f01e3466
commit
5b4fe4ebaa
4 changed files with 520 additions and 572 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Ch09: "Data Availability — A Design" — erasure coding, Merkle tree, on-demand retrieval, fee market, full stack.
|
||||
/// Ch08 (chapter index 8, file Ch09_DA_Design.swift):
|
||||
/// "Erasure shards make the data un-loseable." — DA fix.
|
||||
struct Ch09_DA_Design: View {
|
||||
let sceneIndex: Int
|
||||
let localTime: Double
|
||||
|
|
@ -10,615 +11,323 @@ struct Ch09_DA_Design: View {
|
|||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
render(context: &context, size: size, time: localTime)
|
||||
let t = Ch08Scenes.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: renderErasureCoding(context: &context, size: size, time: time)
|
||||
case 1: renderMerkleTree(context: &context, size: size, time: time)
|
||||
case 2: renderOnDemand(context: &context, size: size, time: time)
|
||||
case 3: renderFeeMarket(context: &context, size: size, time: time)
|
||||
case 4: renderFullStack(context: &context, size: size, time: time)
|
||||
default: break
|
||||
private func render(in context: inout GraphicsContext, size: CGSize, t: Double) {
|
||||
let world = Ch08Timeline.state(at: t)
|
||||
drawLanes(in: &context, size: size)
|
||||
drawCastFigures(in: &context, size: size, world: world)
|
||||
drawAaronVault(in: &context, size: size, world: world)
|
||||
drawCastVaults(in: &context, size: size, world: world, t: t)
|
||||
if let flight = world.shardFlight {
|
||||
drawShardFlight(in: &context, size: size, flight: flight)
|
||||
}
|
||||
if world.aaronOffline {
|
||||
drawAaronOfflineBadge(in: &context, size: size)
|
||||
}
|
||||
if world.reconstructedAlpha > 0 {
|
||||
drawReconstructedBadge(in: &context, size: size,
|
||||
alpha: world.reconstructedAlpha)
|
||||
}
|
||||
if world.finalAlpha > 0 {
|
||||
drawFinalSummary(in: &context, size: size,
|
||||
alpha: world.finalAlpha)
|
||||
}
|
||||
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: Erasure Coding
|
||||
private static let castLanes: [(Ch01Cast, Int)] = [(.aaron, 0), (.ben, 1), (.carl, 2), (.dave, 3)]
|
||||
|
||||
private func renderErasureCoding(context: inout GraphicsContext, size: CGSize, time: Double) {
|
||||
let cx = size.width / 2
|
||||
let cy = size.height / 2
|
||||
let k = 4
|
||||
let n = 8
|
||||
let splitProgress = min(1.0, time * 0.2)
|
||||
let distributeProgress = min(1.0, max(0, time * 0.15 - 0.8))
|
||||
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 - 220, 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Original message block (top center)
|
||||
let origW: CGFloat = 200
|
||||
let origH: CGFloat = 60
|
||||
let origRect = CGRect(x: cx - origW / 2, y: cy - 160, width: origW, height: origH)
|
||||
let origAlpha = max(0, 1.0 - splitProgress * 0.5)
|
||||
context.fill(RoundedRectangle(cornerRadius: 10).path(in: origRect),
|
||||
with: .color(.white.opacity(0.15 * origAlpha)))
|
||||
context.stroke(RoundedRectangle(cornerRadius: 10).path(in: origRect),
|
||||
with: .color(.white.opacity(0.3 * origAlpha)), lineWidth: 1.5)
|
||||
private func drawCastFigures(
|
||||
in context: inout GraphicsContext, size: CGSize, world: Ch08WorldState
|
||||
) {
|
||||
for cast in Ch01Cast.allCases {
|
||||
let pos = castPosition(cast: cast, size: size)
|
||||
let r: CGFloat = 22
|
||||
let color = castColor(cast)
|
||||
let dim = (cast == .aaron && world.aaronOffline) ? 0.35 : 0.95
|
||||
context.fill(
|
||||
Circle().path(in: CGRect(x: pos.x - r, y: pos.y - r,
|
||||
width: r * 2, height: r * 2)),
|
||||
with: .color(color.opacity(dim))
|
||||
)
|
||||
context.draw(
|
||||
Text(String(cast.role.displayName.prefix(1)))
|
||||
.font(.system(size: settings.scaled(15), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(dim)),
|
||||
at: pos
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Aaron's vault on the right showing ξ body + 4 shards once split.
|
||||
private func drawAaronVault(
|
||||
in context: inout GraphicsContext, size: CGSize, world: Ch08WorldState
|
||||
) {
|
||||
let vaultW: CGFloat = 180
|
||||
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)
|
||||
context.fill(RoundedRectangle(cornerRadius: 8).path(in: rect),
|
||||
with: .color(.black.opacity(0.6)))
|
||||
let aaronOff = world.aaronOffline
|
||||
context.stroke(RoundedRectangle(cornerRadius: 8).path(in: rect),
|
||||
with: .color(Cast.coral.opacity(aaronOff ? 0.3 : 0.7)),
|
||||
style: StrokeStyle(lineWidth: 1.2, dash: [4, 4]))
|
||||
context.draw(
|
||||
Text("MESSAGE PAYLOAD")
|
||||
.font(.system(size: settings.scaled(12), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.7 * origAlpha)),
|
||||
at: CGPoint(x: origRect.midX, y: origRect.midY)
|
||||
Text("AARON'S VAULT")
|
||||
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(Cast.coral.opacity(aaronOff ? 0.4 : 0.9)),
|
||||
at: CGPoint(x: rect.midX, y: rect.minY + 12)
|
||||
)
|
||||
|
||||
// Chunks splitting out
|
||||
let chunkW: CGFloat = 60
|
||||
let chunkH: CGFloat = 40
|
||||
let totalChunkW = CGFloat(n) * chunkW + CGFloat(n - 1) * 12
|
||||
let chunksStartX = cx - totalChunkW / 2
|
||||
let chunksY = cy - 30.0
|
||||
|
||||
let chunkColors: [Color] = (0..<n).map { i in
|
||||
let hue = Double(i) / Double(n) * 0.75
|
||||
let sat = i < k ? 0.7 : 0.35
|
||||
return Color(hue: hue, saturation: sat, brightness: 0.85)
|
||||
}
|
||||
|
||||
for i in 0..<n {
|
||||
let targetX = chunksStartX + CGFloat(i) * (chunkW + 12)
|
||||
let startX = cx - chunkW / 2
|
||||
let startY = origRect.maxY
|
||||
let x = startX + (targetX - startX) * splitProgress
|
||||
let y = startY + (chunksY - startY) * splitProgress
|
||||
|
||||
let rect = CGRect(x: x, y: y, width: chunkW, height: chunkH)
|
||||
context.fill(RoundedRectangle(cornerRadius: 6).path(in: rect),
|
||||
with: .color(chunkColors[i].opacity(0.7 * splitProgress)))
|
||||
context.stroke(RoundedRectangle(cornerRadius: 6).path(in: rect),
|
||||
with: .color(chunkColors[i].opacity(0.4 * splitProgress)), lineWidth: 1)
|
||||
|
||||
let label = i < k ? "D\(i + 1)" : "P\(i - k + 1)"
|
||||
let typeLabel = i < k ? "data" : "parity"
|
||||
// Show 4 shards or full body.
|
||||
if world.split {
|
||||
// 4 shards stacked horizontally.
|
||||
let aaronShards = world.shardsAt[.aaron] ?? []
|
||||
let shardW: CGFloat = 32
|
||||
let shardH: CGFloat = 56
|
||||
let gap: CGFloat = 6
|
||||
let totalW = CGFloat(Ch08Timeline.shardIds.count) * (shardW + gap) - gap
|
||||
let startX = rect.midX - totalW / 2
|
||||
let shardY = rect.minY + 30
|
||||
for (i, sid) in Ch08Timeline.shardIds.enumerated() {
|
||||
let x = startX + CGFloat(i) * (shardW + gap)
|
||||
let chunk = CGRect(x: x, y: shardY, width: shardW, height: shardH)
|
||||
let inAaron = aaronShards.contains(sid)
|
||||
context.fill(RoundedRectangle(cornerRadius: 3).path(in: chunk),
|
||||
with: .color(Cast.coral.opacity(inAaron ? 0.85
|
||||
: (aaronOff ? 0.18 : 0.30))))
|
||||
context.stroke(RoundedRectangle(cornerRadius: 3).path(in: chunk),
|
||||
with: .color(.white.opacity(0.4)), lineWidth: 0.8)
|
||||
context.draw(
|
||||
Text(sid)
|
||||
.font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(inAaron ? 0.95 : 0.45)),
|
||||
at: CGPoint(x: chunk.midX, y: chunk.midY)
|
||||
)
|
||||
}
|
||||
context.draw(
|
||||
Text(label)
|
||||
.font(.system(size: settings.scaled(11), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.8 * splitProgress)),
|
||||
at: CGPoint(x: rect.midX, y: rect.midY - 6)
|
||||
Text("split: 4 shards · k=2 of 4")
|
||||
.font(.system(size: settings.scaled(9), weight: .regular, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.55)),
|
||||
at: CGPoint(x: rect.midX, y: rect.maxY - 12)
|
||||
)
|
||||
} else {
|
||||
// Full body grid (4×6 chunks).
|
||||
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..<chunksRows {
|
||||
for col in 0..<chunksCols {
|
||||
let x = gridX + CGFloat(col) * (chunkW + 2)
|
||||
let y = gridY + CGFloat(row) * (chunkH + 2)
|
||||
let chunkRect = CGRect(x: x, y: y, width: chunkW, height: chunkH)
|
||||
context.fill(RoundedRectangle(cornerRadius: 2).path(in: chunkRect),
|
||||
with: .color(Cast.coral.opacity(0.85)))
|
||||
}
|
||||
}
|
||||
context.draw(
|
||||
Text(typeLabel)
|
||||
.font(.system(size: settings.scaled(10), weight: .regular, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.4 * splitProgress)),
|
||||
at: CGPoint(x: rect.midX, y: rect.midY + 10)
|
||||
Text("ξ body · 1 MB")
|
||||
.font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.85)),
|
||||
at: CGPoint(x: rect.midX, y: rect.maxY - 12)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Distribution to storage nodes (phase 2)
|
||||
if distributeProgress > 0 {
|
||||
let storageY = cy + 100.0
|
||||
let storageR: CGFloat = 20
|
||||
|
||||
for i in 0..<n {
|
||||
let chunkX = chunksStartX + CGFloat(i) * (chunkW + 12) + chunkW / 2
|
||||
let storageX = chunkX
|
||||
let storePos = CGPoint(x: storageX, y: storageY + 50)
|
||||
|
||||
// Arrow from chunk to storage
|
||||
var arrow = Path()
|
||||
arrow.move(to: CGPoint(x: chunkX, y: chunksY + chunkH))
|
||||
let arrowEndY = storageY + 50 - storageR
|
||||
arrow.addLine(to: CGPoint(x: storageX, y: chunksY + chunkH + (arrowEndY - chunksY - chunkH) * distributeProgress))
|
||||
context.stroke(arrow, with: .color(chunkColors[i].opacity(0.3 * distributeProgress)), lineWidth: 1)
|
||||
|
||||
// Storage node
|
||||
if distributeProgress > 0.3 {
|
||||
let nodeRect = CGRect(x: storePos.x - storageR, y: storePos.y - storageR,
|
||||
width: storageR * 2, height: storageR * 2)
|
||||
context.fill(RoundedRectangle(cornerRadius: 6).path(in: nodeRect),
|
||||
with: .color(chunkColors[i].opacity(0.3 * distributeProgress)))
|
||||
/// Each non-Aaron cast gets a small vault next to their cast circle
|
||||
/// showing whichever shard(s) they currently hold.
|
||||
private func drawCastVaults(
|
||||
in context: inout GraphicsContext, size: CGSize,
|
||||
world: Ch08WorldState, t: Double
|
||||
) {
|
||||
for (cast, idx) in Self.castLanes where cast != .aaron {
|
||||
let lane = castLaneY(idx, size: size)
|
||||
let castX = castPosition(cast: cast, size: size).x
|
||||
let vaultX = castX + 50
|
||||
let vaultW: CGFloat = 70
|
||||
let vaultH: CGFloat = 36
|
||||
let rect = CGRect(x: vaultX, y: lane - vaultH / 2,
|
||||
width: vaultW, height: vaultH)
|
||||
context.stroke(RoundedRectangle(cornerRadius: 4).path(in: rect),
|
||||
with: .color(castColor(cast).opacity(0.4)),
|
||||
style: StrokeStyle(lineWidth: 0.8, dash: [3, 3]))
|
||||
let shards = (world.shardsAt[cast] ?? []).sorted()
|
||||
if shards.isEmpty {
|
||||
context.draw(
|
||||
Text("(empty)")
|
||||
.font(.system(size: settings.scaled(8), weight: .regular, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.30)),
|
||||
at: CGPoint(x: rect.midX, y: rect.midY)
|
||||
)
|
||||
} else {
|
||||
// Render shards as small filled chips.
|
||||
let chipW: CGFloat = 18
|
||||
let chipGap: CGFloat = 4
|
||||
let totalW = CGFloat(shards.count) * (chipW + chipGap) - chipGap
|
||||
let startX = rect.midX - totalW / 2
|
||||
for (i, sid) in shards.enumerated() {
|
||||
let x = startX + CGFloat(i) * (chipW + chipGap)
|
||||
let chip = CGRect(x: x, y: rect.midY - 10,
|
||||
width: chipW, height: 20)
|
||||
context.fill(RoundedRectangle(cornerRadius: 3).path(in: chip),
|
||||
with: .color(Cast.coral.opacity(0.85)))
|
||||
context.draw(
|
||||
Text("S\(i + 1)")
|
||||
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.6 * distributeProgress)),
|
||||
at: storePos
|
||||
Text(sid)
|
||||
.font(.system(size: settings.scaled(8), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white),
|
||||
at: CGPoint(x: chip.midX, y: chip.midY)
|
||||
)
|
||||
}
|
||||
// Reconstruction halo: pulsing if Carl just reconstructed.
|
||||
let isReconstructing = world.reconstructFlash == cast
|
||||
let hasFullBody = world.reconstructedAt.contains(cast)
|
||||
|| (world.reconstructedAlpha > 0 && cast == .carl)
|
||||
if isReconstructing || hasFullBody {
|
||||
let pulse = isReconstructing ? 0.6 + 0.4 * sin(t * 4) : 0.9
|
||||
context.stroke(RoundedRectangle(cornerRadius: 4).path(in: rect),
|
||||
with: .color(.green.opacity(pulse)),
|
||||
lineWidth: 2.0)
|
||||
if hasFullBody {
|
||||
context.draw(
|
||||
Text("✓ ξ body reconstructed")
|
||||
.font(.system(size: settings.scaled(8), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.green.opacity(0.95)),
|
||||
at: CGPoint(x: rect.midX, y: rect.maxY + 10)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Labels
|
||||
private func drawShardFlight(
|
||||
in context: inout GraphicsContext, size: CGSize,
|
||||
flight: Ch08WorldState.ShardFlight
|
||||
) {
|
||||
let lift: CGFloat = 30
|
||||
let from = castPosition(cast: flight.from, size: size)
|
||||
let to = castPosition(cast: flight.to, size: size)
|
||||
let fromTrack = CGPoint(x: from.x, y: from.y - lift)
|
||||
let toTrack = CGPoint(x: to.x, y: to.y - lift)
|
||||
var path = Path()
|
||||
path.move(to: fromTrack)
|
||||
path.addLine(to: toTrack)
|
||||
context.stroke(path, with: .color(Cast.coral.opacity(0.22)),
|
||||
style: StrokeStyle(lineWidth: 1.0, dash: [3, 5]))
|
||||
let p = CGFloat(flight.progress)
|
||||
let pos = CGPoint(x: fromTrack.x + (toTrack.x - fromTrack.x) * p,
|
||||
y: fromTrack.y + (toTrack.y - fromTrack.y) * p)
|
||||
let envW: CGFloat = 44
|
||||
let envH: CGFloat = 22
|
||||
let rect = CGRect(x: pos.x - envW / 2, y: pos.y - envH / 2,
|
||||
width: envW, height: envH)
|
||||
context.fill(RoundedRectangle(cornerRadius: 4).path(in: rect),
|
||||
with: .color(Cast.coral.opacity(0.95)))
|
||||
context.draw(
|
||||
Text("ERASURE CODING: \(k)-of-\(n) REDUNDANCY")
|
||||
.font(.system(size: settings.scaled(14), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.4)),
|
||||
at: CGPoint(x: cx, y: 30)
|
||||
)
|
||||
context.draw(
|
||||
Text("ANY \(k) CHUNKS SUFFICE TO RECONSTRUCT — NO FULL REPLICATION NEEDED")
|
||||
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.cyan.opacity(0.4)),
|
||||
at: CGPoint(x: cx, y: size.height - 40)
|
||||
Text(flight.id)
|
||||
.font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white),
|
||||
at: pos
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Scene 1: Merkle Tree
|
||||
|
||||
private func renderMerkleTree(context: inout GraphicsContext, size: CGSize, time: Double) {
|
||||
let cx = size.width / 2
|
||||
let levels = 4
|
||||
let nodeR: CGFloat = 16
|
||||
let topY: CGFloat = 80
|
||||
let levelSpacing: CGFloat = (size.height - 200) / CGFloat(levels)
|
||||
|
||||
// Highlighted proof path
|
||||
let proofPath: [Int] = [0, 1, 2, 5] // indices along the tree path
|
||||
|
||||
func drawNode(level: Int, index: Int, parentPos: CGPoint?) {
|
||||
let nodesAtLevel = 1 << level
|
||||
let spacing = (size.width - 100) / CGFloat(nodesAtLevel)
|
||||
let x = 50 + spacing * (CGFloat(index) + 0.5)
|
||||
let y = topY + CGFloat(level) * levelSpacing
|
||||
let pos = CGPoint(x: x, y: y)
|
||||
|
||||
let appear = min(1.0, max(0, time * 0.4 - Double(level) * 0.15))
|
||||
if appear < 0.02 { return }
|
||||
|
||||
// Edge to parent
|
||||
if let parent = parentPos {
|
||||
var edge = Path()
|
||||
edge.move(to: parent)
|
||||
edge.addLine(to: pos)
|
||||
context.stroke(edge, with: .color(.white.opacity(0.15 * appear)), lineWidth: 1)
|
||||
}
|
||||
|
||||
let isLeaf = level == levels - 1
|
||||
let isOnProofPath = proofPath.contains(level * 10 + index) || (level == 0 && index == 0)
|
||||
|
||||
let color: Color = isOnProofPath ? .yellow : (isLeaf ? .cyan : .purple)
|
||||
let rect = CGRect(x: pos.x - nodeR, y: pos.y - nodeR, width: nodeR * 2, height: nodeR * 2)
|
||||
context.fill(Circle().path(in: rect), with: .color(color.opacity(0.6 * appear)))
|
||||
|
||||
if isOnProofPath {
|
||||
context.stroke(Circle().path(in: rect.insetBy(dx: -3, dy: -3)),
|
||||
with: .color(.yellow.opacity(0.5 * appear)), lineWidth: 2)
|
||||
}
|
||||
|
||||
// Hash label
|
||||
let hashLabel = isLeaf ? "chunk\(index)" : "h(\(index))"
|
||||
context.draw(
|
||||
Text(hashLabel)
|
||||
.font(.system(size: settings.scaled(10), weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.5 * appear)),
|
||||
at: CGPoint(x: pos.x, y: pos.y + nodeR + 8)
|
||||
)
|
||||
|
||||
if level < levels - 1 {
|
||||
drawNode(level: level + 1, index: index * 2, parentPos: pos)
|
||||
drawNode(level: level + 1, index: index * 2 + 1, parentPos: pos)
|
||||
}
|
||||
}
|
||||
|
||||
drawNode(level: 0, index: 0, parentPos: nil)
|
||||
|
||||
private func drawAaronOfflineBadge(
|
||||
in context: inout GraphicsContext, size: CGSize
|
||||
) {
|
||||
let pos = castPosition(cast: .aaron, size: size)
|
||||
context.draw(
|
||||
Text("MERKLE TREE OF CHUNKS")
|
||||
.font(.system(size: settings.scaled(14), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.purple.opacity(0.5)),
|
||||
at: CGPoint(x: cx, y: 30)
|
||||
)
|
||||
context.draw(
|
||||
Text("YELLOW PATH = PROOF — VERIFY ANY CHUNK WITHOUT DOWNLOADING ALL")
|
||||
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.yellow.opacity(0.4)),
|
||||
at: CGPoint(x: cx, y: size.height - 40)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Scene 2: On-Demand Retrieval
|
||||
|
||||
private func renderOnDemand(context: inout GraphicsContext, size: CGSize, time: Double) {
|
||||
let cx = size.width / 2
|
||||
let cy = size.height / 2
|
||||
|
||||
// Requester Z (left)
|
||||
let zPos = CGPoint(x: size.width * 0.15, y: cy)
|
||||
let zR: CGFloat = 30
|
||||
let zRect = CGRect(x: zPos.x - zR, y: zPos.y - zR, width: zR * 2, height: zR * 2)
|
||||
context.fill(Circle().path(in: zRect), with: .color(.yellow.opacity(0.8)))
|
||||
context.draw(Text("Z").font(.system(size: settings.scaled(16), weight: .heavy, design: .monospaced)).foregroundColor(.black), at: zPos)
|
||||
context.draw(
|
||||
Text("REQUESTER")
|
||||
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.yellow.opacity(0.5)),
|
||||
at: CGPoint(x: zPos.x, y: zPos.y + zR + 14)
|
||||
)
|
||||
|
||||
// Storage node S (right)
|
||||
let sPos = CGPoint(x: size.width * 0.85, y: cy)
|
||||
let sR: CGFloat = 30
|
||||
let sRect = CGRect(x: sPos.x - sR, y: sPos.y - sR, width: sR * 2, height: sR * 2)
|
||||
context.fill(Circle().path(in: sRect), with: .color(.blue.opacity(0.8)))
|
||||
context.draw(Text("S").font(.system(size: settings.scaled(16), weight: .heavy, design: .monospaced)).foregroundColor(.white), at: sPos)
|
||||
context.draw(
|
||||
Text("STORAGE NODE")
|
||||
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.blue.opacity(0.5)),
|
||||
at: CGPoint(x: sPos.x, y: sPos.y + sR + 14)
|
||||
)
|
||||
|
||||
// PoW fee attached to request
|
||||
let feeBox = CGRect(x: cx - 80, y: cy - 80, width: 160, height: 30)
|
||||
context.fill(RoundedRectangle(cornerRadius: 6).path(in: feeBox),
|
||||
with: .color(.yellow.opacity(0.1)))
|
||||
context.stroke(RoundedRectangle(cornerRadius: 6).path(in: feeBox),
|
||||
with: .color(.yellow.opacity(0.3)), lineWidth: 1)
|
||||
context.draw(
|
||||
Text("REQUEST + FEE (anti-sybil)")
|
||||
.font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.yellow.opacity(0.6)),
|
||||
at: CGPoint(x: feeBox.midX, y: feeBox.midY)
|
||||
)
|
||||
|
||||
// Request arrow (Z → S, top lane)
|
||||
let reqProgress = min(1.0, time * 0.2)
|
||||
let reqY = cy - 25.0
|
||||
if reqProgress > 0 {
|
||||
var arrow = Path()
|
||||
arrow.move(to: CGPoint(x: zPos.x + zR, y: reqY))
|
||||
arrow.addLine(to: CGPoint(x: zPos.x + zR + (sPos.x - zPos.x - 2 * sR) * reqProgress, y: reqY))
|
||||
context.stroke(arrow, with: .color(.yellow.opacity(0.6)), lineWidth: 2.5)
|
||||
|
||||
// Animated packet
|
||||
let packetX = zPos.x + zR + (sPos.x - zPos.x - 2 * sR) * ((time * 0.3).truncatingRemainder(dividingBy: 1.0))
|
||||
let packetRect = CGRect(x: packetX - 6, y: reqY - 6, width: 12, height: 12)
|
||||
context.fill(RoundedRectangle(cornerRadius: 3).path(in: packetRect),
|
||||
with: .color(.yellow.opacity(0.7)))
|
||||
}
|
||||
|
||||
// Response arrow (S → Z, bottom lane)
|
||||
let respProgress = min(1.0, max(0, time * 0.2 - 0.7))
|
||||
let respY = cy + 25.0
|
||||
if respProgress > 0 {
|
||||
var arrow = Path()
|
||||
arrow.move(to: CGPoint(x: sPos.x - sR, y: respY))
|
||||
arrow.addLine(to: CGPoint(x: sPos.x - sR - (sPos.x - zPos.x - 2 * sR) * respProgress, y: respY))
|
||||
context.stroke(arrow, with: .color(.blue.opacity(0.6)), lineWidth: 2.5)
|
||||
|
||||
// Response box
|
||||
let respBox = CGRect(x: cx - 80, y: cy + 50, width: 160, height: 30)
|
||||
context.fill(RoundedRectangle(cornerRadius: 6).path(in: respBox),
|
||||
with: .color(.blue.opacity(0.1 * respProgress)))
|
||||
context.stroke(RoundedRectangle(cornerRadius: 6).path(in: respBox),
|
||||
with: .color(.blue.opacity(0.3 * respProgress)), lineWidth: 1)
|
||||
context.draw(
|
||||
Text("CHUNK + MERKLE PROOF")
|
||||
.font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.blue.opacity(0.6 * respProgress)),
|
||||
at: CGPoint(x: respBox.midX, y: respBox.midY)
|
||||
)
|
||||
}
|
||||
|
||||
context.draw(
|
||||
Text("POINT-TO-POINT RETRIEVAL — NOT BROADCAST")
|
||||
.font(.system(size: settings.scaled(12), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.3)),
|
||||
at: CGPoint(x: cx, y: 40)
|
||||
)
|
||||
context.draw(
|
||||
Text("REQUESTING COSTS SOMETHING → SYBIL ATTACK BECOMES EXPENSIVE")
|
||||
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.green.opacity(0.4)),
|
||||
at: CGPoint(x: cx, y: size.height - 40)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Scene 3: Fee Market
|
||||
|
||||
private func renderFeeMarket(context: inout GraphicsContext, size: CGSize, time: Double) {
|
||||
let cx = size.width / 2
|
||||
let graphW: CGFloat = min(550, size.width * 0.5)
|
||||
let graphH: CGFloat = min(320, size.height * 0.42)
|
||||
let graphCenterY = size.height * 0.38
|
||||
let origin = CGPoint(x: cx - graphW / 2, y: graphCenterY + graphH / 2)
|
||||
|
||||
// Grid lines
|
||||
for tick in stride(from: 0.2, through: 0.8, by: 0.2) {
|
||||
var hGrid = Path()
|
||||
hGrid.move(to: CGPoint(x: origin.x, y: origin.y - graphH * tick))
|
||||
hGrid.addLine(to: CGPoint(x: origin.x + graphW, y: origin.y - graphH * tick))
|
||||
context.stroke(hGrid, with: .color(.white.opacity(0.03)), lineWidth: 0.5)
|
||||
|
||||
var vGrid = Path()
|
||||
vGrid.move(to: CGPoint(x: origin.x + graphW * tick, y: origin.y))
|
||||
vGrid.addLine(to: CGPoint(x: origin.x + graphW * tick, y: origin.y - graphH))
|
||||
context.stroke(vGrid, with: .color(.white.opacity(0.03)), lineWidth: 0.5)
|
||||
}
|
||||
|
||||
// Axes
|
||||
var xAxis = Path()
|
||||
xAxis.move(to: origin)
|
||||
xAxis.addLine(to: CGPoint(x: origin.x + graphW, y: origin.y))
|
||||
context.stroke(xAxis, with: .color(.white.opacity(0.3)), lineWidth: 1.5)
|
||||
|
||||
var yAxis = Path()
|
||||
yAxis.move(to: origin)
|
||||
yAxis.addLine(to: CGPoint(x: origin.x, y: origin.y - graphH))
|
||||
context.stroke(yAxis, with: .color(.white.opacity(0.3)), lineWidth: 1.5)
|
||||
|
||||
// Supply curve (upward, green) — animated
|
||||
let supplyAppear = min(1.0, time * 0.2)
|
||||
if supplyAppear > 0 {
|
||||
// Fill under supply curve
|
||||
var supplyFill = Path()
|
||||
supplyFill.move(to: origin)
|
||||
let steps = 40
|
||||
for s in 0...steps {
|
||||
let t = Double(s) / Double(steps) * supplyAppear
|
||||
let x = origin.x + graphW * t
|
||||
let y = origin.y - graphH * 0.05 - graphH * 0.8 * t * t
|
||||
supplyFill.addLine(to: CGPoint(x: x, y: y))
|
||||
}
|
||||
supplyFill.addLine(to: CGPoint(x: origin.x + graphW * supplyAppear, y: origin.y))
|
||||
supplyFill.closeSubpath()
|
||||
context.fill(supplyFill, with: .color(.green.opacity(0.04 * supplyAppear)))
|
||||
|
||||
var supply = Path()
|
||||
for s in 0...steps {
|
||||
let t = Double(s) / Double(steps) * supplyAppear
|
||||
let x = origin.x + graphW * t
|
||||
let y = origin.y - graphH * 0.05 - graphH * 0.8 * t * t
|
||||
if s == 0 { supply.move(to: CGPoint(x: x, y: y)) }
|
||||
else { supply.addLine(to: CGPoint(x: x, y: y)) }
|
||||
}
|
||||
context.stroke(supply, with: .color(.green.opacity(0.7)), lineWidth: 2.5)
|
||||
|
||||
context.draw(
|
||||
Text("SUPPLY")
|
||||
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.green.opacity(0.6)),
|
||||
at: CGPoint(x: origin.x + graphW * supplyAppear + 30, y: origin.y - graphH * 0.8 * supplyAppear * supplyAppear)
|
||||
)
|
||||
}
|
||||
|
||||
// Demand curve (downward, orange) — animated
|
||||
let demandAppear = min(1.0, max(0, time * 0.2 - 0.4))
|
||||
if demandAppear > 0 {
|
||||
// Fill under demand curve
|
||||
var demandFill = Path()
|
||||
demandFill.move(to: CGPoint(x: origin.x, y: origin.y - graphH * 0.9))
|
||||
let steps = 40
|
||||
for s in 0...steps {
|
||||
let t = Double(s) / Double(steps) * demandAppear
|
||||
let x = origin.x + graphW * t
|
||||
let y = origin.y - graphH * 0.9 + graphH * 0.8 * t * t
|
||||
demandFill.addLine(to: CGPoint(x: x, y: y))
|
||||
}
|
||||
demandFill.addLine(to: CGPoint(x: origin.x + graphW * demandAppear, y: origin.y))
|
||||
demandFill.addLine(to: origin)
|
||||
demandFill.closeSubpath()
|
||||
context.fill(demandFill, with: .color(.orange.opacity(0.03 * demandAppear)))
|
||||
|
||||
var demand = Path()
|
||||
for s in 0...steps {
|
||||
let t = Double(s) / Double(steps) * demandAppear
|
||||
let x = origin.x + graphW * t
|
||||
let y = origin.y - graphH * 0.9 + graphH * 0.8 * t * t
|
||||
if s == 0 { demand.move(to: CGPoint(x: x, y: y)) }
|
||||
else { demand.addLine(to: CGPoint(x: x, y: y)) }
|
||||
}
|
||||
context.stroke(demand, with: .color(.orange.opacity(0.7)), lineWidth: 2.5)
|
||||
|
||||
context.draw(
|
||||
Text("DEMAND")
|
||||
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.orange.opacity(0.6)),
|
||||
at: CGPoint(x: origin.x + graphW * demandAppear + 30,
|
||||
y: origin.y - graphH * 0.9 + graphH * 0.8 * demandAppear * demandAppear)
|
||||
)
|
||||
}
|
||||
|
||||
// Equilibrium point + shading
|
||||
if supplyAppear > 0.7 && demandAppear > 0.7 {
|
||||
let eqX = origin.x + graphW * 0.42
|
||||
let eqY = origin.y - graphH * 0.42
|
||||
let eqR: CGFloat = 8
|
||||
let flash = 0.5 + 0.5 * sin(time * 3)
|
||||
let eqRect = CGRect(x: eqX - eqR, y: eqY - eqR, width: eqR * 2, height: eqR * 2)
|
||||
context.fill(Circle().path(in: eqRect), with: .color(.white.opacity(0.8 * flash)))
|
||||
|
||||
// Glow around equilibrium
|
||||
for ring in 1...3 {
|
||||
let ringR = eqR + CGFloat(ring) * 6
|
||||
let ringRect = CGRect(x: eqX - ringR, y: eqY - ringR, width: ringR * 2, height: ringR * 2)
|
||||
context.stroke(Circle().path(in: ringRect),
|
||||
with: .color(.white.opacity(0.1 * flash / Double(ring))), lineWidth: 1)
|
||||
}
|
||||
|
||||
// Dashed lines to axes
|
||||
let dashStyle = StrokeStyle(lineWidth: 1, dash: [4, 4])
|
||||
var hLine = Path()
|
||||
hLine.move(to: CGPoint(x: origin.x, y: eqY))
|
||||
hLine.addLine(to: CGPoint(x: eqX, y: eqY))
|
||||
context.stroke(hLine, with: .color(.white.opacity(0.2)), style: dashStyle)
|
||||
|
||||
var vLine = Path()
|
||||
vLine.move(to: CGPoint(x: eqX, y: origin.y))
|
||||
vLine.addLine(to: CGPoint(x: eqX, y: eqY))
|
||||
context.stroke(vLine, with: .color(.white.opacity(0.2)), style: dashStyle)
|
||||
|
||||
context.draw(
|
||||
Text("P*")
|
||||
.font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.5)),
|
||||
at: CGPoint(x: origin.x - 14, y: eqY)
|
||||
)
|
||||
context.draw(
|
||||
Text("Q*")
|
||||
.font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.5)),
|
||||
at: CGPoint(x: eqX, y: origin.y + 14)
|
||||
)
|
||||
|
||||
// Equilibrium label
|
||||
context.draw(
|
||||
Text("EQUILIBRIUM PRICE")
|
||||
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.4)),
|
||||
at: CGPoint(x: eqX + 60, y: eqY - 14)
|
||||
)
|
||||
}
|
||||
|
||||
// Axis labels
|
||||
context.draw(
|
||||
Text("PRICE (fee per chunk)")
|
||||
.font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.35)),
|
||||
at: CGPoint(x: origin.x - 10, y: origin.y - graphH / 2)
|
||||
)
|
||||
context.draw(
|
||||
Text("QUANTITY (storage served)")
|
||||
.font(.system(size: settings.scaled(9), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.35)),
|
||||
at: CGPoint(x: origin.x + graphW / 2, y: origin.y + 30)
|
||||
)
|
||||
|
||||
// Bottom: comparative data cards showing popular vs rare pricing
|
||||
let cardsY = origin.y + 70
|
||||
let cardW: CGFloat = 200
|
||||
let cardH: CGFloat = 55
|
||||
|
||||
// Popular data card (left)
|
||||
let popRect = CGRect(x: cx - cardW - 30, y: cardsY, width: cardW, height: cardH)
|
||||
context.fill(RoundedRectangle(cornerRadius: 8).path(in: popRect),
|
||||
with: .color(.green.opacity(0.06)))
|
||||
context.stroke(RoundedRectangle(cornerRadius: 8).path(in: popRect),
|
||||
with: .color(.green.opacity(0.2)), lineWidth: 1)
|
||||
context.draw(
|
||||
Text("POPULAR DATA")
|
||||
Text("⚠ OFFLINE")
|
||||
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.green.opacity(0.6)),
|
||||
at: CGPoint(x: popRect.midX, y: popRect.midY - 10)
|
||||
)
|
||||
context.draw(
|
||||
Text("many providers → low fee")
|
||||
.font(.system(size: settings.scaled(10), weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.green.opacity(0.35)),
|
||||
at: CGPoint(x: popRect.midX, y: popRect.midY + 8)
|
||||
)
|
||||
|
||||
// Rare data card (right)
|
||||
let rareRect = CGRect(x: cx + 30, y: cardsY, width: cardW, height: cardH)
|
||||
context.fill(RoundedRectangle(cornerRadius: 8).path(in: rareRect),
|
||||
with: .color(.orange.opacity(0.06)))
|
||||
context.stroke(RoundedRectangle(cornerRadius: 8).path(in: rareRect),
|
||||
with: .color(.orange.opacity(0.2)), lineWidth: 1)
|
||||
context.draw(
|
||||
Text("RARE DATA")
|
||||
.font(.system(size: settings.scaled(10), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.orange.opacity(0.6)),
|
||||
at: CGPoint(x: rareRect.midX, y: rareRect.midY - 10)
|
||||
)
|
||||
context.draw(
|
||||
Text("few providers → premium fee")
|
||||
.font(.system(size: settings.scaled(10), weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.orange.opacity(0.35)),
|
||||
at: CGPoint(x: rareRect.midX, y: rareRect.midY + 8)
|
||||
)
|
||||
|
||||
context.draw(
|
||||
Text("INCENTIVIZED STORAGE — FEE MARKET DISCOVERY")
|
||||
.font(.system(size: settings.scaled(14), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.3)),
|
||||
at: CGPoint(x: cx, y: 30)
|
||||
)
|
||||
context.draw(
|
||||
Text("STORAGE NODES EARN FEES — PRICE DISCOVERY EMERGES NATURALLY")
|
||||
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.orange.opacity(0.4)),
|
||||
at: CGPoint(x: cx, y: size.height - 30)
|
||||
.foregroundColor(.red.opacity(0.95)),
|
||||
at: CGPoint(x: pos.x, y: pos.y - 38)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Scene 4: Full Stack
|
||||
|
||||
private func renderFullStack(context: inout GraphicsContext, size: CGSize, time: Double) {
|
||||
let cx = size.width / 2
|
||||
let cy = size.height / 2
|
||||
let layerW: CGFloat = min(600, size.width * 0.6)
|
||||
let layerH: CGFloat = 100
|
||||
let gap: CGFloat = 50
|
||||
let appear = min(1.0, time * 0.2)
|
||||
|
||||
// Top: Crisis consensus
|
||||
let topRect = CGRect(x: cx - layerW / 2, y: cy - layerH - gap / 2, width: layerW, height: layerH)
|
||||
context.fill(RoundedRectangle(cornerRadius: 14).path(in: topRect),
|
||||
with: .color(.green.opacity(0.08 * appear)))
|
||||
context.stroke(RoundedRectangle(cornerRadius: 14).path(in: topRect),
|
||||
with: .color(.green.opacity(0.5 * appear)), lineWidth: 2)
|
||||
private func drawReconstructedBadge(
|
||||
in context: inout GraphicsContext, size: CGSize, alpha: Double
|
||||
) {
|
||||
context.draw(
|
||||
Text("CRISIS CONSENSUS")
|
||||
.font(.system(size: settings.scaled(18), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.green.opacity(0.7 * appear)),
|
||||
at: CGPoint(x: topRect.midX, y: topRect.midY - 12)
|
||||
Text("✓ ξ BODY RECONSTRUCTED — k=2 of 4 shards was enough — Aaron NOT NEEDED")
|
||||
.font(.system(size: settings.scaled(13), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.green.opacity(0.95 * alpha)),
|
||||
at: CGPoint(x: size.width / 2, y: size.height - 80)
|
||||
)
|
||||
}
|
||||
|
||||
private func drawFinalSummary(
|
||||
in context: inout GraphicsContext, size: CGSize, alpha: Double
|
||||
) {
|
||||
context.draw(
|
||||
Text("DAG · PoW · Virtual Voting · Total Order")
|
||||
.font(.system(size: settings.scaled(10), weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.green.opacity(0.4 * appear)),
|
||||
at: CGPoint(x: topRect.midX, y: topRect.midY + 12)
|
||||
Text("CRISIS COMPLETE · consensus + DA + Byzantine resilience")
|
||||
.font(.system(size: settings.scaled(13), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.yellow.opacity(0.95 * alpha)),
|
||||
at: CGPoint(x: size.width / 2, y: size.height - 50)
|
||||
)
|
||||
}
|
||||
|
||||
// Bottom: DA Layer
|
||||
let botRect = CGRect(x: cx - layerW / 2, y: cy + gap / 2, width: layerW, height: layerH)
|
||||
context.fill(RoundedRectangle(cornerRadius: 14).path(in: botRect),
|
||||
with: .color(.blue.opacity(0.08 * appear)))
|
||||
context.stroke(RoundedRectangle(cornerRadius: 14).path(in: botRect),
|
||||
with: .color(.blue.opacity(0.5 * appear)), lineWidth: 2)
|
||||
private func drawBeatTag(
|
||||
in context: inout GraphicsContext, size: CGSize, world: Ch08WorldState
|
||||
) {
|
||||
guard let beatId = world.activeBeat?.id else { return }
|
||||
context.draw(
|
||||
Text("DATA AVAILABILITY LAYER")
|
||||
.font(.system(size: settings.scaled(18), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.blue.opacity(0.7 * appear)),
|
||||
at: CGPoint(x: botRect.midX, y: botRect.midY - 12)
|
||||
)
|
||||
context.draw(
|
||||
Text("Erasure Coding · Merkle Proofs · Fee Market")
|
||||
.font(.system(size: settings.scaled(10), weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.blue.opacity(0.4 * appear)),
|
||||
at: CGPoint(x: botRect.midX, y: botRect.midY + 12)
|
||||
)
|
||||
|
||||
// Connecting arrows
|
||||
let arrowAppear = min(1.0, max(0, time * 0.2 - 0.5))
|
||||
if arrowAppear > 0.05 {
|
||||
for xOff in stride(from: -layerW * 0.3, through: layerW * 0.3, by: layerW * 0.3) {
|
||||
var arrow = Path()
|
||||
arrow.move(to: CGPoint(x: cx + xOff, y: topRect.maxY + 3))
|
||||
arrow.addLine(to: CGPoint(x: cx + xOff, y: botRect.minY - 3))
|
||||
context.stroke(arrow, with: .color(.white.opacity(0.3 * arrowAppear)), lineWidth: 1.5)
|
||||
|
||||
// Arrowhead
|
||||
let aY = botRect.minY - 3
|
||||
var head = Path()
|
||||
head.move(to: CGPoint(x: cx + xOff, y: aY))
|
||||
head.addLine(to: CGPoint(x: cx + xOff - 4, y: aY - 8))
|
||||
head.addLine(to: CGPoint(x: cx + xOff + 4, y: aY - 8))
|
||||
head.closeSubpath()
|
||||
context.fill(head, with: .color(.white.opacity(0.3 * arrowAppear)))
|
||||
}
|
||||
|
||||
context.draw(
|
||||
Text("HASH COMMITMENTS")
|
||||
.font(.system(size: settings.scaled(9), weight: .heavy, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.35 * arrowAppear)),
|
||||
at: CGPoint(x: cx, y: cy)
|
||||
)
|
||||
}
|
||||
|
||||
context.draw(
|
||||
Text("NODES PAY FOR WHAT THEY NEED — TWO LAYERS, ONE PROTOCOL")
|
||||
.font(.system(size: settings.scaled(10), weight: .bold, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.3 * appear)),
|
||||
at: CGPoint(x: cx, y: size.height - 40)
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
231
CrisisViz/Sources/CrisisViz/Engine/Ch08Timeline.swift
Normal file
231
CrisisViz/Sources/CrisisViz/Engine/Ch08Timeline.swift
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Ch08 — "Erasure shards make the data un-loseable." (DA fix.)
|
||||
///
|
||||
/// Aaron splits ξ's body into 4 erasure-coded shards (s1, s2, s3, s4)
|
||||
/// where any 2 are enough to reconstruct (k=2 of n=4). One shard each
|
||||
/// flies to Ben, Carl, Dave; one stays with Aaron. Then Aaron goes
|
||||
/// silent. Carl needs ξ's body, asks Ben for s1, combines with his
|
||||
/// own s2 — k=2 reached, body reconstructed. The DA problem is fixed.
|
||||
|
||||
enum Ch08BeatKind {
|
||||
case settle(label: String)
|
||||
case carryForward
|
||||
case splitIntoShards // ξ body split into s1..s4
|
||||
case sendShard(id: String, to: Ch01Cast)
|
||||
case stowShard(at: Ch01Cast, id: String)
|
||||
case aaronOffline
|
||||
case askForShard(asker: Ch01Cast, target: Ch01Cast, id: String)
|
||||
case shardArrives(at: Ch01Cast, id: String)
|
||||
case reconstructBody(at: Ch01Cast) // collected k shards → reform body
|
||||
case reconstructed // green badge
|
||||
case finalSummary // wrap-up
|
||||
}
|
||||
|
||||
struct Ch08Beat: Identifiable {
|
||||
let id: String
|
||||
let kind: Ch08BeatKind
|
||||
let durationSeconds: Double
|
||||
let narration: String
|
||||
var startTime: Double = 0
|
||||
var endTime: Double { startTime + durationSeconds }
|
||||
}
|
||||
|
||||
struct Ch08WorldState {
|
||||
/// Whether ξ has been split into shards.
|
||||
var split: Bool = false
|
||||
/// Shards each cast holds (at most one per cast in this demo).
|
||||
var shardsAt: [Ch01Cast: Set<String>] = [:]
|
||||
/// In-flight shard envelope (success path).
|
||||
var shardFlight: ShardFlight? = nil
|
||||
/// Aaron has gone silent.
|
||||
var aaronOffline: Bool = false
|
||||
/// Reconstruction state: who has reconstructed ξ's body.
|
||||
var reconstructedAt: Set<Ch01Cast> = []
|
||||
var reconstructFlash: Ch01Cast? = nil
|
||||
var reconstructProgress: Double = 0
|
||||
var reconstructedAlpha: Double = 0
|
||||
var finalAlpha: Double = 0
|
||||
var activeBeat: Ch08Beat? = nil
|
||||
var activeProgress: Double = 0
|
||||
|
||||
struct ShardFlight {
|
||||
let id: String
|
||||
let from: Ch01Cast
|
||||
let to: Ch01Cast
|
||||
let progress: Double
|
||||
}
|
||||
}
|
||||
|
||||
enum Ch08Timeline {
|
||||
/// k=2 of n=4. Any 2 of {s1, s2, s3, s4} reconstructs ξ.
|
||||
static let shardIds: [String] = ["s1", "s2", "s3", "s4"]
|
||||
static let k: Int = 2
|
||||
|
||||
static let beats: [Ch08Beat] = {
|
||||
let raw: [Ch08Beat] = [
|
||||
.init(id: "carry-forward", kind: .carryForward, durationSeconds: 4.0,
|
||||
narration: "Coming out of Ch07's DA problem: ξ's body lives only in Aaron's vault. Time for the fix — erasure coding."),
|
||||
|
||||
.init(id: "intro-ec", kind: .settle(label: "Erasure coding"),
|
||||
durationSeconds: 5.0,
|
||||
narration: "Aaron will split ξ's body into 4 shards using Reed-Solomon-like erasure coding. The trick: any 2 of the 4 shards is enough to reconstruct the whole body. So data survives even if half the storage nodes are offline."),
|
||||
|
||||
.init(id: "split", kind: .splitIntoShards, durationSeconds: 8.0,
|
||||
narration: "ξ's body is sliced and erasure-encoded into s1, s2, s3, s4. Each shard is roughly half the size of the original body — but together, even 2 of them suffice."),
|
||||
|
||||
// Distribute one shard to each cast.
|
||||
.init(id: "send-s1-ben", kind: .sendShard(id: "s1", to: .ben), durationSeconds: 4.5,
|
||||
narration: "Aaron sends s1 to Ben."),
|
||||
.init(id: "stow-s1-ben", kind: .stowShard(at: .ben, id: "s1"),
|
||||
durationSeconds: 3.0,
|
||||
narration: "Ben stows s1 in his vault."),
|
||||
.init(id: "send-s2-carl", kind: .sendShard(id: "s2", to: .carl), durationSeconds: 4.5,
|
||||
narration: "Aaron sends s2 to Carl."),
|
||||
.init(id: "stow-s2-carl", kind: .stowShard(at: .carl, id: "s2"),
|
||||
durationSeconds: 3.0,
|
||||
narration: "Carl stows s2."),
|
||||
.init(id: "send-s3-dave", kind: .sendShard(id: "s3", to: .dave), durationSeconds: 4.5,
|
||||
narration: "Aaron sends s3 to Dave. Yes — even Dave the byzantine can be a storage node. Storing a shard is harmless; tampering with it would just be detected via the shard's commitment."),
|
||||
.init(id: "stow-s3-dave", kind: .stowShard(at: .dave, id: "s3"),
|
||||
durationSeconds: 3.0,
|
||||
narration: "Dave stows s3."),
|
||||
.init(id: "stow-s4-aaron", kind: .stowShard(at: .aaron, id: "s4"),
|
||||
durationSeconds: 3.0,
|
||||
narration: "s4 stays in Aaron's own vault. So now: each of the four nodes holds exactly one shard."),
|
||||
|
||||
.init(id: "distributed-settle", kind: .settle(label: "Distributed"),
|
||||
durationSeconds: 4.5,
|
||||
narration: "All four shards are out in the world. The body of ξ no longer lives in any single place. This is the structural shift."),
|
||||
|
||||
// Aaron goes silent.
|
||||
.init(id: "aaron-offline", kind: .aaronOffline, durationSeconds: 5.0,
|
||||
narration: "Now suppose Aaron goes offline. Or refuses to share. Or vanishes. In Ch07 this would have been game over. Watch what happens now."),
|
||||
|
||||
// Reconstruction: Carl needs ξ's body, has s2, asks Ben for s1.
|
||||
.init(id: "ask-ben-s1", kind: .askForShard(asker: .carl, target: .ben, id: "s1"),
|
||||
durationSeconds: 4.5,
|
||||
narration: "Carl needs ξ's body. He already holds s2. He asks Ben for s1."),
|
||||
.init(id: "ben-sends-s1", kind: .sendShard(id: "s1", to: .carl), durationSeconds: 4.5,
|
||||
narration: "Ben has no reason to refuse — sharing a shard is a tiny operation. s1 flies to Carl."),
|
||||
.init(id: "carl-receives-s1", kind: .shardArrives(at: .carl, id: "s1"),
|
||||
durationSeconds: 3.0,
|
||||
narration: "Carl now holds s1 and s2 — that's k=2 of 4 shards. The threshold is reached."),
|
||||
|
||||
.init(id: "reconstruct", kind: .reconstructBody(at: .carl),
|
||||
durationSeconds: 6.0,
|
||||
narration: "Carl runs the erasure-coding decoder on s1 + s2. The original body of ξ pops out — bit-for-bit identical to what Aaron originally wrote."),
|
||||
|
||||
.init(id: "reconstructed", kind: .reconstructed, durationSeconds: 5.0,
|
||||
narration: "ξ's body is reconstructed in Carl's vault — even though Aaron is silent, even without ever talking to Dave. k-of-n is enough."),
|
||||
|
||||
.init(id: "final-summary", kind: .finalSummary, durationSeconds: 6.0,
|
||||
narration: "And that closes the curriculum: consensus on the DAG, derived round numbers, virtual voting, leader election, total order, byzantine resilience under f<n/3, and now data availability via erasure coding. Crisis works. Thanks for watching."),
|
||||
|
||||
.init(id: "outro", kind: .settle(label: "End"),
|
||||
durationSeconds: 4.0,
|
||||
narration: "End of the curriculum. Pull the speed slider in either direction to scrub through any chapter as a movie editor would."),
|
||||
]
|
||||
var t: Double = 0
|
||||
var assigned: [Ch08Beat] = []
|
||||
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) -> Ch08Beat? {
|
||||
let clamped = max(0, min(t, totalDuration))
|
||||
return beats.first { $0.startTime <= clamped && clamped < $0.endTime }
|
||||
?? beats.last
|
||||
}
|
||||
|
||||
static func state(at t: Double) -> Ch08WorldState {
|
||||
var w = Ch08WorldState()
|
||||
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: Ch08Beat, progress: Double, isActive: Bool,
|
||||
into w: inout Ch08WorldState
|
||||
) {
|
||||
switch beat.kind {
|
||||
case .settle, .carryForward:
|
||||
break
|
||||
case .splitIntoShards:
|
||||
w.split = true
|
||||
case .sendShard(let id, let to):
|
||||
if isActive {
|
||||
// Determine sender from the beat id naming convention or
|
||||
// the world state. For simplicity: Aaron is sender for
|
||||
// initial distribution, Ben is sender when carl-asks-ben
|
||||
// beat preceded.
|
||||
let from: Ch01Cast = (w.shardsAt[.ben]?.contains(id) == true && to == .carl)
|
||||
? .ben
|
||||
: .aaron
|
||||
w.shardFlight = .init(id: id, from: from, to: to, progress: progress)
|
||||
}
|
||||
case .stowShard(let at, let id):
|
||||
w.shardsAt[at, default: []].insert(id)
|
||||
case .aaronOffline:
|
||||
w.aaronOffline = true
|
||||
case .askForShard:
|
||||
// Visual could draw an arrow; for now just narration drives.
|
||||
break
|
||||
case .shardArrives(let at, let id):
|
||||
// Permanent: recipient now holds the shard.
|
||||
w.shardsAt[at, default: []].insert(id)
|
||||
case .reconstructBody(let at):
|
||||
if isActive {
|
||||
w.reconstructFlash = at
|
||||
w.reconstructProgress = progress
|
||||
} else {
|
||||
w.reconstructedAt.insert(at)
|
||||
}
|
||||
case .reconstructed:
|
||||
w.reconstructedAlpha = isActive ? progress : 1.0
|
||||
case .finalSummary:
|
||||
w.finalAlpha = isActive ? progress : 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Ch08Scenes {
|
||||
/// 5 scenes mapping to ~85s of timeline at 1×.
|
||||
static let sceneStarts: [Double] = [0, 17, 47, 64, 75]
|
||||
static let sceneDurations: [Double] = [17, 30, 17, 11, 10]
|
||||
|
||||
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 Ch08Timeline.activeBeat(at: t)?.narration ?? ""
|
||||
}
|
||||
}
|
||||
|
|
@ -85,6 +85,12 @@ final class SceneEngine {
|
|||
SceneAddress(chapter: 7, scene: 1): 20.0,
|
||||
SceneAddress(chapter: 7, scene: 2): 21.0,
|
||||
SceneAddress(chapter: 7, scene: 3): 10.0,
|
||||
// Ch08 — erasure shards (5 scenes)
|
||||
SceneAddress(chapter: 8, scene: 0): 17.0,
|
||||
SceneAddress(chapter: 8, scene: 1): 30.0,
|
||||
SceneAddress(chapter: 8, scene: 2): 17.0,
|
||||
SceneAddress(chapter: 8, scene: 3): 11.0,
|
||||
SceneAddress(chapter: 8, scene: 4): 10.0,
|
||||
// Ch09 — Byzantine (2 scenes mapping to Ch09Timeline windows)
|
||||
SceneAddress(chapter: 9, scene: 0): 47.5,
|
||||
SceneAddress(chapter: 9, scene: 1): 32.0,
|
||||
|
|
|
|||
|
|
@ -172,6 +172,8 @@ struct ImmersiveView: View {
|
|||
return Ch06Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
|
||||
case 7:
|
||||
return Ch07Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
|
||||
case 8:
|
||||
return Ch08Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
|
||||
case 9:
|
||||
return Ch09Scenes.narrationAt(sceneIndex: addr.scene, localTime: localTime)
|
||||
default:
|
||||
|
|
|
|||
Loading…
Reference in a new issue