From 1dc31a53df6a2dbef851397d740072e955f1e009 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Wed, 8 Apr 2026 09:52:25 +0200 Subject: [PATCH] Fix zoom transition and allow branch handles on occupied positions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zoom fix: The zoom handler was being completely removed during the fit-to-view animation (svg.on('.zoom', null)), which meant D3's transition dispatched zoom events that nobody handled — so `g` never got its transform updated during the animation. The view stayed stuck at the old scale until the user scrolled again. Fix: Keep handleZoom active during commit animation. When isCommitting is true, it only applies the visual transform (g.attr('transform')) and skips threshold logic. This lets D3's transition smoothly animate `g` to the fit-to-view position. Branch handles: Removed the occupied-position check. In a real metro, stations can be shared between lines (interchanges). Now all 3 branch handles always appear. Ghost preview shows "interchange" label when targeting an occupied position. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../js/Components/MetroMap/MetroCanvas.vue | 32 ++++++------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/resources/js/Components/MetroMap/MetroCanvas.vue b/resources/js/Components/MetroMap/MetroCanvas.vue index 9a5601b..2d4774d 100644 --- a/resources/js/Components/MetroMap/MetroCanvas.vue +++ b/resources/js/Components/MetroMap/MetroCanvas.vue @@ -266,12 +266,13 @@ const initCanvas = () => { // --------------------------------------------------------------------------- const handleZoom = (event) => { - if (isCommitting) return - const t = event.transform g.attr('transform', t) transform.value = t + // During commit animation, just apply the visual transform — skip threshold logic + if (isCommitting) return + emit('zoom-change', { scale: t.k, x: t.x, y: t.y }) const w = containerRef.value?.clientWidth ?? 800 @@ -385,6 +386,7 @@ const commitDimensionChange = (direction, node) => { /** * Smoothly animate to a fit-to-view transform for the current dimension. * Calculates bounding box of all nodes and zooms to fit them in the viewport. + * The zoom handler stays active but isCommitting guards against threshold logic. */ const animateZoomReset = () => { if (!svg || !zoom) { @@ -399,30 +401,14 @@ const animateZoomReset = () => { const targetDim = currentDimensionData.value const targetTransform = computeFitTransform(targetDim, w, h) - // Temporarily disable the zoom handler to prevent re-entry during animation - svg.on('.zoom', null) + // Render the new dimension immediately so the animation shows correct content + renderMap() svg.transition() .duration(400) .ease(d3.easeCubicOut) .call(zoom.transform, targetTransform) .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() }) @@ -528,8 +514,8 @@ const renderBranchHandles = (nodeGroup, node, allNodes) => { const targetX = snapToGrid(node.x + b.dx) const targetY = snapToGrid(node.y + b.dy) - // Skip if target position is occupied - if (allNodes.some(n => n.x === targetX && n.y === targetY)) return + // Allow branching to occupied positions — metro interchanges are valid + const occupied = allNodes.some(n => n.x === targetX && n.y === targetY) const rad = (b.deg * Math.PI) / 180 const hx = Math.cos(rad) * HANDLE_DIST @@ -596,7 +582,7 @@ const renderBranchHandles = (nodeGroup, node, allNodes) => { .attr('font-family', "'VT323', monospace") .attr('font-size', '12px') .attr('opacity', 0.5) - .text(b.deg === 0 ? 'extend' : 'fork') + .text(occupied ? 'interchange' : (b.deg === 0 ? 'extend' : 'fork')) }) .on('mouseleave', () => { handleGroup.selectAll('.ghost-preview').remove()