mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 04:23:34 -06:00
feat: add mobile touch controls with gesture detection
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
Reference in New Issue
Block a user