feat: add mobile touch controls with gesture detection

This commit is contained in:
2026-01-29 23:56:45 -06:00
parent 2fad9c969d
commit 39ba131322
4 changed files with 148 additions and 34 deletions
+109 -3
View File
@@ -32,6 +32,8 @@ import {
MIN_MAXY, MIN_MAXY,
MAX_DT, MAX_DT,
DEFAULT_DT, DEFAULT_DT,
TAP_MAX_DURATION_MS,
TAP_MAX_DISTANCE_PX,
} from "$lib/timeline/constants"; } from "$lib/timeline/constants";
import { createTimelineStore } from "$lib/timeline/store.svelte"; import { createTimelineStore } from "$lib/timeline/store.svelte";
import { import {
@@ -83,6 +85,18 @@ let panVelocity = 0;
let panVelocityY = 0; let panVelocityY = 0;
let pointerSamples: { time: number; x: number; y: number }[] = []; let pointerSamples: { time: number; x: number; y: number }[] = [];
// ── Multi-touch / pinch state ────────────────────────────────────────
let activePointers = new Map<number, { x: number; y: number }>();
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 targetSpan = DEFAULT_SPAN_MS;
let zoomAnchorTime = 0; let zoomAnchorTime = 0;
let zoomAnchorRatio = 0.5; let zoomAnchorRatio = 0.5;
@@ -236,9 +250,45 @@ function updateHover() {
hoverSlotTime = snappedTime; 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 ──────────────────────────────────────────── // ── Interaction handlers ────────────────────────────────────────────
function onPointerDown(e: PointerEvent) { function onPointerDown(e: PointerEvent) {
if (e.button !== 0) return; 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; isDragging = true;
dragStartX = e.clientX; dragStartX = e.clientX;
dragStartY = e.clientY; dragStartY = e.clientY;
@@ -253,8 +303,9 @@ function onPointerDown(e: PointerEvent) {
targetSpan = viewSpan; targetSpan = viewSpan;
tooltipVisible = false; tooltipVisible = false;
hoverSlotTime = null; hoverSlotTime = null;
pointerDownTime = performance.now();
pointerDownPos = { x: e.clientX, y: e.clientY };
pointerSamples = [{ time: performance.now(), 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) { function onPointerMove(e: PointerEvent) {
@@ -262,6 +313,20 @@ function onPointerMove(e: PointerEvent) {
lastPointerClientX = e.clientX; lastPointerClientX = e.clientX;
lastPointerClientY = e.clientY; lastPointerClientY = e.clientY;
pointerOverCanvas = true; 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) { if (isDragging) {
const dx = e.clientX - dragStartX; const dx = e.clientX - dragStartX;
@@ -280,9 +345,43 @@ function onPointerMove(e: PointerEvent) {
} }
function onPointerUp(e: PointerEvent) { function onPointerUp(e: PointerEvent) {
isDragging = false;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); (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) { if (pointerSamples.length >= 2) {
const first = pointerSamples[0]; const first = pointerSamples[0];
const last = pointerSamples[pointerSamples.length - 1]; const last = pointerSamples[pointerSamples.length - 1];
@@ -303,6 +402,12 @@ function onPointerLeave() {
hoverSlotTime = null; 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) { function onWheel(e: WheelEvent) {
e.preventDefault(); e.preventDefault();
if (!canvasEl) return; if (!canvasEl) return;
@@ -518,13 +623,14 @@ onMount(() => {
bind:this={canvasEl} bind:this={canvasEl}
class="w-full h-full cursor-grab outline-none" class="w-full h-full cursor-grab outline-none"
class:cursor-grabbing={isDragging} class:cursor-grabbing={isDragging}
style="display: block;" style="display: block; touch-action: none;"
tabindex="0" tabindex="0"
aria-label="Interactive class schedule timeline chart" aria-label="Interactive class schedule timeline chart"
onpointerdown={(e) => { canvasEl?.focus(); onPointerDown(e); }} onpointerdown={(e) => { canvasEl?.focus(); onPointerDown(e); }}
onpointermove={onPointerMove} onpointermove={onPointerMove}
onpointerup={onPointerUp} onpointerup={onPointerUp}
onpointerleave={onPointerLeave} onpointerleave={onPointerLeave}
onpointercancel={onPointerCancel}
onwheel={onWheel} onwheel={onWheel}
onkeydown={onKeyDown} onkeydown={onKeyDown}
onkeyup={onKeyUp} onkeyup={onKeyUp}
+5 -1
View File
@@ -1,6 +1,6 @@
/** Layout & padding */ /** Layout & padding */
export const PADDING = { top: 20, right: 0, bottom: 40, left: 0 } as const; 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; export const CHART_HEIGHT_RATIO = 0.6;
/** Viewport span limits (ms) */ /** Viewport span limits (ms) */
@@ -71,5 +71,9 @@ export const RENDER_MARGIN_SLOTS = 3;
/** Drawer */ /** Drawer */
export const DRAWER_WIDTH = 220; export const DRAWER_WIDTH = 220;
/** Touch / tap */
export const TAP_MAX_DURATION_MS = 250;
export const TAP_MAX_DISTANCE_PX = 10;
/** Axis text */ /** Axis text */
export const AXIS_FONT = "11px Inter, system-ui, sans-serif"; export const AXIS_FONT = "11px Inter, system-ui, sans-serif";
+13 -9
View File
@@ -1,30 +1,34 @@
<script lang="ts"> <script lang="ts">
import SiDiscord from "@icons-pack/svelte-simple-icons/icons/SiDiscord"; import SiDiscord from "@icons-pack/svelte-simple-icons/icons/SiDiscord";
import { ChevronDown } from "@lucide/svelte"; import { ChevronDown } from "@lucide/svelte";
import { Accordion } from "bits-ui"; import { Accordion } from "bits-ui";
const faqItems = [ const faqItems = [
{ {
value: "what", value: "what",
question: "What does this do?", question: "What does this do?",
answer: "Banner monitors UTSA course availability in real-time. Get notified on Discord when seats open up in the classes you need, so you never miss a registration window.", answer:
"Banner monitors UTSA course availability in real-time. Get notified on Discord when seats open up in the classes you need, so you never miss a registration window.",
}, },
{ {
value: "why-discord", value: "why-discord",
question: "Why sign in with Discord?", question: "Why sign in with Discord?",
answer: "Banner delivers notifications through a Discord bot. Signing in with Discord lets us link your account to send you alerts directly, and lets you manage your watchlist from the web dashboard.", answer:
"Banner delivers notifications through a Discord bot. Signing in with Discord lets us link your account to send you alerts directly, and lets you manage your watchlist from the web dashboard.",
}, },
{ {
value: "data", value: "data",
question: "What data do you access?", question: "What data do you access?",
answer: "We only request your Discord username and avatar. We don't read your messages, join servers on your behalf, or access any other Discord data.", answer:
"We only request your Discord username and avatar. We don't read your messages, join servers on your behalf, or access any other Discord data.",
}, },
{ {
value: "official", value: "official",
question: "Is this an official UTSA tool?", question: "Is this an official UTSA tool?",
answer: "No. Banner is an independent, community-built project. It is not affiliated with, endorsed by, or maintained by UTSA or Ellucian.", answer:
"No. Banner is an independent, community-built project. It is not affiliated with, endorsed by, or maintained by UTSA or Ellucian.",
}, },
]; ];
</script> </script>
<div class="flex flex-1 items-center justify-center px-4 pb-14"> <div class="flex flex-1 items-center justify-center px-4 pb-14">
+5 -5
View File
@@ -5,11 +5,11 @@ import TimelineCanvas from "$lib/components/TimelineCanvas.svelte";
// Prevent body scroll while this page is mounted via a CSS class // Prevent body scroll while this page is mounted via a CSS class
// (avoids conflicting with other components that may manage overflow). // (avoids conflicting with other components that may manage overflow).
onMount(() => { onMount(() => {
document.documentElement.classList.add("overflow-hidden"); document.documentElement.classList.add("overflow-hidden", "overscroll-none");
document.body.classList.add("overflow-hidden"); document.body.classList.add("overflow-hidden", "overscroll-none");
return () => { return () => {
document.documentElement.classList.remove("overflow-hidden"); document.documentElement.classList.remove("overflow-hidden", "overscroll-none");
document.body.classList.remove("overflow-hidden"); document.body.classList.remove("overflow-hidden", "overscroll-none");
}; };
}); });
</script> </script>
@@ -18,6 +18,6 @@ onMount(() => {
<title>Class Timeline</title> <title>Class Timeline</title>
</svelte:head> </svelte:head>
<div class="fixed inset-0 z-0"> <div class="fixed inset-0 z-0 overscroll-none overflow-hidden">
<TimelineCanvas /> <TimelineCanvas />
</div> </div>