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
31 changes: 30 additions & 1 deletion packages/web/src/components/InteractiveGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { computeNodeLayerY, isHierarchicalLayout, parseLayoutMode, LAYOUT_MODE_S
import { edgeBorderEndpoints, minEdgeLength, clampToMinNeighbors } from '../lib/edgeGeometry';
import { directionStrategy, arrowVisibility, labelVisibility, perpendicularOffset } from '../lib/edgeLOD';
import { assignParallelEdgeIndices } from '../lib/parallelEdges';
import { isTap, touchHitSize, isCoarsePointer } from '../lib/touchInteraction';
import { spawnCelebration } from '../lib/celebration';
import { buildNeighborhood } from '../lib/graphAdjacency';
import { UndoStack } from '../lib/undoStack';
Expand Down Expand Up @@ -2826,6 +2827,24 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i
if (d.subgraphId) descendIntoRef.current(d.subgraphId);
});

// 44px touch targets (#29): the header icon buttons draw at iconSize (16-24px),
// far below the 44px minimum, so they're hard to hit on a phone. On a coarse
// pointer, prepend a transparent hit-rect of touchHitSize() to each icon group
// (pointer-events bubble to the group's handlers) — bigger tap area, identical
// look. The rect lives in the group's counter-zoomed local space, so it tracks.
if (isCoarsePointer()) {
const hit = touchHitSize(iconSize, true);
nodeElements.selectAll('.node-edit-icon, .node-relationship-icon, .node-expand-icon, .node-descend-icon')
.insert('rect', ':first-child')
.attr('class', 'touch-hit-area')
.attr('x', -hit / 2)
.attr('y', -hit / 2)
.attr('width', hit)
.attr('height', hit)
.attr('fill', 'transparent')
.style('pointer-events', 'all');
}

// Child-graph count line (LOD-gated like the description text).
sheetNodes.append('text')
.attr('class', 'node-subgraph-count')
Expand Down Expand Up @@ -3568,7 +3587,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i
}
}
})
.on('touchend', function() {
.on('touchend', function(event: TouchEvent, d: any) {
// Clean up long-press detection
const element = d3.select(this);
const touchData = (element.node() as any).__touchData;
Expand All @@ -3577,6 +3596,16 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i
if (touchData.timeout) {
clearTimeout(touchData.timeout);
}
// TAP-TO-OPEN (#29): a quick touch that didn't move = a tap → select/open
// the node, deterministically. preventDefault suppresses the flaky
// synthetic mouse-click so the tap doesn't double-fire (open then toggle
// shut). Long-press (handled by the touchstart timeout) and drag/pan
// (moved past the slop) are excluded by isTap.
const duration = Date.now() - touchData.startTime;
if (isTap(duration, touchData.moved ? 999 : 0)) {
event.preventDefault();
handleNodeClick(event as unknown as MouseEvent, d);
}
delete (element.node() as any).__touchData;
}

Expand Down
59 changes: 59 additions & 0 deletions packages/web/src/lib/__tests__/touchInteraction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import {
MIN_TOUCH_TARGET, isTap, isLongPress, touchHitSize, distance, isCoarsePointer,
} from '../touchInteraction';

describe('isTap', () => {
it('quick + still = tap', () => {
expect(isTap(120, 3)).toBe(true);
expect(isTap(0, 0)).toBe(true);
});
it('moved past slop = not a tap (it is a drag/pan)', () => {
expect(isTap(120, 40)).toBe(false);
});
it('held too long = not a tap (it is a long-press)', () => {
expect(isTap(600, 2)).toBe(false);
});
});

describe('isLongPress', () => {
it('held + still = long-press', () => {
expect(isLongPress(600, 4)).toBe(true);
});
it('moved = not a long-press', () => {
expect(isLongPress(600, 40)).toBe(false);
});
it('quick = not a long-press', () => {
expect(isLongPress(120, 2)).toBe(false);
});
it('tap and long-press are mutually exclusive across the boundary', () => {
expect(isTap(499, 0)).toBe(true);
expect(isLongPress(499, 0)).toBe(false);
expect(isTap(500, 0)).toBe(false);
expect(isLongPress(500, 0)).toBe(true);
});
});

describe('touchHitSize', () => {
it('enforces the 44px minimum on touch, never shrinking a larger control', () => {
expect(touchHitSize(16, true)).toBe(MIN_TOUCH_TARGET);
expect(touchHitSize(24, true)).toBe(44);
expect(touchHitSize(60, true)).toBe(60);
});
it('leaves the visual size untouched on a mouse pointer', () => {
expect(touchHitSize(16, false)).toBe(16);
});
});

describe('distance', () => {
it('is euclidean', () => {
expect(distance(0, 0, 3, 4)).toBe(5);
expect(distance(1, 1, 1, 1)).toBe(0);
});
});

describe('isCoarsePointer', () => {
it('does not throw without a browser matchMedia', () => {
expect(typeof isCoarsePointer()).toBe('boolean');
});
});
51 changes: 51 additions & 0 deletions packages/web/src/lib/touchInteraction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Pure touch-interaction logic for the graph (#29 mobile touch overhaul). The D3
* layer feeds raw touch timing/movement here and applies the verdicts; keeping
* the thresholds + classification here makes them unit-testable without a DOM.
*
* Gestures on a node:
* - tap → select / open (short, didn't move)
* - long-press → context menu (held, didn't move)
* - drag/pan → move the node or pan the canvas (moved past the slop)
*/
export const MIN_TOUCH_TARGET = 44; // px — WCAG 2.5.5 / Apple HIG minimum
export const TAP_MAX_MS = 500; // held longer (without moving) = long-press
export const TAP_MAX_MOVE_PX = 10; // moved farther = drag/pan, not a tap

export function distance(ax: number, ay: number, bx: number, by: number): number {
return Math.hypot(ax - bx, ay - by);
}

/** A quick touch that didn't move = a tap (select/open). */
export function isTap(
durationMs: number,
movePx: number,
{ maxMs = TAP_MAX_MS, maxMove = TAP_MAX_MOVE_PX } = {},
): boolean {
return durationMs >= 0 && durationMs < maxMs && movePx <= maxMove;
}

/** A held touch that didn't move = a long-press (context menu). */
export function isLongPress(
durationMs: number,
movePx: number,
{ minMs = TAP_MAX_MS, maxMove = TAP_MAX_MOVE_PX } = {},
): boolean {
return durationMs >= minMs && movePx <= maxMove;
}

/**
* The hit-area side length for an on-canvas control. On touch we guarantee at
* least MIN_TOUCH_TARGET regardless of how small the icon is drawn; on a mouse
* pointer the visual size is fine.
*/
export function touchHitSize(visualPx: number, isTouch: boolean, min = MIN_TOUCH_TARGET): number {
return isTouch ? Math.max(min, visualPx) : visualPx;
}

/** Coarse-pointer (touch) detection; safe in non-browser/test contexts. */
export function isCoarsePointer(): boolean {
return typeof window !== 'undefined'
&& typeof window.matchMedia === 'function'
&& window.matchMedia('(pointer: coarse)').matches;
}
33 changes: 33 additions & 0 deletions tests/e2e/mobile/touch-graph.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { test, expect } from '@playwright/test';
import { login, TEST_USERS } from '../../lib/auth';

/**
* Mobile touch overhaul (#29). On a phone the graph defaults to LOCKED (touch
* pans the canvas), but a single TAP on a node must still open it — the primary
* way to inspect/edit on touch. Verifies tap-to-open works under a real touch
* context (not a synthetic mouse click).
*/
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });

test.describe('mobile graph touch @mobile @touch', () => {
test('single tap on a node opens the inspector (even while locked)', async ({ page }) => {
test.setTimeout(90000);
await login(page, TEST_USERS.ADMIN);
// Land deterministically on the graph view.
await page.evaluate(() => localStorage.setItem('graphdone:viewMode', 'graph'));
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('.node-bg', { timeout: 30000 });
await page.waitForTimeout(4000); // let physics settle

// The graph starts locked on a phone (pan-by-touch is the default nav).
const lock = page.getByTestId('graph-lock-toggle');
await expect(lock).toBeVisible();

// Inspector is not open yet.
await expect(page.getByTestId('node-inspector')).toHaveCount(0);

// A real touch TAP on a node card opens the inspector.
await page.locator('.node-bg').first().tap();
await expect(page.getByTestId('node-inspector')).toBeVisible({ timeout: 8000 });
});
});
Loading