diff --git a/CrisisViz/Sources/CrisisViz/Chapters/Ch09_DA_Design.swift b/CrisisViz/Sources/CrisisViz/Chapters/Ch09_DA_Design.swift index 96eb13a..616187c 100644 --- a/CrisisViz/Sources/CrisisViz/Chapters/Ch09_DA_Design.swift +++ b/CrisisViz/Sources/CrisisViz/Chapters/Ch09_DA_Design.swift @@ -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.. 0 { - let storageY = cy + 100.0 - let storageR: CGFloat = 20 - - for i in 0.. 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 ) } } diff --git a/CrisisViz/Sources/CrisisViz/Engine/Ch08Timeline.swift b/CrisisViz/Sources/CrisisViz/Engine/Ch08Timeline.swift new file mode 100644 index 0000000..3aefbdc --- /dev/null +++ b/CrisisViz/Sources/CrisisViz/Engine/Ch08Timeline.swift @@ -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] = [:] + /// 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 = [] + 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 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 ?? "" + } +} diff --git a/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift b/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift index 3a47e1a..e58f15c 100644 --- a/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift +++ b/CrisisViz/Sources/CrisisViz/Engine/SceneEngine.swift @@ -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, diff --git a/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift b/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift index 71d2aad..1dc690e 100644 --- a/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift +++ b/CrisisViz/Sources/CrisisViz/Views/ImmersiveView.swift @@ -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: