Files
timebank-cc-public/todo_network_graph.md
T

335 lines
14 KiB
Markdown

# Admin Stats: interactieve netwerkvisualisatie (Cytoscape.js)
Status: **plan, nog niet geïmplementeerd**. Alle cijfers hieronder zijn gemeten op
de dev-database (2026-09-21), niet geschat.
---
## 1. Licentie — akkoord
| pakket | versie | licentie |
|---|---|---|
| `cytoscape` | 3.34.3 | **MIT** (Copyright 2016-2026, The Cytoscape Consortium) |
| `cytoscape-fcose` | 2.2.0 | MIT |
| `cytoscape-cola` | 2.5.1 | MIT |
| `cytoscape-cise` | 2.0.1 | MIT |
Geverifieerd tegen de LICENSE op GitHub en het `license`-veld in de npm-registry.
`cytoscape` heeft **nul runtime-dependencies**, wat voor de bundle uitmaakt: de
huidige `app.js` zit al op ~546 kB.
Let op het onderscheid: **Cytoscape.js** (browser-library, MIT) is iets anders dan
**Cytoscape Desktop** (Java, LGPL). Wij gebruiken de eerste.
---
## 2. Wat de data toelaat — dit stuurt het ontwerp
Gemeten op dev:
| grootheid | waarde |
|---|---|
| knopen (profielen met verkeer) | **1.173** (1.158 users, 8 orgs, 7 banks) |
| kanten (unieke handelsparen) | **2.574** |
| transacties | 34.020 |
| kanten met slechts 1 transactie | **1.195 (46%)** |
| graad: max / p90 / mediaan | **709 / 7 / 2** |
### De twee bevindingen die het ontwerp bepalen
**a. Er is één dominante hub.** Lekkernassuh (Organization #11) zit in 29.414 van
de 34.020 transacties en heeft 709 handelspartners. Een ongefilterde
force-directed layout wordt daardoor een ster met een wolk eromheen: visueel
indrukwekkend, analytisch waardeloos. Elke standaardweergave moet hier rekening
mee houden (zie §4).
**b. De helft van het netwerk is ruis.** 46% van de kanten is een eenmalige
transactie en de mediane knoop heeft 2 partners. Een drempel is geen
optimalisatie maar een voorwaarde voor leesbaarheid:
| drempel | knopen over |
|---|---|
| >= 2 partners | 716 |
| >= 3 partners | 369 |
| >= 5 partners | 176 |
| >= 10 partners | 70 |
Voorstel: **standaard >= 3 partners** (369 knopen), met een schuifregelaar.
**c. Regio-dekking is ongelijk.** 2.237 van 3.298 users heeft een locatie (68%),
maar slechts **4 van de 16 organisaties**. Regio-clustering werkt dus wel voor
users en niet voor organisaties — dat moet de UI eerlijk zeggen in plaats van
orgs stilzwijgend op "onbekend" te zetten.
---
## 3. Architectuur
Volgt het bestaande Stats-patroon, geen nieuwe infrastructuur.
### Backend
`StatsService::network(): array` — één extra methode, dezelfde `StatsFilter`,
dezelfde cachesleutel (`CACHE_VERSION` bumpen). Twee aggregaten:
```sql
-- knopen: profiel + volume + graad
-- kanten: ongerichte paren met richtingssplitsing
SELECT LEAST(...) a, GREATEST(...) b,
SUM(CASE WHEN from IN (a) THEN amount ELSE 0 END) a_naar_b,
SUM(CASE WHEN from IN (b) THEN amount ELSE 0 END) b_naar_a,
COUNT(*) n
FROM transactions ... GROUP BY a, b
```
Let op de bestaande valkuil uit `StatsAggregationTest`: dit is **volume**, geen
saldo, dus **geen** `THEN 0` voor interne overboekingen. Elke transactie telt één
keer.
Belangrijk voor de kosten: de eerste poging met een self-join op `transactions`
liep op dev na 180s nog niet af. De werkende vorm bouwt de kantenlijst met één
`GROUP BY` over de join op `accounts` en telt graden daarna in PHP.
Payload-budget: bij 369 knopen / ~1.200 kanten is dat ruwweg 150-250 kB JSON.
Acceptabel als het via een data-attribuut gaat, net als de andere charts.
### Frontend
`resources/js/stats-network.js`, volgens het contract van `stats-charts.js`:
data via `data-*`, herinitialiseren op `Livewire.hook('morph.updated')` met de
gebruikelijke ~300 ms debounce, en een offscreen render voor de PDF.
### Bundle
Cytoscape + fcose is ongeveer 400-500 kB ongecomprimeerd. Dat verdubbelt bijna
`app.js`. **Aanbeveling: lazy load** via dynamic `import()` op het moment dat de
sectie wordt geopend, zodat de andere 95% van de site er niet voor betaalt.
---
## 4. De vier visualisaties
### A. Geldstroom (currency flow)
Gerichte kanten, dikte ~ volume, pijl in de netto-richting. Kleur per
transactietype (hergebruik `SINGLE_METRIC_COLORS` / de tag-categoriepalet).
**Hub-probleem:** met Lekkernassuh erin verdwijnt de rest. Oplossingen, in
volgorde van voorkeur:
1. schakelaar "hub verbergen" (standaard: aan)
2. hub als apart gemarkeerde knoop buiten de layout
3. ego-netwerk: kies een profiel, toon alleen diens directe omgeving
### B. Volume
Knoopgrootte ~ totaal verhandelde tijd. Zelfde graaf, andere codering. Gebruik
`Math.sqrt()` voor de diameter — lineair schalen maakt de hub 700x te groot.
### C. Clusters
`cytoscape-fcose` voor de layout, plus community-detectie. Cytoscape.js heeft
`elements().markovClustering()` ingebouwd — geen extra afhankelijkheid.
Alternatief `cytoscape-cise` als clusters expliciet omcirkeld moeten worden.
### D. Regio's
Compound nodes: parent = stad/provincie/land uit `locations`, children = profielen.
Hergebruik `LocationFilterResolver` voor de "diepste niveau wint"-logica.
**Eerlijk zijn over dekking:** 32% van de users en 75% van de organisaties heeft
geen locatie. Die horen in een zichtbare groep "locatie onbekend", niet
weggelaten — anders lijkt de kaart completer dan hij is.
---
## 5. Privacy en toegang — niet optioneel
Deze grafiek toont wie met wie handelt. Dat is gevoeliger dan de bestaande
tabellen, want een netwerk onthult relaties die in een top-20 onzichtbaar
blijven: uit "wie handelde er met X" is vaak af te leiden wie X is, ook zonder
namen.
Daarom **twee gescheiden weergaven**, niet één weergave met een schakelaar die
per ongeluk verkeerd kan staan.
### 5.1 Toegang: drie niveaus
| rol | ziet | scope |
|---|---|---|
| **Admin** | volledig, met namen | het hele platform |
| **Bank** | volledig, met namen | **alleen de eigen clients** |
| ingelogd lid | geanonimiseerd (§5.2) | platform, zonder identiteiten |
Een bank ziet dus wel namen, maar alleen van profielen die in `bank_clients`
aan die bank gekoppeld zijn. De scoping is geen UI-filter maar een
server-side beperking: de knopen van niet-clients komen niet in de payload.
**De koppeling bestaat en is gevuld.** Let op de valkuil: `bank_user` is de
tabel met **managers** (3 rijen op dev), niet met clients. Clients zitten in
`bank_clients` (1.816 rijen), polymorf via `client_type` / `client_id`, met
`relationship_type` (op dev overal `local`). `Bank::clients()` voegt
`userClients()` en `organizationClients()` samen — gebruik die relatie, niet
`managers()`.
```php
// Admin: alles. Bank: alleen eigen clients. Anders: 403.
if ($profile instanceof \App\Models\Admin) {
$scope = null; // geen beperking
} elseif ($profile instanceof \App\Models\Bank) {
$scope = $profile->clients(); // bank_clients, niet bank_user
} else {
abort(403, __('Admin or bank access required'));
}
```
Registreer een eigen resource `network` in `AuthServiceProvider::$granular`,
zodat de permissie los van `stats` in te trekken is. Controleren op **elke**
publieke methode, niet alleen `mount()`.
### 5.1.1 Wat de bank-scope oplevert — gemeten op dev
| bank | clients | knopen met verkeer | kanten | max graad | knopen >= 3 partners |
|---|---|---|---|---|---|
| 5 | 146 | 4 | 2 | 1 | 0 |
| 6 | 1.436 | 794 | 1.398 | 667 | 181 |
| 8 | 234 | 47 | 68 | 13 | 19 |
Drie dingen die hieruit volgen:
**a. De standaarddrempel van >= 3 partners werkt niet voor elke bank.** Bij
bank 5 houdt die nul knopen over. De drempel moet dus per scope meebewegen, of
de UI moet melden dat er te weinig onderling verkeer is in plaats van een lege
grafiek te tonen.
**b. Ook binnen een bank-scope is er een hub** (bank 6: max graad 667). De
hub-schakelaar uit §4 is dus niet alleen voor de platformweergave nodig.
**c. Randverkeer — BESLIST.** Standaard *alleen intern* (beide einden client),
met een schakelaar "toon verkeer met buiten de kring".
Regels bij die schakelaar:
- De tegenpartij buiten de kring wordt **altijd geanonimiseerd**, ook voor de
bank. Een bank mag geen namen zien van leden die geen client zijn — dat is
de kern van de scoping en mag niet via een schakelaar te omzeilen zijn.
- Buiten-de-kring-knopen krijgen een eigen visuele stijl (bijvoorbeeld grijs en
gestippeld), zodat direct zichtbaar is dat het geen client is.
- Ze tellen **niet** mee in de partnerdrempel van de clients: de drempel
beoordeelt hoe verweven iemand binnen de kring is.
- De anonimisering gebruikt hetzelfde gezouten-hash-mechanisme als §5.2, zodat
er maar één implementatie is die namen weglaat.
Waarde: alleen intern toont de interne verwevenheid, inclusief rand toont
hoeveel waarde de kring verlaat of binnenkomt. Beide zijn nuttig, maar de
veilige stand is de standaard.
### 5.2 Gedeelde weergave — geanonimiseerd, voor leden
Doel: de vorm van het netwerk laten zien zonder te onthullen wie wie is.
Het cruciale punt: **anonimiseren gebeurt server-side, vóór de JSON de
component verlaat.** Niet in de blade, niet in JavaScript. Als de echte namen
in het data-attribuut staan, is "anonimisering" één rechtermuisklik diep. De
geanonimiseerde payload mag simpelweg geen `name` bevatten.
Wat er wél in mag:
- knoop-id vervangen door een **salted hash** die per weergave stabiel is maar
niet terug te rekenen naar een profiel-id (geen `User:161`, wel `n7f3a2`)
- profieltype (User/Organisatie/Bank) — nodig voor kleur, weinig onthullend
- volume en graad, **in klassen** (klein/middel/groot) in plaats van exacte
bedragen: een exact volume is een vingerafdruk
- regio alleen op voldoende grof niveau (zie k-anonimiteit hieronder)
Wat er **niet** in mag: namen, profiel-id's, exacte bedragen, exacte data,
en de onderliggende kantenlijst als download.
### 5.3 k-anonimiteit
Anonimiseren alleen is niet genoeg bij een graaf. Twee maatregelen:
- **Knopen met < 3 partners weglaten.** Dat is toevallig ook de leesbaarheids-
drempel uit §2, maar hier is het een privacy-eis: een knoop met één kant is
in de praktijk identificeerbaar.
- **Regio's met < k profielen samenvoegen** tot "overig". Een stad met twee
leden is geen regio maar een aanwijzing. Voorstel k = 5.
De hub is hierbij een bijzonder geval: Lekkernassuh is met 709 partners
onmiskenbaar, ook zonder label. Dat is verdedigbaar — het is een organisatie,
geen privépersoon — maar het moet een bewuste keuze zijn. Voorstel: organisaties
bij naam tonen, natuurlijke personen nooit.
### 5.4 Hoe delen
Niet als losse publieke route met een raadbare URL. Twee opties, in volgorde
van voorkeur:
1. **Binnen de ingelogde omgeving**: een read-only sectie die elk lid ziet,
met de geanonimiseerde payload. Geen nieuwe deelmechaniek nodig.
2. **Ondertekende, verlopende link** (`URL::temporarySignedRoute`) als het echt
buiten het platform gedeeld moet worden.
In beide gevallen serveert de route de geanonimiseerde dataset — dezelfde code
kan niet per ongeluk de volledige versie teruggeven omdat het twee
verschillende methodes zijn:
```php
public function network(?Collection $scope = null, bool $edge = false): array // namen; Admin of Bank
public function networkAnonymised(): array // geen namen of id's
```
`$scope` is `null` voor een Admin en de clientverzameling voor een Bank.
`$edge` schakelt het randverkeer uit §5.1.1c aan; die knopen komen er altijd
zonder naam in, ongeacht de rol. De anonieme variant is een aparte methode,
zodat een fout in de scoping nooit namen kan lekken naar de gedeelde
weergave.
Een test die vastlegt dat `networkAnonymised()` géén `name`-sleutel oplevert en
geen ruwe profiel-id's lekt, is hier belangrijker dan de meeste andere tests in
deze feature.
## 6. PDF-export
Cytoscape heeft `cy.png({ full: true, scale: 2 })`, wat past in het bestaande
`chartToPng`-patroon. Aandachtspunten uit eerdere rondes:
- renderen op de weergavegrootte, niet op een vaste 900x400 — dat was de oorzaak
van de vervormde grafieken
- dompdf heeft `isRemoteEnabled => false`, dus de PNG moet inline als data-URL
- een force-directed layout moet **uitgeconvergeerd** zijn voor de capture;
`layoutstop` afwachten in plaats van een vaste timeout
---
## 7. Aanpak in stappen
1. **Spike (halve dag).** `network()` + statische graaf, geen filters, drempel
hard op >= 3. Doel: bevestigen dat 369 knopen soepel renderen en dat de
hub-aanpak werkt. Als dit tegenvalt, is de rest niet de moeite waard.
2. **Drempel + hub-schakelaar**, de twee besturingselementen die het verschil
maken tussen leesbaar en onleesbaar.
3. **Volume- en stroomcodering** (A en B) — zelfde graaf, andere styling.
4. **Clusters** (C), inclusief `markovClustering`.
5. **Regio's** (D) met compound nodes.
6. **PDF-export.**
7. **Geanonimiseerde weergave** (§5.2) plus de deelroute (§5.4). Bewust ná de
volledige versie: de vorm van de data moet vastliggen voordat er een
publieke variant op gebouwd wordt.
8. **Tests**: `network()` in `StatsAggregationTest`, met dezelfde
volume-vs-saldo-fixture als guard. Plus de privacytests uit §5.2:
`networkAnonymised()` bevat geen namen, geen profiel-id's, en laat knopen
met < 3 partners weg. Plus twee scopetests: `network($bankClients)` bevat
**geen** knoop die niet in `bank_clients` van die bank staat, en
`network($bankClients, edge: true)` bevat buiten-de-kring-knopen **zonder
naam**. Mutatietesten om te bevestigen dat ze falen als de anonimisering of
de scoping stukgaat.
---
## 8. Open vragen voor de gebruiker
1. **Wat is de leidende vraag?** "Waar zit de activiteit" (volume) en "wie hangt
samen met wie" (clusters) leiden tot verschillende standaardweergaven.
2. **Lekkernassuh standaard verbergen?** Met hub is de grafiek een ster; zonder
hub zie je het onderlinge verkeer. Mijn voorstel: standaard verbergen, met
een duidelijke melding dat het gebeurt.
3. **Banken tonen?** Elders worden ze uitgesloten (hun saldo is de spiegel van
alle andere). In een stroomgrafiek zijn ze juist interessant als bron en put
van valuta.
4. **Periode of alles?** De huidige Stats-filter geldt per periode; een netwerk
over "alle tijd" is stabieler maar verhult ontwikkeling.