Fix zoom transition and allow branch handles on occupied positions
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) <noreply@anthropic.com>
This commit is contained in:
@@ -266,12 +266,13 @@ const initCanvas = () => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const handleZoom = (event) => {
|
const handleZoom = (event) => {
|
||||||
if (isCommitting) return
|
|
||||||
|
|
||||||
const t = event.transform
|
const t = event.transform
|
||||||
g.attr('transform', t)
|
g.attr('transform', t)
|
||||||
transform.value = 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 })
|
emit('zoom-change', { scale: t.k, x: t.x, y: t.y })
|
||||||
|
|
||||||
const w = containerRef.value?.clientWidth ?? 800
|
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.
|
* 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.
|
* 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 = () => {
|
const animateZoomReset = () => {
|
||||||
if (!svg || !zoom) {
|
if (!svg || !zoom) {
|
||||||
@@ -399,30 +401,14 @@ const animateZoomReset = () => {
|
|||||||
const targetDim = currentDimensionData.value
|
const targetDim = currentDimensionData.value
|
||||||
const targetTransform = computeFitTransform(targetDim, w, h)
|
const targetTransform = computeFitTransform(targetDim, w, h)
|
||||||
|
|
||||||
// Temporarily disable the zoom handler to prevent re-entry during animation
|
// Render the new dimension immediately so the animation shows correct content
|
||||||
svg.on('.zoom', null)
|
renderMap()
|
||||||
|
|
||||||
svg.transition()
|
svg.transition()
|
||||||
.duration(400)
|
.duration(400)
|
||||||
.ease(d3.easeCubicOut)
|
.ease(d3.easeCubicOut)
|
||||||
.call(zoom.transform, targetTransform)
|
.call(zoom.transform, targetTransform)
|
||||||
.on('end', () => {
|
.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
|
isCommitting = false
|
||||||
renderMap()
|
renderMap()
|
||||||
})
|
})
|
||||||
@@ -528,8 +514,8 @@ const renderBranchHandles = (nodeGroup, node, allNodes) => {
|
|||||||
const targetX = snapToGrid(node.x + b.dx)
|
const targetX = snapToGrid(node.x + b.dx)
|
||||||
const targetY = snapToGrid(node.y + b.dy)
|
const targetY = snapToGrid(node.y + b.dy)
|
||||||
|
|
||||||
// Skip if target position is occupied
|
// Allow branching to occupied positions — metro interchanges are valid
|
||||||
if (allNodes.some(n => n.x === targetX && n.y === targetY)) return
|
const occupied = allNodes.some(n => n.x === targetX && n.y === targetY)
|
||||||
|
|
||||||
const rad = (b.deg * Math.PI) / 180
|
const rad = (b.deg * Math.PI) / 180
|
||||||
const hx = Math.cos(rad) * HANDLE_DIST
|
const hx = Math.cos(rad) * HANDLE_DIST
|
||||||
@@ -596,7 +582,7 @@ const renderBranchHandles = (nodeGroup, node, allNodes) => {
|
|||||||
.attr('font-family', "'VT323', monospace")
|
.attr('font-family', "'VT323', monospace")
|
||||||
.attr('font-size', '12px')
|
.attr('font-size', '12px')
|
||||||
.attr('opacity', 0.5)
|
.attr('opacity', 0.5)
|
||||||
.text(b.deg === 0 ? 'extend' : 'fork')
|
.text(occupied ? 'interchange' : (b.deg === 0 ? 'extend' : 'fork'))
|
||||||
})
|
})
|
||||||
.on('mouseleave', () => {
|
.on('mouseleave', () => {
|
||||||
handleGroup.selectAll('.ghost-preview').remove()
|
handleGroup.selectAll('.ghost-preview').remove()
|
||||||
|
|||||||
Reference in New Issue
Block a user