# 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.