Any node can become zoomable: add dimension via right-click

Visual distinction:
- Populated dimension: dashed cyan ring + [+] indicator
- Empty dimension: subtle gray dashed ring + [○] indicator
- No dimension: plain station (no ring)

Context menu changes:
- "Open dimension" on nodes that have children (zooms in directly)
- "Add dimension" on nodes without children (creates empty zoomable space)
- Removed old "Add child node" option

Add dimension creates an empty children structure on the node with
proper parent metadata. The node immediately shows the [○] indicator
and becomes zoomable. User enters and builds tracks with FAB + branch
handles. Truly recursive metro — any station can contain a world.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-04-08 10:10:40 +02:00
parent 1dc31a53df
commit 22cbc26b75

View File

@@ -772,7 +772,7 @@ const renderDimension = (dimData, opacity, parentGroup) => {
})
.attr('stroke-width', 2)
// Child-dimension indicator
// Child-dimension indicator: populated dimension (has nodes inside)
nodeGroups.filter(d => d.children && (d.children.nodes?.length ?? 0) > 0)
.append('circle')
.attr('r', 16)
@@ -783,7 +783,6 @@ const renderDimension = (dimData, opacity, parentGroup) => {
.attr('opacity', 0.5)
.attr('filter', 'url(#childGlow)')
// "[+]" label for nodes with children
nodeGroups.filter(d => d.children && (d.children.nodes?.length ?? 0) > 0)
.append('text')
.attr('x', 13)
@@ -794,6 +793,26 @@ const renderDimension = (dimData, opacity, parentGroup) => {
.attr('opacity', 0.8)
.text('[+]')
// Empty dimension indicator (dimension exists but no nodes yet)
nodeGroups.filter(d => d.children && (d.children.nodes?.length ?? 0) === 0)
.append('circle')
.attr('r', 14)
.attr('fill', 'none')
.attr('stroke', '#8892b0')
.attr('stroke-width', 0.8)
.attr('stroke-dasharray', '2,4')
.attr('opacity', 0.4)
nodeGroups.filter(d => d.children && (d.children.nodes?.length ?? 0) === 0)
.append('text')
.attr('x', 13)
.attr('y', -13)
.attr('fill', '#8892b0')
.attr('font-family', "'VT323', monospace")
.attr('font-size', '10px')
.attr('opacity', 0.5)
.text('[○]')
// Station label
nodeGroups.append('text')
.attr('x', 0)
@@ -880,22 +899,35 @@ const handleContextEdit = () => {
closeContextMenu()
}
const handleContextAddChild = () => {
if (contextMenu.value.node) {
emit('create-node', {
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,
})
/** "Add dimension" — attach an empty children object to a node, making it zoomable */
const handleContextAddDimension = () => {
const node = contextMenu.value.node
if (!node) { closeContextMenu(); return }
// Create empty dimension structure on the node
node.children = {
lines: [],
nodes: [],
connections: [],
parentEntityType: node.entityType ?? null,
parentEntityId: node.entityId ?? null,
parentName: node.name ?? null,
}
// Re-render to show the new [○] indicator
renderMap()
closeContextMenu()
}
/** "Open dimension" — zoom into a node that already has children */
const handleContextZoomIn = () => {
const node = contextMenu.value.node
if (!node?.children) { closeContextMenu(); return }
closeContextMenu()
commitDimensionChange('in', node)
}
const handleContextDelete = () => {
if (contextMenu.value.node) emit('delete-node', contextMenu.value.node)
closeContextMenu()
@@ -1055,9 +1087,16 @@ defineExpose({
<button
v-if="contextMenu.node?.children"
class="context-item"
@click="handleContextAddChild"
@click="handleContextZoomIn"
>
+ Add child node
&gt; Open dimension
</button>
<button
v-if="!contextMenu.node?.children"
class="context-item"
@click="handleContextAddDimension"
>
+ Add dimension
</button>
<button class="context-item danger" @click="handleContextDelete">Delete</button>
</template>