Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 44 additions & 21 deletions packages/web/src/components/InteractiveGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1318,8 +1318,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i
const zoom = d3.zoom<SVGSVGElement, unknown>()
.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');
Expand Down Expand Up @@ -1761,8 +1761,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i
const zoom = d3.zoom<SVGSVGElement, unknown>()
.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;
Expand Down Expand Up @@ -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')
Expand All @@ -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) => {
Expand All @@ -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')
Expand All @@ -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')
Expand Down Expand Up @@ -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')
Expand All @@ -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')
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -3660,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})`;
});
};
Expand Down Expand Up @@ -4071,18 +4089,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<SVGSVGElement, unknown>().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<SVGSVGElement, unknown>().transform as any, transform);
}

// Update mini-map viewport
if ((window as any).updateMiniMapViewport) {
Expand Down
5 changes: 3 additions & 2 deletions tests/e2e/camera.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading