Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,334 @@
|
||||
# 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.
|
||||
Reference in new issue
Block a user