Files
innovatieplatform/resources/js/Components/MetroMap/FloatingActions.vue
znetsixe d41ca76e0d Metro map interaction redesign: fit-to-view zoom, grid, branch handles, custom tracks
Phase 1 — Fit-to-view zoom:
- computeFitTransform() calculates bounding box and scales to fit all nodes
- Replaces hardcoded scale=1 reset in animateZoomReset() and initCanvas()
- Dim 1 no longer appears tiny after zooming out from dim 2

Phase 2 — Grid system:
- Shared gridConstants.js (GRID=50, GRID_STEP_X=200, GRID_STEP_Y=150)
- MapDataService snapToGrid() aligns all node positions server-side
- Canvas renders subtle grid lines (shown on interaction only, with fade)
- Line highlighting support via setHighlightedLine() for FAB hover

Phase 3 — Branch handles:
- Hover any station node → 3 "+" handles appear (0°/45°/315°)
- 0° extends the current line, 45°/315° fork to create new branch
- Ghost preview (dashed line + circle) on handle hover
- Handles only show at unoccupied grid positions
- Grid fades in during handle interaction, fades out after

Phase 4 — Custom tracks database:
- metro_lines table (project_id, naam, color, type, order)
- metro_nodes table (metro_line_id, naam, status, x, y, order)
- MetroLine + MetroNode models, controllers, routes
- Project.metroLines() relationship added

Phase 5+6 — FAB redesign + MetroMap wiring:
- FAB shows "Nieuw thema (lijn)" at root, "Nieuwe lijn" in project dim
- Track creation modal with retro-styled form
- MetroMap handles create-node events from branch handles
- Extend (0°) opens commitment/document form, fork opens track form
- Canvas context menu replaced with "hover to branch" hint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:40:56 +02:00

203 lines
4.6 KiB
Vue

<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
depth: { type: Number, default: 1 },
parentEntityType: { type: String, default: null },
parentProjectId: { type: Number, default: null },
})
const emit = defineEmits([
'create-theme',
'create-track',
'item-hover',
'item-leave',
])
const menuOpen = ref(false)
const toggle = () => {
menuOpen.value = !menuOpen.value
if (!menuOpen.value) emit('item-leave')
}
/** Menu items adapt to the current dimension */
const menuItems = computed(() => {
if (props.depth > 1 && props.parentEntityType === 'project') {
return [
{ label: 'Nieuwe lijn', event: 'create-track', color: null, icon: '═' },
]
}
return [
{ 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')
}
</script>
<template>
<div class="fab-container">
<!-- Expanded menu -->
<Transition name="fab-menu">
<div v-if="menuOpen" class="fab-menu">
<button
v-for="item in menuItems"
:key="item.event"
class="fab-menu-item"
@click="handleItemClick(item)"
@mouseenter="handleItemHover(item)"
@mouseleave="handleItemLeave"
>
<span class="fab-menu-icon" :style="item.color ? { color: item.color } : {}">
{{ item.icon }}
</span>
<span class="fab-menu-label">{{ item.label }}</span>
<span v-if="item.color" class="fab-color-dot" :style="{ background: item.color }"></span>
</button>
</div>
</Transition>
<!-- Main FAB button -->
<button
class="fab-btn"
:class="{ 'fab-btn--open': menuOpen }"
:title="menuOpen ? 'Sluiten' : 'Nieuw aanmaken'"
@click="toggle"
>
<span class="fab-icon">{{ menuOpen ? '[X]' : '[+]' }}</span>
</button>
</div>
</template>
<style scoped>
.fab-container {
position: fixed;
bottom: 64px;
right: 20px;
z-index: 150;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.fab-btn {
width: 48px;
height: 48px;
border-radius: 50%;
background: #0f3460;
border: 2px solid #00d2ff;
color: #00d2ff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 0 12px rgba(0, 210, 255, 0.35),
0 0 24px rgba(0, 210, 255, 0.15);
transition: all 0.2s ease;
}
.fab-btn:hover {
background: rgba(0, 210, 255, 0.15);
box-shadow:
0 0 20px rgba(0, 210, 255, 0.5),
0 0 40px rgba(0, 210, 255, 0.2);
transform: scale(1.08);
}
.fab-btn--open {
border-color: #e94560;
color: #e94560;
box-shadow:
0 0 12px rgba(233, 69, 96, 0.35),
0 0 24px rgba(233, 69, 96, 0.15);
}
.fab-btn--open:hover {
box-shadow:
0 0 20px rgba(233, 69, 96, 0.5),
0 0 40px rgba(233, 69, 96, 0.2);
}
.fab-icon {
font-family: 'VT323', monospace;
font-size: 18px;
line-height: 1;
text-shadow: 0 0 8px currentColor;
}
.fab-menu {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-end;
}
.fab-menu-item {
display: flex;
align-items: center;
gap: 8px;
background: #16213e;
border: 1px solid rgba(0, 210, 255, 0.4);
border-radius: 4px;
padding: 8px 14px;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
box-shadow: 0 0 10px rgba(0, 210, 255, 0.1);
}
.fab-menu-item:hover {
background: rgba(0, 210, 255, 0.1);
border-color: #00d2ff;
box-shadow: 0 0 16px rgba(0, 210, 255, 0.25);
}
.fab-menu-icon {
font-family: 'VT323', monospace;
font-size: 16px;
color: #00d2ff;
text-shadow: 0 0 6px rgba(0, 210, 255, 0.5);
}
.fab-menu-label {
font-family: 'VT323', monospace;
font-size: 16px;
color: #e8e8e8;
letter-spacing: 0.5px;
}
.fab-color-dot {
width: 8px;
height: 8px;
border-radius: 50%;
box-shadow: 0 0 6px currentColor;
}
/* Menu transition */
.fab-menu-enter-active,
.fab-menu-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.fab-menu-enter-from,
.fab-menu-leave-to {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
</style>