| 1 | <!-- π€ Public Investing page: the funding model (docs/funding.md β the repo is |
| 2 | private; this page is what makes the model public) and its open ledger. |
| 3 | The JSON is COMMITTED data (web/status/funding.json β empty until the |
| 4 | model is adopted and a series opens), republished as-is by |
| 5 | deploy/status-publish.sh. The simulator imports ./waterfall.js β the |
| 6 | normative reference implementation, served from this same directory β so |
| 7 | the page RUNS the published algorithm rather than describing a copy of |
| 8 | it. Self-contained otherwise: inline CSS/JS; the only requests are |
| 9 | ./funding.json and ./waterfall.js (relative, same origin). |
| 10 | Chart palette (violet/yellow/aqua + a dashed neutral reference line) is |
| 11 | dataviz-validated for both modes; the light-mode contrast WARN on |
| 12 | yellow/aqua is covered by the relief rule: legend, right-edge direct |
| 13 | labels, and the table view below the chart. --> |
| 14 | <!doctype html> |
| 15 | <html lang="en"> |
| 16 | <head> |
| 17 | <meta charset="utf-8" /> |
| 18 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 19 | <meta name="color-scheme" content="light dark" /> |
| 20 | <title>Eleven β investing</title> |
| 21 | <link rel="icon" type="image/png" href="./icon-192.png" /> |
| 22 | <link rel="stylesheet" href="./status.css" /> |
| 23 | <style> |
| 24 | /* Investing-only: the status callout, simulator controls, chart figure, and |
| 25 | ledger tables. Everything shared lives in status.css. */ |
| 26 | .callout { background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--warn); border-radius: 12px; padding: 14px 16px; margin-bottom: 28px; } |
| 27 | .callout .head { font-weight: 650; } |
| 28 | .callout p { margin-top: 6px; font-size: 15px; } |
| 29 | .callout.adopted { border-left-color: var(--good-mark); } |
| 30 | |
| 31 | ul.prose { padding-left: 20px; display: grid; gap: 8px; font-size: 15px; } |
| 32 | ul.prose .lead-in { font-weight: 650; } |
| 33 | |
| 34 | /* The three pool hues β validated for light and dark against the status |
| 35 | surfaces. The remaining-cap reference line is neutral ink, dashed: it is |
| 36 | a bound, not a stream, so it never takes a categorical slot. */ |
| 37 | :root { --pool-investors: #4a3aa7; --pool-team: #eda100; --pool-commons: #1baf7a; } |
| 38 | @media (prefers-color-scheme: dark) { |
| 39 | :root { --pool-investors: #9085e9; --pool-team: #c98500; --pool-commons: #199e70; } |
| 40 | } |
| 41 | |
| 42 | .controls { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px 18px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; margin-bottom: 14px; } |
| 43 | .ctl { display: grid; gap: 2px; } |
| 44 | .ctl label { font-size: 13px; color: var(--ink-2); } |
| 45 | .ctl output { font-weight: 600; font-variant-numeric: tabular-nums; } |
| 46 | .ctl input[type="range"] { width: 100%; margin: 6px 0 0; accent-color: var(--series); } |
| 47 | .ctl input[type="range"]:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 4px; } |
| 48 | |
| 49 | .legend { display: flex; flex-wrap: wrap; gap: 6px 18px; font-size: 14px; margin: 0 0 8px; list-style: none; padding: 0; } |
| 50 | .legend .chip { display: inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; } |
| 51 | .legend .chip.dash { height: 0; width: 16px; border-top: 3px dashed var(--ink-2); border-radius: 0; vertical-align: 3px; background: none; } |
| 52 | |
| 53 | figure { position: relative; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px; margin: 0 0 8px; } |
| 54 | figure svg { display: block; width: 100%; height: auto; } |
| 55 | figure svg:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 8px; } |
| 56 | figcaption { color: var(--ink-2); font-size: 13px; padding: 4px 8px 8px; } |
| 57 | #tip { |
| 58 | display: none; position: absolute; z-index: 1; pointer-events: none; |
| 59 | background: var(--surface); border: 1px solid var(--border); border-radius: 8px; |
| 60 | padding: 8px 10px; font-size: 13px; max-width: 260px; box-shadow: 0 2px 8px rgba(0,0,0,0.12); |
| 61 | } |
| 62 | #tip .big { font-size: 15px; font-weight: 650; } |
| 63 | #tip .sub { color: var(--ink-2); font-variant-numeric: tabular-nums; } |
| 64 | |
| 65 | .tablewrap { overflow-x: auto; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; } |
| 66 | table { border-collapse: collapse; width: 100%; font-size: 14px; } |
| 67 | caption { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } |
| 68 | th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--grid); } |
| 69 | thead th { font-size: 13px; color: var(--ink-2); font-weight: 650; white-space: nowrap; } |
| 70 | tbody tr:last-child th, tbody tr:last-child td { border-bottom: none; } |
| 71 | tbody th { font-weight: 600; white-space: nowrap; } |
| 72 | .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } |
| 73 | |
| 74 | details.tableview { margin-top: 8px; } |
| 75 | details.tableview summary { cursor: pointer; font-size: 14px; font-weight: 600; padding: 4px 0; } |
| 76 | details.tableview summary:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 4px; } |
| 77 | details.tableview .tablewrap { margin-top: 8px; } |
| 78 | |
| 79 | .empty { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; color: var(--ink-2); font-size: 14px; } |
| 80 | </style> |
| 81 | </head> |
| 82 | <body> |
| 83 | <div class="wrap"> |
| 84 | <header data-title="Investing"></header> |
| 85 | <nav class="status-nav" aria-label="Status"></nav> |
| 86 | <script src="./status-nav.js"></script> |
| 87 | <p class="lede">How money can fund Eleven without owning it. Eleven takes no |
| 88 | venture capital: investment buys a position in a published algorithm β capped |
| 89 | returns, no shares, no votes, no board seats β and every parameter, input, |
| 90 | and payment lands on this page. The simulator below runs the actual published |
| 91 | algorithm in your browser.</p> |
| 92 | <p class="asof">Ledger dated <time id="asof" datetime="">β</time></p> |
| 93 | |
| 94 | <p id="error" role="alert">Couldnβt load the funding ledger. It may be |
| 95 | mid-publish β this page retries every minute.</p> |
| 96 | |
| 97 | <div class="callout" id="status-callout"> |
| 98 | <p class="head" id="status-head">Status: designed, not adopted</p> |
| 99 | <p id="status-body">No notes exist and no money has been taken. The model |
| 100 | below is a published design β open so it can be argued with β and becomes |
| 101 | live only after independent legal and tax review, and only then by a |
| 102 | deliberately opened series. Nothing on this page is an offer.</p> |
| 103 | </div> |
| 104 | |
| 105 | <section aria-labelledby="h-model"> |
| 106 | <h2 id="h-model">The model in one minute</h2> |
| 107 | <ul class="prose"> |
| 108 | <li><span class="lead-in">Notes, not shares.</span> Investors buy |
| 109 | profit-participating notes in named series β principal plus a fixed cap |
| 110 | multiple, and the cap is the whole return. No interest, no maturity date, |
| 111 | no equity, no exit.</li> |
| 112 | <li><span class="lead-in">Money has no control.</span> Notes carry no |
| 113 | votes, no board seats, no vetoes. The only enforceable investor rights |
| 114 | are transparency promises: this ledger publishes, the independent |
| 115 | verification panel stays funded, and distributions follow the published |
| 116 | algorithm. Breach makes unreturned principal repayable β investors can |
| 117 | force the money back out; they can never take the wheel.</li> |
| 118 | <li><span class="lead-in">Money sits before it streams.</span> No |
| 119 | distributions until a series has seasoned and the treasury holds a |
| 120 | reserve floor of forward operating costs β new money builds first.</li> |
| 121 | <li><span class="lead-in">An open algorithm decides the rest.</span> |
| 122 | Each quarter, distributable surplus streams out by a fixed split β |
| 123 | noteholders pro-rata until each cap fills and the note extinguishes, |
| 124 | alongside the team pool and the commons (price cuts, free-tier subsidy, |
| 125 | aligned grants).</li> |
| 126 | <li><span class="lead-in">Verification is funded first.</span> An |
| 127 | independent panel β funded as an operating cost, senior to every |
| 128 | distribution β audits the claims: the encryption architecture, the |
| 129 | published economics, this ledger against bank records, and whether each |
| 130 | quarterβs payments were the algorithmβs outputs.</li> |
| 131 | <li><span class="lead-in">The algorithm is versioned.</span> A note pins |
| 132 | the version it was sold under, forever. Changes apply to future series |
| 133 | only.</li> |
| 134 | </ul> |
| 135 | </section> |
| 136 | |
| 137 | <section aria-labelledby="h-params"> |
| 138 | <h2 id="h-params">The algorithmβs parameters</h2> |
| 139 | <p class="note explain">Read live from <a href="./waterfall.js">waterfall.js</a> β |
| 140 | the reference implementation this page serves and runs. Changing any of |
| 141 | these is a new algorithm version, applied to future series only.</p> |
| 142 | <div class="tiles" id="param-tiles"></div> |
| 143 | </section> |
| 144 | |
| 145 | <section aria-labelledby="h-sim"> |
| 146 | <h2 id="h-sim">Run the waterfall</h2> |
| 147 | <p class="note explain">Pick a scenario; the page runs the published |
| 148 | algorithm over it, quarter by quarter. An illustration, not a forecast β |
| 149 | and not an offer.</p> |
| 150 | <div class="controls"> |
| 151 | <div class="ctl"> |
| 152 | <label for="c-raise">Series raise</label> |
| 153 | <output id="o-raise" for="c-raise">β</output> |
| 154 | <input type="range" id="c-raise" min="50000" max="1000000" step="25000" value="300000" /> |
| 155 | </div> |
| 156 | <div class="ctl"> |
| 157 | <label for="c-cap">Cap multiple</label> |
| 158 | <output id="o-cap" for="c-cap">β</output> |
| 159 | <input type="range" id="c-cap" min="1.5" max="3" step="0.1" value="2" /> |
| 160 | </div> |
| 161 | <div class="ctl"> |
| 162 | <label for="c-rev">Quarterly revenue (starting)</label> |
| 163 | <output id="o-rev" for="c-rev">β</output> |
| 164 | <input type="range" id="c-rev" min="0" max="150000" step="2500" value="40000" /> |
| 165 | </div> |
| 166 | <div class="ctl"> |
| 167 | <label for="c-growth">Revenue growth per quarter</label> |
| 168 | <output id="o-growth" for="c-growth">β</output> |
| 169 | <input type="range" id="c-growth" min="0" max="15" step="0.5" value="4" /> |
| 170 | </div> |
| 171 | <div class="ctl"> |
| 172 | <label for="c-costs">Quarterly operating costs</label> |
| 173 | <output id="o-costs" for="c-costs">β</output> |
| 174 | <input type="range" id="c-costs" min="5000" max="150000" step="1000" value="28000" /> |
| 175 | </div> |
| 176 | </div> |
| 177 | |
| 178 | <div class="tiles"> |
| 179 | <div class="tile"><div class="label">First distribution</div><div class="value" id="t-first" style="font-size:22px">β</div><div class="sub" id="t-first-sub"></div></div> |
| 180 | <div class="tile"><div class="label">Noteholders fully repaid</div><div class="value" id="t-repaid" style="font-size:22px">β</div><div class="sub" id="t-repaid-sub"></div></div> |
| 181 | <div class="tile"><div class="label">To the commons by then</div><div class="value" id="t-commons" style="font-size:22px">β</div><div class="sub" id="t-commons-sub"></div></div> |
| 182 | </div> |
| 183 | |
| 184 | <ul class="legend" id="legend"> |
| 185 | <li><span class="chip" style="background:var(--pool-investors)"></span>Noteholders (cumulative)</li> |
| 186 | <li><span class="chip" style="background:var(--pool-team)"></span>Team (cumulative)</li> |
| 187 | <li><span class="chip" style="background:var(--pool-commons)"></span>Commons (cumulative)</li> |
| 188 | <li><span class="chip dash"></span>Remaining cap</li> |
| 189 | </ul> |
| 190 | <figure aria-labelledby="h-sim"> |
| 191 | <svg id="chart" viewBox="0 0 720 260" role="img" aria-label="" tabindex="0"></svg> |
| 192 | <div id="tip"></div> |
| 193 | <figcaption id="chart-cap">Cumulative euros streamed to each pool over |
| 194 | the scenario, against the falling remaining cap. Hover, or focus the |
| 195 | chart and use the arrow keys, to read any quarter; the table view below |
| 196 | carries the same numbers.<span id="chart-window-note"></span></figcaption> |
| 197 | </figure> |
| 198 | <details class="tableview"> |
| 199 | <summary>Table view (yearly)</summary> |
| 200 | <div class="tablewrap"> |
| 201 | <table id="sim-table"> |
| 202 | <caption>Simulated yearly totals for the chosen scenario</caption> |
| 203 | <thead><tr> |
| 204 | <th scope="col">Year end</th> |
| 205 | <th scope="col" class="num">Revenue / quarter</th> |
| 206 | <th scope="col" class="num">Noteholders (cum.)</th> |
| 207 | <th scope="col" class="num">Team (cum.)</th> |
| 208 | <th scope="col" class="num">Commons (cum.)</th> |
| 209 | <th scope="col" class="num">Remaining cap</th> |
| 210 | </tr></thead> |
| 211 | <tbody></tbody> |
| 212 | </table> |
| 213 | </div> |
| 214 | </details> |
| 215 | <p class="note" id="sim-note"></p> |
| 216 | </section> |
| 217 | |
| 218 | <section aria-labelledby="h-series"> |
| 219 | <h2 id="h-series">Series</h2> |
| 220 | <div id="series-out"></div> |
| 221 | </section> |
| 222 | |
| 223 | <section aria-labelledby="h-notes"> |
| 224 | <h2 id="h-notes">Notes</h2> |
| 225 | <p class="note explain">Pseudonymous by design: amounts and movements are |
| 226 | public; identities never are. A note id buys no influence β there is |
| 227 | nothing to buy.</p> |
| 228 | <div id="notes-out"></div> |
| 229 | </section> |
| 230 | |
| 231 | <section aria-labelledby="h-quarters"> |
| 232 | <h2 id="h-quarters">Quarterly distributions</h2> |
| 233 | <div id="quarters-out"></div> |
| 234 | </section> |
| 235 | |
| 236 | <footer> |
| 237 | <p><strong>Check this page yourself.</strong> The algorithm is |
| 238 | <a href="./waterfall.js">waterfall.js</a> (served here, imported by this |
| 239 | page β the simulator runs it, not a copy); the ledger is |
| 240 | <a href="./funding.json">funding.json</a>. All ledger amounts are integer |
| 241 | cents, and every computation is deterministic β re-running the algorithm |
| 242 | over the ledger must reproduce every published payment. An audit is a |
| 243 | re-execution.</p> |
| 244 | </footer> |
| 245 | </div> |
| 246 | |
| 247 | <script type="module"> |
| 248 | import { applyQuarter, SPLIT, DEFAULT_SEASONING_MONTHS, RESERVE_MONTHS, |
| 249 | CAP_RANGE, DEFAULT_CAP_MULTIPLE } from "./waterfall.js"; |
| 250 | |
| 251 | const $ = (id) => document.getElementById(id); |
| 252 | const NS = "http://www.w3.org/2000/svg"; |
| 253 | const eur = new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }); |
| 254 | const eurCents = (c) => eur.format(Math.round(c / 100)); |
| 255 | const plur = (n, w) => n.toLocaleString() + " " + w + (n === 1 ? "" : "s"); |
| 256 | |
| 257 | // ---- Parameters (from the algorithm module β one source, no copy) ---------- |
| 258 | { |
| 259 | const tiles = $("param-tiles"); |
| 260 | const tile = (label, value, sub) => { |
| 261 | const d = document.createElement("div"); |
| 262 | d.className = "tile"; |
| 263 | const l = document.createElement("div"); l.className = "label"; l.textContent = label; |
| 264 | const v = document.createElement("div"); v.className = "value"; v.style.fontSize = "22px"; v.textContent = value; |
| 265 | d.append(l, v); |
| 266 | if (sub) { const s = document.createElement("div"); s.className = "sub"; s.textContent = sub; d.append(s); } |
| 267 | tiles.append(d); |
| 268 | }; |
| 269 | tile("Noteholders", SPLIT.investors + "%", "of distributable surplus, pro-rata by remaining cap"); |
| 270 | tile("Team", SPLIT.team + "%", "the profit-share pool"); |
| 271 | tile("Commons", SPLIT.commons + "%", "price cuts, free-tier subsidy, grants β plus every rounding cent"); |
| 272 | tile("Seasoning", plur(DEFAULT_SEASONING_MONTHS, "month"), "before a series can receive anything"); |
| 273 | tile("Reserve floor", plur(RESERVE_MONTHS, "month"), "of forward costs held before anything is distributable"); |
| 274 | tile("Cap multiple", CAP_RANGE[0].toLocaleString() + "β" + CAP_RANGE[1].toLocaleString() + "Γ", "fixed per series; the whole return, then the note ends"); |
| 275 | } |
| 276 | |
| 277 | // ---- Scenario simulator ----------------------------------------------------- |
| 278 | // The waterfall is the imported published algorithm; only the scenario AROUND |
| 279 | // it is modelled here (one series, flat costs, compounding revenue). Kept |
| 280 | // deliberately simple: the point is to show how the algorithm behaves, not to |
| 281 | // predict anything. |
| 282 | const HORIZON_QUARTERS = 60; // 15 years |
| 283 | const QENDS = ["03-31", "06-30", "09-30", "12-31"]; |
| 284 | |
| 285 | function simulate(p) { |
| 286 | const cents = (e) => Math.round(e * 100); |
| 287 | let ledger = { |
| 288 | series: [{ id: "S1", closedAt: "2027-01-01", capMultiple: p.capMultiple, seasoningMonths: DEFAULT_SEASONING_MONTHS }], |
| 289 | notes: [{ id: "S1-001", series: "S1", principal: cents(p.raise), remainingCap: Math.round(cents(p.raise) * p.capMultiple) }], |
| 290 | quarters: [], |
| 291 | }; |
| 292 | // The raise sits in the treasury; the floor is RESERVE_MONTHS of the cost |
| 293 | // base. Retained pools and residue stay in the treasury (their destination |
| 294 | // is a published decision, so the scenario just holds them). |
| 295 | let treasury = cents(p.raise); |
| 296 | const floor = Math.round(cents(p.costs) * (RESERVE_MONTHS / 3)); |
| 297 | const rows = []; |
| 298 | let cumI = 0, cumT = 0, cumC = 0, brokeAt = -1; |
| 299 | for (let q = 0; q < HORIZON_QUARTERS; q++) { |
| 300 | const year = 2027 + Math.floor(q / 4), qi = q % 4; |
| 301 | const revenue = Math.round(cents(p.revenue0) * Math.pow(1 + p.growthPct / 100, q)); |
| 302 | const costs = cents(p.costs); |
| 303 | const reserveTopUp = Math.max(0, Math.min(Math.max(0, revenue - costs), floor - treasury)); |
| 304 | const step = applyQuarter(ledger, { |
| 305 | id: year + "-Q" + (qi + 1), asOf: year + "-" + QENDS[qi], |
| 306 | revenue, costs, reserveTopUp, floorMet: false, // patched below once cash is known |
| 307 | }); |
| 308 | const r = step.result; |
| 309 | const paid = r.payments.reduce((s, x) => s + x.amount, 0); |
| 310 | const out = r.distributed ? paid + r.teamPool + r.commonsPool - r.retained : 0; |
| 311 | treasury += revenue - costs - out; |
| 312 | // floorMet is a fact about the treasury at quarter end; the algorithm only |
| 313 | // ever reads it from PRIOR quarters, so patching the fresh record is sound. |
| 314 | step.ledger.quarters[step.ledger.quarters.length - 1].floorMet = treasury >= floor; |
| 315 | ledger = step.ledger; |
| 316 | if (treasury < 0 && brokeAt < 0) brokeAt = q; |
| 317 | cumI += paid; cumT += r.distributed ? r.teamPool : 0; cumC += r.distributed ? r.commonsPool : 0; |
| 318 | rows.push({ |
| 319 | q, label: year + " Q" + (qi + 1), yearEnd: qi === 3 ? year : 0, |
| 320 | revenue, paid, cumI, cumT, cumC, |
| 321 | remainingCap: ledger.notes[0].remainingCap, treasury, |
| 322 | }); |
| 323 | } |
| 324 | const capTotal = Math.round(cents(p.raise) * p.capMultiple); |
| 325 | return { |
| 326 | rows, capTotal, brokeAt, |
| 327 | firstAt: rows.findIndex((r) => r.paid > 0), |
| 328 | repaidAt: rows.findIndex((r) => r.remainingCap === 0), |
| 329 | }; |
| 330 | } |
| 331 | |
| 332 | // ---- Controls --------------------------------------------------------------- |
| 333 | const CTLS = [ |
| 334 | { id: "c-raise", out: "o-raise", key: "raise", fmt: (v) => eur.format(v) }, |
| 335 | { id: "c-cap", out: "o-cap", key: "capMultiple", fmt: (v) => v.toLocaleString(undefined, { minimumFractionDigits: 1 }) + "Γ" }, |
| 336 | { id: "c-rev", out: "o-rev", key: "revenue0", fmt: (v) => eur.format(v) }, |
| 337 | { id: "c-growth", out: "o-growth", key: "growthPct", fmt: (v) => v.toLocaleString() + "% / quarter" }, |
| 338 | { id: "c-costs", out: "o-costs", key: "costs", fmt: (v) => eur.format(v) }, |
| 339 | ]; |
| 340 | $("c-cap").min = CAP_RANGE[0]; $("c-cap").max = CAP_RANGE[1]; $("c-cap").value = DEFAULT_CAP_MULTIPLE; |
| 341 | |
| 342 | function params() { |
| 343 | const p = {}; |
| 344 | for (const c of CTLS) { |
| 345 | const v = Number($(c.id).value); |
| 346 | p[c.key] = v; |
| 347 | $(c.out).textContent = c.fmt(v); |
| 348 | } |
| 349 | return p; |
| 350 | } |
| 351 | |
| 352 | // ---- Chart ------------------------------------------------------------------ |
| 353 | // Four marks, one euro axis: three cumulative pool lines (categorical hues) |
| 354 | // and the falling remaining-cap bound as a dashed neutral line. Crosshair |
| 355 | // snaps to the nearest quarter; β/β walk quarters when the chart is focused. |
| 356 | // No animation, so there is nothing to reduce for prefers-reduced-motion. |
| 357 | let sim = null, tipIdx = -1; |
| 358 | |
| 359 | function yearsWord(q) { |
| 360 | const yrs = (q + 1) / 4; |
| 361 | const r = Math.round(yrs * 2) / 2; |
| 362 | return r === 1 ? "1 year" : r.toLocaleString() + " years"; |
| 363 | } |
| 364 | |
| 365 | function drawChart() { |
| 366 | const svg = $("chart"); |
| 367 | svg.replaceChildren(); |
| 368 | const { capTotal } = sim; |
| 369 | // Window to the payback story: through repayment plus two years when the |
| 370 | // cap fills, the whole horizon otherwise. The table view always carries the |
| 371 | // full horizon, so nothing is hidden β just focused. |
| 372 | const lastQ = sim.repaidAt >= 0 ? Math.min(sim.rows.length - 1, sim.repaidAt + 8) : sim.rows.length - 1; |
| 373 | const rows = sim.rows.slice(0, lastQ + 1); |
| 374 | $("chart-window-note").textContent = lastQ < sim.rows.length - 1 |
| 375 | ? " Chart shown through repayment plus two years; the table runs the full horizon." |
| 376 | : ""; |
| 377 | const W = 720, H = 260, m = { l: 56, r: 118, t: 14, b: 26 }; |
| 378 | const x = (q) => m.l + (W - m.l - m.r) * (q / (rows.length - 1)); |
| 379 | // Every plotted value must fit: the cap bound AND all three streams β team |
| 380 | // and commons keep accruing after the cap fills, so they can top everything. |
| 381 | const top = Math.max(1, ...rows.map((r) => Math.max(r.remainingCap, r.cumI, r.cumT, r.cumC))); |
| 382 | let step = 1; |
| 383 | for (const s of [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000]) { |
| 384 | if (top / 100000 / s <= 4) { step = s; break; } // ticks in β¬k, β€5 lines |
| 385 | } |
| 386 | const yMax = Math.max(step, Math.ceil(top / 100000 / step) * step) * 100000; |
| 387 | const y = (c) => H - m.b - (H - m.b - m.t) * (c / yMax); |
| 388 | const el = (name, attrs, text) => { |
| 389 | const n = document.createElementNS(NS, name); |
| 390 | for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v); |
| 391 | if (text !== undefined) n.textContent = text; |
| 392 | svg.append(n); |
| 393 | return n; |
| 394 | }; |
| 395 | |
| 396 | for (let v = 0; v <= yMax; v += step * 100000) { |
| 397 | el("line", { x1: m.l, x2: W - m.r, y1: y(v), y2: y(v), stroke: "var(--grid)", "stroke-width": 1 }); |
| 398 | el("text", { x: m.l - 8, y: y(v) + 4, "text-anchor": "end", "font-size": 12, |
| 399 | fill: "var(--ink-2)", style: "font-variant-numeric: tabular-nums" }, v === 0 ? "β¬0" : "β¬" + (v / 100000) + "k"); |
| 400 | } |
| 401 | const yearEnds = rows.filter((r) => r.yearEnd); |
| 402 | const yStep = Math.max(1, Math.ceil(yearEnds.length / 6)); |
| 403 | yearEnds.forEach((r, i) => { |
| 404 | if (i % yStep === yStep - 1) { |
| 405 | el("text", { x: x(r.q), y: H - 8, "text-anchor": "middle", "font-size": 12, fill: "var(--ink-2)" }, String(r.yearEnd)); |
| 406 | } |
| 407 | }); |
| 408 | |
| 409 | const path = (pick) => rows.map((r, i) => (i ? "L" : "M") + x(r.q).toFixed(1) + " " + y(pick(r)).toFixed(1)).join(" "); |
| 410 | el("path", { d: "M" + x(0) + " " + y(capTotal) + " " + path((r) => r.remainingCap).slice(1), |
| 411 | fill: "none", stroke: "var(--ink-2)", "stroke-width": 2, "stroke-dasharray": "6 5", "stroke-linejoin": "round" }); |
| 412 | const seriesDefs = [ |
| 413 | { pick: (r) => r.cumI, color: "var(--pool-investors)", name: "noteholders" }, |
| 414 | { pick: (r) => r.cumT, color: "var(--pool-team)", name: "team" }, |
| 415 | { pick: (r) => r.cumC, color: "var(--pool-commons)", name: "commons" }, |
| 416 | ]; |
| 417 | for (const s of seriesDefs) { |
| 418 | el("path", { d: path(s.pick), fill: "none", stroke: s.color, |
| 419 | "stroke-width": 2, "stroke-linejoin": "round", "stroke-linecap": "round" }); |
| 420 | } |
| 421 | |
| 422 | // Right-edge direct labels (the relief rule for the low-contrast hues β |
| 423 | // identity is never color alone). Nudge apart when lines end close together. |
| 424 | // Identity-only direct labels (the relief rule): the values live in the |
| 425 | // tooltip, the tiles, and the table β a name fits the margin, a sum doesn't. |
| 426 | const end = rows[rows.length - 1]; |
| 427 | const band = [m.t + 8, H - m.b - 4]; |
| 428 | const labels = [ |
| 429 | { y: y(end.remainingCap), text: "remaining cap", fill: "var(--ink-2)", w: 400 }, |
| 430 | { y: y(end.cumI), text: "noteholders", fill: "var(--ink)", w: 650 }, |
| 431 | { y: y(end.cumT), text: "team", fill: "var(--ink-2)", w: 400 }, |
| 432 | { y: y(end.cumC), text: "commons", fill: "var(--ink-2)", w: 400 }, |
| 433 | ].map((L) => ({ ...L, y: Math.min(band[1], Math.max(band[0], L.y)) })) |
| 434 | .sort((a, b) => a.y - b.y); |
| 435 | for (let i = 1; i < labels.length; i++) { |
| 436 | if (labels[i].y - labels[i - 1].y < 14) labels[i].y = labels[i - 1].y + 14; |
| 437 | } |
| 438 | const over = labels[labels.length - 1].y - band[1]; |
| 439 | if (over > 0) for (const L of labels) L.y -= over; // slide the stack back into the band |
| 440 | for (const L of labels) { |
| 441 | el("text", { x: W - m.r + 8, y: L.y + 4, "font-size": 12, "font-weight": L.w, fill: L.fill }, L.text); |
| 442 | } |
| 443 | |
| 444 | const horizon = yearsWord(rows.length - 1); |
| 445 | const repaid = sim.repaidAt >= 0 |
| 446 | ? "the cap fills in " + yearsWord(sim.repaidAt) |
| 447 | : "the cap does not fill within " + horizon; |
| 448 | svg.setAttribute("aria-label", "Simulated streams over " + horizon + ": " + |
| 449 | eurCents(end.cumI) + " to noteholders, " + eurCents(end.cumT) + " to the team, " + |
| 450 | eurCents(end.cumC) + " to the commons; " + repaid + "."); |
| 451 | |
| 452 | // Crosshair + tooltip: the whole plot is the hit area, snapping to the |
| 453 | // nearest quarter; arrow keys walk quarters when the svg has focus. |
| 454 | const hair = el("line", { y1: m.t, y2: H - m.b, stroke: "var(--grid)", "stroke-width": 1, visibility: "hidden" }); |
| 455 | const tip = $("tip"); |
| 456 | const show = (i) => { |
| 457 | tipIdx = i; |
| 458 | const r = rows[i]; |
| 459 | hair.setAttribute("x1", x(r.q)); hair.setAttribute("x2", x(r.q)); |
| 460 | hair.setAttribute("visibility", "visible"); |
| 461 | tip.replaceChildren(); |
| 462 | const big = document.createElement("div"); |
| 463 | big.className = "big"; |
| 464 | big.textContent = r.label; |
| 465 | tip.append(big); |
| 466 | for (const [label, v] of [["Noteholders", r.cumI], ["Team", r.cumT], ["Commons", r.cumC], ["Remaining cap", r.remainingCap]]) { |
| 467 | const d = document.createElement("div"); |
| 468 | d.className = "sub"; |
| 469 | d.textContent = label + ": " + eurCents(v); |
| 470 | tip.append(d); |
| 471 | } |
| 472 | tip.style.display = "block"; |
| 473 | const box = svg.getBoundingClientRect(); |
| 474 | const px = (x(r.q) / W) * box.width; |
| 475 | tip.style.left = Math.min(Math.max(0, px - 70), box.width - tip.offsetWidth - 4) + "px"; |
| 476 | tip.style.top = Math.max(0, (y(r.cumI) / H) * box.height - tip.offsetHeight - 14) + "px"; |
| 477 | }; |
| 478 | const hide = () => { tipIdx = -1; hair.setAttribute("visibility", "hidden"); tip.style.display = "none"; }; |
| 479 | svg.addEventListener("pointermove", (ev) => { |
| 480 | const box = svg.getBoundingClientRect(); |
| 481 | const mx = ((ev.clientX - box.left) / box.width) * W; |
| 482 | let best = 0; |
| 483 | for (const r of rows) if (Math.abs(x(r.q) - mx) < Math.abs(x(best) - mx)) best = r.q; |
| 484 | show(best); |
| 485 | }); |
| 486 | svg.addEventListener("pointerleave", hide); |
| 487 | svg.addEventListener("keydown", (ev) => { |
| 488 | if (ev.key === "ArrowRight" || ev.key === "ArrowLeft") { |
| 489 | ev.preventDefault(); |
| 490 | const next = tipIdx < 0 ? (ev.key === "ArrowRight" ? 0 : rows.length - 1) |
| 491 | : Math.min(rows.length - 1, Math.max(0, tipIdx + (ev.key === "ArrowRight" ? 1 : -1))); |
| 492 | show(next); |
| 493 | } else if (ev.key === "Escape") hide(); |
| 494 | }); |
| 495 | svg.addEventListener("blur", hide); |
| 496 | } |
| 497 | |
| 498 | function renderSim() { |
| 499 | sim = simulate(params()); |
| 500 | const horizon = yearsWord(HORIZON_QUARTERS - 1); |
| 501 | |
| 502 | const firstTile = $("t-first"), firstSub = $("t-first-sub"); |
| 503 | if (sim.firstAt >= 0) { |
| 504 | firstTile.textContent = sim.rows[sim.firstAt].label; |
| 505 | firstSub.textContent = "after " + yearsWord(sim.firstAt) + " β seasoning plus the reserve floor"; |
| 506 | } else { |
| 507 | firstTile.textContent = "Not within " + horizon; |
| 508 | firstSub.textContent = "this scenario never clears seasoning and the reserve floor"; |
| 509 | } |
| 510 | const repaidTile = $("t-repaid"), repaidSub = $("t-repaid-sub"); |
| 511 | if (sim.repaidAt >= 0) { |
| 512 | repaidTile.textContent = sim.rows[sim.repaidAt].label; |
| 513 | repaidSub.textContent = eurCents(sim.capTotal) + " streamed β every note extinguished after " + yearsWord(sim.repaidAt); |
| 514 | } else { |
| 515 | const endCum = sim.rows[sim.rows.length - 1].cumI; |
| 516 | repaidTile.textContent = "Not within " + horizon; |
| 517 | repaidSub.textContent = eurCents(endCum) + " of " + eurCents(sim.capTotal) + " streamed by the horizon β patient money, by construction"; |
| 518 | } |
| 519 | const upTo = sim.repaidAt >= 0 ? sim.repaidAt : sim.rows.length - 1; |
| 520 | $("t-commons").textContent = eurCents(sim.rows[upTo].cumC); |
| 521 | $("t-commons-sub").textContent = "cumulative by " + sim.rows[upTo].label + " (team: " + eurCents(sim.rows[upTo].cumT) + ")"; |
| 522 | |
| 523 | $("sim-note").textContent = sim.brokeAt >= 0 |
| 524 | ? "Honesty check: this scenario runs out of money in " + sim.rows[sim.brokeAt].label + |
| 525 | " β costs outrun revenue and the raise. Nothing streams from a treasury that cannot cover operations." |
| 526 | : ""; |
| 527 | |
| 528 | const tbody = $("sim-table").querySelector("tbody"); |
| 529 | tbody.replaceChildren(); |
| 530 | for (const r of sim.rows) { |
| 531 | if (!r.yearEnd) continue; |
| 532 | const tr = document.createElement("tr"); |
| 533 | const th = document.createElement("th"); |
| 534 | th.scope = "row"; |
| 535 | th.textContent = String(r.yearEnd); |
| 536 | tr.append(th); |
| 537 | for (const v of [r.revenue, r.cumI, r.cumT, r.cumC, r.remainingCap]) { |
| 538 | const td = document.createElement("td"); |
| 539 | td.className = "num"; |
| 540 | td.textContent = eurCents(v); |
| 541 | tr.append(td); |
| 542 | } |
| 543 | tbody.append(tr); |
| 544 | } |
| 545 | |
| 546 | drawChart(); |
| 547 | } |
| 548 | |
| 549 | for (const c of CTLS) $(c.id).addEventListener("input", renderSim); |
| 550 | renderSim(); |
| 551 | |
| 552 | // ---- The ledger (funding.json β committed data) ----------------------------- |
| 553 | function emptyNote(target, text) { |
| 554 | const d = document.createElement("p"); |
| 555 | d.className = "empty"; |
| 556 | d.textContent = text; |
| 557 | target.replaceChildren(d); |
| 558 | } |
| 559 | |
| 560 | function table(target, caption, heads, rows) { |
| 561 | const wrap = document.createElement("div"); |
| 562 | wrap.className = "tablewrap"; |
| 563 | const t = document.createElement("table"); |
| 564 | const cap = document.createElement("caption"); |
| 565 | cap.textContent = caption; |
| 566 | const thead = document.createElement("thead"); |
| 567 | const hr = document.createElement("tr"); |
| 568 | for (const h of heads) { |
| 569 | const th = document.createElement("th"); |
| 570 | th.scope = "col"; |
| 571 | if (h.num) th.className = "num"; |
| 572 | th.textContent = h.label; |
| 573 | hr.append(th); |
| 574 | } |
| 575 | thead.append(hr); |
| 576 | const tbody = document.createElement("tbody"); |
| 577 | for (const row of rows) { |
| 578 | const tr = document.createElement("tr"); |
| 579 | row.forEach((cell, i) => { |
| 580 | const td = document.createElement(i === 0 ? "th" : "td"); |
| 581 | if (i === 0) td.scope = "row"; |
| 582 | if (heads[i].num) td.className = "num"; |
| 583 | td.textContent = cell; |
| 584 | tr.append(td); |
| 585 | }); |
| 586 | tbody.append(tr); |
| 587 | } |
| 588 | t.append(cap, thead, tbody); |
| 589 | wrap.append(t); |
| 590 | target.replaceChildren(wrap); |
| 591 | } |
| 592 | |
| 593 | function renderLedger(d) { |
| 594 | $("asof").dateTime = d.as_of; |
| 595 | $("asof").textContent = new Date(d.as_of + "T00:00:00Z") |
| 596 | .toLocaleDateString(undefined, { dateStyle: "long" }); |
| 597 | |
| 598 | if (d.adopted) { |
| 599 | const co = $("status-callout"); |
| 600 | co.classList.add("adopted"); |
| 601 | $("status-head").textContent = "Status: adopted"; |
| 602 | $("status-body").textContent = "The model is live. Every series below names " + |
| 603 | "the algorithm version it was sold under; every quarterβs distributions " + |
| 604 | "must be that versionβs outputs for the published inputs."; |
| 605 | } |
| 606 | |
| 607 | if (!d.series.length) { |
| 608 | emptyNote($("series-out"), "None β no series has opened. When one does, it appears here before any money moves: size, cap multiple, seasoning, and the pinned algorithm version."); |
| 609 | } else { |
| 610 | table($("series-out"), "Note series", [ |
| 611 | { label: "Series" }, { label: "Opened" }, { label: "Closed" }, |
| 612 | { label: "Raised", num: true }, { label: "Cap", num: true }, |
| 613 | { label: "Seasoning" }, { label: "Algorithm" }, |
| 614 | ], d.series.map((s) => [ |
| 615 | s.id, s.openedAt || "β", s.closedAt || "β", |
| 616 | s.raised != null ? eurCents(s.raised) : "β", |
| 617 | (s.capMultiple ?? DEFAULT_CAP_MULTIPLE).toLocaleString(undefined, { minimumFractionDigits: 1 }) + "Γ", |
| 618 | plur(s.seasoningMonths ?? DEFAULT_SEASONING_MONTHS, "month"), |
| 619 | s.algorithm || "β", |
| 620 | ])); |
| 621 | } |
| 622 | |
| 623 | if (!d.notes.length) { |
| 624 | emptyNote($("notes-out"), "None β no notes exist. Each will show its pseudonymous id, principal, and remaining cap; a note at zero has been fully repaid and no longer exists."); |
| 625 | } else { |
| 626 | table($("notes-out"), "Outstanding notes", [ |
| 627 | { label: "Note" }, { label: "Series" }, |
| 628 | { label: "Principal", num: true }, { label: "Remaining cap", num: true }, |
| 629 | ], d.notes.map((n) => [n.id, n.series, eurCents(n.principal), eurCents(n.remainingCap)])); |
| 630 | } |
| 631 | |
| 632 | if (!d.quarters.length) { |
| 633 | emptyNote($("quarters-out"), "None β nothing has been computed because nothing has been raised. Every quarter will publish its inputs (revenue, costs, reserve top-up) and every output, so re-running the algorithm reproduces each payment."); |
| 634 | } else { |
| 635 | table($("quarters-out"), "Quarterly waterfall computations", [ |
| 636 | { label: "Quarter" }, { label: "Revenue", num: true }, { label: "Costs", num: true }, |
| 637 | { label: "Reserve", num: true }, { label: "Noteholders", num: true }, |
| 638 | { label: "Team", num: true }, { label: "Commons", num: true }, |
| 639 | ], d.quarters.map((q) => [ |
| 640 | q.id, eurCents(q.revenue), eurCents(q.costs), eurCents(q.reserveTopUp), |
| 641 | eurCents((q.payments || []).reduce((s, p) => s + p.amount, 0)), |
| 642 | eurCents(q.teamPool || 0), eurCents(q.commonsPool || 0), |
| 643 | ])); |
| 644 | } |
| 645 | } |
| 646 | |
| 647 | async function refresh() { |
| 648 | try { |
| 649 | const res = await fetch("./funding.json", { cache: "no-cache" }); |
| 650 | if (!res.ok) throw new Error("HTTP " + res.status); |
| 651 | renderLedger(await res.json()); |
| 652 | $("error").classList.remove("show"); |
| 653 | } catch { |
| 654 | $("error").classList.add("show"); |
| 655 | } |
| 656 | } |
| 657 | refresh(); |
| 658 | setInterval(refresh, 60000); |
| 659 | </script> |
| 660 | </body> |
| 661 | </html> |
| 662 | |