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,
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<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 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}
+5 -1
View File
@@ -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";
+29 -25
View File
@@ -1,30 +1,34 @@
<script lang="ts">
import SiDiscord from "@icons-pack/svelte-simple-icons/icons/SiDiscord";
import { ChevronDown } from "@lucide/svelte";
import { Accordion } from "bits-ui";
import SiDiscord from "@icons-pack/svelte-simple-icons/icons/SiDiscord";
import { ChevronDown } from "@lucide/svelte";
import { Accordion } from "bits-ui";
const faqItems = [
{
value: "what",
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.",
},
{
value: "why-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.",
},
{
value: "data",
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.",
},
{
value: "official",
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.",
},
];
const faqItems = [
{
value: "what",
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.",
},
{
value: "why-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.",
},
{
value: "data",
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.",
},
{
value: "official",
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.",
},
];
</script>
<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
// (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");
};
});
</script>
@@ -18,6 +18,6 @@ onMount(() => {
<title>Class Timeline</title>
</svelte:head>
<div class="fixed inset-0 z-0">
<div class="fixed inset-0 z-0 overscroll-none overflow-hidden">
<TimelineCanvas />
</div>