From 39ba13132266e044349936b99b042e69e7a77cd6 Mon Sep 17 00:00:00 2001 From: Xevion Date: Thu, 29 Jan 2026 23:56:45 -0600 Subject: [PATCH] feat: add mobile touch controls with gesture detection --- web/src/lib/components/TimelineCanvas.svelte | 112 ++++++++++++++++++- web/src/lib/timeline/constants.ts | 6 +- web/src/routes/login/+page.svelte | 54 ++++----- web/src/routes/timeline/+page.svelte | 10 +- 4 files changed, 148 insertions(+), 34 deletions(-) diff --git a/web/src/lib/components/TimelineCanvas.svelte b/web/src/lib/components/TimelineCanvas.svelte index 1c31818..576cacf 100644 --- a/web/src/lib/components/TimelineCanvas.svelte +++ b/web/src/lib/components/TimelineCanvas.svelte @@ -32,6 +32,8 @@ import { MIN_MAXY, MAX_DT, DEFAULT_DT, + TAP_MAX_DURATION_MS, + TAP_MAX_DISTANCE_PX, } from "$lib/timeline/constants"; import { createTimelineStore } from "$lib/timeline/store.svelte"; import { @@ -83,6 +85,18 @@ let panVelocity = 0; let panVelocityY = 0; let pointerSamples: { time: number; x: number; y: number }[] = []; +// ── Multi-touch / pinch state ──────────────────────────────────────── +let activePointers = new Map(); +let isPinching = false; +let pinchStartDist = 0; +let pinchStartSpan = 0; +let pinchAnchorTime = 0; +let pinchAnchorRatio = 0.5; + +// ── Tap detection ──────────────────────────────────────────────────── +let pointerDownTime = 0; +let pointerDownPos = { x: 0, y: 0 }; + let targetSpan = DEFAULT_SPAN_MS; let zoomAnchorTime = 0; let zoomAnchorRatio = 0.5; @@ -236,9 +250,45 @@ function updateHover() { hoverSlotTime = snappedTime; } +// ── Interaction helpers ─────────────────────────────────────────────── +function pinchDistance(): number { + const pts = [...activePointers.values()]; + if (pts.length < 2) return 0; + const dx = pts[1].x - pts[0].x; + const dy = pts[1].y - pts[0].y; + return Math.hypot(dx, dy); +} + +function pinchMidpoint(): { x: number; y: number } { + const pts = [...activePointers.values()]; + if (pts.length < 2) return { x: 0, y: 0 }; + return { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 }; +} + // ── Interaction handlers ──────────────────────────────────────────── function onPointerDown(e: PointerEvent) { if (e.button !== 0) return; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + + // Two fingers down → start pinch-to-zoom + if (activePointers.size === 2) { + isDragging = false; + isPinching = true; + pinchStartDist = pinchDistance(); + pinchStartSpan = viewSpan; + + const mid = pinchMidpoint(); + const rect = canvasEl?.getBoundingClientRect(); + const midX = rect ? mid.x - rect.left : mid.x; + const chartWidth = width - PADDING.left - PADDING.right; + + pinchAnchorTime = xScale.invert(midX).getTime(); + pinchAnchorRatio = (midX - PADDING.left) / chartWidth; + return; + } + + // Single finger / mouse → start drag isDragging = true; dragStartX = e.clientX; dragStartY = e.clientY; @@ -253,8 +303,9 @@ function onPointerDown(e: PointerEvent) { targetSpan = viewSpan; tooltipVisible = false; hoverSlotTime = null; + pointerDownTime = performance.now(); + pointerDownPos = { x: e.clientX, y: e.clientY }; pointerSamples = [{ time: performance.now(), x: e.clientX, y: e.clientY }]; - (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } function onPointerMove(e: PointerEvent) { @@ -262,6 +313,20 @@ function onPointerMove(e: PointerEvent) { lastPointerClientX = e.clientX; lastPointerClientY = e.clientY; pointerOverCanvas = true; + activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + + // Pinch-to-zoom (two-finger gesture) + if (isPinching && activePointers.size >= 2) { + const dist = pinchDistance(); + if (pinchStartDist > 0) { + const scale = pinchStartDist / dist; // fingers apart = zoom in + const newSpan = Math.min(MAX_SPAN_MS, Math.max(MIN_SPAN_MS, pinchStartSpan * scale)); + viewSpan = newSpan; + targetSpan = newSpan; + viewCenter = pinchAnchorTime + (0.5 - pinchAnchorRatio) * viewSpan; + } + return; + } if (isDragging) { const dx = e.clientX - dragStartX; @@ -280,9 +345,43 @@ function onPointerMove(e: PointerEvent) { } function onPointerUp(e: PointerEvent) { - isDragging = false; (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + activePointers.delete(e.pointerId); + // End pinch when fewer than 2 fingers remain + if (isPinching) { + if (activePointers.size < 2) { + isPinching = false; + // If one finger remains, reset drag origin to that finger's position + if (activePointers.size === 1) { + const remaining = [...activePointers.values()][0]; + isDragging = true; + dragStartX = remaining.x; + dragStartY = remaining.y; + dragStartCenter = viewCenter; + dragStartYRatio = viewYRatio; + pointerSamples = [{ time: performance.now(), x: remaining.x, y: remaining.y }]; + } + } + return; + } + + isDragging = false; + + // Tap detection: short duration + minimal movement → show tooltip + const elapsed = performance.now() - pointerDownTime; + const dist = Math.hypot(e.clientX - pointerDownPos.x, e.clientY - pointerDownPos.y); + if (elapsed < TAP_MAX_DURATION_MS && dist < TAP_MAX_DISTANCE_PX) { + lastPointerClientX = e.clientX; + lastPointerClientY = e.clientY; + pointerOverCanvas = true; + // Bypass the isDragging guard in updateHover since we just cleared it + updateHover(); + pointerSamples = []; + return; + } + + // Momentum from drag if (pointerSamples.length >= 2) { const first = pointerSamples[0]; const last = pointerSamples[pointerSamples.length - 1]; @@ -303,6 +402,12 @@ function onPointerLeave() { hoverSlotTime = null; } +function onPointerCancel(e: PointerEvent) { + activePointers.delete(e.pointerId); + if (activePointers.size < 2) isPinching = false; + if (activePointers.size === 0) isDragging = false; +} + function onWheel(e: WheelEvent) { e.preventDefault(); if (!canvasEl) return; @@ -518,13 +623,14 @@ onMount(() => { bind:this={canvasEl} class="w-full h-full cursor-grab outline-none" class:cursor-grabbing={isDragging} - style="display: block;" + style="display: block; touch-action: none;" tabindex="0" aria-label="Interactive class schedule timeline chart" onpointerdown={(e) => { canvasEl?.focus(); onPointerDown(e); }} onpointermove={onPointerMove} onpointerup={onPointerUp} onpointerleave={onPointerLeave} + onpointercancel={onPointerCancel} onwheel={onWheel} onkeydown={onKeyDown} onkeyup={onKeyUp} diff --git a/web/src/lib/timeline/constants.ts b/web/src/lib/timeline/constants.ts index 81aa051..1b27891 100644 --- a/web/src/lib/timeline/constants.ts +++ b/web/src/lib/timeline/constants.ts @@ -1,6 +1,6 @@ /** Layout & padding */ export const PADDING = { top: 20, right: 0, bottom: 40, left: 0 } as const; -export const DEFAULT_AXIS_RATIO = 0.80; +export const DEFAULT_AXIS_RATIO = 0.8; export const CHART_HEIGHT_RATIO = 0.6; /** Viewport span limits (ms) */ @@ -71,5 +71,9 @@ export const RENDER_MARGIN_SLOTS = 3; /** Drawer */ export const DRAWER_WIDTH = 220; +/** Touch / tap */ +export const TAP_MAX_DURATION_MS = 250; +export const TAP_MAX_DISTANCE_PX = 10; + /** Axis text */ export const AXIS_FONT = "11px Inter, system-ui, sans-serif"; diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte index 413cf0c..7833ea7 100644 --- a/web/src/routes/login/+page.svelte +++ b/web/src/routes/login/+page.svelte @@ -1,30 +1,34 @@
diff --git a/web/src/routes/timeline/+page.svelte b/web/src/routes/timeline/+page.svelte index ab37825..b4afaf5 100644 --- a/web/src/routes/timeline/+page.svelte +++ b/web/src/routes/timeline/+page.svelte @@ -5,11 +5,11 @@ import TimelineCanvas from "$lib/components/TimelineCanvas.svelte"; // Prevent body scroll while this page is mounted via a CSS class // (avoids conflicting with other components that may manage overflow). onMount(() => { - document.documentElement.classList.add("overflow-hidden"); - document.body.classList.add("overflow-hidden"); + document.documentElement.classList.add("overflow-hidden", "overscroll-none"); + document.body.classList.add("overflow-hidden", "overscroll-none"); return () => { - document.documentElement.classList.remove("overflow-hidden"); - document.body.classList.remove("overflow-hidden"); + document.documentElement.classList.remove("overflow-hidden", "overscroll-none"); + document.body.classList.remove("overflow-hidden", "overscroll-none"); }; }); @@ -18,6 +18,6 @@ onMount(() => { Class Timeline -
+