ADPC-stats / stats.html
derek-thomas's picture
Simplify page language and remove posting share
570f994
Raw
History Blame Contribute Delete
25.6 kB
<!doctype html>
<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 &rarr;</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!important;animation:none!important}}
</style>
<div class="wrap">
<header>
<p class="eyebrow">WhatsApp group export &middot; 20 Apr 2025 &ndash; 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&ndash;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&rsquo;s creation forward.</p>
<p>Three tricky cases matter here. Multi-person adds
(<i>&ldquo;X added A, B, and C&rdquo;</i>) are one line of text but three people. The line
<i>&ldquo;removed this group from the community&rdquo;</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> &middot;
38,165 lines &middot; 13,577 messages &middot; 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>&minus;" + 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 ? "&minus;" + m.left : "0") +
"</td><td>" + (net > 0 ? "+" : "") + net + "</td><td>" + m.active +
"</td><td>" + m.msgs.toLocaleString() + "</td></tr>";
}).join("");
</script>
</body>
</html>