diff --git a/app/Http/Controllers/MetroLineController.php b/app/Http/Controllers/MetroLineController.php new file mode 100644 index 0000000..1c5fee0 --- /dev/null +++ b/app/Http/Controllers/MetroLineController.php @@ -0,0 +1,55 @@ +validate([ + 'project_id' => 'required|exists:projects,id', + 'naam' => 'required|string|max:255', + 'color' => 'nullable|string|max:7', + ]); + + $maxOrder = MetroLine::where('project_id', $validated['project_id'])->max('order') ?? 0; + + $line = MetroLine::create([ + 'project_id' => $validated['project_id'], + 'naam' => $validated['naam'], + 'color' => $validated['color'] ?? $this->nextColor($validated['project_id']), + 'type' => 'custom', + 'order' => $maxOrder + 1, + ]); + + return back()->with('success', "Lijn '{$line->naam}' aangemaakt."); + } + + public function destroy(MetroLine $metroLine) + { + if ($metroLine->isBuiltIn()) { + return back()->with('error', 'Ingebouwde lijnen kunnen niet verwijderd worden.'); + } + + $metroLine->delete(); + + return back()->with('success', 'Lijn verwijderd.'); + } + + private function nextColor(int $projectId): string + { + $colors = ['#ff6b6b', '#ffd93d', '#6bcb77', '#4d96ff', '#ff8fab', '#a8dadc', '#e07a5f', '#81b29a']; + $used = MetroLine::where('project_id', $projectId)->pluck('color')->toArray(); + + foreach ($colors as $c) { + if (!in_array($c, $used)) { + return $c; + } + } + + return $colors[0]; + } +} diff --git a/app/Http/Controllers/MetroNodeController.php b/app/Http/Controllers/MetroNodeController.php new file mode 100644 index 0000000..a2db539 --- /dev/null +++ b/app/Http/Controllers/MetroNodeController.php @@ -0,0 +1,51 @@ +validate([ + 'metro_line_id' => 'required|exists:metro_lines,id', + 'naam' => 'required|string|max:255', + 'beschrijving' => 'nullable|string', + 'x' => 'required|integer', + 'y' => 'required|integer', + 'eigenaar_id' => 'nullable|exists:users,id', + ]); + + $maxOrder = MetroNode::where('metro_line_id', $validated['metro_line_id'])->max('order') ?? 0; + $validated['order'] = $maxOrder + 1; + $validated['status'] = 'active'; + + $node = MetroNode::create($validated); + + return back()->with('success', "Node '{$node->naam}' aangemaakt."); + } + + public function update(Request $request, MetroNode $metroNode) + { + $validated = $request->validate([ + 'naam' => 'sometimes|string|max:255', + 'beschrijving' => 'nullable|string', + 'status' => 'sometimes|string|in:active,completed,parked', + 'x' => 'sometimes|integer', + 'y' => 'sometimes|integer', + ]); + + $metroNode->update($validated); + + return back()->with('success', 'Node bijgewerkt.'); + } + + public function destroy(MetroNode $metroNode) + { + $metroNode->delete(); + + return back()->with('success', 'Node verwijderd.'); + } +} diff --git a/app/Models/MetroLine.php b/app/Models/MetroLine.php new file mode 100644 index 0000000..fb98918 --- /dev/null +++ b/app/Models/MetroLine.php @@ -0,0 +1,38 @@ +belongsTo(Project::class); + } + + public function metroNodes(): HasMany + { + return $this->hasMany(MetroNode::class); + } + + public function isBuiltIn(): bool + { + return in_array($this->type, ['lifecycle', 'commitments', 'documents']); + } +} diff --git a/app/Models/MetroNode.php b/app/Models/MetroNode.php new file mode 100644 index 0000000..2946595 --- /dev/null +++ b/app/Models/MetroNode.php @@ -0,0 +1,35 @@ +belongsTo(MetroLine::class); + } + + public function eigenaar(): BelongsTo + { + return $this->belongsTo(User::class, 'eigenaar_id'); + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php index 8ea8c07..4763707 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -105,4 +105,9 @@ class Project extends Model { return $this->hasMany(Overdrachtsplan::class); } + + public function metroLines(): HasMany + { + return $this->hasMany(MetroLine::class); + } } diff --git a/app/Services/MapDataService.php b/app/Services/MapDataService.php index 5b71a49..5706da3 100644 --- a/app/Services/MapDataService.php +++ b/app/Services/MapDataService.php @@ -9,6 +9,19 @@ use Illuminate\Support\Str; class MapDataService { + /** + * Grid unit — all node positions snap to multiples of this value. + * Keep in sync with resources/js/Components/MetroMap/gridConstants.js + */ + private const GRID = 50; + private const GRID_STEP_X = 200; // horizontal spacing between nodes + private const GRID_STEP_Y = 150; // vertical spacing between metro lines + + private function snapToGrid(float $value): int + { + return (int) (round($value / self::GRID) * self::GRID); + } + /** * Build the Level 1 (Strategy) metro map data. * Each theme = a metro line, each project = a station. @@ -38,7 +51,7 @@ class MapDataService ]; $projects = $thema->speerpunten->flatMap->projects; - $xOffset = -200; + $xStart = $this->snapToGrid(-200); foreach ($projects as $order => $project) { $nodes[] = [ @@ -47,8 +60,8 @@ class MapDataService 'entityType' => 'project', 'name' => $project->naam, 'lineId' => "thema-{$thema->id}", - 'x' => $xOffset + ($order * 200), - 'y' => $yOffset, + 'x' => $this->snapToGrid($xStart + ($order * self::GRID_STEP_X)), + 'y' => $this->snapToGrid($yOffset), 'order' => $order + 1, 'status' => $project->status->value, 'description' => Str::limit($project->beschrijving, 100), @@ -58,7 +71,7 @@ class MapDataService ]; } - $yOffset += 130; + $yOffset += self::GRID_STEP_Y; } // Get dependencies as connections @@ -99,7 +112,7 @@ class MapDataService ]; $nodes = []; - $xOffset = -300; + $xStart = $this->snapToGrid(-300); // Phase nodes on lifecycle line foreach ($project->fases->sortBy('type') as $order => $fase) { @@ -109,8 +122,8 @@ class MapDataService 'entityType' => 'fase', 'name' => ucfirst(str_replace('_', ' ', $fase->type->value)), 'lineId' => 'lifecycle', - 'x' => $xOffset + ($order * 180), - 'y' => -50, + 'x' => $this->snapToGrid($xStart + ($order * self::GRID_STEP_X)), + 'y' => $this->snapToGrid(-50), 'order' => $order + 1, 'status' => $fase->status->value, 'badge' => ucfirst($fase->status->value), @@ -126,8 +139,8 @@ class MapDataService 'entityType' => 'commitment', 'name' => Str::limit($commitment->beschrijving, 40), 'lineId' => 'commitments', - 'x' => $xOffset + ($order * 180), - 'y' => 80, + 'x' => $this->snapToGrid($xStart + ($order * self::GRID_STEP_X)), + 'y' => $this->snapToGrid(100), 'order' => $order + 1, 'status' => $commitment->status->value, 'owner' => $commitment->eigenaar?->name, @@ -144,8 +157,8 @@ class MapDataService 'entityType' => 'document', 'name' => $doc->titel, 'lineId' => 'documents', - 'x' => $xOffset + ($order * 180), - 'y' => 210, + 'x' => $this->snapToGrid($xStart + ($order * self::GRID_STEP_X)), + 'y' => $this->snapToGrid(250), 'order' => $order + 1, 'status' => 'active', 'badge' => "v{$doc->versie}", @@ -181,8 +194,7 @@ class MapDataService ]; $nodes = []; - $xOffset = -250; - $spacing = 200; + $xStart = $this->snapToGrid(-250); // Phase nodes foreach ($project->fases->sortBy('created_at') as $i => $fase) { @@ -192,8 +204,8 @@ class MapDataService 'entityType' => 'fase', 'name' => ucfirst(str_replace('_', ' ', $fase->type->value)), 'lineId' => "lifecycle-{$project->id}", - 'x' => $xOffset + ($i * $spacing), - 'y' => -60, + 'x' => $this->snapToGrid($xStart + ($i * self::GRID_STEP_X)), + 'y' => $this->snapToGrid(-50), 'order' => $i + 1, 'status' => $fase->status->value, 'badge' => ucfirst($fase->status->value), @@ -209,8 +221,8 @@ class MapDataService 'entityType' => 'commitment', 'name' => Str::limit($commitment->beschrijving, 35), 'lineId' => "commitments-{$project->id}", - 'x' => $xOffset + ($i * $spacing), - 'y' => 80, + 'x' => $this->snapToGrid($xStart + ($i * self::GRID_STEP_X)), + 'y' => $this->snapToGrid(100), 'order' => $i + 1, 'status' => $commitment->status->value, 'owner' => $commitment->eigenaar?->name, @@ -227,8 +239,8 @@ class MapDataService 'entityType' => 'document', 'name' => $doc->titel, 'lineId' => "documents-{$project->id}", - 'x' => $xOffset + ($i * $spacing), - 'y' => 220, + 'x' => $this->snapToGrid($xStart + ($i * self::GRID_STEP_X)), + 'y' => $this->snapToGrid(250), 'order' => $i + 1, 'status' => 'active', 'badge' => "v{$doc->versie}", @@ -240,7 +252,6 @@ class MapDataService 'lines' => $lines, 'nodes' => $nodes, 'connections' => [], - // Metadata so the frontend knows what dimension it's in 'parentEntityType' => 'project', 'parentEntityId' => $project->id, 'parentName' => $project->naam, @@ -249,13 +260,11 @@ class MapDataService /** * Return children for any node by entity type and ID. - * Used by the API endpoint for on-demand dimension data. */ public function getNodeChildren(string $entityType, int $entityId): ?array { return match ($entityType) { 'project' => $this->buildProjectChildren(Project::findOrFail($entityId)), - // Future: 'commitment' => $this->buildCommitmentChildren(...) default => null, }; } diff --git a/database/migrations/2026_04_08_000001_create_metro_lines_table.php b/database/migrations/2026_04_08_000001_create_metro_lines_table.php new file mode 100644 index 0000000..9253afd --- /dev/null +++ b/database/migrations/2026_04_08_000001_create_metro_lines_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('project_id')->nullable()->constrained('projects')->cascadeOnDelete(); + $table->string('naam', 255); + $table->string('color', 7); + $table->string('type'); + $table->integer('order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('metro_lines'); + } +}; diff --git a/database/migrations/2026_04_08_000002_create_metro_nodes_table.php b/database/migrations/2026_04_08_000002_create_metro_nodes_table.php new file mode 100644 index 0000000..177cfaa --- /dev/null +++ b/database/migrations/2026_04_08_000002_create_metro_nodes_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('metro_line_id')->constrained('metro_lines')->cascadeOnDelete(); + $table->string('naam', 255); + $table->text('beschrijving')->nullable(); + $table->string('status')->default('active'); + $table->integer('x')->default(0); + $table->integer('y')->default(0); + $table->integer('order')->default(0); + $table->foreignId('eigenaar_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('metro_nodes'); + } +}; diff --git a/resources/js/Components/MetroMap/FloatingActions.vue b/resources/js/Components/MetroMap/FloatingActions.vue index 76c1f85..05967cf 100644 --- a/resources/js/Components/MetroMap/FloatingActions.vue +++ b/resources/js/Components/MetroMap/FloatingActions.vue @@ -8,38 +8,44 @@ const props = defineProps({ }) const emit = defineEmits([ - 'create-project', 'create-theme', - 'create-commitment', - 'create-document', + 'create-track', + 'item-hover', + 'item-leave', ]) const menuOpen = ref(false) const toggle = () => { menuOpen.value = !menuOpen.value + if (!menuOpen.value) emit('item-leave') } -/** Options change based on which dimension we're in */ +/** Menu items adapt to the current dimension */ const menuItems = computed(() => { 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' }, + { label: 'Nieuwe lijn', event: 'create-track', color: null, icon: '═' }, ] } - // Root dimension: create top-level items return [ - { label: 'Nieuw project', event: 'create-project' }, - { label: 'Nieuw thema', event: 'create-theme' }, + { label: 'Nieuw thema (lijn)', event: 'create-theme', color: '#00d2ff', icon: '═' }, ] }) const handleItemClick = (item) => { menuOpen.value = false + emit('item-leave') emit(item.event) } + +const handleItemHover = (item) => { + emit('item-hover', item) +} + +const handleItemLeave = () => { + emit('item-leave') +}