"""Draw the study's charts as SVG from results/portals.csv. python3 make_charts.py results/portals.csv out_dir/ Standard library only. Every chart states its own basis in the image, because an embedded chart travels without the page's caption. """ import csv, os, sys from xml.sax.saxutils import escape NAMES = {"armor": "Armor Games", "coolmath": "Coolmath Games", "abcya": "ABCya", "kizi": "Kizi", "y8": "Y8", "itch": "itch.io", "friv": "Friv", "manxlab": "MANXLAB (ours)"} BG, CARD, TEXT, MUTED, RULE = "#08120f", "#0d1a16", "#f0ede4", "#8fa89c", "#1d2f29" HOT, MID, COOL = "#e8735f", "#e0b35a", "#3bd48f" FONT = "Inter, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" BASIS = ("Median of 10 games per portal (8 for MANXLAB), each the median of 3 loads. " "Chrome, US connection, 28 Sep 2026, nothing clicked.") CREDIT = "manxlab.com/research/free-game-trackers" def colour(v): return HOT if v >= 100 else MID if v >= 5 else COOL def fmt(v): v = float(v) return f"{v:,.0f}" if v == int(v) else f"{v:,.1f}" def wrap(text, n): words, lines, cur = text.split(), [], "" for w in words: if len(cur) + len(w) + 1 > n: lines.append(cur) cur = w else: cur = (cur + " " + w).strip() return lines + [cur] def bars(path, title, subtitle, rows, value_key, range_keys=None, unit=""): W, L, R, top, band = 720, 170, 170, 110, 40 rows = sorted(rows, key=lambda r: -float(r[value_key])) vmax = max(float(r[value_key]) for r in rows) or 1 H = top + band * len(rows) + 110 s = [f'', f'{escape(title)}{escape(subtitle + " " + BASIS)}', f'', f'{escape(title)}'] for k, line in enumerate(wrap(subtitle, 78)): s.append(f'{escape(line)}') span = W - L - R for i, r in enumerate(rows): y = top + i * band v = float(r[value_key]) w = max(2, span * v / vmax) s.append(f'' f'{escape(NAMES.get(r["portal"], r["portal"]))}') if v > 0: s.append(f'') label = fmt(v) + unit if range_keys: lo, hi = r[range_keys[0]], r[range_keys[1]] if lo != hi: label += f" ({fmt(lo)}–{fmt(hi)})" s.append(f'' f'{escape(label)}') y0 = top + band * len(rows) + 22 for k, line in enumerate(wrap(BASIS + (" Brackets: lowest–highest game." if range_keys else ""), 100)): s.append(f'{escape(line)}') s.append(f'{CREDIT}') open(path, "w").write("\n".join(s)) print("wrote", path, f"{W}x{H}") def timeline(path, rows): W, H, L, R, top, bottom = 720, 470, 70, 210, 110, 370 title = "The auction starts before the game does" sub = "Advertising and tracking companies contacted, counted at 5, 10, 30 and 60 seconds after the page opened." xs = [5, 10, 30, 60] keep = [r for r in rows if r["portal"] in ("armor", "coolmath", "abcya", "manxlab")] vmax = max(float(r["ad_tracking_companies_60s"]) for r in keep) px = lambda i: L + (W - L - R) * i / (len(xs) - 1) py = lambda v: bottom - (bottom - top) * v / vmax s = [f'', f'{escape(title)}{escape(sub + " " + BASIS)}', f'', f'{escape(title)}'] for k, line in enumerate(wrap(sub, 78)): s.append(f'{escape(line)}') for i, x in enumerate(xs): s.append(f'') s.append(f'{x} s') for v in range(0, int(vmax) + 1, 50): s.append(f'') s.append(f'{v}') for r in sorted(keep, key=lambda r: -float(r["ad_tracking_companies_60s"])): vals = [float(r[f"ad_tracking_companies_{x}s"]) for x in xs] c = colour(vals[-1]) dash = ' stroke-dasharray="7 5"' if r["portal"] == "coolmath" else "" pts = " ".join(f"{px(i):.1f},{py(v):.1f}" for i, v in enumerate(vals)) s.append(f'') for i, v in enumerate(vals): s.append(f'') s.append(f'' f'{escape(NAMES[r["portal"]])} {fmt(vals[-1])}') for k, line in enumerate(wrap(BASIS, 100)): s.append(f'{escape(line)}') s.append(f'{CREDIT}') open(path, "w").write("\n".join(s)) print("wrote", path, f"{W}x{H}") def main(portals_csv, out_dir): rows = list(csv.DictReader(open(portals_csv))) os.makedirs(out_dir, exist_ok=True) bars(os.path.join(out_dir, "companies.svg"), "Ad and tracking companies, first minute", "Advertising and tracking companies a free game page contacted in its first 60 seconds, before anything was clicked.", rows, "ad_tracking_companies_60s", ("ad_tracking_companies_60s_min", "ad_tracking_companies_60s_max")) bars(os.path.join(out_dir, "cookies.svg"), "Outside domains setting cookies", "Domains other than the portal's own that had set a cookie after 60 seconds, before anything was clicked.", rows, "third_party_cookie_domains_60s", ("third_party_cookie_domains_60s_min", "third_party_cookie_domains_60s_max")) bars(os.path.join(out_dir, "requests.svg"), "Network requests, first minute", "Requests a free game page made in its first 60 seconds, before anything was clicked.", rows, "requests_60s", ("requests_60s_min", "requests_60s_max")) timeline(os.path.join(out_dir, "timeline.svg"), rows) if __name__ == "__main__": main(*sys.argv[1:])