Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>Abu Dhabi Pickleball Club: 16 months of group growth</title> | |
| </head> | |
| <body> | |
| <nav class="sitenav"><a href="index.html">The case for courts →</a></nav> | |
| <style>.sitenav{max-width:980px;margin:0 auto 1.4rem;font:600 .78rem/1 system-ui,-apple-system,sans-serif; | |
| letter-spacing:.06em;text-transform:uppercase} | |
| .sitenav a{color:inherit;opacity:.62;text-decoration:none;border-bottom:1px solid currentColor;padding-bottom:2px} | |
| .sitenav a:hover,.sitenav a:focus-visible{opacity:1}</style> | |
| <style> | |
| :root{ | |
| --plane:#f2f2ee; --surface:#fcfcfb; --ink:#12130f; --ink-2:#52544c; --muted:#8a8b82; | |
| --grid:#e1e0d9; --axis:#c3c2b7; --ring:rgba(11,11,11,.10); | |
| --grow:#2a78d6; --churn:#e34948; --wash:rgba(42,120,214,.10); | |
| --sp:clamp(1rem,3vw,1.6rem); | |
| } | |
| @media (prefers-color-scheme:dark){ :root:where(:not([data-theme="light"])){ | |
| --plane:#0d0d0c; --surface:#1a1a19; --ink:#fff; --ink-2:#c3c2b7; --muted:#898781; | |
| --grid:#2c2c2a; --axis:#383835; --ring:rgba(255,255,255,.10); | |
| --grow:#3987e5; --churn:#e66767; --wash:rgba(57,135,229,.13); | |
| }} | |
| :root[data-theme="dark"]{ | |
| --plane:#0d0d0c; --surface:#1a1a19; --ink:#fff; --ink-2:#c3c2b7; --muted:#898781; | |
| --grid:#2c2c2a; --axis:#383835; --ring:rgba(255,255,255,.10); | |
| --grow:#3987e5; --churn:#e66767; --wash:rgba(57,135,229,.13); | |
| } | |
| *{box-sizing:border-box} | |
| body{background:var(--plane);color:var(--ink); | |
| font:400 16px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif;margin:0;padding:var(--sp)} | |
| .wrap{max-width:1000px;margin:0 auto;display:flex;flex-direction:column;gap:2.6rem} | |
| .eyebrow{font-size:.72rem;letter-spacing:.11em;text-transform:uppercase;color:var(--muted); | |
| font-weight:600;margin:0} | |
| h1{font-size:clamp(1.8rem,4.6vw,2.85rem);line-height:1.06;letter-spacing:-.03em;font-weight:700; | |
| margin:.5rem 0 0;text-wrap:balance;max-width:19ch} | |
| h2{font-size:clamp(1.1rem,2.4vw,1.4rem);letter-spacing:-.02em;font-weight:700;margin:0; | |
| text-wrap:balance} | |
| .lede{font-family:"Iowan Old Style",Georgia,serif;font-size:1.1rem;line-height:1.62; | |
| color:var(--ink-2);max-width:60ch;margin:1.1rem 0 0} | |
| .prose{font-family:"Iowan Old Style",Georgia,serif;line-height:1.65;color:var(--ink-2); | |
| max-width:62ch;margin:0} | |
| .prose b,.prose strong{color:var(--ink);font-weight:600} | |
| header{border-bottom:1px solid var(--axis);padding-bottom:2rem} | |
| .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(158px,1fr));gap:1px; | |
| background:var(--axis);border:1px solid var(--axis);border-radius:3px;overflow:hidden} | |
| .stat{background:var(--surface);padding:1rem 1.1rem;display:flex;flex-direction:column;gap:.3rem} | |
| .stat .k{font-size:.7rem;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);font-weight:600} | |
| .stat .v{font-size:1.85rem;font-weight:700;letter-spacing:-.03em;line-height:1} | |
| .stat .n{font-size:.8rem;color:var(--muted)} | |
| .panel{background:var(--surface);border:1px solid var(--ring);border-radius:3px; | |
| padding:1.3rem var(--sp) 1.1rem;display:flex;flex-direction:column;gap:.35rem} | |
| .phead{display:flex;flex-wrap:wrap;gap:.6rem 1.2rem;align-items:baseline;justify-content:space-between} | |
| .psub{font-size:.85rem;color:var(--muted);margin:0;max-width:58ch} | |
| .figbox{overflow-x:auto;margin-top:.7rem} | |
| svg{display:block;width:100%;height:auto;overflow:visible} | |
| .figbox.wide svg{min-width:560px} | |
| .figbox.narrow svg{min-width:290px} | |
| .legend{display:flex;gap:1.1rem;flex-wrap:wrap;font-size:.8rem;color:var(--ink-2);align-items:center} | |
| .legend span{display:inline-flex;align-items:center;gap:.42rem} | |
| .sw{width:11px;height:11px;border-radius:2px;flex:none} | |
| .tick{font-size:11px;fill:var(--muted);font-variant-numeric:tabular-nums} | |
| .dlabel{font-size:12px;font-weight:600;fill:var(--ink);font-variant-numeric:tabular-nums} | |
| .grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:1.1rem} | |
| .section{display:flex;flex-direction:column;gap:1.1rem} | |
| table{border-collapse:collapse;width:100%;font-size:.84rem;font-variant-numeric:tabular-nums} | |
| th,td{padding:.44rem .6rem;text-align:right;border-bottom:1px solid var(--grid);white-space:nowrap} | |
| th:first-child,td:first-child{text-align:left} | |
| thead th{font-size:.68rem;letter-spacing:.07em;text-transform:uppercase;color:var(--muted); | |
| font-weight:600;border-bottom:1px solid var(--axis)} | |
| tbody tr:last-child td{border-bottom:none;color:var(--muted)} | |
| details>summary{cursor:pointer;font-size:.85rem;color:var(--ink-2);font-weight:600; | |
| padding:.4rem 0;list-style:none;display:flex;gap:.5rem;align-items:center} | |
| details>summary::before{content:"\25B8";color:var(--muted);transition:transform .15s} | |
| details[open]>summary::before{transform:rotate(90deg)} | |
| details>summary:focus-visible{outline:2px solid var(--grow);outline-offset:3px;border-radius:2px} | |
| .note{border-left:2px solid var(--axis);padding:.15rem 0 .15rem 1.1rem; | |
| display:flex;flex-direction:column;gap:.7rem} | |
| .note h3{font-size:.72rem;letter-spacing:.1em;text-transform:uppercase;color:var(--muted); | |
| font-weight:600;margin:0} | |
| .note p{margin:0;font-size:.92rem;line-height:1.6;color:var(--ink-2);max-width:64ch} | |
| #tip{position:fixed;pointer-events:none;opacity:0;transition:opacity .1s;z-index:9; | |
| background:var(--surface);border:1px solid var(--axis);border-radius:3px;padding:.5rem .7rem; | |
| font-size:.78rem;line-height:1.45;color:var(--ink);box-shadow:0 4px 16px rgba(0,0,0,.14); | |
| font-variant-numeric:tabular-nums;white-space:nowrap} | |
| #tip b{font-weight:600} | |
| #tip .r{display:flex;justify-content:space-between;gap:1rem;color:var(--ink-2)} | |
| #tip .r i{font-style:normal;color:var(--ink);font-weight:600} | |
| footer{color:var(--muted);font-size:.8rem;border-top:1px solid var(--axis);padding-top:1.2rem} | |
| @media (prefers-reduced-motion:reduce){*{transition:none;animation:none}} | |
| </style> | |
| <div class="wrap"> | |
| <header> | |
| <p class="eyebrow">WhatsApp group export · 20 Apr 2025 – 8 Aug 2026</p> | |
| <h1>From 13 friends to 505 members in 16 months</h1> | |
| <p class="lede">Abu Dhabi Pickleball Club began with a small group organising a | |
| Tuesday morning game. Sixteen months later, the group has more than 500 members. | |
| This page shows how the membership changed and how many members posted each month.</p> | |
| </header> | |
| <div class="stats"> | |
| <div class="stat"><span class="k">Members now</span><span class="v">505</span><span class="n">from 13 at founding</span></div> | |
| <div class="stat"><span class="k">Ever joined</span><span class="v">695</span><span class="n">across 16 months</span></div> | |
| <div class="stat"><span class="k">Departed</span><span class="v">190</span><span class="n">27% of everyone who joined</span></div> | |
| <div class="stat"><span class="k">Messages</span><span class="v">13,577</span><span class="n">peak 1,924 in Apr 2026</span></div> | |
| </div> | |
| <section class="section"> | |
| <div class="panel"> | |
| <div class="phead"><h2>Membership, day by day</h2></div> | |
| <p class="psub">Reconstructed from every add, invite, leave and removal event in | |
| the export. Hover for any date.</p> | |
| <div class="figbox wide"><svg id="c1" viewBox="0 0 900 330" role="img" | |
| aria-label="Membership grew from 14 on 20 April 2025 to 505 on 8 August 2026, | |
| climbing steadily through the first year then steepening sharply from April 2026."></svg></div> | |
| </div> | |
| <p class="prose">Growth was steady but not constant. The first twelve months added | |
| about <b>23 members a month</b>. Then something shifted in spring 2026: the four | |
| months from April to July alone brought in <b>307 people</b>, taking membership from | |
| 288 to 491. That turning point is the clearest single feature in the data.</p> | |
| </section> | |
| <section class="section"> | |
| <div class="panel"> | |
| <div class="phead"> | |
| <h2>Who arrived, who left</h2> | |
| <div class="legend"> | |
| <span><i class="sw" style="background:var(--grow)"></i>Joined</span> | |
| <span><i class="sw" style="background:var(--churn)"></i>Left</span> | |
| </div> | |
| </div> | |
| <p class="psub">Monthly arrivals above the line, departures below, on one shared | |
| scale. Nobody left at all for the first three months.</p> | |
| <div class="figbox wide"><svg id="c2" viewBox="0 0 900 300" role="img" | |
| aria-label="Monthly joins peaked at 94 in May 2026; departures peaked at 36 in | |
| June 2026. No departures occurred before July 2025."></svg></div> | |
| </div> | |
| <p class="prose">People started leaving in July 2025. No one left in the first | |
| <b>three months</b>. Since then, <b>13–36 people a month</b> have left, while | |
| more people have joined each month.</p> | |
| </section> | |
| <section class="section"> | |
| <div class="panel"> | |
| <div class="phead"><h2>Members who posted</h2></div> | |
| <p class="psub">Number of different members who posted each month.</p> | |
| <div class="figbox narrow"><svg id="c3" viewBox="0 0 440 260" role="img" | |
| aria-label="The number of different members posting each month rose from 10 to a peak | |
| of 131 in May 2026, then fell to 89 in July 2026."></svg></div> | |
| </div> | |
| <p class="prose">The number of members who posted grew as the group grew. It reached | |
| <b>131 members</b> in May 2026. The July figure was <b>89 members</b>.</p> | |
| </section> | |
| <div class="panel"> | |
| <details> | |
| <summary>Month-by-month figures</summary> | |
| <div class="figbox wide" style="margin-top:.8rem"> | |
| <table> | |
| <thead><tr><th>Month</th><th>Members</th><th>Joined</th><th>Left</th> | |
| <th>Net</th><th>Posted</th><th>Messages</th></tr></thead> | |
| <tbody id="tbody"></tbody> | |
| </table> | |
| </div> | |
| </details> | |
| </div> | |
| <div class="note"> | |
| <h3>How this was counted</h3> | |
| <p>A WhatsApp export does not include a member list. It only includes events that change | |
| membership. This timeline replays all 908 membership events (adds, group-link joins, personal invites, | |
| leaves, removals) from the group’s creation forward.</p> | |
| <p>Three tricky cases matter here. Multi-person adds | |
| (<i>“X added A, B, and C”</i>) are one line of text but three people. The line | |
| <i>“removed this group from the community”</i> is not someone leaving. And | |
| arrivals are written in four different ways: a group link, an invite link, | |
| a personal invite, and a direct add. Missing any one of them would make the | |
| count too low. All four are included.</p> | |
| <p>Thirteen members are counted as already there on day one: seven were posting before | |
| any join event was recorded, and six more are inferred. Every other way of | |
| joining the group leaves a record, so a member with no record of joining can only have | |
| been there from the start. That makes the total match the <b>505</b> members WhatsApp | |
| reports exactly. Seven later members post under a display name that never matched a | |
| join event; they are counted from when they first appear, which places a few arrivals | |
| slightly later than they truly happened.</p> | |
| <p>August 2026 covers only 8 days and is shown with dashed lines. Do not read | |
| it as a monthly figure. Members whose contact is not saved appear as phone numbers, | |
| and 22 people changed their number mid-history, so a small number of individuals may | |
| be represented twice.</p> | |
| </div> | |
| <footer>Source: <i>WhatsApp Chat - Abu Dhabi Pickleball Club.zip</i> · | |
| 38,165 lines · 13,577 messages · 908 membership events.</footer> | |
| </div> | |
| <div id="tip" role="status" aria-live="polite"></div> | |
| <script> | |
| const DAILY = [14, 15, 19, 19, 19, 22, 23, 25, 25, 26, 26, 27, 27, 27, 27, 27, 28, 29, 29, 29, 29, 29, 29, 29, 29, 30, 30, 30, 31, 31, 31, 31, 31, 34, 39, 42, 42, 42, 43, 43, 43, 50, 52, 52, 52, 52, 52, 53, 54, 60, 61, 62, 63, 63, 63, 63, 66, 66, 66, 66, 67, 68, 68, 68, 71, 73, 74, 74, 74, 76, 76, 76, 76, 78, 78, 79, 84, 85, 85, 84, 85, 86, 86, 86, 87, 87, 87, 88, 87, 87, 88, 88, 88, 88, 88, 90, 90, 91, 93, 106, 115, 120, 120, 120, 121, 122, 122, 122, 122, 122, 123, 124, 127, 127, 127, 127, 128, 128, 129, 130, 133, 134, 138, 139, 141, 141, 141, 142, 142, 142, 146, 146, 148, 150, 152, 154, 154, 153, 153, 153, 153, 154, 154, 154, 154, 154, 154, 154, 157, 157, 157, 156, 155, 155, 156, 159, 159, 159, 161, 161, 162, 164, 167, 167, 167, 168, 169, 171, 171, 172, 172, 172, 173, 173, 171, 175, 175, 177, 177, 177, 176, 177, 177, 178, 179, 178, 178, 179, 179, 179, 177, 177, 177, 180, 182, 181, 184, 185, 188, 188, 189, 190, 191, 191, 192, 192, 193, 192, 192, 195, 196, 196, 196, 198, 198, 198, 202, 206, 209, 211, 211, 211, 214, 214, 213, 211, 212, 215, 215, 215, 216, 216, 221, 221, 222, 222, 222, 222, 222, 223, 222, 224, 224, 223, 222, 221, 220, 220, 221, 221, 222, 223, 223, 223, 225, 225, 225, 225, 228, 228, 228, 229, 229, 233, 233, 234, 238, 244, 242, 242, 244, 246, 245, 246, 249, 253, 254, 256, 259, 259, 257, 259, 263, 262, 262, 263, 265, 269, 269, 269, 269, 270, 271, 272, 272, 272, 273, 272, 272, 276, 277, 275, 274, 276, 276, 276, 276, 275, 276, 276, 275, 275, 274, 277, 277, 277, 277, 276, 276, 276, 275, 274, 274, 276, 276, 276, 277, 278, 279, 280, 280, 282, 283, 283, 284, 285, 285, 284, 284, 285, 283, 285, 284, 283, 284, 288, 289, 294, 300, 302, 301, 302, 302, 302, 303, 304, 311, 313, 313, 316, 321, 322, 323, 325, 327, 329, 331, 330, 332, 329, 330, 331, 330, 329, 329, 331, 334, 337, 335, 340, 341, 343, 352, 356, 357, 357, 359, 361, 360, 362, 365, 372, 375, 382, 384, 384, 390, 390, 392, 393, 396, 396, 399, 400, 399, 399, 404, 408, 413, 416, 418, 420, 421, 424, 425, 426, 426, 430, 431, 435, 439, 440, 439, 439, 439, 442, 442, 444, 442, 446, 446, 447, 447, 447, 448, 447, 449, 450, 450, 450, 447, 450, 451, 448, 446, 445, 445, 445, 460, 464, 468, 473, 474, 477, 477, 480, 480, 481, 482, 482, 482, 483, 485, 484, 491, 490, 490, 491, 492, 491, 493, 494, 497, 499, 503, 505]; | |
| const M = [{"month":"2025-04","members":26,"joined":13,"left":0,"active":10,"msgs":63,"pct":38.5},{"month":"2025-05","members":50,"joined":24,"left":0,"active":21,"msgs":127,"pct":42.0},{"month":"2025-06","members":76,"joined":26,"left":0,"active":33,"msgs":562,"pct":43.4},{"month":"2025-07","members":120,"joined":48,"left":4,"active":60,"msgs":981,"pct":50.0},{"month":"2025-08","members":150,"joined":37,"left":7,"active":75,"msgs":1363,"pct":50.0},{"month":"2025-09","members":167,"joined":25,"left":8,"active":74,"msgs":1001,"pct":44.3},{"month":"2025-10","members":182,"joined":25,"left":10,"active":61,"msgs":433,"pct":33.5},{"month":"2025-11","members":213,"joined":45,"left":14,"active":63,"msgs":919,"pct":29.6},{"month":"2025-12","members":225,"joined":28,"left":16,"active":67,"msgs":687,"pct":29.8},{"month":"2026-01","members":265,"joined":58,"left":18,"active":98,"msgs":1028,"pct":37.0},{"month":"2026-02","members":277,"joined":26,"left":14,"active":79,"msgs":561,"pct":28.5},{"month":"2026-03","members":288,"joined":24,"left":13,"active":74,"msgs":962,"pct":25.7},{"month":"2026-04","members":331,"joined":68,"left":25,"active":122,"msgs":1924,"pct":36.9},{"month":"2026-05","members":404,"joined":94,"left":21,"active":131,"msgs":1480,"pct":32.4},{"month":"2026-06","members":449,"joined":81,"left":36,"active":123,"msgs":870,"pct":27.4},{"month":"2026-07","members":491,"joined":64,"left":22,"active":89,"msgs":561,"pct":18.1},{"month":"2026-08","members":505,"joined":17,"left":3,"active":19,"msgs":55,"pct":3.8}]; | |
| const START = Date.UTC(2025, 3, 20); | |
| const MN = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; | |
| const NS = "http://www.w3.org/2000/svg"; | |
| const PARTIAL = M.length - 1; // Aug 2026 = 8 days only | |
| const el = (t, a) => { const n = document.createElementNS(NS, t); | |
| for (const k in a) n.setAttribute(k, a[k]); return n; }; | |
| const fmtM = s => { const [y, m] = s.split("-"); return MN[+m - 1] + " " + y.slice(2); }; | |
| const dayLabel = i => { const d = new Date(START + i * 864e5); | |
| return d.getUTCDate() + " " + MN[d.getUTCMonth()] + " " + d.getUTCFullYear(); }; | |
| /* bar path: rounded at the data end, square at the baseline */ | |
| function bar(x, w, base, val, r, up) { | |
| const h = Math.abs(val - base); if (h < .5) return ""; | |
| const rr = Math.min(r, h, w / 2), y = up ? val : base; | |
| return up | |
| ? "M" + x + "," + base + " V" + (y + rr) + " q0," + (-rr) + " " + rr + "," + (-rr) + | |
| " h" + (w - 2 * rr) + " q" + rr + ",0 " + rr + "," + rr + " V" + base + " Z" | |
| : "M" + x + "," + base + " V" + (base + h - rr) + " q0," + rr + " " + rr + "," + rr + | |
| " h" + (w - 2 * rr) + " q" + rr + ",0 " + rr + "," + (-rr) + " V" + base + " Z"; | |
| } | |
| const tip = document.getElementById("tip"); | |
| function showTip(e, html) { | |
| tip.innerHTML = html; tip.style.opacity = 1; | |
| const r = tip.getBoundingClientRect(); | |
| let x = e.clientX + 14, y = e.clientY - r.height - 12; | |
| if (x + r.width > innerWidth - 8) x = e.clientX - r.width - 14; | |
| if (y < 8) y = e.clientY + 18; | |
| tip.style.left = x + "px"; tip.style.top = y + "px"; | |
| } | |
| const hideTip = () => tip.style.opacity = 0; | |
| const rowsFor = m => "<div class='r'><span>Joined</span><i>+" + m.joined + "</i></div>" + | |
| "<div class='r'><span>Left</span><i>−" + m.left + "</i></div>" + | |
| "<div class='r'><span>Roster end</span><i>" + m.members + "</i></div>" + | |
| "<div class='r'><span>Posted</span><i>" + m.active + "</i></div>" + | |
| "<div class='r'><span>Messages</span><i>" + m.msgs.toLocaleString() + "</i></div>"; | |
| /* ---------- 1. daily membership ---------- */ | |
| (function () { | |
| const svg = document.getElementById("c1"); | |
| const W = 900, H = 330, L = 44, R = 52, T = 16, B = 30; | |
| const pw = W - L - R, ph = H - T - B, max = 550; | |
| const X = i => L + i * pw / (DAILY.length - 1); | |
| const Y = v => T + ph - v / max * ph; | |
| for (let v = 0; v <= 500; v += 100) { | |
| svg.append(el("line", { x1: L, x2: L + pw, y1: Y(v), y2: Y(v), | |
| stroke: "var(--grid)", "stroke-width": 1 })); | |
| const t = el("text", { x: L - 8, y: Y(v) + 4, "text-anchor": "end", class: "tick" }); | |
| t.textContent = v; svg.append(t); | |
| } | |
| let area = "M" + X(0) + "," + Y(0), line = ""; | |
| DAILY.forEach((v, i) => { area += " L" + X(i) + "," + Y(v); | |
| line += (i ? " L" : "M") + X(i) + "," + Y(v); }); | |
| area += " L" + X(DAILY.length - 1) + "," + Y(0) + " Z"; | |
| svg.append(el("path", { d: area, fill: "var(--wash)" })); | |
| svg.append(el("path", { d: line, fill: "none", stroke: "var(--grow)", | |
| "stroke-width": 2, "stroke-linejoin": "round", "stroke-linecap": "round" })); | |
| M.forEach(m => { | |
| const [y, mo] = m.month.split("-"); | |
| if (+mo % 3 !== 1) return; | |
| const i = Math.max(0, Math.round((Date.UTC(+y, +mo - 1, 1) - START) / 864e5)); | |
| if (i >= DAILY.length) return; | |
| const t = el("text", { x: X(i), y: H - 8, "text-anchor": "middle", class: "tick" }); | |
| t.textContent = fmtM(m.month); svg.append(t); | |
| }); | |
| const last = DAILY.length - 1; | |
| svg.append(el("circle", { cx: X(last), cy: Y(DAILY[last]), r: 4.5, fill: "var(--grow)", | |
| stroke: "var(--surface)", "stroke-width": 2 })); | |
| const lab = el("text", { x: X(last) + 10, y: Y(DAILY[last]) + 4, class: "dlabel" }); | |
| lab.textContent = "505"; svg.append(lab); | |
| const cross = el("line", { y1: T, y2: T + ph, stroke: "var(--axis)", "stroke-width": 1, opacity: 0 }); | |
| const dot = el("circle", { r: 4.5, fill: "var(--grow)", stroke: "var(--surface)", | |
| "stroke-width": 2, opacity: 0 }); | |
| svg.append(cross, dot); | |
| const hit = el("rect", { x: L, y: T, width: pw, height: ph, fill: "transparent" }); | |
| svg.append(hit); | |
| hit.addEventListener("pointermove", e => { | |
| const b = svg.getBoundingClientRect(); | |
| const i = Math.max(0, Math.min(DAILY.length - 1, | |
| Math.round(((e.clientX - b.left) / b.width * W - L) / pw * (DAILY.length - 1)))); | |
| cross.setAttribute("x1", X(i)); cross.setAttribute("x2", X(i)); cross.setAttribute("opacity", 1); | |
| dot.setAttribute("cx", X(i)); dot.setAttribute("cy", Y(DAILY[i])); dot.setAttribute("opacity", 1); | |
| showTip(e, "<b>" + DAILY[i] + " members</b><div class='r'>" + dayLabel(i) + "</div>"); | |
| }); | |
| hit.addEventListener("pointerleave", () => { | |
| hideTip(); cross.setAttribute("opacity", 0); dot.setAttribute("opacity", 0); | |
| }); | |
| })(); | |
| /* ---------- 2. joins / leaves, one shared scale ---------- */ | |
| (function () { | |
| const svg = document.getElementById("c2"); | |
| const W = 900, H = 300, L = 44, R = 14, T = 14, B = 36; | |
| const pw = W - L - R, ph = H - T - B; | |
| const UP = 100, DN = 40, unit = ph / (UP + DN), zero = T + UP * unit; | |
| const YU = v => zero - v * unit, YD = v => zero + v * unit; | |
| const band = pw / M.length, bw = Math.min(24, band - 2); | |
| const addGrid = (y, v) => { | |
| svg.append(el("line", { x1: L, x2: L + pw, y1: y, y2: y, stroke: "var(--grid)", "stroke-width": 1 })); | |
| const t = el("text", { x: L - 8, y: y + 4, "text-anchor": "end", class: "tick" }); | |
| t.textContent = v; svg.append(t); | |
| }; | |
| [25, 50, 75, 100].forEach(v => addGrid(YU(v), v)); | |
| [25].forEach(v => addGrid(YD(v), v)); | |
| svg.append(el("line", { x1: L, x2: L + pw, y1: zero, y2: zero, | |
| stroke: "var(--axis)", "stroke-width": 1 })); | |
| M.forEach((m, i) => { | |
| const x = L + i * band + (band - bw) / 2, part = i === PARTIAL; | |
| const mk = (d, fill) => { if (!d) return; | |
| svg.append(el("path", { d, fill: part ? "none" : fill, stroke: part ? fill : "none", | |
| "stroke-width": part ? 1.5 : 0, "stroke-dasharray": part ? "3 2" : "" })); }; | |
| mk(bar(x, bw, zero - 1, YU(m.joined), 4, true), "var(--grow)"); | |
| mk(bar(x, bw, zero + 1, YD(m.left), 4, false), "var(--churn)"); | |
| const t = el("text", { x: x + bw / 2, y: H - 12, "text-anchor": "middle", class: "tick" }); | |
| t.textContent = fmtM(m.month).split(" ")[0]; | |
| t.setAttribute("opacity", part ? .55 : 1); svg.append(t); | |
| if (i === 0 || m.month.endsWith("-01")) { | |
| const y2 = el("text", { x: x + bw / 2, y: H, "text-anchor": "middle", class: "tick" }); | |
| y2.textContent = "20" + m.month.slice(2, 4); svg.append(y2); | |
| } | |
| if (m.joined === 94) { | |
| const v = el("text", { x: x + bw / 2, y: YU(m.joined) - 8, "text-anchor": "middle", class: "dlabel" }); | |
| v.textContent = "94"; svg.append(v); | |
| } | |
| const hit = el("rect", { x: L + i * band, y: T, width: band, height: ph, fill: "transparent" }); | |
| hit.addEventListener("pointermove", e => showTip(e, | |
| "<b>" + fmtM(m.month) + (part ? " (8 days)" : "") + "</b>" + rowsFor(m))); | |
| hit.addEventListener("pointerleave", hideTip); | |
| svg.append(hit); | |
| }); | |
| })(); | |
| /* ---------- 3. monthly posters ---------- */ | |
| function small(id, key, max, ticks, fmt, kind) { | |
| const svg = document.getElementById(id); | |
| const W = 440, H = 260, L = 38, R = 14, T = 16, B = 30; | |
| const pw = W - L - R, ph = H - T - B; | |
| const Y = v => T + ph - v / max * ph; | |
| const band = pw / M.length, bw = Math.min(22, band - 2); | |
| const cx = i => L + i * band + band / 2; | |
| ticks.forEach(v => { | |
| svg.append(el("line", { x1: L, x2: L + pw, y1: Y(v), y2: Y(v), stroke: "var(--grid)", "stroke-width": 1 })); | |
| const t = el("text", { x: L - 7, y: Y(v) + 4, "text-anchor": "end", class: "tick" }); | |
| t.textContent = fmt(v); svg.append(t); | |
| }); | |
| svg.append(el("line", { x1: L, x2: L + pw, y1: Y(0), y2: Y(0), stroke: "var(--axis)", "stroke-width": 1 })); | |
| if (kind === "line") { | |
| let d = ""; | |
| M.forEach((m, i) => { if (i === PARTIAL) return; | |
| d += (d ? " L" : "M") + cx(i) + "," + Y(m[key]); }); | |
| svg.append(el("path", { d, fill: "none", stroke: "var(--grow)", "stroke-width": 2, | |
| "stroke-linejoin": "round", "stroke-linecap": "round" })); | |
| svg.append(el("path", { d: "M" + cx(PARTIAL - 1) + "," + Y(M[PARTIAL - 1][key]) + | |
| " L" + cx(PARTIAL) + "," + Y(M[PARTIAL][key]), fill: "none", stroke: "var(--grow)", | |
| "stroke-width": 2, "stroke-dasharray": "3 3", opacity: .55 })); | |
| M.forEach((m, i) => { if (i === PARTIAL) return; | |
| svg.append(el("circle", { cx: cx(i), cy: Y(m[key]), r: 3.4, fill: "var(--grow)", | |
| stroke: "var(--surface)", "stroke-width": 2 })); }); | |
| svg.append(el("circle", { cx: cx(PARTIAL), cy: Y(M[PARTIAL][key]), r: 3.4, fill: "var(--surface)", | |
| stroke: "var(--grow)", "stroke-width": 1.5, opacity: .7 })); | |
| } else { | |
| M.forEach((m, i) => { | |
| const x = L + i * band + (band - bw) / 2, part = i === PARTIAL; | |
| const d = bar(x, bw, Y(0), Y(m[key]), 4, true); if (!d) return; | |
| svg.append(el("path", { d, fill: part ? "none" : "var(--grow)", | |
| stroke: part ? "var(--grow)" : "none", "stroke-width": part ? 1.5 : 0, | |
| "stroke-dasharray": part ? "3 2" : "" })); | |
| }); | |
| } | |
| const iL = PARTIAL - 1, mL = M[iL]; | |
| const lab = el("text", { x: cx(iL), y: Y(mL[key]) - 10, class: "dlabel", "text-anchor": "middle" }); | |
| lab.textContent = fmt(mL[key]); svg.append(lab); | |
| M.forEach((m, i) => { | |
| if (i % 3 === 0) { | |
| const t = el("text", { x: cx(i), y: H - 10, "text-anchor": "middle", class: "tick" }); | |
| t.textContent = fmtM(m.month); svg.append(t); | |
| } | |
| const hit = el("rect", { x: L + i * band, y: T, width: band, height: ph, fill: "transparent" }); | |
| hit.addEventListener("pointermove", e => showTip(e, | |
| "<b>" + fmtM(m.month) + (i === PARTIAL ? " (8 days)" : "") + "</b>" + rowsFor(m))); | |
| hit.addEventListener("pointerleave", hideTip); | |
| svg.append(hit); | |
| }); | |
| } | |
| small("c3", "active", 140, [0, 35, 70, 105, 140], v => v, "bar"); | |
| /* ---------- table ---------- */ | |
| document.getElementById("tbody").innerHTML = M.map((m, i) => { | |
| const net = m.joined - m.left; | |
| return "<tr><td>" + fmtM(m.month) + (i === PARTIAL ? " (8d)" : "") + "</td><td>" + m.members + | |
| "</td><td>+" + m.joined + "</td><td>" + (m.left ? "−" + m.left : "0") + | |
| "</td><td>" + (net > 0 ? "+" : "") + net + "</td><td>" + m.active + | |
| "</td><td>" + m.msgs.toLocaleString() + "</td></tr>"; | |
| }).join(""); | |
| </script> | |
| </body> | |
| </html> | |