Replace demo data with 2026 R&D planning, fix zoom and dimension-aware creation

Seeder: Replace 12 demo projects with 6 real 2026 projects from Planning PPTX:
- BRIDGE (Pilot Klundert), CRISP (Compressor Aanbesteding), WISE (Monsternamekast),
  Gemaal 3.0, Afvlakkingsregeling, Structuur & Borging
- 4 strategic themes: Architectuur, Productiewaardig, Lab, Governance
- Real team members, commitments, documents, and dependencies

MetroCanvas: Fix zoom-out scaling
- Wider transition range (0.6→0.25 instead of 0.5→0.1) for smoother feel
- Animated zoom reset on dimension commit (400ms ease) instead of jarring snap
- Guard against re-entry during transitions with isCommitting flag
- Expose dimension metadata (parentEntityType/Id/Name) for parent components

FloatingActions: Dimension-aware creation
- Shows "Nieuw commitment/document" when inside a project dimension
- Shows "Nieuw project/thema" at root level
- Receives depth and parentEntityType props from MetroMap

MetroMap: Wire dimension tracking
- Tracks canvasDepth/canvasDimension from MetroCanvas dimension-change events
- Updates breadcrumb for both page-level and canvas-level navigation
- Passes dimension context to FloatingActions and CommitmentForm

MapDataService: Add parent metadata to buildProjectChildren output

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-04-08 08:50:51 +02:00
parent 926872a082
commit 6711cd01a3
6 changed files with 531 additions and 415 deletions

View File

@@ -197,7 +197,7 @@ class MapDataService
'order' => $i + 1, 'order' => $i + 1,
'status' => $fase->status->value, 'status' => $fase->status->value,
'badge' => ucfirst($fase->status->value), 'badge' => ucfirst($fase->status->value),
'children' => null, // Phases could have children in future 'children' => null,
]; ];
} }
@@ -215,7 +215,7 @@ class MapDataService
'status' => $commitment->status->value, 'status' => $commitment->status->value,
'owner' => $commitment->eigenaar?->name, 'owner' => $commitment->eigenaar?->name,
'badge' => $commitment->deadline?->format('d M Y'), 'badge' => $commitment->deadline?->format('d M Y'),
'children' => null, // Could contain acties in future 'children' => null,
]; ];
} }
@@ -240,6 +240,10 @@ class MapDataService
'lines' => $lines, 'lines' => $lines,
'nodes' => $nodes, 'nodes' => $nodes,
'connections' => [], 'connections' => [],
// Metadata so the frontend knows what dimension it's in
'parentEntityType' => 'project',
'parentEntityId' => $project->id,
'parentName' => $project->naam,
]; ];
} }

View File

@@ -25,6 +25,9 @@ class DatabaseSeeder extends Seeder
{ {
/** /**
* Seed the application's database. * Seed the application's database.
*
* Planning 2026 R&D Lab Waterschap Brabantse Delta
* Source: Planning 2026.pptx
*/ */
public function run(): void public function run(): void
{ {
@@ -56,483 +59,442 @@ class DatabaseSeeder extends Seeder
]); ]);
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
// 2. Users // 2. Users (R&D team)
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
$adminUser = User::create([ $admin = User::create([
'name' => 'Admin Gebruiker', 'name' => 'Admin',
'email' => 'admin@innovatieplatform.nl', 'email' => 'admin@innovatieplatform.nl',
'password' => Hash::make('password'), 'password' => Hash::make('password'),
'functie' => 'Platform Beheerder', 'functie' => 'Platform Beheerder',
'afdeling' => 'R&D Lab', 'afdeling' => 'R&D Lab',
'email_verified_at' => now(), 'email_verified_at' => now(),
]); ]);
$adminUser->roles()->attach($roleAdmin); $admin->roles()->attach($roleAdmin);
$testUser = User::create([ $rene = User::create([
'name' => 'Rene de Ren', 'name' => 'Rene de Ren',
'email' => 'rene@wbd-rd.nl', 'email' => 'rene@wbd-rd.nl',
'password' => Hash::make('password'), 'password' => Hash::make('password'),
'functie' => 'R&D Engineer', 'functie' => 'R&D Engineer / Teamlead',
'afdeling' => 'R&D Lab', 'afdeling' => 'R&D Lab',
'email_verified_at' => now(), 'email_verified_at' => now(),
]); ]);
$testUser->roles()->attach($roleProjectOwner); $rene->roles()->attach($roleProjectOwner);
$analyst = User::create([ $pim = User::create([
'name' => 'Lisanne Bakker', 'name' => 'Pim Moerman',
'email' => 'l.bakker@wbd-rd.nl', 'email' => 'p.moerman@wbd-rd.nl',
'password' => Hash::make('password'), 'password' => Hash::make('password'),
'functie' => 'Data Analist', 'functie' => 'Technisch Adviseur OT/IT',
'afdeling' => 'Watermanagement', 'afdeling' => 'R&D Lab',
'email_verified_at' => now(), 'email_verified_at' => now(),
]); ]);
$analyst->roles()->attach($roleTeamMember); $pim->roles()->attach($roleTeamMember);
$engineer = User::create([ $sjoerd = User::create([
'name' => 'Joris van Dam', 'name' => 'Sjoerd Fijnje',
'email' => 'j.vandam@wbd-rd.nl', 'email' => 's.fijnje@wbd-rd.nl',
'password' => Hash::make('password'), 'password' => Hash::make('password'),
'functie' => 'Senior Technisch Adviseur', 'functie' => 'Werktuigbouwkundige / Elektro',
'afdeling' => 'Infrastructuur', 'afdeling' => 'R&D Lab',
'email_verified_at' => now(), 'email_verified_at' => now(),
]); ]);
$engineer->roles()->attach($roleTeamMember); $sjoerd->roles()->attach($roleTeamMember);
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
// 3. Themas (4 strategic themes) // 3. Themas 2026 strategic themes
//
// Kaders: geen nieuwe ML-modellen, geen nieuwe digital twins,
// focus op werkend krijgen, architectuur aantonen, overdragen.
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
$themaWater = Thema::create([ $themaArchitectuur = Thema::create([
'naam' => 'Waterkwaliteit', 'naam' => 'Architectuur & Veiligheid',
'beschrijving' => 'Verbetering van de kwaliteit van oppervlaktewater en grondwater door innovatieve monitoring- en zuiveringstechnieken.', 'beschrijving' => 'Aantonen dat de EDGE-laag architectuur veilig, betrouwbaar en schaalbaar is. OT/IT-scheiding, Siemens-koppeling, CI/CD implementatie.',
'prioriteit' => Prioriteit::Hoog, 'prioriteit' => Prioriteit::Hoog,
'periode_start' => '2025-01-01', 'periode_start' => '2026-01-01',
'periode_eind' => '2028-12-31', 'periode_eind' => '2026-12-31',
]); ]);
$themaInfra = Thema::create([ $themaProductie = Thema::create([
'naam' => 'Slimme Infrastructuur', 'naam' => 'Productiewaardig Maken',
'beschrijving' => 'Digitalisering en automatisering van waterkeringen, gemalen en sluizen voor efficiënter beheer en snellere responstijden.', 'beschrijving' => 'Bestaande innovaties werkend, overdraagbaar en schaalbaar maken. Begeleiding van aanbestedingen en overdracht naar Bouwen/Beheer.',
'prioriteit' => Prioriteit::Hoog, 'prioriteit' => Prioriteit::Hoog,
'periode_start' => '2025-01-01', 'periode_start' => '2026-01-01',
'periode_eind' => '2028-12-31', 'periode_eind' => '2026-12-31',
]); ]);
$themaData = Thema::create([ $themaLab = Thema::create([
'naam' => 'Data-gedreven Beheer', 'naam' => 'Lab & Prototyping',
'beschrijving' => 'Inzet van data-analyse, AI en digitale tweelingen voor betere besluitvorming in waterbeheer.', 'beschrijving' => 'Fysieke opstellingen valideren, uitbreiden en koppelen tot een realistische testomgeving voor afvlakkingsregelingen.',
'prioriteit' => Prioriteit::Midden, 'prioriteit' => Prioriteit::Midden,
'periode_start' => '2025-06-01', 'periode_start' => '2026-01-01',
'periode_eind' => '2029-06-30', 'periode_eind' => '2026-12-31',
]); ]);
$themaDuurzaam = Thema::create([ $themaGovernance = Thema::create([
'naam' => 'Duurzaamheid & Klimaatadaptatie', 'naam' => 'Governance & Teamborging',
'beschrijving' => 'Innovaties gericht op energieneutraliteit, circulaire waterketens en klimaatrobuuste inrichting van het beheergebied.', 'beschrijving' => 'R&D minder persoonsafhankelijk maken. Projectstructuur, documentatie, besluitvorming en rolafbakening vastleggen.',
'prioriteit' => Prioriteit::Midden, 'prioriteit' => Prioriteit::Midden,
'periode_start' => '2025-01-01', 'periode_start' => '2026-01-01',
'periode_eind' => '2030-12-31', 'periode_eind' => '2026-12-31',
]); ]);
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
// 4. Speerpunten (2 per thema) // 4. Speerpunten (2 per thema)
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
// Waterkwaliteit // Architectuur & Veiligheid
$spWaterMonitoring = Speerpunt::create([ $spEdge = Speerpunt::create([
'thema_id' => $themaWater->id, 'thema_id' => $themaArchitectuur->id,
'naam' => 'Real-time Watermonitoring', 'naam' => 'EDGE-laag & OT/IT-scheiding',
'beschrijving' => 'Continue meting van waterkwaliteitsparameters via IoT-sensoren in het beheergebied.', 'beschrijving' => 'Implementatie en validatie van de EDGE-architectuur met beveiligde OT/IT-scheiding op pilotlocatie.',
'eigenaar_id' => $testUser->id, 'eigenaar_id' => $rene->id,
'status' => SpeerpuntStatus::Actief, 'status' => SpeerpuntStatus::Actief,
]); ]);
$spMicropollution = Speerpunt::create([ $spCicd = Speerpunt::create([
'thema_id' => $themaWater->id, 'thema_id' => $themaArchitectuur->id,
'naam' => 'Microverontreinigingen', 'naam' => 'CI/CD & DevOps R&D-stack',
'beschrijving' => 'Detectie en verwijdering van opkomende stoffen zoals medicijnresten en PFAS.', 'beschrijving' => 'Continuous Integration en Deployment implementeren in de R&D-stack voor maximale efficiëntie en herhaalbaarheid.',
'eigenaar_id' => $engineer->id, 'eigenaar_id' => $rene->id,
'status' => SpeerpuntStatus::Concept, 'status' => SpeerpuntStatus::Concept,
]); ]);
// Slimme Infrastructuur // Productiewaardig Maken
$spDigitaalBeheer = Speerpunt::create([ $spOverdracht = Speerpunt::create([
'thema_id' => $themaInfra->id, 'thema_id' => $themaProductie->id,
'naam' => 'Digitaal Kunstwerkenregister', 'naam' => 'Overdracht naar Bouwen/Beheer',
'beschrijving' => 'Volledig digitaal beheer van kunstwerken met BIM-koppeling en conditiebewaking.', 'beschrijving' => 'Innovaties begeleiden van R&D naar productie: documentatie, oplevering, kennisoverdracht.',
'eigenaar_id' => $engineer->id, 'eigenaar_id' => $rene->id,
'status' => SpeerpuntStatus::Actief, 'status' => SpeerpuntStatus::Actief,
]); ]);
$spSmartGemaal = Speerpunt::create([ $spAanbesteding = Speerpunt::create([
'thema_id' => $themaInfra->id, 'thema_id' => $themaProductie->id,
'naam' => 'Slimme Gemaalbesturing', 'naam' => 'Aanbesteding & TCO-toetsing',
'beschrijving' => 'Predictieve sturing van gemalen op basis van weersvoorspelling en waterstanden.', 'beschrijving' => 'Technische toetsing van aanbestedingen op prestatie-eisen, energie-efficiëntie en inpasbaarheid.',
'eigenaar_id' => $testUser->id, 'eigenaar_id' => $pim->id,
'status' => SpeerpuntStatus::Actief, 'status' => SpeerpuntStatus::Actief,
]); ]);
// Data-gedreven Beheer // Lab & Prototyping
$spDigitaalTwin = Speerpunt::create([ $spDtValidatie = Speerpunt::create([
'thema_id' => $themaData->id, 'thema_id' => $themaLab->id,
'naam' => 'Digitale Tweeling Watersysteem', 'naam' => 'Digital Twin Validatie',
'beschrijving' => 'Virtueel model van het watersysteem voor scenario-analyse en operationele ondersteuning.', 'beschrijving' => 'Valideren van 2025-ontwikkelingen (Measurement, Rotating Machine, Aeration Tank, MGC, Diffuser) op het fysieke prototype.',
'eigenaar_id' => $analyst->id, 'eigenaar_id' => $rene->id,
'status' => SpeerpuntStatus::Actief,
]);
$spGemalenketen = Speerpunt::create([
'thema_id' => $themaLab->id,
'naam' => 'Gemalenketen Testomgeving',
'beschrijving' => 'Realiseren van een volledige gemalenketen in het lab voor experimentele afvlakkingsregelingen.',
'eigenaar_id' => $sjoerd->id,
'status' => SpeerpuntStatus::Concept, 'status' => SpeerpuntStatus::Concept,
]); ]);
$spAIVoorspelling = Speerpunt::create([ // Governance & Teamborging
'thema_id' => $themaData->id, $spProjectstructuur = Speerpunt::create([
'naam' => 'AI-gestuurde Waterstandsvoorspelling', 'thema_id' => $themaGovernance->id,
'beschrijving' => 'Machine learning modellen voor nauwkeurige korte- en middellangetermijnwaterstanden.', 'naam' => 'Projectstructuur & Documentatie',
'eigenaar_id' => $analyst->id, 'beschrijving' => 'Afspraken vastleggen over projectstructuur, documentatie, besluitvorming en beleid.',
'eigenaar_id' => $rene->id,
'status' => SpeerpuntStatus::Actief, 'status' => SpeerpuntStatus::Actief,
]); ]);
// Duurzaamheid $spRolafbakening = Speerpunt::create([
$spEnergieneutraal = Speerpunt::create([ 'thema_id' => $themaGovernance->id,
'thema_id' => $themaDuurzaam->id, 'naam' => 'Rolafbakening R&D ↔ Organisatie',
'naam' => 'Energieneutrale Zuivering', 'beschrijving' => 'Verduidelijken wat R&D wel en niet doet. Afbakening richting Beheer, Bouwen, ICT en Datalab.',
'beschrijving' => 'Zelfvoorzienende rwzi\'s door terugwinning van energie uit afvalwater.', 'eigenaar_id' => $rene->id,
'eigenaar_id' => $engineer->id,
'status' => SpeerpuntStatus::Actief,
]);
$spCirculaireWater = Speerpunt::create([
'thema_id' => $themaDuurzaam->id,
'naam' => 'Circulaire Waterketen',
'beschrijving' => 'Terugwinning van grondstoffen (fosfaat, cellulose, warmte) uit afvalwater.',
'eigenaar_id' => $testUser->id,
'status' => SpeerpuntStatus::Concept, 'status' => SpeerpuntStatus::Concept,
]); ]);
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
// 5. Projects (1012 spread across themes and lifecycle phases) // 5. Projects 2026 planning (6 projects from the presentation)
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
$projects = []; // --- B.R.I.D.G.E (Bidirectional Real-time Interface for Data & Grid Exchange) ---
$bridge = $this->createProject([
// --- Waterkwaliteit projects --- 'speerpunt_id' => $spEdge->id,
$p1 = $this->createProject([ 'naam' => 'B.R.I.D.G.E Pilot Klundert',
'speerpunt_id' => $spWaterMonitoring->id, 'beschrijving' => 'Aantonen dat de nieuwe EDGE-laag veilig en betrouwbaar assets kan uitlezen en aansturen, en correct kan koppelen met Siemens-omgevingen. Implementatie Ubuntu LTS, Node-RED orkestratie, OPC UA communicatie met Siemens PLC (T-serie).',
'naam' => 'LoRaWAN Sensornetwerk Biesbosch', 'eigenaar_id' => $rene->id,
'beschrijving' => 'Uitrol van een draadloos sensornetwerk in het Biesbosch-gebied voor real-time meting van waterkwaliteitsparameters (pH, DO, troebelheid, geleidbaarheid).',
'eigenaar_id' => $testUser->id,
'status' => ProjectStatus::Pilot, 'status' => ProjectStatus::Pilot,
'prioriteit' => Prioriteit::Hoog, 'prioriteit' => Prioriteit::Hoog,
'startdatum' => '2025-03-01', 'startdatum' => '2026-01-15',
'streef_einddatum' => '2025-12-31', 'streef_einddatum' => '2026-09-30',
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment, FaseType::Pilot]); ], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment, FaseType::Pilot]);
$p2 = $this->createProject([ // --- C.R.I.S.P (Compressor Replacement for Immediate System Performance) ---
'speerpunt_id' => $spWaterMonitoring->id, $crisp = $this->createProject([
'naam' => 'Drone-inspectie Waterbodem', 'speerpunt_id' => $spAanbesteding->id,
'beschrijving' => 'Onderzoek naar de inzet van autonome onderwaterdrones voor sedimentkartering en vervuilingdetectie in kanalen en sloten.', 'naam' => 'C.R.I.S.P Compressor Aanbesteding',
'eigenaar_id' => $engineer->id, 'beschrijving' => 'Technische toetsing van aanbestedingsmethodiek voor compressorvervanging Nieuwveer. Prestatie-eisen, energie-efficiëntie, inpasbaarheid in procesautomatisering. R&D als inhoudelijke sparringpartner voor Bouwen.',
'status' => ProjectStatus::Experiment, 'eigenaar_id' => $pim->id,
'prioriteit' => Prioriteit::Midden,
'startdatum' => '2025-06-01',
'streef_einddatum' => '2026-06-30',
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment]);
$p3 = $this->createProject([
'speerpunt_id' => $spMicropollution->id,
'naam' => 'PFAS Detectiemethode Ontwikkeling',
'beschrijving' => 'Ontwikkeling van een snelle en goedkope veldmethode voor PFAS-detectie in oppervlaktewater, als alternatief voor kostbare laboratoriumanalyse.',
'eigenaar_id' => $analyst->id,
'status' => ProjectStatus::Verkenning, 'status' => ProjectStatus::Verkenning,
'prioriteit' => Prioriteit::Hoog, 'prioriteit' => Prioriteit::Hoog,
'startdatum' => '2025-09-01', 'startdatum' => '2026-02-01',
'streef_einddatum' => '2026-09-30', 'streef_einddatum' => '2026-08-31',
], [FaseType::Signaal, FaseType::Verkenning]); ], [FaseType::Signaal, FaseType::Verkenning]);
// --- Slimme Infrastructuur projects --- // --- W.I.S.E (Weather and Influent Sampling Engine) ---
$p4 = $this->createProject([ $wise = $this->createProject([
'speerpunt_id' => $spDigitaalBeheer->id, 'speerpunt_id' => $spOverdracht->id,
'naam' => 'BIM-model Gemaal De Donge', 'naam' => 'W.I.S.E Monsternamekast Overdracht',
'beschrijving' => 'Digitale driedimensionale representatie van gemaal De Donge inclusief alle technische installaties, leidingen en elektrotechnische componenten.', 'beschrijving' => 'Overdracht monsternamekast naar Bouwen. Opleveren Node-RED flows, documentatie, afbakening R&D vs beheer. Samenwerking met Datalab (data & integratie voorspellend model) en Beheer/Operatie.',
'eigenaar_id' => $engineer->id, 'eigenaar_id' => $rene->id,
'status' => ProjectStatus::OverdrachtBouwen, 'status' => ProjectStatus::OverdrachtBouwen,
'prioriteit' => Prioriteit::Midden, 'prioriteit' => Prioriteit::Hoog,
'startdatum' => '2024-09-01', 'startdatum' => '2025-06-01',
'streef_einddatum' => '2025-06-30', 'streef_einddatum' => '2026-06-30',
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment, FaseType::Pilot, FaseType::Besluitvorming, FaseType::OverdrachtBouwen]); ], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment, FaseType::Pilot, FaseType::Besluitvorming, FaseType::OverdrachtBouwen]);
$p5 = $this->createProject([ // --- Gemaal 3.0 Prototype Validatie ---
'speerpunt_id' => $spSmartGemaal->id, $gemaal = $this->createProject([
'naam' => 'Predictieve Gemaalbesturing Mark-Vliet', 'speerpunt_id' => $spDtValidatie->id,
'beschrijving' => 'Implementatie van een ML-algoritme dat op basis van KNMI-weerdata en historische afvoerpatronen de optimale pompsturing berekent voor het Mark-Vliet systeem.', 'naam' => 'Gemaal 3.0 Prototype Validatie',
'eigenaar_id' => $testUser->id, 'beschrijving' => 'Valideren dat het recent gebouwde prototype functioneel correct is. Testen van alle elektrische aansluitingen, I/O-functionaliteit en randapparatuur. Go/no-go voor verdere uitrol naar 2 kopieën.',
'status' => ProjectStatus::Besluitvorming, 'eigenaar_id' => $sjoerd->id,
'prioriteit' => Prioriteit::Hoog, 'status' => ProjectStatus::Experiment,
'startdatum' => '2025-01-01', 'prioriteit' => Prioriteit::Midden,
'streef_einddatum' => '2025-12-31', 'startdatum' => '2026-03-01',
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment, FaseType::Pilot, FaseType::Besluitvorming]); 'streef_einddatum' => '2026-07-31',
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment]);
$p6 = $this->createProject([ // --- Afvlakkingsregeling Lab Gemalenketen ---
'speerpunt_id' => $spSmartGemaal->id, $afvlak = $this->createProject([
'naam' => 'Remote Monitoring Waterkeringen', 'speerpunt_id' => $spGemalenketen->id,
'beschrijving' => 'Continuemonitoring van primaire waterkeringen met IoT-sensoren voor zakking, piping-detectie en grondwaterstand.', 'naam' => 'Afvlakkingsregeling Lab Keten',
'eigenaar_id' => $engineer->id, 'beschrijving' => 'Nabootsen van een volledige gemalenketen in het lab, klaarstomen voor afvlakkingsregeling. Realiseren 2 extra opstellingen, koppelen tot keten, basissoftware testen op ketengedrag.',
'status' => ProjectStatus::Signaal, 'eigenaar_id' => $sjoerd->id,
'prioriteit' => Prioriteit::Laag,
'startdatum' => '2026-01-01',
'streef_einddatum' => null,
], [FaseType::Signaal]);
// --- Data-gedreven Beheer projects ---
$p7 = $this->createProject([
'speerpunt_id' => $spDigitaalTwin->id,
'naam' => 'Digitale Tweeling Pilot Roosendaalse Vliet',
'beschrijving' => 'Eerste proof-of-concept van een digitale tweeling voor het deelgebied Roosendaalse Vliet, gekoppeld aan het SOBEK-hydraulisch model.',
'eigenaar_id' => $analyst->id,
'status' => ProjectStatus::Concept, 'status' => ProjectStatus::Concept,
'prioriteit' => Prioriteit::Hoog, 'prioriteit' => Prioriteit::Midden,
'startdatum' => '2025-07-01', 'startdatum' => '2026-06-01',
'streef_einddatum' => '2026-12-31', 'streef_einddatum' => '2026-12-31',
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept]); ], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept]);
$p8 = $this->createProject([ // --- Structuur & Borging R&D-team ---
'speerpunt_id' => $spAIVoorspelling->id, $governance = $this->createProject([
'naam' => 'AI Waterstandsmodel Hollandsch Diep', 'speerpunt_id' => $spProjectstructuur->id,
'beschrijving' => 'Training en validatie van een LSTM-neuraal netwerk voor 48-uurs waterstandsvoorspellingen op het Hollandsch Diep, ter vervanging van het huidige regressiemodel.', 'naam' => 'Structuur & Borging R&D',
'eigenaar_id' => $analyst->id, 'beschrijving' => 'R&D minder persoonsafhankelijk en beter voorspelbaar maken. Afspraken over projectstructuur, documentatie, besluitvorming, beleid. Inrichten ICT in Bouvigne voor R&D en Datanetwerkteam.',
'status' => ProjectStatus::Experiment, 'eigenaar_id' => $rene->id,
'prioriteit' => Prioriteit::Hoog,
'startdatum' => '2025-04-01',
'streef_einddatum' => '2026-03-31',
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment]);
$p9 = $this->createProject([
'speerpunt_id' => $spAIVoorspelling->id,
'naam' => 'Open Data Platform Waterschap',
'beschrijving' => 'Ontwikkeling van een publiekstoegankelijk data-portaal voor het ontsluiten van historische en actuele meetdata van het waterschap.',
'eigenaar_id' => $testUser->id,
'status' => ProjectStatus::Geparkeerd,
'prioriteit' => Prioriteit::Laag,
'startdatum' => '2024-06-01',
'streef_einddatum' => '2025-12-31',
], [FaseType::Signaal, FaseType::Verkenning]);
// --- Duurzaamheid projects ---
$p10 = $this->createProject([
'speerpunt_id' => $spEnergieneutraal->id,
'naam' => 'Biogasopwaardering RWZI Bath',
'beschrijving' => 'Opwaardering van slibvergistingsgas naar groengas-kwaliteit voor invoeding op het gasnet en verkoop aan een energiemaatschappij.',
'eigenaar_id' => $engineer->id,
'status' => ProjectStatus::Evaluatie,
'prioriteit' => Prioriteit::Midden,
'startdatum' => '2024-01-01',
'streef_einddatum' => '2025-06-30',
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment, FaseType::Pilot, FaseType::Besluitvorming, FaseType::OverdrachtBouwen, FaseType::OverdrachtBeheer, FaseType::Evaluatie]);
$p11 = $this->createProject([
'speerpunt_id' => $spCirculaireWater->id,
'naam' => 'Fosfaatterugwinning Struviet',
'beschrijving' => 'Implementatie van struvietkristallisatie-technologie op RWZI Nieuw-Vossemeer voor de terugwinning van fosfaat als meststof.',
'eigenaar_id' => $testUser->id,
'status' => ProjectStatus::Pilot,
'prioriteit' => Prioriteit::Midden,
'startdatum' => '2025-02-01',
'streef_einddatum' => '2026-02-28',
], [FaseType::Signaal, FaseType::Verkenning, FaseType::Concept, FaseType::Experiment, FaseType::Pilot]);
$p12 = $this->createProject([
'speerpunt_id' => $spEnergieneutraal->id,
'naam' => 'Warmteterugwinning Afvalwater Centrum',
'beschrijving' => 'Pilotinstallatie voor warmtewisselaars op het rioolstelsel in Breda-centrum om warmte terug te winnen voor stadsverwarming.',
'eigenaar_id' => $engineer->id,
'status' => ProjectStatus::Verkenning, 'status' => ProjectStatus::Verkenning,
'prioriteit' => Prioriteit::Midden, 'prioriteit' => Prioriteit::Midden,
'startdatum' => '2025-10-01', 'startdatum' => '2026-01-01',
'streef_einddatum' => '2027-03-31', 'streef_einddatum' => '2026-12-31',
], [FaseType::Signaal, FaseType::Verkenning]); ], [FaseType::Signaal, FaseType::Verkenning]);
$projects = [$p1, $p2, $p3, $p4, $p5, $p6, $p7, $p8, $p9, $p10, $p11, $p12];
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
// 6. Assign team members // 6. Assign team members
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
$p1->teamleden()->attach($analyst->id, ['rol' => ProjectRol::Lid->value]); $bridge->teamleden()->attach($pim->id, ['rol' => ProjectRol::Lid->value]);
$p2->teamleden()->attach($testUser->id, ['rol' => ProjectRol::Reviewer->value]); $bridge->teamleden()->attach($sjoerd->id, ['rol' => ProjectRol::Lid->value]);
$p5->teamleden()->attach($analyst->id, ['rol' => ProjectRol::Lid->value]); $crisp->teamleden()->attach($rene->id, ['rol' => ProjectRol::Reviewer->value]);
$p5->teamleden()->attach($engineer->id, ['rol' => ProjectRol::Reviewer->value]); $wise->teamleden()->attach($pim->id, ['rol' => ProjectRol::Lid->value]);
$p8->teamleden()->attach($engineer->id, ['rol' => ProjectRol::Lid->value]); $gemaal->teamleden()->attach($rene->id, ['rol' => ProjectRol::Reviewer->value]);
$afvlak->teamleden()->attach($rene->id, ['rol' => ProjectRol::Reviewer->value]);
$governance->teamleden()->attach($pim->id, ['rol' => ProjectRol::Lid->value]);
$governance->teamleden()->attach($sjoerd->id, ['rol' => ProjectRol::Lid->value]);
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
// 7. Commitments // 7. Commitments
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
// BRIDGE
Commitment::create([ Commitment::create([
'project_id' => $p1->id, 'project_id' => $bridge->id,
'beschrijving' => 'Installatie van 20 sensorknooppunten in het veld vóór einde Q3 2025', 'beschrijving' => 'Architectuur validatiedocument opleveren conform stackKlundertPilot.pdf',
'eigenaar_id' => $testUser->id, 'eigenaar_id' => $rene->id,
'deadline' => '2025-09-30', 'deadline' => '2026-04-30',
'status' => CommitmentStatus::InUitvoering, 'status' => CommitmentStatus::InUitvoering,
'bron' => 'Stuurgroep Waterkwaliteit — 12 maart 2025', 'bron' => 'Planning 2026 slide 3',
]); ]);
Commitment::create([ Commitment::create([
'project_id' => $p1->id, 'project_id' => $bridge->id,
'beschrijving' => 'Validatierapport sensornauwkeurigheid opleveren aan dataplatformteam', 'beschrijving' => 'OT/IT beveiligingsassessment en scheidingsrapport',
'eigenaar_id' => $analyst->id, 'eigenaar_id' => $pim->id,
'deadline' => '2025-11-30', 'deadline' => '2026-06-30',
'status' => CommitmentStatus::Open, 'status' => CommitmentStatus::Open,
'bron' => 'Stuurgroep Waterkwaliteit — 12 maart 2025', 'bron' => 'Planning 2026 slide 3',
]); ]);
Commitment::create([ Commitment::create([
'project_id' => $p5->id, 'project_id' => $bridge->id,
'beschrijving' => 'Afstemming met Rijkswaterstaat over databeschikbaarheid afvoermetingen', 'beschrijving' => 'Beslisdocument voor opschaling EDGE-laag naar andere locaties',
'eigenaar_id' => $engineer->id, 'eigenaar_id' => $rene->id,
'deadline' => '2025-07-31', 'deadline' => '2026-09-30',
'status' => CommitmentStatus::Afgerond,
'bron' => 'Projectstartup 15 januari 2025',
]);
Commitment::create([
'project_id' => $p5->id,
'beschrijving' => 'Businesscase energiebesparing opstellen voor directie',
'eigenaar_id' => $testUser->id,
'deadline' => '2025-10-15',
'status' => CommitmentStatus::Open, 'status' => CommitmentStatus::Open,
'bron' => 'Besluitvormingsrapport fase 5', 'bron' => 'Planning 2026 slide 3',
]);
// CRISP
Commitment::create([
'project_id' => $crisp->id,
'beschrijving' => 'TCO-onderbouwde aanbestedingsdocumenten technisch getoetst vóór marktgang',
'eigenaar_id' => $pim->id,
'deadline' => '2026-05-31',
'status' => CommitmentStatus::Open,
'bron' => 'Planning 2026 slide 4',
]);
// WISE
Commitment::create([
'project_id' => $wise->id,
'beschrijving' => 'Node-RED flows opleveren aan Bouwen / uitvoerende partij',
'eigenaar_id' => $rene->id,
'deadline' => '2026-03-31',
'status' => CommitmentStatus::InUitvoering,
'bron' => 'Planning 2026 slide 5',
]); ]);
Commitment::create([ Commitment::create([
'project_id' => $p8->id, 'project_id' => $wise->id,
'beschrijving' => 'Trainingsdata leveren: minimaal 5 jaar uurlijkse waterstandsmetingen', 'beschrijving' => 'Overdracht- en documentatiepakket compleet (afbakening R&D vs beheer)',
'eigenaar_id' => $analyst->id, 'eigenaar_id' => $rene->id,
'deadline' => '2025-08-01', 'deadline' => '2026-06-30',
'status' => CommitmentStatus::Afgerond, 'status' => CommitmentStatus::Open,
'bron' => 'Projectplan AI Waterstandsmodel v1.0', 'bron' => 'Planning 2026 slide 5',
]);
// Gemaal 3.0
Commitment::create([
'project_id' => $gemaal->id,
'beschrijving' => 'Go/no-go besluit voor uitrol naar 2 kopieën op basis van testresultaten',
'eigenaar_id' => $sjoerd->id,
'deadline' => '2026-06-30',
'status' => CommitmentStatus::Open,
'bron' => 'Planning 2026 slide 6',
]); ]);
Commitment::create([ Commitment::create([
'project_id' => $p10->id, 'project_id' => $gemaal->id,
'beschrijving' => 'Eindrapportage energieopbrengst en milieuprestatie biogasinstallatie', 'beschrijving' => 'Overzicht software/hardware functionaliteiten en validatie digital twins',
'eigenaar_id' => $engineer->id, 'eigenaar_id' => $rene->id,
'deadline' => '2025-05-31', 'deadline' => '2026-07-31',
'status' => CommitmentStatus::Afgerond, 'status' => CommitmentStatus::Open,
'bron' => 'Evaluatieprogramma RWZI Bath', 'bron' => 'Planning 2026 slide 6',
]);
// Afvlakkingsregeling
Commitment::create([
'project_id' => $afvlak->id,
'beschrijving' => '2 extra opstellingen gerealiseerd en gekoppeld tot keten',
'eigenaar_id' => $sjoerd->id,
'deadline' => '2026-10-31',
'status' => CommitmentStatus::Open,
'bron' => 'Planning 2026 slide 7',
]);
// Governance
Commitment::create([
'project_id' => $governance->id,
'beschrijving' => 'Projectstructuur- en documentatieafspraken vastgelegd',
'eigenaar_id' => $rene->id,
'deadline' => '2026-03-31',
'status' => CommitmentStatus::InUitvoering,
'bron' => 'Planning 2026 slide 8',
]); ]);
Commitment::create([ Commitment::create([
'project_id' => $p11->id, 'project_id' => $governance->id,
'beschrijving' => 'Technische specificaties struvietreactor aanleveren aan leverancier', 'beschrijving' => 'Rolafbakening R&D ↔ Beheer ↔ Bouwen vastgelegd en gecommuniceerd',
'eigenaar_id' => $testUser->id, 'eigenaar_id' => $rene->id,
'deadline' => '2025-05-01', 'deadline' => '2026-06-30',
'status' => CommitmentStatus::Afgerond, 'status' => CommitmentStatus::Open,
'bron' => 'Pilotopzet Fosfaatterugwinning', 'bron' => 'Planning 2026 slide 8',
]); ]);
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
// 8. Documents // 8. Documents
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
Document::create([ Document::create([
'project_id' => $p1->id, 'project_id' => $bridge->id,
'titel' => 'Technisch Ontwerp LoRaWAN Netwerk', 'titel' => 'Stack Architectuur Klundert Pilot',
'type' => 'technisch_ontwerp', 'type' => 'technisch_ontwerp',
'inhoud' => 'Systeemarchitectuur, gatewaylocaties, frequentieplan en databeheerprotocol voor het LoRaWAN sensornetwerk in het Biesboschgebied.', 'inhoud' => 'Systeemarchitectuur EDGE-laag: Ubuntu LTS, Node-RED orkestratie, OPC UA communicatie met Siemens PLC T-serie, InfluxDB/Grafana datastromen.',
'versie' => 2,
'auteur_id' => $engineer->id,
]);
Document::create([
'project_id' => $p1->id,
'titel' => 'Projectplan Fase Pilot',
'type' => 'projectplan',
'inhoud' => 'Doelstellingen, activiteiten, planning en risico\'s voor de pilotfase van het LoRaWAN sensornetwerk.',
'versie' => 1, 'versie' => 1,
'auteur_id' => $testUser->id, 'auteur_id' => $rene->id,
]); ]);
Document::create([ Document::create([
'project_id' => $p5->id, 'project_id' => $wise->id,
'titel' => 'Businesscase Predictieve Gemaalbesturing', 'titel' => 'Overdrachtsprotocol Monsternamekast',
'type' => 'businesscase',
'inhoud' => 'Kosten-batenanalyse voor de implementatie van ML-gestuurde pompsturing, inclusief energiebesparingspotentieel en investeringskosten.',
'versie' => 3,
'auteur_id' => $testUser->id,
]);
Document::create([
'project_id' => $p8->id,
'titel' => 'Modelarchitectuur LSTM Waterstandsvoorspelling',
'type' => 'technisch_rapport',
'inhoud' => 'Gedetailleerde beschrijving van de LSTM-netwerkarchitectuur, feature engineering, trainingsopzet en validatiemethodiek.',
'versie' => 1,
'auteur_id' => $analyst->id,
]);
Document::create([
'project_id' => $p10->id,
'titel' => 'Evaluatierapport Biogasopwaardering RWZI Bath',
'type' => 'evaluatierapport',
'inhoud' => 'Eindresultaten van de biogasopwaarderingsinstallatie: energieproductie, CH4-gehalte, opbrengst en geleerde lessen.',
'versie' => 1,
'auteur_id' => $engineer->id,
]);
Document::create([
'project_id' => $p4->id,
'titel' => 'BIM-protocol Kunstwerken v2.0',
'type' => 'protocol', 'type' => 'protocol',
'inhoud' => 'Afspraken voor objectcodering, LOD-niveaus, attribuutvelden en uitwisselformaten (IFC) voor het BIM-model van kunstwerken.', 'inhoud' => 'Opleverdocument voor de overdracht van de monsternamekast naar Bouwen. Bevat: Node-RED flow specificaties, integratie voorspellend model, afbakening verantwoordelijkheden.',
'versie' => 2, 'versie' => 1,
'auteur_id' => $engineer->id, 'auteur_id' => $rene->id,
]);
Document::create([
'project_id' => $crisp->id,
'titel' => 'Technische Toetsing Aanbesteding Compressoren',
'type' => 'technisch_rapport',
'inhoud' => 'Q&A en technische beoordeling van aanbestedingsdocumenten voor compressorvervanging Nieuwveer. TCO-analyse, prestatie-eisen, energie-efficiëntie.',
'versie' => 1,
'auteur_id' => $pim->id,
]);
Document::create([
'project_id' => $gemaal->id,
'titel' => 'Testrapport Prototype Gemaal 3.0',
'type' => 'technisch_rapport',
'inhoud' => 'Bevindingen en verbeterpunten uit validatie van het prototype: elektrische aansluitingen, I/O-functionaliteit, randapparatuur, digital twin koppelingen.',
'versie' => 1,
'auteur_id' => $sjoerd->id,
]);
Document::create([
'project_id' => $governance->id,
'titel' => 'R&D Projectstructuur & Beleidskader',
'type' => 'projectplan',
'inhoud' => 'Afspraken over projectstructuur, documentatiestandaarden, besluitvormingsproces en beleid voor het R&D-lab.',
'versie' => 1,
'auteur_id' => $rene->id,
]); ]);
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
// 9. Dependencies between projects // 9. Dependencies between projects
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
// AI Waterstandsmodel is afhankelijk van LoRaWAN Sensornetwerk (databron) // Afvlakkingsregeling hangt af van Gemaal 3.0 (prototype moet gevalideerd zijn)
Afhankelijkheid::create([ Afhankelijkheid::create([
'project_id' => $p8->id, 'project_id' => $afvlak->id,
'afhankelijk_van_project_id' => $p1->id, 'afhankelijk_van_project_id' => $gemaal->id,
'type' => 'data',
'beschrijving' => 'Het AI-model heeft real-time sensordata nodig uit het LoRaWAN netwerk als input feature.',
'status' => 'open',
]);
// Digitale Tweeling is afhankelijk van AI Waterstandsmodel
Afhankelijkheid::create([
'project_id' => $p7->id,
'afhankelijk_van_project_id' => $p8->id,
'type' => 'technisch', 'type' => 'technisch',
'beschrijving' => 'De digitale tweeling integreert de voorspellingsmodule van het AI waterstandsmodel.', 'beschrijving' => 'De afvlakkingsregeling vereist gevalideerde prototypes van Gemaal 3.0 (× 2 kopieën) als basis voor de ketenopstelling.',
'status' => 'open', 'status' => 'open',
]); ]);
// Predictieve Gemaalbesturing is afhankelijk van AI Waterstandsmodel // WISE overdracht profiteert van BRIDGE architectuur (gedeelde EDGE-infrastructuur)
Afhankelijkheid::create([ Afhankelijkheid::create([
'project_id' => $p5->id, 'project_id' => $wise->id,
'afhankelijk_van_project_id' => $p8->id, 'afhankelijk_van_project_id' => $bridge->id,
'type' => 'technisch',
'beschrijving' => 'De predictieve besturing gebruikt de 48-uurs waterstandsvoorspellingen als stuurinput.',
'status' => 'open',
]);
// Remote Monitoring Waterkeringen is afhankelijk van LoRaWAN Sensornetwerk
Afhankelijkheid::create([
'project_id' => $p6->id,
'afhankelijk_van_project_id' => $p1->id,
'type' => 'infrastructuur', 'type' => 'infrastructuur',
'beschrijving' => 'Het monitoring-project maakt gebruik van de LoRaWAN-infrastructuur voor datatransport.', 'beschrijving' => 'De monsternamekast draait op dezelfde EDGE-architectuur die BRIDGE valideert.',
'status' => 'open',
]);
// Gemaal 3.0 validatie van digital twins hangt samen met BRIDGE architectuur
Afhankelijkheid::create([
'project_id' => $gemaal->id,
'afhankelijk_van_project_id' => $bridge->id,
'type' => 'technisch',
'beschrijving' => 'Digital twin validatie in Gemaal 3.0 gebruikt dezelfde EDGE-laag als bewezen in BRIDGE.',
'status' => 'open', 'status' => 'open',
]); ]);
} }
/** /**
* Helper: create a project with its completed and active phases. * Helper: create a project with its completed and active phases.
*
* @param array<string, mixed> $attributes
* @param FaseType[] $faseTypes All phase types in chronological order; the last one is the active phase.
*/ */
private function createProject(array $attributes, array $faseTypes): Project private function createProject(array $attributes, array $faseTypes): Project
{ {
$project = Project::create($attributes); $project = Project::create($attributes);
// Attach the project owner as eigenaar in the pivot table
$project->teamleden()->attach($attributes['eigenaar_id'], ['rol' => ProjectRol::Eigenaar->value]); $project->teamleden()->attach($attributes['eigenaar_id'], ['rol' => ProjectRol::Eigenaar->value]);
foreach ($faseTypes as $index => $faseType) { foreach ($faseTypes as $index => $faseType) {

Binary file not shown.

View File

@@ -1,7 +1,18 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref, computed } from 'vue'
const emit = defineEmits(['create-project', 'create-theme']) const props = defineProps({
depth: { type: Number, default: 1 },
parentEntityType: { type: String, default: null },
parentProjectId: { type: Number, default: null },
})
const emit = defineEmits([
'create-project',
'create-theme',
'create-commitment',
'create-document',
])
const menuOpen = ref(false) const menuOpen = ref(false)
@@ -9,14 +20,25 @@ const toggle = () => {
menuOpen.value = !menuOpen.value menuOpen.value = !menuOpen.value
} }
const handleCreateProject = () => { /** Options change based on which dimension we're in */
menuOpen.value = false const menuItems = computed(() => {
emit('create-project') if (props.depth > 1 && props.parentEntityType === 'project') {
// Inside a project dimension: create project-level items
return [
{ label: 'Nieuw commitment', event: 'create-commitment' },
{ label: 'Nieuw document', event: 'create-document' },
]
} }
// Root dimension: create top-level items
return [
{ label: 'Nieuw project', event: 'create-project' },
{ label: 'Nieuw thema', event: 'create-theme' },
]
})
const handleCreateTheme = () => { const handleItemClick = (item) => {
menuOpen.value = false menuOpen.value = false
emit('create-theme') emit(item.event)
} }
</script> </script>
@@ -25,13 +47,14 @@ const handleCreateTheme = () => {
<!-- Expanded menu --> <!-- Expanded menu -->
<Transition name="fab-menu"> <Transition name="fab-menu">
<div v-if="menuOpen" class="fab-menu"> <div v-if="menuOpen" class="fab-menu">
<button class="fab-menu-item" @click="handleCreateProject"> <button
v-for="item in menuItems"
:key="item.event"
class="fab-menu-item"
@click="handleItemClick(item)"
>
<span class="fab-menu-icon">+</span> <span class="fab-menu-icon">+</span>
<span class="fab-menu-label">Nieuw project</span> <span class="fab-menu-label">{{ item.label }}</span>
</button>
<button class="fab-menu-item" @click="handleCreateTheme">
<span class="fab-menu-icon">+</span>
<span class="fab-menu-label">Nieuw thema</span>
</button> </button>
</div> </div>
</Transition> </Transition>
@@ -51,7 +74,7 @@ const handleCreateTheme = () => {
<style scoped> <style scoped>
.fab-container { .fab-container {
position: fixed; position: fixed;
bottom: 64px; /* above the CLI bar */ bottom: 64px;
right: 20px; right: 20px;
z-index: 150; z-index: 150;
display: flex; display: flex;

View File

@@ -7,10 +7,10 @@ import * as d3 from 'd3'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const props = defineProps({ const props = defineProps({
dimensions: { type: Object, default: () => ({}) }, // root dimension data dimensions: { type: Object, default: () => ({}) },
currentPath: { type: Array, default: () => [] }, // breadcrumb path of dimension IDs currentPath: { type: Array, default: () => [] },
// Legacy flat-prop support (for backward compat with MetroMap.vue) // Legacy flat-prop support
nodes: { type: Array, default: () => [] }, nodes: { type: Array, default: () => [] },
lines: { type: Array, default: () => [] }, lines: { type: Array, default: () => [] },
connections: { type: Array, default: () => [] }, connections: { type: Array, default: () => [] },
@@ -33,8 +33,10 @@ const emit = defineEmits([
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const ZOOM_IN_THRESHOLD = 2.5 const ZOOM_IN_THRESHOLD = 2.5
const ZOOM_OUT_THRESHOLD = 0.5 const ZOOM_OUT_THRESHOLD = 0.6
const TRANSITION_RANGE = 1.5 const ZOOM_IN_COMMIT = 4.0 // commit at this scale (smoother range)
const ZOOM_OUT_COMMIT = 0.25 // commit at this scale
const NODE_PROXIMITY = 250 // how close to a node to trigger zoom-in
const LINE_COLORS = [ const LINE_COLORS = [
'#00d2ff', '#e94560', '#00ff88', '#7b68ee', '#00d2ff', '#e94560', '#00ff88', '#7b68ee',
@@ -51,29 +53,32 @@ const containerRef = ref(null)
const transform = ref(d3.zoomIdentity) const transform = ref(d3.zoomIdentity)
const hoveredNode = ref(null) const hoveredNode = ref(null)
// Dimension stack: each entry is a full metro-map dimension object // Dimension stack
const dimensionStack = ref([]) const dimensionStack = ref([])
const currentDimensionIndex = ref(0) const currentDimensionIndex = ref(0)
// Transition state // Transition state
const transitionState = ref({ const transitionState = ref({
active: false, active: false,
direction: null, // 'in' | 'out' direction: null,
progress: 0, progress: 0,
targetNode: null, targetNode: null,
childDimension: null, childDimension: null,
}) })
// Tracks whether we're mid-commit (prevent re-entry)
let isCommitting = false
// Context menu // Context menu
const contextMenu = ref({ const contextMenu = ref({
show: false, show: false,
x: 0, y: 0, x: 0, y: 0,
type: null, // 'canvas' | 'node' type: null,
node: null, node: null,
canvasX: 0, canvasY: 0, canvasX: 0, canvasY: 0,
}) })
// D3 references (module-level, not reactive) // D3 references
let svg, g, zoom let svg, g, zoom
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -82,26 +87,30 @@ let svg, g, zoom
const getLineColor = (index) => LINE_COLORS[index % LINE_COLORS.length] const getLineColor = (index) => LINE_COLORS[index % LINE_COLORS.length]
/** The dimension data that is currently "active" (top of stack) */
const currentDimensionData = computed(() => { const currentDimensionData = computed(() => {
return dimensionStack.value[currentDimensionIndex.value] ?? null return dimensionStack.value[currentDimensionIndex.value] ?? null
}) })
/** Flat list of nodes in the current dimension */
const currentNodes = computed(() => { const currentNodes = computed(() => {
return currentDimensionData.value?.nodes ?? [] return currentDimensionData.value?.nodes ?? []
}) })
/** Current depth (1-based) */
const currentDepth = computed(() => currentDimensionIndex.value + 1) const currentDepth = computed(() => currentDimensionIndex.value + 1)
/** Whether we're currently inside a child dimension (not root) */
const isInChildDimension = computed(() => currentDimensionIndex.value > 0)
/** The parent node we zoomed into (if in child dimension) */
const parentNode = computed(() => {
if (!isInChildDimension.value) return null
return currentDimensionData.value?.parentNodeId ?? null
})
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Bootstrap dimension stack from props // Bootstrap dimension stack from props
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const buildRootDimension = () => { const buildRootDimension = () => {
// If a structured `dimensions` prop is provided, use it as root.
// Otherwise fall back to the legacy flat props (nodes/lines/connections).
if (props.dimensions && (props.dimensions.nodes || props.dimensions.lines)) { if (props.dimensions && (props.dimensions.nodes || props.dimensions.lines)) {
return { return {
id: 'root', id: 'root',
@@ -154,7 +163,6 @@ const initCanvas = () => {
feMerge.append('feMergeNode').attr('in', 'coloredBlur') feMerge.append('feMergeNode').attr('in', 'coloredBlur')
feMerge.append('feMergeNode').attr('in', 'SourceGraphic') feMerge.append('feMergeNode').attr('in', 'SourceGraphic')
// Subtle glow for child indicator
const childGlowFilter = defs.append('filter') const childGlowFilter = defs.append('filter')
.attr('id', 'childGlow') .attr('id', 'childGlow')
.attr('x', '-100%').attr('y', '-100%') .attr('x', '-100%').attr('y', '-100%')
@@ -173,7 +181,7 @@ const initCanvas = () => {
// --- Zoom --- // --- Zoom ---
zoom = d3.zoom() zoom = d3.zoom()
.scaleExtent([0.1, 8]) .scaleExtent([0.15, 6])
.on('zoom', handleZoom) .on('zoom', handleZoom)
svg.call(zoom) svg.call(zoom)
@@ -212,13 +220,14 @@ const initCanvas = () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const handleZoom = (event) => { const handleZoom = (event) => {
if (isCommitting) return
const t = event.transform const t = event.transform
g.attr('transform', t) g.attr('transform', t)
transform.value = t transform.value = t
emit('zoom-change', { scale: t.k, x: t.x, y: t.y }) emit('zoom-change', { scale: t.k, x: t.x, y: t.y })
// Find nearest node to the viewport centre (in canvas space)
const w = containerRef.value?.clientWidth ?? 800 const w = containerRef.value?.clientWidth ?? 800
const h = containerRef.value?.clientHeight ?? 600 const h = containerRef.value?.clientHeight ?? 600
const cx = (w / 2 - t.x) / t.k const cx = (w / 2 - t.x) / t.k
@@ -229,9 +238,10 @@ const handleZoom = (event) => {
if (t.k > ZOOM_IN_THRESHOLD && if (t.k > ZOOM_IN_THRESHOLD &&
nearest && nearest &&
nearest.node.children && nearest.node.children &&
nearest.distance < 200) nearest.distance < NODE_PROXIMITY)
{ {
const progress = Math.min(1, (t.k - ZOOM_IN_THRESHOLD) / TRANSITION_RANGE) const range = ZOOM_IN_COMMIT - ZOOM_IN_THRESHOLD
const progress = Math.min(1, Math.max(0, (t.k - ZOOM_IN_THRESHOLD) / range))
transitionState.value = { transitionState.value = {
active: true, active: true,
@@ -251,10 +261,8 @@ const handleZoom = (event) => {
// --- Zoom-out transition --- // --- Zoom-out transition ---
if (t.k < ZOOM_OUT_THRESHOLD && dimensionStack.value.length > 1) { if (t.k < ZOOM_OUT_THRESHOLD && dimensionStack.value.length > 1) {
const progress = Math.min( const range = ZOOM_OUT_THRESHOLD - ZOOM_OUT_COMMIT
1, const progress = Math.min(1, Math.max(0, (ZOOM_OUT_THRESHOLD - t.k) / range))
(ZOOM_OUT_THRESHOLD - t.k) / (ZOOM_OUT_THRESHOLD - 0.1)
)
transitionState.value = { transitionState.value = {
active: true, active: true,
@@ -284,50 +292,94 @@ const handleZoom = (event) => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const commitDimensionChange = (direction, node) => { const commitDimensionChange = (direction, node) => {
if (isCommitting) return
isCommitting = true
if (direction === 'in' && node) { if (direction === 'in' && node) {
const children = node.children
const child = { const child = {
id: node.id, id: node.id,
parentNodeId: node.id, parentNodeId: node.id,
lines: node.children.lines ?? [], // Prefer metadata from backend, fall back to node's own entity info
nodes: node.children.nodes ?? [], parentEntityType: children.parentEntityType ?? node.entityType ?? null,
connections: node.children.connections ?? [], parentEntityId: children.parentEntityId ?? node.entityId ?? null,
parentName: children.parentName ?? node.name ?? null,
lines: children.lines ?? [],
nodes: children.nodes ?? [],
connections: children.connections ?? [],
opacity: 1, opacity: 1,
} }
dimensionStack.value.push(child) dimensionStack.value.push(child)
currentDimensionIndex.value++ currentDimensionIndex.value++
resetZoomToCenter() animateZoomReset()
transitionState.value.active = false transitionState.value.active = false
renderMap()
emit('dimension-change', { emit('dimension-change', {
direction: 'in', direction: 'in',
node, node,
depth: dimensionStack.value.length, depth: dimensionStack.value.length,
dimension: child,
}) })
} else if (direction === 'out') { } else if (direction === 'out') {
dimensionStack.value.pop() dimensionStack.value.pop()
currentDimensionIndex.value = Math.max(0, currentDimensionIndex.value - 1) currentDimensionIndex.value = Math.max(0, currentDimensionIndex.value - 1)
resetZoomToCenter() animateZoomReset()
transitionState.value.active = false transitionState.value.active = false
renderMap()
emit('dimension-change', { emit('dimension-change', {
direction: 'out', direction: 'out',
depth: dimensionStack.value.length, depth: dimensionStack.value.length,
dimension: currentDimensionData.value,
}) })
} }
} }
const resetZoomToCenter = () => { /**
if (!svg || !zoom) return * Smoothly animate back to the default zoom (scale=1, centered).
* This prevents the jarring snap that a hard reset causes.
*/
const animateZoomReset = () => {
if (!svg || !zoom) {
isCommitting = false
renderMap()
return
}
const w = containerRef.value?.clientWidth ?? 800 const w = containerRef.value?.clientWidth ?? 800
const h = containerRef.value?.clientHeight ?? 600 const h = containerRef.value?.clientHeight ?? 600
svg.call(
// Temporarily disable the zoom handler to prevent re-entry during animation
svg.on('.zoom', null)
svg.transition()
.duration(400)
.ease(d3.easeCubicOut)
.call(
zoom.transform, zoom.transform,
d3.zoomIdentity.translate(w / 2, h / 2).scale(1) d3.zoomIdentity.translate(w / 2, h / 2).scale(1)
) )
.on('end', () => {
// Re-enable zoom handler
svg.call(zoom)
svg.on('contextmenu', (event) => {
event.preventDefault()
const [x, y] = d3.pointer(event, g.node())
const clickedNode = findNodeAt(x, y)
contextMenu.value = {
show: true,
x: event.clientX,
y: event.clientY,
type: clickedNode ? 'node' : 'canvas',
node: clickedNode,
canvasX: x,
canvasY: y,
}
})
isCommitting = false
renderMap()
})
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -358,7 +410,6 @@ const findNodeAt = (x, y, dimData = null) => {
// Rendering // Rendering
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Render a complete metro-map dimension into a D3 group element */
const renderDimension = (dimData, opacity, parentGroup) => { const renderDimension = (dimData, opacity, parentGroup) => {
if (!dimData) return if (!dimData) return
@@ -378,7 +429,20 @@ const renderDimension = (dimData, opacity, parentGroup) => {
.filter(n => n.lineId === line.id) .filter(n => n.lineId === line.id)
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
if (lineNodes.length < 2) {
// Still render a single node's line label
if (lineNodes.length === 1) {
group.append('text')
.attr('x', lineNodes[0].x - 10)
.attr('y', lineNodes[0].y - 35)
.attr('fill', color)
.attr('font-family', "'VT323', monospace")
.attr('font-size', '16px')
.attr('opacity', 0.85)
.text(line.name)
}
if (lineNodes.length < 2) return if (lineNodes.length < 2) return
}
const lineGen = d3.line() const lineGen = d3.line()
.x(d => d.x) .x(d => d.x)
@@ -489,7 +553,7 @@ const renderDimension = (dimData, opacity, parentGroup) => {
}) })
.attr('stroke-width', 2) .attr('stroke-width', 2)
// Child-dimension indicator (pulsing ring for nodes that have children) // Child-dimension indicator
nodeGroups.filter(d => d.children && (d.children.nodes?.length ?? 0) > 0) nodeGroups.filter(d => d.children && (d.children.nodes?.length ?? 0) > 0)
.append('circle') .append('circle')
.attr('r', 16) .attr('r', 16)
@@ -533,7 +597,7 @@ const renderDimension = (dimData, opacity, parentGroup) => {
.text(d => d.badge) .text(d => d.badge)
} }
/** Normal (no transition) render */ /** Normal render */
const renderMap = () => { const renderMap = () => {
if (!g) return if (!g) return
g.selectAll('*').remove() g.selectAll('*').remove()
@@ -550,9 +614,7 @@ const renderWithTransition = () => {
const eased = d3.easeCubicInOut(progress) const eased = d3.easeCubicInOut(progress)
if (direction === 'in') { if (direction === 'in') {
// Parent fades out
renderDimension(currentDimensionData.value, 1 - eased, g) renderDimension(currentDimensionData.value, 1 - eased, g)
// Child fades in
if (childDimension) { if (childDimension) {
renderDimension( renderDimension(
{ {
@@ -565,9 +627,7 @@ const renderWithTransition = () => {
) )
} }
} else if (direction === 'out' && dimensionStack.value.length > 1) { } else if (direction === 'out' && dimensionStack.value.length > 1) {
// Current child fades out
renderDimension(currentDimensionData.value, 1 - eased, g) renderDimension(currentDimensionData.value, 1 - eased, g)
// Parent fades in
const parentDim = dimensionStack.value[currentDimensionIndex.value - 1] const parentDim = dimensionStack.value[currentDimensionIndex.value - 1]
if (parentDim) { if (parentDim) {
renderDimension(parentDim, eased, g) renderDimension(parentDim, eased, g)
@@ -588,7 +648,10 @@ const handleContextCreate = () => {
x: contextMenu.value.canvasX, x: contextMenu.value.canvasX,
y: contextMenu.value.canvasY, y: contextMenu.value.canvasY,
parentNodeId: currentDimensionData.value?.parentNodeId ?? null, parentNodeId: currentDimensionData.value?.parentNodeId ?? null,
parentEntityType: currentDimensionData.value?.parentEntityType ?? null,
parentEntityId: currentDimensionData.value?.parentEntityId ?? null,
dimensionId: currentDimensionData.value?.id ?? 'root', dimensionId: currentDimensionData.value?.id ?? 'root',
depth: currentDepth.value,
}) })
closeContextMenu() closeContextMenu()
} }
@@ -604,7 +667,10 @@ const handleContextAddChild = () => {
x: contextMenu.value.canvasX, x: contextMenu.value.canvasX,
y: contextMenu.value.canvasY, y: contextMenu.value.canvasY,
parentNodeId: contextMenu.value.node.id, parentNodeId: contextMenu.value.node.id,
parentEntityType: contextMenu.value.node.entityType ?? null,
parentEntityId: contextMenu.value.node.entityId ?? null,
dimensionId: contextMenu.value.node.id, dimensionId: contextMenu.value.node.id,
depth: currentDepth.value + 1,
addToNode: contextMenu.value.node, addToNode: contextMenu.value.node,
}) })
} }
@@ -616,12 +682,15 @@ const handleContextDelete = () => {
closeContextMenu() closeContextMenu()
} }
/** FAB / [+] button creates a node in the current dimension */ /** FAB creates a node in the current dimension */
const handleFabCreate = () => { const handleFabCreate = () => {
emit('create-node', { emit('create-node', {
x: 0, y: 0, x: 0, y: 0,
parentNodeId: currentDimensionData.value?.parentNodeId ?? null, parentNodeId: currentDimensionData.value?.parentNodeId ?? null,
parentEntityType: currentDimensionData.value?.parentEntityType ?? null,
parentEntityId: currentDimensionData.value?.parentEntityId ?? null,
dimensionId: currentDimensionData.value?.id ?? 'root', dimensionId: currentDimensionData.value?.id ?? 'root',
depth: currentDepth.value,
}) })
} }
@@ -640,7 +709,6 @@ const handleResize = () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const onDocumentClick = (e) => { const onDocumentClick = (e) => {
// Close context menu on any click outside it
if (contextMenu.value.show) { if (contextMenu.value.show) {
closeContextMenu() closeContextMenu()
} }
@@ -653,7 +721,6 @@ const onDocumentClick = (e) => {
watch( watch(
() => [props.nodes, props.lines, props.connections, props.dimensions], () => [props.nodes, props.lines, props.connections, props.dimensions],
() => { () => {
// Rebuild the root dimension from updated props
if (dimensionStack.value.length > 0) { if (dimensionStack.value.length > 0) {
dimensionStack.value[0] = buildRootDimension() dimensionStack.value[0] = buildRootDimension()
} else { } else {
@@ -693,7 +760,13 @@ const zoomTo = (x, y, scale) => {
) )
} }
defineExpose({ zoomTo, handleFabCreate }) defineExpose({
zoomTo,
handleFabCreate,
currentDepth,
currentDimensionData,
isInChildDimension,
})
</script> </script>
<template> <template>
@@ -706,7 +779,7 @@ defineExpose({ zoomTo, handleFabCreate })
<div class="depth-indicator"> <div class="depth-indicator">
DEPTH:&nbsp;{{ currentDepth }} DEPTH:&nbsp;{{ currentDepth }}
<span v-if="currentDepth > 1" class="depth-back" @click.stop="commitDimensionChange('out', null)"> <span v-if="currentDepth > 1" class="depth-back" @click.stop="commitDimensionChange('out', null)">
[ BACK] [&uarr; BACK]
</span> </span>
</div> </div>
@@ -746,13 +819,23 @@ defineExpose({ zoomTo, handleFabCreate })
@click.stop @click.stop
> >
<template v-if="contextMenu.type === 'canvas'"> <template v-if="contextMenu.type === 'canvas'">
<div class="context-menu-header">CANVAS</div> <div class="context-menu-header">
<button class="context-item" @click="handleContextCreate">+ New node here</button> {{ currentDepth > 1 ? 'PROJECT' : 'CANVAS' }}
</div>
<button class="context-item" @click="handleContextCreate">
+ {{ currentDepth > 1 ? 'New item here' : 'New node here' }}
</button>
</template> </template>
<template v-else-if="contextMenu.type === 'node'"> <template v-else-if="contextMenu.type === 'node'">
<div class="context-menu-header">{{ contextMenu.node?.name ?? 'NODE' }}</div> <div class="context-menu-header">{{ contextMenu.node?.name ?? 'NODE' }}</div>
<button class="context-item" @click="handleContextEdit">Edit</button> <button class="context-item" @click="handleContextEdit">Edit</button>
<button class="context-item" @click="handleContextAddChild">+ Add child node</button> <button
v-if="contextMenu.node?.children"
class="context-item"
@click="handleContextAddChild"
>
+ Add child node
</button>
<button class="context-item danger" @click="handleContextDelete">Delete</button> <button class="context-item danger" @click="handleContextDelete">Delete</button>
</template> </template>
</div> </div>

View File

@@ -17,34 +17,62 @@ const props = defineProps({
speerpunten: { type: Array, default: () => [] }, speerpunten: { type: Array, default: () => [] },
}) })
// Canvas ref
const canvasRef = ref(null)
// Navigation state // Navigation state
const selectedNode = ref(null) const selectedNode = ref(null)
const showPreview = ref(false) const showPreview = ref(false)
// Reactive breadcrumb based on mapData level and project // Dimension tracking (synced from canvas zoom transitions)
const canvasDepth = ref(1)
const canvasDimension = ref(null)
// Reactive breadcrumb based on both page-level and canvas-level navigation
const breadcrumbPath = computed(() => { const breadcrumbPath = computed(() => {
const level = props.mapData.level ?? 1 const pageLevel = props.mapData.level ?? 1
const project = props.mapData.project ?? null const project = props.mapData.project ?? null
const path = [{ label: 'Strategie', level: 1, data: null }] const path = [{ label: 'Strategie', level: 1, data: null }]
if (level === 2 && project) {
path.push({ label: project.name ?? project, level: 2, data: project }) if (pageLevel === 2 && project) {
// Page-level project view
path.push({ label: project.naam ?? project.name ?? 'Project', level: 2, data: project })
} else if (canvasDepth.value > 1 && canvasDimension.value) {
// Canvas zoom-in dimension
path.push({
label: canvasDimension.value.parentName ?? 'Detail',
level: 2,
data: canvasDimension.value,
})
} }
return path return path
}) })
// Dimension-aware project ID: from page props OR from canvas zoom
const currentProjectId = computed(() => {
// Page-level project
if (props.mapData.project?.id) return props.mapData.project.id
// Canvas zoom-level project
if (canvasDimension.value?.parentEntityType === 'project') {
return canvasDimension.value.parentEntityId
}
return null
})
// Entity type in current dimension (for FAB awareness)
const currentParentEntityType = computed(() => {
if (props.mapData.level === 2) return 'project'
return canvasDimension.value?.parentEntityType ?? null
})
// Handlers // Handlers
const handleNodeClick = (node) => { const handleNodeClick = (node) => {
selectedNode.value = node selectedNode.value = node
showPreview.value = true showPreview.value = true
} }
const handleNodeHover = (node) => { const handleNodeHover = (node) => {}
// Could highlight related nodes/connections const handleNodeLeave = () => {}
}
const handleNodeLeave = () => {
// Reset highlights
}
const handleZoomIn = (node) => { const handleZoomIn = (node) => {
showPreview.value = false showPreview.value = false
@@ -61,9 +89,17 @@ const handleBreadcrumbNavigate = (item, index) => {
} }
} }
const handleDimensionChange = (event) => {
canvasDepth.value = event.depth
canvasDimension.value = event.dimension ?? null
// Close preview when dimension changes
showPreview.value = false
selectedNode.value = null
}
const handleCliCommand = (command) => { const handleCliCommand = (command) => {
console.log('CLI command:', command) console.log('CLI command:', command)
// Will be connected to AI service
} }
const logout = () => { const logout = () => {
@@ -71,7 +107,6 @@ const logout = () => {
} }
const user = computed(() => page.props.auth?.user) const user = computed(() => page.props.auth?.user)
const hasNodes = computed(() => props.mapData.nodes && props.mapData.nodes.length > 0) const hasNodes = computed(() => props.mapData.nodes && props.mapData.nodes.length > 0)
// Modal state // Modal state
@@ -88,8 +123,10 @@ const handleCreateCommitment = () => {
showCommitmentForm.value = true showCommitmentForm.value = true
} }
// Get current project ID when at level 2 const handleCreateDocument = () => {
const currentProjectId = computed(() => props.mapData.project?.id ?? null) // Future: document upload modal
console.log('Create document in project:', currentProjectId.value)
}
</script> </script>
<template> <template>
@@ -108,6 +145,7 @@ const currentProjectId = computed(() => props.mapData.project?.id ?? null)
<template v-if="hasNodes"> <template v-if="hasNodes">
<MetroCanvas <MetroCanvas
ref="canvasRef"
:nodes="props.mapData.nodes" :nodes="props.mapData.nodes"
:lines="props.mapData.lines" :lines="props.mapData.lines"
:connections="props.mapData.connections" :connections="props.mapData.connections"
@@ -115,6 +153,7 @@ const currentProjectId = computed(() => props.mapData.project?.id ?? null)
@node-click="handleNodeClick" @node-click="handleNodeClick"
@node-hover="handleNodeHover" @node-hover="handleNodeHover"
@node-leave="handleNodeLeave" @node-leave="handleNodeLeave"
@dimension-change="handleDimensionChange"
/> />
<NodePreview <NodePreview
@@ -130,8 +169,13 @@ const currentProjectId = computed(() => props.mapData.project?.id ?? null)
</div> </div>
<FloatingActions <FloatingActions
:depth="canvasDepth"
:parent-entity-type="currentParentEntityType"
:parent-project-id="currentProjectId"
@create-project="handleCreateProject" @create-project="handleCreateProject"
@create-theme="handleCreateProject" @create-theme="handleCreateProject"
@create-commitment="handleCreateCommitment"
@create-document="handleCreateDocument"
/> />
<ProjectForm <ProjectForm
@@ -212,7 +256,7 @@ const currentProjectId = computed(() => props.mapData.project?.id ?? null)
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
padding-top: 48px; /* account for top-bar */ padding-top: 48px;
} }
.empty-message { .empty-message {