Compare commits

..

4 Commits

Author SHA1 Message Date
CyberMind
b5764cb52c
Merge pull request #710 from CyberMind-FR/feature/709-kbin-pdf-integrate-carto-graph-emoji-dat
Some checks are pending
License Headers / check (push) Waiting to run
kbin PDF: carto network map + emoji data tables (closes #709)
2026-06-22 12:17:55 +02:00
e9b20cdd44 feat(toolbox): carto network map + emoji data tables in the PDF (closes #709)
- _carto_graph: radial "carto" (TOI hub → top-8 trackers, nodes sized by hits,
  country flag + domain labels, spokes) mirroring /social/me, drawn with fpdf2.
- _emoji_table: generic emoji table; render Traceurs (flag/domain/hits/sites),
  Pays (flag/iso/trackers/hits) and DPI top-destinations (cat/service/part).
- api.py report_me: pass carto_nodes + carto_country (social graph) to the PDF.

Verified on gk2 (Linux UA): 3-page PDF, page 2 shows the carto hub graph + the
three emoji tables with live data.
2026-06-22 12:17:48 +02:00
CyberMind
28b1c3e91e
Merge pull request #708 from CyberMind-FR/feature/707-kbin-report-cyberpunk-netrunner-characte
kbin report: Cyberpunk-Netrunner character sheet (HTML + PDF) (closes #707)
2026-06-22 12:09:45 +02:00
f4ac537c5a feat(toolbox): Cyberpunk-Netrunner character-sheet report (HTML + PDF) (closes #707)
Reskins the kbin report as an RPG netrunner fiche, driven entirely by LIVE data:
- Persona: tag VILLAGE3B·#xxxx, device class + emoji from the request User-Agent
  (live device, not the stale onboarding label), level = R3 when the client is a
  wg-toolbox peer (_is_wg_r3_peer) regardless of the stored pref, alignement from
  exposure.
- Bars: ICE/intégrité (100-exposure) + Exposition; XP = Ko exchanged (7d, DPI).
- 4 CARACTÉRISTIQUES (Défense/Discrétion/Riposte/Intel) as pip bars, derived from
  protections active + level, trackers, ads blocked, DPI category/proto diversity.
- Inventaire (Tor/Cert-MITM/WireGuard/Ad-block on·off), Bestiaire (top trackers),
  Quêtes (DPI exfil alerts).
- HTML: neon cyberpunk sheet above the Pistage/DPI/Overall dossiers.
- PDF: _persona_block (FICHE NETRUNNER) replaces the zeroed events dashboard.

Verified live on gk2: Linux/Firefox UA → 🐧 Ordinateur Linux · R3; PDF renders
the sheet + attribute tiles + bestiary, donut grid multi-segment.
2026-06-22 12:09:38 +02:00
3 changed files with 360 additions and 4 deletions

View File

@ -78,6 +78,32 @@ ul{list-style:none;padding-left:.2rem}li{padding:.12rem 0;font-size:.82rem}li::b
.tabs button:hover{color:var(--text)} .tabs button:hover{color:var(--text)}
.tab-pane{display:none} .tab-pane{display:none}
.tab-pane.active{display:block} .tab-pane.active{display:block}
/* ── #707 netrunner character sheet ── */
.nr{border:1px solid #00d4ff;background:linear-gradient(135deg,rgba(0,212,255,.06),rgba(110,64,201,.06));border-radius:12px;padding:1rem;margin-bottom:1.1rem;position:relative;overflow:hidden}
.nr::before{content:"";position:absolute;inset:0;background:repeating-linear-gradient(0deg,transparent,transparent 3px,rgba(0,212,255,.025) 3px,rgba(0,212,255,.025) 4px);pointer-events:none}
.nr>*{position:relative}
.nr-head{display:flex;gap:1rem;align-items:center;flex-wrap:wrap}
.nr-ava{font-size:2.6rem;filter:drop-shadow(0 0 7px #00d4ff)}
.nr-id{flex:1;min-width:160px}
.nr-name{font-family:ui-monospace,monospace;font-size:1.15rem;color:#00d4ff;letter-spacing:.06em;text-shadow:0 0 8px rgba(0,212,255,.5)}
.nr-class{font-size:.8rem;color:var(--violet)}
.nr-bars{min-width:170px;flex:1}
.nr-bar{font-size:.7rem;color:var(--dim);margin:.18rem 0}
.nr-bar .t{height:.6rem;background:#0d0f15;border-radius:99px;overflow:hidden;margin-top:.12rem}
.nr-bar .f{display:block;height:100%;border-radius:99px}
.nr-grid{display:grid;grid-template-columns:1fr 1fr;gap:.8rem;margin-top:.9rem}
@media(max-width:560px){.nr-grid{grid-template-columns:1fr}}
.nr-box{border:1px solid var(--line);border-radius:8px;padding:.6rem .7rem;background:rgba(0,0,0,.22)}
.nr-box h3{font-size:.7rem;color:var(--violet);text-transform:uppercase;letter-spacing:.12em;margin-bottom:.4rem}
.nr-attr{display:flex;align-items:center;gap:.4rem;font-size:.82rem;margin:.28rem 0;flex-wrap:wrap}
.nr-attr .nm{width:6.2rem;color:var(--text)}
.nr-attr .pips{letter-spacing:.12em;color:#00ff41;text-shadow:0 0 5px rgba(0,255,65,.4)}
.nr-attr .vv{margin-left:auto;font-family:ui-monospace,monospace;color:#00d4ff;font-weight:700}
.nr-attr .nt{flex-basis:100%;font-size:.64rem;color:var(--dim);padding-left:6.6rem;margin-top:-.2rem}
.nr-inv{display:flex;flex-wrap:wrap;gap:.4rem}
.nr-chip{font-size:.74rem;padding:.25rem .55rem;border-radius:99px;border:1px solid var(--line)}
.nr-chip.on{border-color:#00ff41;color:#00ff41;box-shadow:0 0 8px rgba(0,255,65,.15)}
.nr-chip.off{color:var(--dim);opacity:.55;text-decoration:line-through}
</style></head> </style></head>
<body> <body>
@ -119,6 +145,63 @@ ul{list-style:none;padding-left:.2rem}li{padding:.12rem 0;font-size:.82rem}li::b
<h1>👁️ VILLAGE3B <span style="font-size:.8rem;color:var(--dim);font-weight:400">· mon rapport</span></h1> <h1>👁️ VILLAGE3B <span style="font-size:.8rem;color:var(--dim);font-weight:400">· mon rapport</span></h1>
<p class="sub">Diagnostic live de ce que ton appareil envoie sur le réseau · anonyme · se rafraîchit tout seul</p> <p class="sub">Diagnostic live de ce que ton appareil envoie sur le réseau · anonyme · se rafraîchit tout seul</p>
{# ── #707 NETRUNNER CHARACTER SHEET ── #}
{% set p = persona or {} %}
{% if p %}
<div class="nr">
<div class="nr-head">
<div class="nr-ava">{{ p.emoji|default('🧑‍💻') }}</div>
<div class="nr-id">
<div class="nr-name">{{ p.tag }}</div>
<div class="nr-class">⟁ Classe <b style="color:var(--text)">{{ p.klass }}</b> · Niveau <b style="color:#00d4ff">{{ p.level }}</b> · {{ p.align }}</div>
</div>
<div class="nr-bars">
<div class="nr-bar">🧬 ICE / intégrité <b style="color:#00ff41">{{ p.hp }}</b>/100
<span class="t"><span class="f" style="width:{{ p.hp }}%;background:linear-gradient(90deg,#00ff41,#00d4ff)"></span></span></div>
<div class="nr-bar">☣️ Exposition <b style="color:var(--amber)">{{ p.exposure }}</b>/100
<span class="t"><span class="f" style="width:{{ p.exposure }}%;background:linear-gradient(90deg,var(--amber),var(--red))"></span></span></div>
<div class="nr-bar">✦ XP <b style="color:#00d4ff">{{ "{:,}".format(p.xp) }}</b> Ko échangés sur 7 j</div>
</div>
</div>
<div class="nr-grid">
<div class="nr-box">
<h3>⚡ Caractéristiques</h3>
{% for a in p.attrs %}
<div class="nr-attr"><span>{{ a.icon }}</span><span class="nm">{{ a.name }}</span>
<span class="pips">{{ '●' * a.pips }}{{ '○' * (6 - a.pips) }}</span><span class="vv">{{ a.v }}</span>
{% if a.note %}<span class="nt">{{ a.note }}</span>{% endif %}
</div>
{% endfor %}
</div>
<div class="nr-box">
<h3>🎒 Inventaire · protections</h3>
<div class="nr-inv">
{% for it in p.inventory %}
<span class="nr-chip {{ 'on' if it.on else 'off' }}">{{ it.icon }} {{ it.name }} {{ '✓' if it.on else '✗' }}</span>
{% endfor %}
</div>
<h3 style="margin-top:.75rem">🐉 Bestiaire · qui te traque</h3>
{% if ch.trackers %}
<div style="font-size:.78rem">
{% for t in ch.trackers[:4] %}
<div>{{ ['👹','🧛','👺','🦠','🕷️','👾'][loop.index0 % 6] }} {{ t.label[:18] }} <b style="color:var(--violet)">×{{ t.count }}</b></div>
{% endfor %}
</div>
{% else %}<div class="empty">Aucun ennemi repéré 🎉</div>{% endif %}
</div>
</div>
{% set dme = (dpi_exfil or {}).me or {} %}
<div class="nr-box" style="margin-top:.8rem">
<h3>⚔️ Quêtes en cours · menaces</h3>
{% if dme.alerts %}
{% for q in dme.alerts[:5] %}
<div style="font-size:.8rem">🗡️ <b style="color:var(--amber)">{{ (q.label or q.kind or '?')|upper }}</b> — {{ q.service or q.dst or '' }} <span style="color:var(--dim)">{{ q.detail or '' }}</span></div>
{% endfor %}
{% else %}<div class="empty" style="color:var(--phos)">✓ Aucune menace active — zone sûre, runner.</div>{% endif %}
</div>
</div>
{% endif %}
{# ── TABS (#699) : Pistage / DPI-Exfil / Overall ── #} {# ── TABS (#699) : Pistage / DPI-Exfil / Overall ── #}
<div class="tabs"> <div class="tabs">
<button class="active" data-tab="pistage">🍪 Pistage</button> <button class="active" data-tab="pistage">🍪 Pistage</button>

View File

@ -2523,6 +2523,113 @@ def _build_pdf_donuts(mac_hash: str | None, data: dict) -> list:
] ]
def _ua_class(ua: str) -> tuple:
"""(emoji, label) device class from a User-Agent. Order matters: Android UAs
also contain 'linux', iPad desktop-mode contains 'macintosh'."""
u = (ua or "").lower()
if "android" in u:
return ("🤖", "Android")
if "iphone" in u or "ipad" in u or "ipod" in u:
return ("📱", "iPhone/iPad")
if "cros" in u:
return ("💻", "ChromeOS")
if "windows" in u:
return ("🪟", "PC Windows")
if "macintosh" in u or "mac os" in u:
return ("💻", "Mac")
if "linux" in u or "x11" in u or "ubuntu" in u or "fedora" in u:
return ("🐧", "Ordinateur Linux")
return ("🧑‍💻", "Runner")
def _is_wg_r3_peer(mac_hash: str) -> bool:
"""True if mac_hash == sha256(wg_pubkey)[:16] of a wg-toolbox peer → the
device is physically on the R3 tunnel, regardless of the stored client-level
preference (which can lag at r1)."""
import json
import hashlib
try:
doc = json.loads(Path("/var/lib/secubox/toolbox/wg-peers.json").read_text())
except Exception:
return False
for pk in (doc.get("peers") or {}):
if hashlib.sha256(pk.encode()).hexdigest()[:16] == mac_hash:
return True
return False
# #707 — Cyberpunk-Netrunner character sheet. Maps the LIVE telemetry (exposure,
# trackers, DPI cumulative, ads blocked, active protections) onto RPG stats.
def _persona_sheet(mac_hash: str, current_level: str, gs: dict,
exposure: int, dpi_e: dict, device_type: str = "",
ua: str = "") -> dict:
me = (dpi_e or {}).get("me") or {}
emoji, klass = _ua_class(ua) # live device from the request's User-Agent
try:
ads_total = int((store.ad_client_stats(mac_hash, hours=7 * 24, top=1) or {}).get("total", 0) or 0)
except Exception:
ads_total = 0
try:
from .filters import get_filters as _gf
tor_on = bool((_gf() or {}).get("tor_mode"))
except Exception:
tor_on = False
level = (current_level or "r1").lower()
if _is_wg_r3_peer(mac_hash): # on the R3 tunnel → effective R3 regardless of stored pref
level = "r3"
lvl_bonus = {"r0": 0, "r1": 1, "r2": 3, "r3": 6}.get(level, 1)
inventory = [
{"icon": "🧅", "name": "Tunnel Tor", "on": tor_on},
{"icon": "🔒", "name": "Cert MITM", "on": level in ("r2", "r3")},
{"icon": "🛰️", "name": "WireGuard R3", "on": level == "r3"},
{"icon": "🚫", "name": "Ad-blocker", "on": level in ("r1", "r2", "r3")},
]
n_prot = sum(1 for p in inventory if p["on"])
trackers = int(gs.get("total_trackers", 0) or 0)
n_cats = len(me.get("categories") or [])
n_protos = len(me.get("protocols") or [])
flows = int(me.get("flows", 0) or 0)
data_kb = int(((me.get("up", 0) or 0) + (me.get("down", 0) or 0)) / 1024)
def _clamp(v):
return max(3, min(20, int(v)))
defense = _clamp(6 + n_prot * 2 + lvl_bonus)
discretion = _clamp(20 - min(16, trackers // 2) - (0 if tor_on else 2))
riposte = _clamp(4 + int(ads_total ** 0.38)) if ads_total > 0 else 4
intel = _clamp(4 + n_cats * 2 + n_protos)
def _attr(icon, name, v, note=""):
return {"icon": icon, "name": name, "v": v,
"pips": max(0, min(6, round(v / 20 * 6))), "note": note}
hp = max(0, 100 - int(exposure or 0))
align = ("🛡️ Protégé" if exposure < 30 else
"⚖️ Exposé" if exposure < 70 else "☣️ Vulnérable")
return {
"tag": f"VILLAGE3B·#{mac_hash[:4].upper()}",
"id": mac_hash[:8],
"klass": klass,
"emoji": emoji,
"level": level.upper(),
"exposure": int(exposure or 0),
"hp": hp,
"xp": data_kb,
"align": align,
"attrs": [
_attr("🛡️", "DÉFENSE", defense),
_attr("👁️", "DISCRÉTION", discretion),
_attr("⚔️", "RIPOSTE", riposte, f"{ads_total} pubs tuées"),
_attr("🧠", "INTEL", intel, f"{n_cats} cat · {flows} flux"),
],
"inventory": inventory,
"ads_total": ads_total,
"flows": flows,
}
# NOTE: route order matters in FastAPI — specific routes (/report/me, # NOTE: route order matters in FastAPI — specific routes (/report/me,
# /report/me/html) MUST be declared BEFORE the catch-all /report/{token}, # /report/me/html) MUST be declared BEFORE the catch-all /report/{token},
# otherwise FastAPI matches /report/me with token="me" and returns 404. # otherwise FastAPI matches /report/me with token="me" and returns 404.
@ -2570,15 +2677,20 @@ async def report_me_html(request: Request) -> HTMLResponse:
wg_enabled = Path("/etc/secubox/toolbox/wg/server.pubkey").exists() wg_enabled = Path("/etc/secubox/toolbox/wg/server.pubkey").exists()
# Phase 6.D (#497) : cumulative anonymous stats for visual context # Phase 6.D (#497) : cumulative anonymous stats for visual context
cumulative = _cumulative_stats() cumulative = _cumulative_stats()
_level = store.get_client_level(mac_hash) if mac_hash else "r1"
_dpi_e = _dpi_stats(mac_hash)
html = _env.get_template("report-live.html.j2").render( html = _env.get_template("report-live.html.j2").render(
mac_hash=mac_hash, ip=ip, mac_hash=mac_hash, ip=ip,
request_args=dict(request.query_params), request_args=dict(request.query_params),
current_level=store.get_client_level(mac_hash) if mac_hash else "r1", current_level=_level,
wg_enabled=wg_enabled, wg_enabled=wg_enabled,
cumulative=cumulative, cumulative=cumulative,
graph=graph, graph_stats=gs, exposure_score=exposure_score, graph=graph, graph_stats=gs, exposure_score=exposure_score,
charts=_build_report_charts(graph), charts=_build_report_charts(graph),
dpi_exfil=_dpi_stats(mac_hash), dpi_exfil=_dpi_e,
persona=_persona_sheet(mac_hash, _level, gs, exposure_score, _dpi_e,
session.get("device_type", ""),
request.headers.get("user-agent", "")),
**session, **session,
) )
return HTMLResponse(html, headers={ return HTMLResponse(html, headers={
@ -2608,6 +2720,23 @@ async def report_me(request: Request) -> Response:
data = reports.build_report_data(mac_hash, session) data = reports.build_report_data(mac_hash, session)
data["dpi_exfil"] = _dpi_stats(mac_hash) # #701 — DPI parity with the HTML report data["dpi_exfil"] = _dpi_stats(mac_hash) # #701 — DPI parity with the HTML report
data["pdf_donuts"] = _build_pdf_donuts(mac_hash, data) # #703 — visual donuts data["pdf_donuts"] = _build_pdf_donuts(mac_hash, data) # #703 — visual donuts
# #707 — Netrunner persona sheet (live graph + DPI + ads + request UA)
try:
from . import social as _social
_graph = _social.fetch_graph(mac_hash, since_seconds=7 * 86400)
except Exception:
_graph = {"stats": {}, "nodes": [], "by_country": []}
_gs = _graph.get("stats") or {}
_exp = min(100, int((_gs.get("total_trackers", 0) or 0) * 1.5
+ (_gs.get("opgrade_sites", 0) or 0) * 12
+ (_gs.get("antibot_sites", 0) or 0) * 8))
_lvl = store.get_client_level(mac_hash) if mac_hash else "r1"
data["persona"] = _persona_sheet(mac_hash, _lvl, _gs, _exp, data["dpi_exfil"],
data.get("device_type", ""),
request.headers.get("user-agent", ""))
data["bestiary"] = (_build_report_charts(_graph).get("trackers") or [])[:5]
data["carto_nodes"] = _graph.get("nodes") or [] # #709 carto + tables
data["carto_country"] = _graph.get("by_country") or []
pdf_bytes = reports.render_pdf(data) pdf_bytes = reports.render_pdf(data)
fname = f"gondwana-toolbox-{mac_hash[:8]}.pdf" fname = f"gondwana-toolbox-{mac_hash[:8]}.pdf"
return Response( return Response(

View File

@ -143,7 +143,10 @@ def render_pdf(report: dict) -> bytes:
pdf.cell(0, 5, "Rapport d'analyse de session — Cabine numérique VILLAGE3B", ln=True, align="C") pdf.cell(0, 5, "Rapport d'analyse de session — Cabine numérique VILLAGE3B", ln=True, align="C")
pdf.ln(3) pdf.ln(3)
# ── HERO DASHBOARD : big global numbers ── # ── HERO : Netrunner persona sheet (#707), falls back to the old dashboard ──
if report.get("persona"):
_persona_block(pdf, family, report)
else:
_dashboard_hero(pdf, family, report) _dashboard_hero(pdf, family, report)
# Anonymous ID # Anonymous ID
@ -221,6 +224,30 @@ def render_pdf(report: dict) -> bytes:
# ── DPI device donut charts (mitm/certs/ads/dpi) — #703 ── # ── DPI device donut charts (mitm/certs/ads/dpi) — #703 ──
_pdf_donut_grid(pdf, report.get("pdf_donuts") or []) _pdf_donut_grid(pdf, report.get("pdf_donuts") or [])
# ── #709 carto network map + emoji data tables ──
carto = report.get("carto_nodes") or []
_carto_graph(pdf, family, carto)
if carto:
_emoji_table(pdf, family, "🍪 TRACEURS — qui te suit",
[("Pays", 0.12), ("Domaine", 0.46), ("Hits", 0.14), ("Sites", 0.28)],
[[n.get("country_flag", "🏴"), n.get("domain", "?"),
n.get("hits", 0), f"{n.get('sites_count', 0)} sites"]
for n in sorted(carto, key=lambda x: x.get("hits", 0), reverse=True)[:12]])
by_country = report.get("carto_country") or []
if by_country:
_emoji_table(pdf, family, "🌍 PAYS — destinations du pistage",
[("Flag", 0.12), ("Pays", 0.30), ("Traceurs", 0.28), ("Hits", 0.30)],
[[c.get("flag", "🏴"), c.get("country_iso", "?"),
c.get("tracker_count", 0), c.get("hits", 0)]
for c in by_country[:10]])
dme = (report.get("dpi_exfil") or {}).get("me") or {}
dests = dme.get("destinations") or []
if dests:
_emoji_table(pdf, family, "🛰️ DPI — top destinations (envoi)",
[("Cat", 0.12), ("Service / hôte", 0.58), ("Part", 0.30)],
[[d.get("emoji", "🌐"), d.get("label", "?"), f"{d.get('pct', 0)}%"]
for d in dests[:10]])
# ── DPI / EXFILTRATION (R3 per-device + overall) — #701 (parity with HTML) ── # ── DPI / EXFILTRATION (R3 per-device + overall) — #701 (parity with HTML) ──
dexf = report.get("dpi_exfil") or {} dexf = report.get("dpi_exfil") or {}
dme = dexf.get("me") or {} dme = dexf.get("me") or {}
@ -488,6 +515,59 @@ def _widget(pdf, family: str, x: float, y: float, w: float, h: float,
pdf.cell(w, 3, _safe(label), ln=False, align="C") pdf.cell(w, 3, _safe(label), ln=False, align="C")
def _persona_bar(pdf, family: str, label: str, pct: int, col: tuple) -> None:
"""A labelled horizontal progress bar (ICE / exposition)."""
pct = max(0, min(100, int(pct or 0)))
pdf.set_x(pdf.l_margin)
pdf.set_font(family, "", 8)
pdf.set_text_color(150, 150, 150)
pdf.cell(40, 4, _safe(f"{label} {pct}/100"), ln=True)
x, y, w = pdf.l_margin, pdf.get_y(), _page_w(pdf)
pdf.set_fill_color(20, 22, 28)
pdf.rect(x, y, w, 2.6, style="F")
pdf.set_fill_color(*col)
pdf.rect(x, y, w * pct / 100.0, 2.6, style="F")
pdf.set_y(y + 4)
def _persona_block(pdf, family: str, report: dict) -> None:
"""#707 — Cyberpunk-Netrunner character sheet header for the PDF."""
p = report.get("persona") or {}
pdf.set_font(family, "B", 12)
pdf.set_text_color(0, 212, 255)
pdf.cell(0, 6, _safe("🎮 FICHE NETRUNNER"), ln=True)
pdf.set_font(family, "B", 11)
pdf.set_text_color(0, 212, 255)
pdf.cell(0, 6, _safe(f"{p.get('emoji','')} {p.get('tag','?')}"), ln=True)
pdf.set_font(family, "", 9)
pdf.set_text_color(150, 120, 230)
pdf.cell(0, 5, _safe(f"Classe {p.get('klass','?')} · Niveau {p.get('level','?')} · {p.get('align','')}"), ln=True)
pdf.ln(1)
_persona_bar(pdf, family, "ICE / integrite", p.get("hp", 0), (0, 255, 65))
_persona_bar(pdf, family, "Exposition", p.get("exposure", 0), (255, 179, 71))
pdf.set_font(family, "", 8)
pdf.set_text_color(120, 120, 120)
pdf.cell(0, 4, _safe(f"XP {p.get('xp',0):,} Ko echanges (7j)"), ln=True)
pdf.ln(1)
# 4 attribute widgets
y = pdf.get_y()
bw = (_page_w(pdf) - 6) / 4
bh = 17
for i, a in enumerate((p.get("attrs") or [])[:4]):
x = pdf.l_margin + i * (bw + 2)
_widget(pdf, family, x, y, bw, bh, a.get("icon", "?"),
str(a.get("v", 0)), a.get("name", "")[:10], (15, 30, 40), fg=(0, 212, 255))
pdf.set_y(y + bh + 2)
# inventory + bestiary
_kv(pdf, "Inventaire",
" ".join(f"{it.get('name','')} {'OK' if it.get('on') else 'x'}" for it in (p.get("inventory") or [])))
best = report.get("bestiary") or []
if best:
_kv(pdf, "Bestiaire",
" · ".join(f"{b.get('label','?')[:14]} x{b.get('count',0)}" for b in best[:4]))
pdf.ln(2)
def _dashboard_hero(pdf, family: str, report: dict) -> None: def _dashboard_hero(pdf, family: str, report: dict) -> None:
"""Phase 3 (#492) : 3-row widget dashboard, banner-style, with aggregations. """Phase 3 (#492) : 3-row widget dashboard, banner-style, with aggregations.
@ -718,6 +798,70 @@ def _pdf_donut_grid(pdf, donuts: list) -> None:
pdf.set_y(y0 + rows * row_h + 2) pdf.set_y(y0 + rows * row_h + 2)
# #709 — radial "carto" network map (TOI hub → top trackers) for the PDF.
def _carto_graph(pdf, family: str, nodes: list) -> None:
import math
nodes = sorted([n for n in (nodes or []) if n.get("hits")],
key=lambda n: n.get("hits", 0), reverse=True)[:8]
if not nodes:
return
_section(pdf, "🗺️ CARTO — qui te piste (carte du réseau)")
cx = pdf.l_margin + _page_w(pdf) / 2.0
cy = pdf.get_y() + 34
R = 27.0
maxh = max(n.get("hits", 1) for n in nodes) or 1
pdf.set_draw_color(70, 90, 120)
pdf.set_line_width(0.2)
placed = []
for i, n in enumerate(nodes):
ang = math.radians(-90 + i * 360.0 / len(nodes))
x, y = cx + R * math.cos(ang), cy + R * math.sin(ang)
pdf.line(cx, cy, x, y)
placed.append((x, y, n))
for (x, y, n) in placed:
r = 1.6 + 3.2 * (n.get("hits", 1) / maxh)
pdf.set_fill_color(255, 80, 110)
pdf.ellipse(x - r, y - r, 2 * r, 2 * r, style="F")
lbl = f"{n.get('country_flag','')} {(n.get('domain','') or '?')[:12]}"
pdf.set_font(family, "", 6)
pdf.set_text_color(90, 90, 90)
pdf.set_xy(x - 17, (y + r + 0.5) if y >= cy else (y - r - 3.5))
pdf.cell(34, 3, _safe(lbl), align="C")
pdf.set_fill_color(0, 212, 255)
pdf.ellipse(cx - 4.5, cy - 4.5, 9, 9, style="F")
pdf.set_xy(cx - 9, cy - 1.6)
pdf.set_font(family, "B", 6)
pdf.set_text_color(10, 10, 15)
pdf.cell(18, 3, "TOI", align="C")
pdf.set_text_color(0)
pdf.set_y(cy + R + 7)
# #709 — generic emoji data table. cols = [(header, width_fraction), ...]
def _emoji_table(pdf, family: str, title: str, cols: list, rows: list) -> None:
if not rows:
return
_section(pdf, title)
w = _page_w(pdf)
widths = [w * f for (_, f) in cols]
pdf.set_font(family, "B", 8)
pdf.set_fill_color(16, 22, 30)
pdf.set_text_color(0, 212, 255)
pdf.set_x(pdf.l_margin)
for (h, _), cw in zip(cols, widths):
pdf.cell(cw, 5.5, _safe(h), border=0, fill=True)
pdf.ln()
pdf.set_font(family, "", 8)
pdf.set_text_color(35, 35, 35)
for r in rows:
pdf.set_x(pdf.l_margin)
for val, cw in zip(r, widths):
cap = max(6, int(cw / 1.7))
pdf.cell(cw, 5, _safe(str(val))[:cap], border="B")
pdf.ln()
pdf.ln(2)
def _render_text_fallback(report: dict) -> str: def _render_text_fallback(report: dict) -> str:
"""Plain text fallback when fpdf2 isn't installed.""" """Plain text fallback when fpdf2 isn't installed."""
lines = [ lines = [