14 KiB
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:
-- 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:
- schakelaar "hub verbergen" (standaard: aan)
- hub als apart gemarkeerde knoop buiten de layout
- 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().
// 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, weln7f3a2) - 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:
- Binnen de ingelogde omgeving: een read-only sectie die elk lid ziet, met de geanonimiseerde payload. Geen nieuwe deelmechaniek nodig.
- 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:
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;
layoutstopafwachten in plaats van een vaste timeout
7. Aanpak in stappen
- 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. - Drempel + hub-schakelaar, de twee besturingselementen die het verschil maken tussen leesbaar en onleesbaar.
- Volume- en stroomcodering (A en B) — zelfde graaf, andere styling.
- Clusters (C), inclusief
markovClustering. - Regio's (D) met compound nodes.
- PDF-export.
- 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.
- Tests:
network()inStatsAggregationTest, 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 inbank_clientsvan die bank staat, ennetwork($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
- Wat is de leidende vraag? "Waar zit de activiteit" (volume) en "wie hangt samen met wie" (clusters) leiden tot verschillende standaardweergaven.
- 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.
- 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.
- Periode of alles? De huidige Stats-filter geldt per periode; een netwerk over "alle tijd" is stabieler maar verhult ontwikkeling.