From a6345a66ccbb163cd92ed880ddc0b4715fb029c2 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Wed, 17 Jun 2026 17:54:55 -0700 Subject: [PATCH 1/4] UX: flip graph mouse-wheel zoom to the standard direction (scroll up = zoom in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom wheelDelta inverted the d3 default, so scrolling up zoomed OUT — the opposite of maps and every other app. Drop the extra *-1 at both zoom setups so scroll-up zooms IN (now consistent with the minimap, which was already correct). camera.spec wheel test updated to use negative deltaY for zoom-in. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../web/src/components/InteractiveGraphVisualization.tsx | 8 ++++---- tests/e2e/camera.spec.ts | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index c875e073..82be0db6 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -1318,8 +1318,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const zoom = d3.zoom() .scaleExtent([0.1, 3]) .wheelDelta((event: WheelEvent) => { - // Reverse wheel direction: negative deltaY for zoom in, positive for zoom out - return -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002) * -1; + // Standard direction (like maps/most apps): scroll UP (deltaY<0) zooms IN. + return -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002); }) .on('zoom', (event) => { const g = svg.select('g.main-graph-group'); @@ -1761,8 +1761,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const zoom = d3.zoom() .scaleExtent([0.1, 4]) .wheelDelta((event: WheelEvent) => { - // Reverse wheel direction: negative deltaY for zoom in, positive for zoom out - return -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002) * -1; + // Standard direction (like maps/most apps): scroll UP (deltaY<0) zooms IN. + return -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002); }); const g = isFirstInit ? svg.append('g').attr('class', 'main-graph-group') : existingMainGroup; diff --git a/tests/e2e/camera.spec.ts b/tests/e2e/camera.spec.ts index 48522d72..b8d6c107 100644 --- a/tests/e2e/camera.spec.ts +++ b/tests/e2e/camera.spec.ts @@ -50,14 +50,15 @@ test.describe('camera framing + persistence @camera', () => { // Zoom IN hard over the canvas centre (wheel, not drag) so peripheral nodes // leave the viewport. Wheel avoids the minimap (bottom-right) and never grabs - // a node the way a drag-pan can. Custom wheelDelta: +deltaY == zoom in. + // a node the way a drag-pan can. Standard direction: scroll UP (negative + // deltaY) zooms in. const canvas = await page.locator('.graph-container').first().boundingBox(); if (!canvas) throw new Error('no canvas'); const cx = canvas.x + canvas.width / 2; const cy = canvas.y + canvas.height / 2; await page.mouse.move(cx, cy); for (let i = 0; i < 7; i++) { - await page.mouse.wheel(0, 320); + await page.mouse.wheel(0, -320); await page.waitForTimeout(90); } await page.waitForTimeout(500); From e0664a668891f70c0ac161febd46d5bcd1a1dad3 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Wed, 17 Jun 2026 18:00:01 -0700 Subject: [PATCH 2/4] =?UTF-8?q?UX:=20right-click=20=E2=86=92=20Center=20on?= =?UTF-8?q?=20Node=20centers=20immediately=20(was=20deferred/abrupt)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit centerOnNode applied the transform via a throwaway d3.zoom() instance, which only set the svg's stored __zoom without firing the bound zoom's handler — so the graph didn't move until the next gesture snapped to the stale __zoom (the deferred, abrupt jump). Apply through zoomBehaviorRef.current (like fitViewToNodes) and use the live scale, so it animates smoothly on click. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../InteractiveGraphVisualization.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 82be0db6..0acf3523 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -4071,18 +4071,23 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const centerX = width / 2; const centerY = height / 2; - // Get current zoom scale (maintain it) - const currentScale = currentTransform?.scale || 1; - + // Maintain the LIVE zoom scale (read from the element, not stale React state). + const currentScale = d3.zoomTransform(svgElement).k || currentTransform?.scale || 1; + // Calculate translation to center the node const translateX = centerX - nodeX * currentScale; const translateY = centerY - nodeY * currentScale; - - // Apply transform with smooth transition + + // Apply through the BOUND zoom behavior so its 'zoom' handler actually moves + // the graph group (and keeps currentTransform coherent) — using a throwaway + // d3.zoom() only set the svg's stored __zoom, so nothing moved until the next + // gesture snapped to it (the deferred/abrupt jump). Mirrors fitViewToNodes. const transform = d3.zoomIdentity.translate(translateX, translateY).scale(currentScale); - svg.transition() - .duration(750) - .call(d3.zoom().transform as any, transform); + if (zoomBehaviorRef.current) { + svg.transition().duration(600).call(zoomBehaviorRef.current.transform as any, transform); + } else { + svg.transition().duration(600).call(d3.zoom().transform as any, transform); + } // Update mini-map viewport if ((window as any).updateMiniMapViewport) { From 0cf1d14fd44393b70e3e1717085f064e129ceba7 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Wed, 17 Jun 2026 18:03:21 -0700 Subject: [PATCH 3/4] =?UTF-8?q?UX:=20node=20header=20=E2=80=94=20center=20?= =?UTF-8?q?type=20label=20in=20the=20gap,=20center=20glyphs,=20add=20toolt?= =?UTF-8?q?ips?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Node type label sat at the header centre (x=0) but the open space is between the single left (gear) button and the two right buttons (expand + grow); move it to the gap midpoint x=-(iconSize+8)/2 so it's visually centred in the gap. - Glyphs (gear ⚙, plus +, expand ⛶) now all use dominant-baseline=central for consistent vertical centring inside their square buttons. - Add native tooltips to all three header buttons (Open details / edit, Add a connection (grow), Expand — read contents & diagram). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../InteractiveGraphVisualization.tsx | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 0acf3523..39fe67fe 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -2496,13 +2496,18 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i }) .attr('stroke-width', 1.5); - // Node type text in colored title bar (centered) + const iconSize = Math.max(16, Math.min(24, titleBarHeight * 0.7)); + // Node type text: centered in the OPEN GAP between the single left (gear) + // button and the right button cluster (expand + grow), NOT the whole header — + // gear right edge ≈ -W/2+iconSize+12, expand left edge ≈ W/2-2*iconSize-20, so + // the gap midpoint is -(iconSize+8)/2 (independent of node width). + const typeGapX = -(iconSize + 8) / 2; nodeElements.append('text') .attr('class', 'node-type-text') - .attr('x', 0) + .attr('x', typeGapX) .attr('y', (d: WorkItem) => -getNodeDimensions(d).height / 2 + titleBarHeight / 2 + 2) .attr('text-anchor', 'middle') - .attr('dominant-baseline', 'middle') + .attr('dominant-baseline', 'central') .text((d: WorkItem) => d.type) .style('opacity', (currentTransform?.k || 1) >= LOD_THRESHOLDS.FAR ? 1 : 0) .style('font-size', '13px') @@ -2514,7 +2519,6 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .style('pointer-events', 'none'); // Edit icon in title bar (centered vertically, left side) - scales with zoom - const iconSize = Math.max(16, Math.min(24, titleBarHeight * 0.7)); const editIcons = nodeElements.append('g') .attr('class', 'node-edit-icon') .attr('transform', (d: WorkItem) => { @@ -2525,7 +2529,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .style('cursor', 'pointer') .style('opacity', (currentTransform?.k || 1) >= LOD_THRESHOLDS.FAR ? 0.85 : 0) .style('pointer-events', 'all'); - + editIcons.append('title').text('Open details / edit'); // native hover tooltip + // Edit icon background - scales with icon const editBg = editIcons.append('rect') .attr('class', 'edit-bg') @@ -2544,7 +2549,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .attr('x', 0) .attr('y', 0) .attr('text-anchor', 'middle') - .attr('dominant-baseline', 'middle') + .attr('dominant-baseline', 'central') .style('font-size', `${iconSize * 1.1}px`) .style('fill', '#ffffff') .style('pointer-events', 'none') @@ -2611,7 +2616,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .style('cursor', 'pointer') .style('opacity', (currentTransform?.k || 1) >= LOD_THRESHOLDS.FAR ? 0.85 : 0) .style('pointer-events', 'all'); - + relationshipIcons.append('title').text('Add a connection (grow)'); // native hover tooltip + // Relationship icon background - same as edit icon relationshipIcons.append('rect') .attr('class', 'relationship-bg') @@ -2630,7 +2636,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .attr('x', 0) .attr('y', 0) .attr('text-anchor', 'middle') - .attr('dominant-baseline', 'middle') + .attr('dominant-baseline', 'central') .style('font-size', `${iconSize * 1.2}px`) // Slightly bigger than gear for visual balance .style('font-weight', 'bold') .style('fill', '#ffffff') @@ -2705,6 +2711,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i .style('cursor', 'pointer') .style('opacity', (currentTransform?.k || 1) >= LOD_THRESHOLDS.FAR ? 0.85 : 0) .style('pointer-events', 'all'); + expandIcons.append('title').text('Expand — read contents & diagram'); // native hover tooltip expandIcons.append('rect') .attr('class', 'expand-bg') .attr('x', -iconSize / 2) From 9b57a5017cc54692081da384c8d55f4896f49b67 Mon Sep 17 00:00:00 2001 From: Matthew Valancy <matthew.valancy@gmail.com> Date: Wed, 17 Jun 2026 18:06:20 -0700 Subject: [PATCH 4/4] UX: edge-label direction arrow flips in place (no longer overlaps the label text) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The directional icon's flip used a bare rotate(180), which pivots on the group origin (0,0) — throwing the icon across onto the label text (the overlap) and leaving it upside-down. Rotate around the icon's own centre (x+7, y+7) so it flips in place: the arrow tracks the true edge direction AND stays beside the text with proper spacing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../components/InteractiveGraphVisualization.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 39fe67fe..65cb483c 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -3667,9 +3667,20 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i // points "backward" — which leaves the directional label icon pointing // the wrong way after a flip. Carry that lost 180° on the icon so its // arrow tracks the real edge direction (and flips when the edge flips). + // CRITICAL: rotate around the ICON's own centre — a bare rotate(180) + // pivots on the group origin (0,0), which threw the icon across onto the + // label text (overlap) and left it upside-down. The icon is a 14×14 + // foreignObject at (x,y), so its centre is (x+7, y+7). const trueAngle = (Math.atan2(target.y - source.y, target.x - source.x) * 180) / Math.PI; const iconFlipped = trueAngle > 90 || trueAngle < -90; - d3.select(this).select('.edge-label-icon').attr('transform', iconFlipped ? 'rotate(180)' : null); + const iconSel = d3.select(this).select('.edge-label-icon'); + if (iconFlipped) { + const ix = parseFloat(iconSel.attr('x') || '-7'); + const iy = parseFloat(iconSel.attr('y') || '-7'); + iconSel.attr('transform', `rotate(180, ${ix + 7}, ${iy + 7})`); + } else { + iconSel.attr('transform', null); + } return `translate(${placement.x},${placement.y}) rotate(${placement.rotation})`; }); };