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

@@ -1,7 +1,18 @@
<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)
@@ -9,14 +20,25 @@ const toggle = () => {
menuOpen.value = !menuOpen.value
}
const handleCreateProject = () => {
menuOpen.value = false
emit('create-project')
}
/** Options change based on which dimension we're in */
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' },
]
}
// 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
emit('create-theme')
emit(item.event)
}
</script>
@@ -25,13 +47,14 @@ const handleCreateTheme = () => {
<!-- Expanded menu -->
<Transition name="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-label">Nieuw project</span>
</button>
<button class="fab-menu-item" @click="handleCreateTheme">
<span class="fab-menu-icon">+</span>
<span class="fab-menu-label">Nieuw thema</span>
<span class="fab-menu-label">{{ item.label }}</span>
</button>
</div>
</Transition>
@@ -51,7 +74,7 @@ const handleCreateTheme = () => {
<style scoped>
.fab-container {
position: fixed;
bottom: 64px; /* above the CLI bar */
bottom: 64px;
right: 20px;
z-index: 150;
display: flex;

View File

@@ -7,10 +7,10 @@ import * as d3 from 'd3'
// ---------------------------------------------------------------------------
const props = defineProps({
dimensions: { type: Object, default: () => ({}) }, // root dimension data
currentPath: { type: Array, default: () => [] }, // breadcrumb path of dimension IDs
dimensions: { type: Object, default: () => ({}) },
currentPath: { type: Array, default: () => [] },
// Legacy flat-prop support (for backward compat with MetroMap.vue)
// Legacy flat-prop support
nodes: { type: Array, default: () => [] },
lines: { type: Array, default: () => [] },
connections: { type: Array, default: () => [] },
@@ -33,8 +33,10 @@ const emit = defineEmits([
// ---------------------------------------------------------------------------
const ZOOM_IN_THRESHOLD = 2.5
const ZOOM_OUT_THRESHOLD = 0.5
const TRANSITION_RANGE = 1.5
const ZOOM_OUT_THRESHOLD = 0.6
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 = [
'#00d2ff', '#e94560', '#00ff88', '#7b68ee',
@@ -51,29 +53,32 @@ const containerRef = ref(null)
const transform = ref(d3.zoomIdentity)
const hoveredNode = ref(null)
// Dimension stack: each entry is a full metro-map dimension object
// Dimension stack
const dimensionStack = ref([])
const currentDimensionIndex = ref(0)
// Transition state
const transitionState = ref({
active: false,
direction: null, // 'in' | 'out'
direction: null,
progress: 0,
targetNode: null,
childDimension: null,
})
// Tracks whether we're mid-commit (prevent re-entry)
let isCommitting = false
// Context menu
const contextMenu = ref({
show: false,
x: 0, y: 0,
type: null, // 'canvas' | 'node'
type: null,
node: null,
canvasX: 0, canvasY: 0,
})
// D3 references (module-level, not reactive)
// D3 references
let svg, g, zoom
// ---------------------------------------------------------------------------
@@ -82,26 +87,30 @@ let svg, g, zoom
const getLineColor = (index) => LINE_COLORS[index % LINE_COLORS.length]
/** The dimension data that is currently "active" (top of stack) */
const currentDimensionData = computed(() => {
return dimensionStack.value[currentDimensionIndex.value] ?? null
})
/** Flat list of nodes in the current dimension */
const currentNodes = computed(() => {
return currentDimensionData.value?.nodes ?? []
})
/** Current depth (1-based) */
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
// ---------------------------------------------------------------------------
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)) {
return {
id: 'root',
@@ -154,7 +163,6 @@ const initCanvas = () => {
feMerge.append('feMergeNode').attr('in', 'coloredBlur')
feMerge.append('feMergeNode').attr('in', 'SourceGraphic')
// Subtle glow for child indicator
const childGlowFilter = defs.append('filter')
.attr('id', 'childGlow')
.attr('x', '-100%').attr('y', '-100%')
@@ -173,7 +181,7 @@ const initCanvas = () => {
// --- Zoom ---
zoom = d3.zoom()
.scaleExtent([0.1, 8])
.scaleExtent([0.15, 6])
.on('zoom', handleZoom)
svg.call(zoom)
@@ -212,13 +220,14 @@ const initCanvas = () => {
// ---------------------------------------------------------------------------
const handleZoom = (event) => {
if (isCommitting) return
const t = event.transform
g.attr('transform', t)
transform.value = t
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 h = containerRef.value?.clientHeight ?? 600
const cx = (w / 2 - t.x) / t.k
@@ -229,9 +238,10 @@ const handleZoom = (event) => {
if (t.k > ZOOM_IN_THRESHOLD &&
nearest &&
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 = {
active: true,
@@ -251,10 +261,8 @@ const handleZoom = (event) => {
// --- Zoom-out transition ---
if (t.k < ZOOM_OUT_THRESHOLD && dimensionStack.value.length > 1) {
const progress = Math.min(
1,
(ZOOM_OUT_THRESHOLD - t.k) / (ZOOM_OUT_THRESHOLD - 0.1)
)
const range = ZOOM_OUT_THRESHOLD - ZOOM_OUT_COMMIT
const progress = Math.min(1, Math.max(0, (ZOOM_OUT_THRESHOLD - t.k) / range))
transitionState.value = {
active: true,
@@ -284,50 +292,94 @@ const handleZoom = (event) => {
// ---------------------------------------------------------------------------
const commitDimensionChange = (direction, node) => {
if (isCommitting) return
isCommitting = true
if (direction === 'in' && node) {
const children = node.children
const child = {
id: node.id,
parentNodeId: node.id,
lines: node.children.lines ?? [],
nodes: node.children.nodes ?? [],
connections: node.children.connections ?? [],
// Prefer metadata from backend, fall back to node's own entity info
parentEntityType: children.parentEntityType ?? node.entityType ?? null,
parentEntityId: children.parentEntityId ?? node.entityId ?? null,
parentName: children.parentName ?? node.name ?? null,
lines: children.lines ?? [],
nodes: children.nodes ?? [],
connections: children.connections ?? [],
opacity: 1,
}
dimensionStack.value.push(child)
currentDimensionIndex.value++
resetZoomToCenter()
animateZoomReset()
transitionState.value.active = false
renderMap()
emit('dimension-change', {
direction: 'in',
node,
depth: dimensionStack.value.length,
dimension: child,
})
} else if (direction === 'out') {
dimensionStack.value.pop()
currentDimensionIndex.value = Math.max(0, currentDimensionIndex.value - 1)
resetZoomToCenter()
animateZoomReset()
transitionState.value.active = false
renderMap()
emit('dimension-change', {
direction: 'out',
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 h = containerRef.value?.clientHeight ?? 600
svg.call(
zoom.transform,
d3.zoomIdentity.translate(w / 2, h / 2).scale(1)
)
// 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,
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
// ---------------------------------------------------------------------------
/** Render a complete metro-map dimension into a D3 group element */
const renderDimension = (dimData, opacity, parentGroup) => {
if (!dimData) return
@@ -378,7 +429,20 @@ const renderDimension = (dimData, opacity, parentGroup) => {
.filter(n => n.lineId === line.id)
.sort((a, b) => a.order - b.order)
if (lineNodes.length < 2) return
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
}
const lineGen = d3.line()
.x(d => d.x)
@@ -489,7 +553,7 @@ const renderDimension = (dimData, opacity, parentGroup) => {
})
.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)
.append('circle')
.attr('r', 16)
@@ -533,7 +597,7 @@ const renderDimension = (dimData, opacity, parentGroup) => {
.text(d => d.badge)
}
/** Normal (no transition) render */
/** Normal render */
const renderMap = () => {
if (!g) return
g.selectAll('*').remove()
@@ -550,9 +614,7 @@ const renderWithTransition = () => {
const eased = d3.easeCubicInOut(progress)
if (direction === 'in') {
// Parent fades out
renderDimension(currentDimensionData.value, 1 - eased, g)
// Child fades in
if (childDimension) {
renderDimension(
{
@@ -565,9 +627,7 @@ const renderWithTransition = () => {
)
}
} else if (direction === 'out' && dimensionStack.value.length > 1) {
// Current child fades out
renderDimension(currentDimensionData.value, 1 - eased, g)
// Parent fades in
const parentDim = dimensionStack.value[currentDimensionIndex.value - 1]
if (parentDim) {
renderDimension(parentDim, eased, g)
@@ -588,7 +648,10 @@ const handleContextCreate = () => {
x: contextMenu.value.canvasX,
y: contextMenu.value.canvasY,
parentNodeId: currentDimensionData.value?.parentNodeId ?? null,
parentEntityType: currentDimensionData.value?.parentEntityType ?? null,
parentEntityId: currentDimensionData.value?.parentEntityId ?? null,
dimensionId: currentDimensionData.value?.id ?? 'root',
depth: currentDepth.value,
})
closeContextMenu()
}
@@ -604,7 +667,10 @@ const handleContextAddChild = () => {
x: contextMenu.value.canvasX,
y: contextMenu.value.canvasY,
parentNodeId: contextMenu.value.node.id,
parentEntityType: contextMenu.value.node.entityType ?? null,
parentEntityId: contextMenu.value.node.entityId ?? null,
dimensionId: contextMenu.value.node.id,
depth: currentDepth.value + 1,
addToNode: contextMenu.value.node,
})
}
@@ -616,12 +682,15 @@ const handleContextDelete = () => {
closeContextMenu()
}
/** FAB / [+] button creates a node in the current dimension */
/** FAB creates a node in the current dimension */
const handleFabCreate = () => {
emit('create-node', {
x: 0, y: 0,
parentNodeId: currentDimensionData.value?.parentNodeId ?? null,
parentEntityType: currentDimensionData.value?.parentEntityType ?? null,
parentEntityId: currentDimensionData.value?.parentEntityId ?? null,
dimensionId: currentDimensionData.value?.id ?? 'root',
depth: currentDepth.value,
})
}
@@ -640,7 +709,6 @@ const handleResize = () => {
// ---------------------------------------------------------------------------
const onDocumentClick = (e) => {
// Close context menu on any click outside it
if (contextMenu.value.show) {
closeContextMenu()
}
@@ -653,7 +721,6 @@ const onDocumentClick = (e) => {
watch(
() => [props.nodes, props.lines, props.connections, props.dimensions],
() => {
// Rebuild the root dimension from updated props
if (dimensionStack.value.length > 0) {
dimensionStack.value[0] = buildRootDimension()
} else {
@@ -693,7 +760,13 @@ const zoomTo = (x, y, scale) => {
)
}
defineExpose({ zoomTo, handleFabCreate })
defineExpose({
zoomTo,
handleFabCreate,
currentDepth,
currentDimensionData,
isInChildDimension,
})
</script>
<template>
@@ -706,7 +779,7 @@ defineExpose({ zoomTo, handleFabCreate })
<div class="depth-indicator">
DEPTH:&nbsp;{{ currentDepth }}
<span v-if="currentDepth > 1" class="depth-back" @click.stop="commitDimensionChange('out', null)">
[ BACK]
[&uarr; BACK]
</span>
</div>
@@ -746,13 +819,23 @@ defineExpose({ zoomTo, handleFabCreate })
@click.stop
>
<template v-if="contextMenu.type === 'canvas'">
<div class="context-menu-header">CANVAS</div>
<button class="context-item" @click="handleContextCreate">+ New node here</button>
<div class="context-menu-header">
{{ currentDepth > 1 ? 'PROJECT' : 'CANVAS' }}
</div>
<button class="context-item" @click="handleContextCreate">
+ {{ currentDepth > 1 ? 'New item here' : 'New node here' }}
</button>
</template>
<template v-else-if="contextMenu.type === 'node'">
<div class="context-menu-header">{{ contextMenu.node?.name ?? 'NODE' }}</div>
<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>
</template>
</div>