335 lines
14 KiB
Markdown
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.
|