api: POST /api/scenario returns result plus graph topology (M3 slice 1)

One panel's whole world in one call: effective config in, echoed
config + cascade result + undirected edge list out. Edges are
[from, to] pairs with from < to in deterministic node order;
Graph.Edges() walks the adjacency once, GraphEdges(config) rebuilds
the seeded world (~25us) so Result stays lean and /api/comparison
stays untouched. This closes the topology gap the design brief
flagged; the frontend's seeded d3-force layout consumes these pairs.
Go bits: [][2]int is a slice of fixed-size arrays; [2]int is a value
type, comparable, and JSON-marshals to [a, b], exactly the wire
shape the spec asks for.
tygo regen includes a fix: engine.Strategy now maps to the generated
Strategy type instead of decaying to 'any' in ScenarioRequest.
Verified live through the dev stack: 7/120 reached, 351 edges.
This commit is contained in:
Justin Visser 2026-06-10 15:48:48 +02:00
parent 8c8d5bca11
commit 9a392b1860
12 changed files with 1790 additions and 2 deletions

1
docs/mockup/data.json Normal file
View file

@ -0,0 +1 @@
{"config":{"numStudents":120,"edgesPerNode":3,"triangleProb":0.45,"forwardProb":0.38,"numEducated":36,"origin":0,"graphSeed":17,"thresholdSeed":2,"educationSeed":1},"results":[{"strategy":"none","educated":[],"reachedAtRound":[0,4,4,2,3,3,5,2,3,4,4,4,1,7,5,1,5,6,5,5,5,2,3,7,3,4,2,4,4,3,4,4,5,6,2,5,1,5,-1,2,6,5,3,4,-1,5,4,5,4,4,3,5,3,-1,6,5,3,-1,4,5,-1,5,-1,5,2,-1,1,4,3,4,3,5,2,4,4,3,3,-1,5,3,-1,5,-1,-1,5,3,3,-1,6,3,5,5,-1,-1,6,2,5,5,8,5,-1,5,6,6,4,7,-1,4,4,-1,-1,5,3,-1,1,-1,3,3,-1,5],"numReached":99,"numRounds":9,"reachedPct":82.5},{"strategy":"random","educated":[2,4,6,7,8,10,21,23,24,27,28,33,37,39,40,43,48,50,53,56,61,62,63,68,74,79,80,85,87,89,90,97,101,102,111,117],"reachedAtRound":[0,4,4,2,3,3,5,2,3,-1,7,4,1,7,5,1,5,6,-1,6,5,2,-1,7,3,7,2,5,5,-1,-1,6,6,6,2,5,1,5,-1,2,6,-1,3,4,-1,-1,4,-1,4,-1,-1,5,-1,-1,6,5,3,-1,-1,-1,-1,-1,-1,-1,2,-1,1,-1,5,4,-1,5,2,-1,-1,-1,3,-1,-1,3,-1,5,-1,-1,6,3,-1,-1,6,3,7,-1,-1,-1,7,2,-1,7,8,5,-1,5,6,-1,-1,7,-1,4,4,-1,-1,5,-1,-1,1,-1,3,-1,-1,-1],"numReached":70,"numRounds":9,"reachedPct":58.333333333333336},{"strategy":"most-connected","educated":[1,2,3,4,5,6,7,8,9,10,11,12,13,15,16,19,20,21,22,23,24,25,26,27,28,30,31,32,33,35,37,38,39,45,47,77],"reachedAtRound":[0,-1,-1,-1,-1,-1,-1,2,-1,-1,-1,-1,1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1],"numReached":7,"numRounds":3,"reachedPct":5.833333333333333}]}

445
docs/mockup/index.html Normal file
View file

@ -0,0 +1,445 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>spreadlab mockup v2 (design artifact, not app code)</title>
<style>
:root {
--bg: #F8FAFC;
--surface: #FFFFFF;
--border: #E2E8F0;
--border-soft: #EEF2F7;
--edge: #E6E9EF;
--unreached: #D8DFE9;
--spread: #F43F5E;
--spread-soft: #FFF1F2;
--edu: #14B8A6;
--edu-soft: #F0FDFA;
--ink: #0F172A;
--ink-2: #334155;
--ink-3: #64748B;
--ink-4: #94A3B8;
--shadow: 0 1px 2px rgba(15,23,42,.05), 0 4px 16px -8px rgba(15,23,42,.08);
--shadow-lift: 0 2px 4px rgba(15,23,42,.06), 0 12px 32px -12px rgba(15,23,42,.16);
--radius: 16px;
}
body.dark {
--bg: #0B1120;
--surface: #111A2E;
--border: #1E293B;
--border-soft: #1A2438;
--edge: #243349;
--unreached: #3B4A61;
--spread: #FB7185;
--spread-soft: rgba(251,113,133,.08);
--edu: #2DD4BF;
--edu-soft: rgba(45,212,191,.08);
--ink: #F1F5F9;
--ink-2: #CBD5E1;
--ink-3: #94A3B8;
--ink-4: #64748B;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.5);
--shadow-lift: 0 2px 4px rgba(0,0,0,.5), 0 16px 40px -16px rgba(0,0,0,.6);
}
* { box-sizing: border-box; margin: 0; }
html { -webkit-font-smoothing: antialiased; }
body {
background: var(--bg); color: var(--ink);
font: 15px/1.5 "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
}
svg.ic { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; display: block; }
/* ---------- app bar ---------- */
.appbar {
background: var(--surface); border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 20;
}
.appbar .in {
max-width: 1240px; margin: 0 auto; padding: 0 28px; height: 56px;
display: flex; align-items: center; gap: 14px;
}
.wordmark { display: flex; align-items: center; gap: 9px; font-weight: 650; font-size: 15.5px; letter-spacing: -0.01em; }
.wordmark .glyph {
width: 26px; height: 26px; border-radius: 8px;
background: linear-gradient(135deg, var(--edu), #0D9488);
display: grid; place-items: center;
}
.wordmark .glyph svg { width: 15px; height: 15px; stroke: #fff; stroke-width: 2.2; }
.badge {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12.5px; font-weight: 500; color: var(--ink-3);
background: var(--bg); border: 1px solid var(--border);
border-radius: 999px; padding: 4px 12px 4px 9px; cursor: pointer;
}
.badge svg.ic { width: 14px; height: 14px; }
.appbar .grow { flex: 1; }
.iconbtn {
width: 34px; height: 34px; border-radius: 10px; border: none; background: none;
color: var(--ink-3); display: grid; place-items: center; cursor: pointer;
}
.iconbtn:hover { background: var(--bg); color: var(--ink); }
.page { max-width: 1240px; margin: 0 auto; padding: 36px 28px 48px; }
/* ---------- hero ---------- */
.hero { max-width: 760px; }
.hero h1 {
font-size: 30px; line-height: 1.18; font-weight: 700; letter-spacing: -0.022em;
}
.hero p {
margin-top: 10px; font-size: 16.5px; color: var(--ink-3); line-height: 1.55;
}
.hero p b { font-weight: 650; font-variant-numeric: tabular-nums; }
.hero p b.bad { color: var(--spread); }
.hero p b.good { color: var(--edu); }
/* ---------- toolbar ---------- */
.toolbar { display: flex; align-items: center; margin: 30px 0 14px; gap: 10px; }
.toolbar h2 {
font-size: 13px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--ink-4);
}
.toolbar .grow { flex: 1; }
.btn {
display: inline-flex; align-items: center; gap: 7px;
font: 600 13.5px/1 inherit; font-family: inherit; color: var(--ink-2);
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 8px 14px; cursor: pointer; box-shadow: 0 1px 2px rgba(15,23,42,.04);
}
.btn:hover { border-color: var(--ink-4); }
.btn.primary { background: var(--ink); color: var(--surface); border-color: var(--ink); }
.btn svg.ic { width: 15px; height: 15px; }
/* ---------- panels ---------- */
.panels { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
.panel {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow);
padding: 18px 20px 10px; position: relative; transition: box-shadow .15s;
}
.panel:hover { box-shadow: var(--shadow-lift); }
.panel .head { display: flex; align-items: center; gap: 8px; }
.panel .swatch { width: 9px; height: 9px; border-radius: 3px; flex: none; }
.panel .label { font-size: 13.5px; font-weight: 600; color: var(--ink-2); letter-spacing: -0.005em; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;}
.panel .kebab { color: var(--ink-4); margin-right: -6px; }
.panel .pct {
margin-top: 10px; font-size: 40px; font-weight: 700; letter-spacing: -0.03em;
line-height: 1; font-variant-numeric: tabular-nums;
}
.panel .pct small { font-size: 22px; font-weight: 600; color: var(--ink-4); letter-spacing: 0; }
.panel .pct.bad { color: var(--spread); } .panel .pct.good { color: var(--edu); }
.panel .meta { margin-top: 7px; font-size: 12.5px; color: var(--ink-4); display: flex; gap: 5px; flex-wrap: wrap; align-items: center;}
.panel .meta span { white-space: nowrap; }
.panel .meta .sep { color: var(--border); }
.panel .chips { display: flex; gap: 6px; margin-top: 9px; min-height: 22px; flex-wrap: wrap; }
.chip {
font-size: 11.5px; font-weight: 550; color: var(--ink-3);
background: var(--bg); border: 1px solid var(--border-soft);
border-radius: 7px; padding: 2px 8px; font-variant-numeric: tabular-nums;
}
.panel svg.net { width: 100%; height: auto; display: block; margin-top: 4px; }
/* ---------- legend ---------- */
.legend { display: flex; gap: 22px; margin: 14px 2px 0; font-size: 13px; color: var(--ink-3); flex-wrap: wrap; }
.legend .k { display: inline-flex; align-items: center; gap: 7px; }
.lg { width: 11px; height: 11px; border-radius: 50%; flex: none; }
/* ---------- player ---------- */
.playerwrap { display: flex; justify-content: center; margin-top: 26px; }
.player {
display: flex; align-items: center; gap: 6px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 999px; box-shadow: var(--shadow);
padding: 8px 18px 8px 10px; width: min(680px, 100%);
}
.player .iconbtn { width: 36px; height: 36px; border-radius: 999px; flex: none; }
.player .iconbtn.play {
width: 42px; height: 42px; background: var(--ink); color: var(--surface);
}
.player .iconbtn.play:hover { background: var(--ink); opacity: .9; }
.scrub { flex: 1; position: relative; height: 28px; margin: 0 10px; }
.scrub .track { position: absolute; top: 13px; left: 0; right: 0; height: 3px; border-radius: 3px; background: var(--border); }
.scrub .fill { position: absolute; top: 13px; left: 0; width: 100%; height: 3px; border-radius: 3px; background: var(--ink); }
.scrub .dot { position: absolute; top: 12.5px; width: 4px; height: 4px; border-radius: 50%; background: var(--surface); border: 1px solid var(--ink-4); transform: translateX(-2px); }
.scrub .thumb {
position: absolute; top: 6px; right: -2px; width: 17px; height: 17px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--ink); box-shadow: 0 1px 3px rgba(15,23,42,.25);
}
.player .meta { font-size: 13px; font-weight: 550; color: var(--ink-3); font-variant-numeric: tabular-nums; white-space: nowrap; }
.player .pill {
font-size: 12.5px; font-weight: 600; color: var(--ink-2);
border: 1px solid var(--border); border-radius: 999px; padding: 4px 11px; margin-left: 10px; cursor: pointer;
}
/* ---------- lower grid ---------- */
.lower { display: grid; grid-template-columns: 7fr 5fr; gap: 18px; margin-top: 26px; align-items: stretch; }
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px 22px;
}
.card h3 { font-size: 13px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-4); }
.chart svg { width: 100%; height: auto; margin-top: 10px; }
.ctl .row { display: grid; grid-template-columns: 150px 1fr 78px; align-items: center; gap: 14px; margin-top: 18px; }
.ctl .row label { font-size: 14px; font-weight: 550; color: var(--ink-2); }
.ctl .row .hint { display: block; font-size: 12px; font-weight: 400; color: var(--ink-4); margin-top: 1px; }
.ctl .val {
justify-self: end; font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums;
color: var(--ink-2); background: var(--bg); border: 1px solid var(--border-soft);
border-radius: 8px; padding: 3px 10px;
}
.slider { position: relative; height: 20px; }
.slider .track { position: absolute; top: 8.5px; left: 0; right: 0; height: 3px; border-radius: 3px; background: var(--border); }
.slider .fill { position: absolute; top: 8.5px; left: 0; height: 3px; border-radius: 3px; background: var(--edu); }
.slider .thumb {
position: absolute; top: 2.5px; width: 15px; height: 15px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--edu); box-shadow: 0 1px 3px rgba(15,23,42,.2);
}
.adv { margin-top: 20px; border-top: 1px solid var(--border-soft); padding-top: 14px; }
.adv .toggle { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: var(--ink-3); cursor: pointer; }
.adv .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; margin-top: 12px; }
.field {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
border: 1px solid var(--border-soft); background: var(--bg);
border-radius: 10px; padding: 7px 11px; font-size: 13px;
}
.field .fl { color: var(--ink-3); font-weight: 500; }
.field .fv { font-weight: 600; color: var(--ink-2); font-variant-numeric: tabular-nums; display: flex; align-items: center; gap: 7px; }
.field .fv svg.ic { width: 14px; height: 14px; color: var(--ink-4); cursor: pointer; }
footer { margin-top: 30px; display: flex; justify-content: space-between; font-size: 12.5px; color: var(--ink-4); }
footer em { font-style: italic; }
/* ---------- mobile ---------- */
.strip { display: none; }
.hero .mobile-note { display: none; }
@media (max-width: 760px) {
.page { padding: 22px 16px 110px; }
.appbar .badge { display: none; }
.hero h1 { font-size: 22px; }
.hero p { font-size: 15px; }
.hero .mobile-note { display: flex; align-items: center; gap: 6px; margin-top: 10px; font-size: 12.5px; color: var(--ink-4); }
.strip {
display: flex; gap: 8px; overflow-x: auto; margin-top: 18px;
position: sticky; top: 56px; z-index: 10; background: var(--bg);
padding: 10px 0; scrollbar-width: none;
}
.s-chip {
flex: none; display: flex; align-items: baseline; gap: 8px;
background: var(--surface); border: 1px solid var(--border); border-radius: 999px;
padding: 6px 14px; font-size: 12.5px; font-weight: 550; color: var(--ink-3);
box-shadow: var(--shadow);
}
.s-chip b { font-size: 15px; font-variant-numeric: tabular-nums; }
.toolbar { margin-top: 16px; }
.panels { grid-template-columns: 1fr; gap: 14px; }
.lower { grid-template-columns: 1fr; }
.playerwrap { position: fixed; left: 12px; right: 12px; bottom: 12px; z-index: 15; margin: 0; }
.player { box-shadow: var(--shadow-lift); }
.player .pill, .player .iconbtn.step { display: none; }
.legend { gap: 14px; }
footer { flex-direction: column; gap: 4px; }
}
</style>
</head>
<body>
<div class="appbar"><div class="in">
<span class="wordmark">
<span class="glyph"><svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.2"/><circle cx="18" cy="9" r="2.2"/><circle cx="10" cy="18" r="2.2"/><path d="M8 7l8 1.6M16.5 10.8l-5 5.4M7 8l2.4 8"/></svg></span>
spreadlab
</span>
<span class="badge"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8v.01"/></svg> Illustrative model, not validated</span>
<span class="grow"></span>
<button class="iconbtn" title="Toggle theme"><svg class="ic" viewBox="0 0 24 24"><path d="M20 14.5A8 8 0 1 1 9.5 4 6.5 6.5 0 0 0 20 14.5z"/></svg></button>
<button class="iconbtn" title="Source"><svg class="ic" viewBox="0 0 24 24"><path d="M9 19c-4.6 1.4-4.6-2.5-6.5-3m13 5v-3.5c0-1 .1-1.4-.5-2 2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2 4.2 4.2 0 0 0-.1-3.2s-1.1-.3-3.5 1.3a12.3 12.3 0 0 0-6.2 0C7.1 2.8 6 3.1 6 3.1a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 4.6 9.5c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21"/></svg></button>
</div></div>
<div class="page">
<div class="hero">
<h1>How a non&#8209;consensual deepfake spreads through a school</h1>
<p>One simulated year group, 120 students. Educate the same 30% of them, but change <i>who</i>:
the fake reaches <b class="bad" id="h0">82%</b> of the school with no program,
<b class="bad" id="h1">58%</b> educating at random, and
<b class="good" id="h2">6%</b> educating the best&#8209;connected students.</p>
<span class="mobile-note"><svg class="ic" style="width:14px;height:14px" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8v.01"/></svg> Illustrative model, not validated</span>
</div>
<div class="strip" id="strip"></div>
<div class="toolbar">
<h2>Scenarios</h2>
<span class="grow"></span>
<button class="btn"><svg class="ic" viewBox="0 0 24 24"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14"/></svg> Export</button>
<button class="btn primary"><svg class="ic" viewBox="0 0 24 24"><path d="M12 6v12M6 12h12"/></svg> Add scenario</button>
</div>
<div class="panels" id="panels"></div>
<div class="legend">
<span class="k"><span class="lg" style="background:var(--spread)"></span> Forwarded the fake</span>
<span class="k"><span class="lg" style="background:none;border:2.5px solid var(--edu);width:8px;height:8px"></span> Educated to refuse</span>
<span class="k"><span class="lg" style="background:var(--unreached)"></span> Not reached</span>
<span class="k"><span class="lg" style="background:none;border:2px solid var(--spread);width:9px;height:9px"></span> Origin</span>
</div>
<div class="playerwrap"><div class="player">
<button class="iconbtn" title="Replay"><svg class="ic" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 2.6-6.3M5 3v4h4"/></svg></button>
<button class="iconbtn step" title="Back one round"><svg class="ic" viewBox="0 0 24 24"><path d="M17 6l-7 6 7 6M7 6v12"/></svg></button>
<button class="iconbtn play" title="Play"><svg class="ic" viewBox="0 0 24 24" style="fill:currentColor;stroke:none"><path d="M8.5 6.2v11.6c0 .8.9 1.3 1.6.9l9-5.8a1 1 0 0 0 0-1.8l-9-5.8a1.05 1.05 0 0 0-1.6.9z"/></svg></button>
<button class="iconbtn step" title="Forward one round"><svg class="ic" viewBox="0 0 24 24"><path d="M7 6l7 6-7 6M17 6v12"/></svg></button>
<div class="scrub" id="scrub"><div class="track"></div><div class="fill"></div><div class="thumb"></div></div>
<span class="meta">Round 9 of 9</span>
<span class="pill">1&#215;</span>
</div></div>
<div class="lower">
<div class="card chart">
<h3>Reach over time</h3>
<svg id="chart" viewBox="0 0 640 230"></svg>
</div>
<div class="card ctl">
<h3>World</h3>
<div class="row">
<label>Chance to forward<span class="hint">per friendship, per round</span></label>
<div class="slider"><div class="track"></div><div class="fill" style="width:38%"></div><div class="thumb" style="left:calc(38% - 8px)"></div></div>
<span class="val">38%</span>
</div>
<div class="row">
<label>Education budget<span class="hint">students the program reaches</span></label>
<div class="slider"><div class="track"></div><div class="fill" style="width:30%"></div><div class="thumb" style="left:calc(30% - 8px)"></div></div>
<span class="val">36 &middot; 30%</span>
</div>
<div class="adv">
<span class="toggle"><svg class="ic" style="width:14px;height:14px" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg> Advanced</span>
<div class="grid">
<span class="field"><span class="fl">Students</span><span class="fv">120</span></span>
<span class="field"><span class="fl">Friends per student</span><span class="fv">3</span></span>
<span class="field"><span class="fl">Clique tendency</span><span class="fv">0.45</span></span>
<span class="field"><span class="fl">First poster</span><span class="fv">#0</span></span>
<span class="field"><span class="fl">Friendship network</span><span class="fv">17 <svg class="ic" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M9 9h.01M15 15h.01M15 9h.01M9 15h.01"/></svg></span></span>
<span class="field"><span class="fl">Who resists</span><span class="fv">2 <svg class="ic" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M9 9h.01M15 15h.01M15 9h.01M9 15h.01"/></svg></span></span>
</div>
</div>
</div>
</div>
<footer>
<span><em>Illustrative (not validated output)</em> &middot; an agent&#8209;based toy model, parameters chosen for clarity</span>
<span>spreadlab &middot; AGPL</span>
</footer>
</div>
<script>
if (location.search.includes('dark')) document.body.classList.add('dark');
const DATA = __DATA__;
const ACCENTS = ['#6366F1', '#F59E0B', '#0EA5E9', '#8B5CF6', '#EC4899', '#84CC16'];
const LABELS = ['No program', 'Educate 30% at random', 'Educate best-connected 30%'];
// seeded stand-in layout (real app: d3-force over real topology)
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}
const rnd = mulberry32(DATA.config.graphSeed);
const N = DATA.config.numStudents;
const pos = [];
while (pos.length < N) {
const a = rnd()*Math.PI*2, r = Math.sqrt(rnd());
const x = 190 + Math.cos(a)*r*172, y = 95 + Math.sin(a)*r*82;
if (pos.every(p => (p[0]-x)**2 + (p[1]-y)**2 > 14.5**2)) pos.push([x,y]);
}
const edges = [];
for (let i=0;i<N;i++){
const d = pos.map((p,j)=>[(p[0]-pos[i][0])**2 + (p[1]-pos[i][1])**2, j]).sort((a,b)=>a[0]-b[0]);
for (let k=1;k<=2;k++) if (d[k]) edges.push([i, d[k][1]]);
}
function netSVG(result){
const edu = new Set(result.educated);
let s = `<svg class="net" viewBox="0 0 380 190">`;
for (const [a,b] of edges) s += `<line x1="${pos[a][0].toFixed(1)}" y1="${pos[a][1].toFixed(1)}" x2="${pos[b][0].toFixed(1)}" y2="${pos[b][1].toFixed(1)}" stroke="var(--edge)" stroke-width="1"/>`;
for (let i=0;i<N;i++){
const [x,y] = pos[i];
const reached = result.reachedAtRound[i] >= 0;
if (i === DATA.config.origin) {
s += `<circle cx="${x}" cy="${y}" r="7.5" fill="none" stroke="var(--spread)" stroke-width="1.4" opacity=".75"/><circle cx="${x}" cy="${y}" r="4" fill="var(--spread)"/>`;
} else if (edu.has(i)) {
s += `<circle cx="${x}" cy="${y}" r="3.8" fill="${reached ? 'var(--spread)' : 'var(--surface)'}" stroke="var(--edu)" stroke-width="2.2"/>`;
} else if (reached) {
s += `<circle cx="${x}" cy="${y}" r="4" fill="var(--spread)"/>`;
} else {
s += `<circle cx="${x}" cy="${y}" r="3" fill="var(--unreached)"/>`;
}
}
return s + `</svg>`;
}
const fmt = p => (Math.round(p*10)/10).toFixed(0);
const panelsEl = document.getElementById('panels');
const stripEl = document.getElementById('strip');
DATA.results.forEach((res, i) => {
const pct = fmt(res.reachedPct);
const tone = res.reachedPct > 30 ? 'bad' : 'good';
const el = document.createElement('div');
el.className = 'panel';
el.innerHTML = `
<div class="head">
<span class="swatch" style="background:${ACCENTS[i]}"></span>
<span class="label">${LABELS[i]}</span>
<span class="kebab"><svg class="ic" viewBox="0 0 24 24"><path d="M12 6h.01M12 12h.01M12 18h.01"/></svg></span>
</div>
<div class="pct ${tone}">${pct}<small>%</small></div>
<div class="meta">
<span>${res.numReached} of ${N} reached</span><span class="sep">&bull;</span>
<span>${res.numRounds} rounds</span><span class="sep">&bull;</span>
<span>${res.educated.length} educated</span>
</div>
<div class="chips">${i===2 ? '<span class="chip">strategy &middot; most-connected</span>' : i===1 ? '<span class="chip">strategy &middot; random</span>' : ''}</div>
${netSVG(res)}`;
panelsEl.appendChild(el);
document.getElementById('h'+i).textContent = pct + '%';
const chip = document.createElement('span');
chip.className = 's-chip';
chip.innerHTML = `${LABELS[i].replace('Educate 30% at random','Random 30%').replace('Educate best-connected 30%','Connected 30%')} <b style="color:${tone==='bad'?'var(--spread)':'var(--edu)'}">${pct}%</b>`;
stripEl.appendChild(chip);
});
// scrub ticks
const maxR = Math.max(...DATA.results.map(r=>r.numRounds));
const scrub = document.getElementById('scrub');
for (let r=1; r<maxR; r++){
const d = document.createElement('div'); d.className = 'dot'; d.style.left = (r/maxR*100)+'%';
scrub.appendChild(d);
}
// chart
const chart = document.getElementById('chart');
const CW = 640, CH = 230, L = 40, R = 56, T = 14, B = 30;
let svg = '';
for (let g=0; g<=4; g++){
const y = T + g*(CH-T-B)/4;
svg += `<line x1="${L}" y1="${y}" x2="${CW-R}" y2="${y}" stroke="var(--border-soft)" stroke-width="1" ${g<4?'stroke-dasharray="2 4"':''}/>`;
svg += `<text x="${L-9}" y="${y+3.5}" text-anchor="end" font-size="11" fill="var(--ink-4)">${100-g*25}</text>`;
}
DATA.results.forEach((res, i) => {
const pts = [];
for (let r=0; r<=maxR; r++){
const c = res.reachedAtRound.filter(v => v>=0 && v<=r).length;
pts.push([L + r*(CW-L-R)/maxR, T + (1-c/N)*(CH-T-B)]);
}
svg += `<polyline points="${pts.map(p=>p.map(v=>v.toFixed(1)).join(',')).join(' ')}" fill="none" stroke="${ACCENTS[i]}" stroke-width="2.4" stroke-linejoin="round" stroke-linecap="round"/>`;
const last = pts[pts.length-1];
svg += `<circle cx="${last[0]}" cy="${last[1]}" r="3.5" fill="${ACCENTS[i]}"/>`;
svg += `<text x="${last[0]+9}" y="${last[1]+4}" font-size="12" font-weight="650" fill="${ACCENTS[i]}">${fmt(res.reachedPct)}%</text>`;
});
for (let r=0; r<=maxR; r++){
svg += `<text x="${L + r*(CW-L-R)/maxR}" y="${CH-8}" text-anchor="middle" font-size="11" fill="var(--ink-4)">${r}</text>`;
}
svg += `<text x="${L}" y="${CH-8}" text-anchor="start" font-size="0"> </text>`;
chart.innerHTML = svg;
</script>
</body>
</html>

445
docs/mockup/rendered.html Normal file
View file

@ -0,0 +1,445 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>spreadlab mockup v2 (design artifact, not app code)</title>
<style>
:root {
--bg: #F8FAFC;
--surface: #FFFFFF;
--border: #E2E8F0;
--border-soft: #EEF2F7;
--edge: #E6E9EF;
--unreached: #D8DFE9;
--spread: #F43F5E;
--spread-soft: #FFF1F2;
--edu: #14B8A6;
--edu-soft: #F0FDFA;
--ink: #0F172A;
--ink-2: #334155;
--ink-3: #64748B;
--ink-4: #94A3B8;
--shadow: 0 1px 2px rgba(15,23,42,.05), 0 4px 16px -8px rgba(15,23,42,.08);
--shadow-lift: 0 2px 4px rgba(15,23,42,.06), 0 12px 32px -12px rgba(15,23,42,.16);
--radius: 16px;
}
body.dark {
--bg: #0B1120;
--surface: #111A2E;
--border: #1E293B;
--border-soft: #1A2438;
--edge: #243349;
--unreached: #3B4A61;
--spread: #FB7185;
--spread-soft: rgba(251,113,133,.08);
--edu: #2DD4BF;
--edu-soft: rgba(45,212,191,.08);
--ink: #F1F5F9;
--ink-2: #CBD5E1;
--ink-3: #94A3B8;
--ink-4: #64748B;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.5);
--shadow-lift: 0 2px 4px rgba(0,0,0,.5), 0 16px 40px -16px rgba(0,0,0,.6);
}
* { box-sizing: border-box; margin: 0; }
html { -webkit-font-smoothing: antialiased; }
body {
background: var(--bg); color: var(--ink);
font: 15px/1.5 "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
}
svg.ic { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; display: block; }
/* ---------- app bar ---------- */
.appbar {
background: var(--surface); border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 20;
}
.appbar .in {
max-width: 1240px; margin: 0 auto; padding: 0 28px; height: 56px;
display: flex; align-items: center; gap: 14px;
}
.wordmark { display: flex; align-items: center; gap: 9px; font-weight: 650; font-size: 15.5px; letter-spacing: -0.01em; }
.wordmark .glyph {
width: 26px; height: 26px; border-radius: 8px;
background: linear-gradient(135deg, var(--edu), #0D9488);
display: grid; place-items: center;
}
.wordmark .glyph svg { width: 15px; height: 15px; stroke: #fff; stroke-width: 2.2; }
.badge {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12.5px; font-weight: 500; color: var(--ink-3);
background: var(--bg); border: 1px solid var(--border);
border-radius: 999px; padding: 4px 12px 4px 9px; cursor: pointer;
}
.badge svg.ic { width: 14px; height: 14px; }
.appbar .grow { flex: 1; }
.iconbtn {
width: 34px; height: 34px; border-radius: 10px; border: none; background: none;
color: var(--ink-3); display: grid; place-items: center; cursor: pointer;
}
.iconbtn:hover { background: var(--bg); color: var(--ink); }
.page { max-width: 1240px; margin: 0 auto; padding: 36px 28px 48px; }
/* ---------- hero ---------- */
.hero { max-width: 760px; }
.hero h1 {
font-size: 30px; line-height: 1.18; font-weight: 700; letter-spacing: -0.022em;
}
.hero p {
margin-top: 10px; font-size: 16.5px; color: var(--ink-3); line-height: 1.55;
}
.hero p b { font-weight: 650; font-variant-numeric: tabular-nums; }
.hero p b.bad { color: var(--spread); }
.hero p b.good { color: var(--edu); }
/* ---------- toolbar ---------- */
.toolbar { display: flex; align-items: center; margin: 30px 0 14px; gap: 10px; }
.toolbar h2 {
font-size: 13px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--ink-4);
}
.toolbar .grow { flex: 1; }
.btn {
display: inline-flex; align-items: center; gap: 7px;
font: 600 13.5px/1 inherit; font-family: inherit; color: var(--ink-2);
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 8px 14px; cursor: pointer; box-shadow: 0 1px 2px rgba(15,23,42,.04);
}
.btn:hover { border-color: var(--ink-4); }
.btn.primary { background: var(--ink); color: var(--surface); border-color: var(--ink); }
.btn svg.ic { width: 15px; height: 15px; }
/* ---------- panels ---------- */
.panels { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
.panel {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow);
padding: 18px 20px 10px; position: relative; transition: box-shadow .15s;
}
.panel:hover { box-shadow: var(--shadow-lift); }
.panel .head { display: flex; align-items: center; gap: 8px; }
.panel .swatch { width: 9px; height: 9px; border-radius: 3px; flex: none; }
.panel .label { font-size: 13.5px; font-weight: 600; color: var(--ink-2); letter-spacing: -0.005em; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;}
.panel .kebab { color: var(--ink-4); margin-right: -6px; }
.panel .pct {
margin-top: 10px; font-size: 40px; font-weight: 700; letter-spacing: -0.03em;
line-height: 1; font-variant-numeric: tabular-nums;
}
.panel .pct small { font-size: 22px; font-weight: 600; color: var(--ink-4); letter-spacing: 0; }
.panel .pct.bad { color: var(--spread); } .panel .pct.good { color: var(--edu); }
.panel .meta { margin-top: 7px; font-size: 12.5px; color: var(--ink-4); display: flex; gap: 5px; flex-wrap: wrap; align-items: center;}
.panel .meta span { white-space: nowrap; }
.panel .meta .sep { color: var(--border); }
.panel .chips { display: flex; gap: 6px; margin-top: 9px; min-height: 22px; flex-wrap: wrap; }
.chip {
font-size: 11.5px; font-weight: 550; color: var(--ink-3);
background: var(--bg); border: 1px solid var(--border-soft);
border-radius: 7px; padding: 2px 8px; font-variant-numeric: tabular-nums;
}
.panel svg.net { width: 100%; height: auto; display: block; margin-top: 4px; }
/* ---------- legend ---------- */
.legend { display: flex; gap: 22px; margin: 14px 2px 0; font-size: 13px; color: var(--ink-3); flex-wrap: wrap; }
.legend .k { display: inline-flex; align-items: center; gap: 7px; }
.lg { width: 11px; height: 11px; border-radius: 50%; flex: none; }
/* ---------- player ---------- */
.playerwrap { display: flex; justify-content: center; margin-top: 26px; }
.player {
display: flex; align-items: center; gap: 6px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 999px; box-shadow: var(--shadow);
padding: 8px 18px 8px 10px; width: min(680px, 100%);
}
.player .iconbtn { width: 36px; height: 36px; border-radius: 999px; flex: none; }
.player .iconbtn.play {
width: 42px; height: 42px; background: var(--ink); color: var(--surface);
}
.player .iconbtn.play:hover { background: var(--ink); opacity: .9; }
.scrub { flex: 1; position: relative; height: 28px; margin: 0 10px; }
.scrub .track { position: absolute; top: 13px; left: 0; right: 0; height: 3px; border-radius: 3px; background: var(--border); }
.scrub .fill { position: absolute; top: 13px; left: 0; width: 100%; height: 3px; border-radius: 3px; background: var(--ink); }
.scrub .dot { position: absolute; top: 12.5px; width: 4px; height: 4px; border-radius: 50%; background: var(--surface); border: 1px solid var(--ink-4); transform: translateX(-2px); }
.scrub .thumb {
position: absolute; top: 6px; right: -2px; width: 17px; height: 17px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--ink); box-shadow: 0 1px 3px rgba(15,23,42,.25);
}
.player .meta { font-size: 13px; font-weight: 550; color: var(--ink-3); font-variant-numeric: tabular-nums; white-space: nowrap; }
.player .pill {
font-size: 12.5px; font-weight: 600; color: var(--ink-2);
border: 1px solid var(--border); border-radius: 999px; padding: 4px 11px; margin-left: 10px; cursor: pointer;
}
/* ---------- lower grid ---------- */
.lower { display: grid; grid-template-columns: 7fr 5fr; gap: 18px; margin-top: 26px; align-items: stretch; }
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow); padding: 20px 22px;
}
.card h3 { font-size: 13px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-4); }
.chart svg { width: 100%; height: auto; margin-top: 10px; }
.ctl .row { display: grid; grid-template-columns: 150px 1fr 78px; align-items: center; gap: 14px; margin-top: 18px; }
.ctl .row label { font-size: 14px; font-weight: 550; color: var(--ink-2); }
.ctl .row .hint { display: block; font-size: 12px; font-weight: 400; color: var(--ink-4); margin-top: 1px; }
.ctl .val {
justify-self: end; font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums;
color: var(--ink-2); background: var(--bg); border: 1px solid var(--border-soft);
border-radius: 8px; padding: 3px 10px;
}
.slider { position: relative; height: 20px; }
.slider .track { position: absolute; top: 8.5px; left: 0; right: 0; height: 3px; border-radius: 3px; background: var(--border); }
.slider .fill { position: absolute; top: 8.5px; left: 0; height: 3px; border-radius: 3px; background: var(--edu); }
.slider .thumb {
position: absolute; top: 2.5px; width: 15px; height: 15px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--edu); box-shadow: 0 1px 3px rgba(15,23,42,.2);
}
.adv { margin-top: 20px; border-top: 1px solid var(--border-soft); padding-top: 14px; }
.adv .toggle { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: var(--ink-3); cursor: pointer; }
.adv .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; margin-top: 12px; }
.field {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
border: 1px solid var(--border-soft); background: var(--bg);
border-radius: 10px; padding: 7px 11px; font-size: 13px;
}
.field .fl { color: var(--ink-3); font-weight: 500; }
.field .fv { font-weight: 600; color: var(--ink-2); font-variant-numeric: tabular-nums; display: flex; align-items: center; gap: 7px; }
.field .fv svg.ic { width: 14px; height: 14px; color: var(--ink-4); cursor: pointer; }
footer { margin-top: 30px; display: flex; justify-content: space-between; font-size: 12.5px; color: var(--ink-4); }
footer em { font-style: italic; }
/* ---------- mobile ---------- */
.strip { display: none; }
.hero .mobile-note { display: none; }
@media (max-width: 760px) {
.page { padding: 22px 16px 110px; }
.appbar .badge { display: none; }
.hero h1 { font-size: 22px; }
.hero p { font-size: 15px; }
.hero .mobile-note { display: flex; align-items: center; gap: 6px; margin-top: 10px; font-size: 12.5px; color: var(--ink-4); }
.strip {
display: flex; gap: 8px; overflow-x: auto; margin-top: 18px;
position: sticky; top: 56px; z-index: 10; background: var(--bg);
padding: 10px 0; scrollbar-width: none;
}
.s-chip {
flex: none; display: flex; align-items: baseline; gap: 8px;
background: var(--surface); border: 1px solid var(--border); border-radius: 999px;
padding: 6px 14px; font-size: 12.5px; font-weight: 550; color: var(--ink-3);
box-shadow: var(--shadow);
}
.s-chip b { font-size: 15px; font-variant-numeric: tabular-nums; }
.toolbar { margin-top: 16px; }
.panels { grid-template-columns: 1fr; gap: 14px; }
.lower { grid-template-columns: 1fr; }
.playerwrap { position: fixed; left: 12px; right: 12px; bottom: 12px; z-index: 15; margin: 0; }
.player { box-shadow: var(--shadow-lift); }
.player .pill, .player .iconbtn.step { display: none; }
.legend { gap: 14px; }
footer { flex-direction: column; gap: 4px; }
}
</style>
</head>
<body>
<div class="appbar"><div class="in">
<span class="wordmark">
<span class="glyph"><svg viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.2"/><circle cx="18" cy="9" r="2.2"/><circle cx="10" cy="18" r="2.2"/><path d="M8 7l8 1.6M16.5 10.8l-5 5.4M7 8l2.4 8"/></svg></span>
spreadlab
</span>
<span class="badge"><svg class="ic" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8v.01"/></svg> Illustrative model, not validated</span>
<span class="grow"></span>
<button class="iconbtn" title="Toggle theme"><svg class="ic" viewBox="0 0 24 24"><path d="M20 14.5A8 8 0 1 1 9.5 4 6.5 6.5 0 0 0 20 14.5z"/></svg></button>
<button class="iconbtn" title="Source"><svg class="ic" viewBox="0 0 24 24"><path d="M9 19c-4.6 1.4-4.6-2.5-6.5-3m13 5v-3.5c0-1 .1-1.4-.5-2 2.8-.3 5.5-1.4 5.5-6a4.6 4.6 0 0 0-1.3-3.2 4.2 4.2 0 0 0-.1-3.2s-1.1-.3-3.5 1.3a12.3 12.3 0 0 0-6.2 0C7.1 2.8 6 3.1 6 3.1a4.2 4.2 0 0 0-.1 3.2A4.6 4.6 0 0 0 4.6 9.5c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21"/></svg></button>
</div></div>
<div class="page">
<div class="hero">
<h1>How a non&#8209;consensual deepfake spreads through a school</h1>
<p>One simulated year group, 120 students. Educate the same 30% of them, but change <i>who</i>:
the fake reaches <b class="bad" id="h0">82%</b> of the school with no program,
<b class="bad" id="h1">58%</b> educating at random, and
<b class="good" id="h2">6%</b> educating the best&#8209;connected students.</p>
<span class="mobile-note"><svg class="ic" style="width:14px;height:14px" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8v.01"/></svg> Illustrative model, not validated</span>
</div>
<div class="strip" id="strip"></div>
<div class="toolbar">
<h2>Scenarios</h2>
<span class="grow"></span>
<button class="btn"><svg class="ic" viewBox="0 0 24 24"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14"/></svg> Export</button>
<button class="btn primary"><svg class="ic" viewBox="0 0 24 24"><path d="M12 6v12M6 12h12"/></svg> Add scenario</button>
</div>
<div class="panels" id="panels"></div>
<div class="legend">
<span class="k"><span class="lg" style="background:var(--spread)"></span> Forwarded the fake</span>
<span class="k"><span class="lg" style="background:none;border:2.5px solid var(--edu);width:8px;height:8px"></span> Educated to refuse</span>
<span class="k"><span class="lg" style="background:var(--unreached)"></span> Not reached</span>
<span class="k"><span class="lg" style="background:none;border:2px solid var(--spread);width:9px;height:9px"></span> Origin</span>
</div>
<div class="playerwrap"><div class="player">
<button class="iconbtn" title="Replay"><svg class="ic" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 2.6-6.3M5 3v4h4"/></svg></button>
<button class="iconbtn step" title="Back one round"><svg class="ic" viewBox="0 0 24 24"><path d="M17 6l-7 6 7 6M7 6v12"/></svg></button>
<button class="iconbtn play" title="Play"><svg class="ic" viewBox="0 0 24 24" style="fill:currentColor;stroke:none"><path d="M8.5 6.2v11.6c0 .8.9 1.3 1.6.9l9-5.8a1 1 0 0 0 0-1.8l-9-5.8a1.05 1.05 0 0 0-1.6.9z"/></svg></button>
<button class="iconbtn step" title="Forward one round"><svg class="ic" viewBox="0 0 24 24"><path d="M7 6l7 6-7 6M17 6v12"/></svg></button>
<div class="scrub" id="scrub"><div class="track"></div><div class="fill"></div><div class="thumb"></div></div>
<span class="meta">Round 9 of 9</span>
<span class="pill">1&#215;</span>
</div></div>
<div class="lower">
<div class="card chart">
<h3>Reach over time</h3>
<svg id="chart" viewBox="0 0 640 230"></svg>
</div>
<div class="card ctl">
<h3>World</h3>
<div class="row">
<label>Chance to forward<span class="hint">per friendship, per round</span></label>
<div class="slider"><div class="track"></div><div class="fill" style="width:38%"></div><div class="thumb" style="left:calc(38% - 8px)"></div></div>
<span class="val">38%</span>
</div>
<div class="row">
<label>Education budget<span class="hint">students the program reaches</span></label>
<div class="slider"><div class="track"></div><div class="fill" style="width:30%"></div><div class="thumb" style="left:calc(30% - 8px)"></div></div>
<span class="val">36 &middot; 30%</span>
</div>
<div class="adv">
<span class="toggle"><svg class="ic" style="width:14px;height:14px" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg> Advanced</span>
<div class="grid">
<span class="field"><span class="fl">Students</span><span class="fv">120</span></span>
<span class="field"><span class="fl">Friends per student</span><span class="fv">3</span></span>
<span class="field"><span class="fl">Clique tendency</span><span class="fv">0.45</span></span>
<span class="field"><span class="fl">First poster</span><span class="fv">#0</span></span>
<span class="field"><span class="fl">Friendship network</span><span class="fv">17 <svg class="ic" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M9 9h.01M15 15h.01M15 9h.01M9 15h.01"/></svg></span></span>
<span class="field"><span class="fl">Who resists</span><span class="fv">2 <svg class="ic" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3"/><path d="M9 9h.01M15 15h.01M15 9h.01M9 15h.01"/></svg></span></span>
</div>
</div>
</div>
</div>
<footer>
<span><em>Illustrative (not validated output)</em> &middot; an agent&#8209;based toy model, parameters chosen for clarity</span>
<span>spreadlab &middot; AGPL</span>
</footer>
</div>
<script>
if (location.search.includes('dark')) document.body.classList.add('dark');
const DATA = {"config":{"numStudents":120,"edgesPerNode":3,"triangleProb":0.45,"forwardProb":0.38,"numEducated":36,"origin":0,"graphSeed":17,"thresholdSeed":2,"educationSeed":1},"results":[{"strategy":"none","educated":[],"reachedAtRound":[0,4,4,2,3,3,5,2,3,4,4,4,1,7,5,1,5,6,5,5,5,2,3,7,3,4,2,4,4,3,4,4,5,6,2,5,1,5,-1,2,6,5,3,4,-1,5,4,5,4,4,3,5,3,-1,6,5,3,-1,4,5,-1,5,-1,5,2,-1,1,4,3,4,3,5,2,4,4,3,3,-1,5,3,-1,5,-1,-1,5,3,3,-1,6,3,5,5,-1,-1,6,2,5,5,8,5,-1,5,6,6,4,7,-1,4,4,-1,-1,5,3,-1,1,-1,3,3,-1,5],"numReached":99,"numRounds":9,"reachedPct":82.5},{"strategy":"random","educated":[2,4,6,7,8,10,21,23,24,27,28,33,37,39,40,43,48,50,53,56,61,62,63,68,74,79,80,85,87,89,90,97,101,102,111,117],"reachedAtRound":[0,4,4,2,3,3,5,2,3,-1,7,4,1,7,5,1,5,6,-1,6,5,2,-1,7,3,7,2,5,5,-1,-1,6,6,6,2,5,1,5,-1,2,6,-1,3,4,-1,-1,4,-1,4,-1,-1,5,-1,-1,6,5,3,-1,-1,-1,-1,-1,-1,-1,2,-1,1,-1,5,4,-1,5,2,-1,-1,-1,3,-1,-1,3,-1,5,-1,-1,6,3,-1,-1,6,3,7,-1,-1,-1,7,2,-1,7,8,5,-1,5,6,-1,-1,7,-1,4,4,-1,-1,5,-1,-1,1,-1,3,-1,-1,-1],"numReached":70,"numRounds":9,"reachedPct":58.333333333333336},{"strategy":"most-connected","educated":[1,2,3,4,5,6,7,8,9,10,11,12,13,15,16,19,20,21,22,23,24,25,26,27,28,30,31,32,33,35,37,38,39,45,47,77],"reachedAtRound":[0,-1,-1,-1,-1,-1,-1,2,-1,-1,-1,-1,1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,1,-1,-1,-1,-1,-1],"numReached":7,"numRounds":3,"reachedPct":5.833333333333333}]};
const ACCENTS = ['#6366F1', '#F59E0B', '#0EA5E9', '#8B5CF6', '#EC4899', '#84CC16'];
const LABELS = ['No program', 'Educate 30% at random', 'Educate best-connected 30%'];
// seeded stand-in layout (real app: d3-force over real topology)
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}
const rnd = mulberry32(DATA.config.graphSeed);
const N = DATA.config.numStudents;
const pos = [];
while (pos.length < N) {
const a = rnd()*Math.PI*2, r = Math.sqrt(rnd());
const x = 190 + Math.cos(a)*r*172, y = 95 + Math.sin(a)*r*82;
if (pos.every(p => (p[0]-x)**2 + (p[1]-y)**2 > 14.5**2)) pos.push([x,y]);
}
const edges = [];
for (let i=0;i<N;i++){
const d = pos.map((p,j)=>[(p[0]-pos[i][0])**2 + (p[1]-pos[i][1])**2, j]).sort((a,b)=>a[0]-b[0]);
for (let k=1;k<=2;k++) if (d[k]) edges.push([i, d[k][1]]);
}
function netSVG(result){
const edu = new Set(result.educated);
let s = `<svg class="net" viewBox="0 0 380 190">`;
for (const [a,b] of edges) s += `<line x1="${pos[a][0].toFixed(1)}" y1="${pos[a][1].toFixed(1)}" x2="${pos[b][0].toFixed(1)}" y2="${pos[b][1].toFixed(1)}" stroke="var(--edge)" stroke-width="1"/>`;
for (let i=0;i<N;i++){
const [x,y] = pos[i];
const reached = result.reachedAtRound[i] >= 0;
if (i === DATA.config.origin) {
s += `<circle cx="${x}" cy="${y}" r="7.5" fill="none" stroke="var(--spread)" stroke-width="1.4" opacity=".75"/><circle cx="${x}" cy="${y}" r="4" fill="var(--spread)"/>`;
} else if (edu.has(i)) {
s += `<circle cx="${x}" cy="${y}" r="3.8" fill="${reached ? 'var(--spread)' : 'var(--surface)'}" stroke="var(--edu)" stroke-width="2.2"/>`;
} else if (reached) {
s += `<circle cx="${x}" cy="${y}" r="4" fill="var(--spread)"/>`;
} else {
s += `<circle cx="${x}" cy="${y}" r="3" fill="var(--unreached)"/>`;
}
}
return s + `</svg>`;
}
const fmt = p => (Math.round(p*10)/10).toFixed(0);
const panelsEl = document.getElementById('panels');
const stripEl = document.getElementById('strip');
DATA.results.forEach((res, i) => {
const pct = fmt(res.reachedPct);
const tone = res.reachedPct > 30 ? 'bad' : 'good';
const el = document.createElement('div');
el.className = 'panel';
el.innerHTML = `
<div class="head">
<span class="swatch" style="background:${ACCENTS[i]}"></span>
<span class="label">${LABELS[i]}</span>
<span class="kebab"><svg class="ic" viewBox="0 0 24 24"><path d="M12 6h.01M12 12h.01M12 18h.01"/></svg></span>
</div>
<div class="pct ${tone}">${pct}<small>%</small></div>
<div class="meta">
<span>${res.numReached} of ${N} reached</span><span class="sep">&bull;</span>
<span>${res.numRounds} rounds</span><span class="sep">&bull;</span>
<span>${res.educated.length} educated</span>
</div>
<div class="chips">${i===2 ? '<span class="chip">strategy &middot; most-connected</span>' : i===1 ? '<span class="chip">strategy &middot; random</span>' : ''}</div>
${netSVG(res)}`;
panelsEl.appendChild(el);
document.getElementById('h'+i).textContent = pct + '%';
const chip = document.createElement('span');
chip.className = 's-chip';
chip.innerHTML = `${LABELS[i].replace('Educate 30% at random','Random 30%').replace('Educate best-connected 30%','Connected 30%')} <b style="color:${tone==='bad'?'var(--spread)':'var(--edu)'}">${pct}%</b>`;
stripEl.appendChild(chip);
});
// scrub ticks
const maxR = Math.max(...DATA.results.map(r=>r.numRounds));
const scrub = document.getElementById('scrub');
for (let r=1; r<maxR; r++){
const d = document.createElement('div'); d.className = 'dot'; d.style.left = (r/maxR*100)+'%';
scrub.appendChild(d);
}
// chart
const chart = document.getElementById('chart');
const CW = 640, CH = 230, L = 40, R = 56, T = 14, B = 30;
let svg = '';
for (let g=0; g<=4; g++){
const y = T + g*(CH-T-B)/4;
svg += `<line x1="${L}" y1="${y}" x2="${CW-R}" y2="${y}" stroke="var(--border-soft)" stroke-width="1" ${g<4?'stroke-dasharray="2 4"':''}/>`;
svg += `<text x="${L-9}" y="${y+3.5}" text-anchor="end" font-size="11" fill="var(--ink-4)">${100-g*25}</text>`;
}
DATA.results.forEach((res, i) => {
const pts = [];
for (let r=0; r<=maxR; r++){
const c = res.reachedAtRound.filter(v => v>=0 && v<=r).length;
pts.push([L + r*(CW-L-R)/maxR, T + (1-c/N)*(CH-T-B)]);
}
svg += `<polyline points="${pts.map(p=>p.map(v=>v.toFixed(1)).join(',')).join(' ')}" fill="none" stroke="${ACCENTS[i]}" stroke-width="2.4" stroke-linejoin="round" stroke-linecap="round"/>`;
const last = pts[pts.length-1];
svg += `<circle cx="${last[0]}" cy="${last[1]}" r="3.5" fill="${ACCENTS[i]}"/>`;
svg += `<text x="${last[0]+9}" y="${last[1]+4}" font-size="12" font-weight="650" fill="${ACCENTS[i]}">${fmt(res.reachedPct)}%</text>`;
});
for (let r=0; r<=maxR; r++){
svg += `<text x="${L + r*(CW-L-R)/maxR}" y="${CH-8}" text-anchor="middle" font-size="11" fill="var(--ink-4)">${r}</text>`;
}
svg += `<text x="${L}" y="${CH-8}" text-anchor="start" font-size="0"> </text>`;
chart.innerHTML = svg;
</script>
</body>
</html>