Files
public-weather/src/lib/components/SwipeableWeatherGrid.svelte
T
2026-06-15 14:41:23 -07:00

272 lines
9.4 KiB
Svelte

<script lang="ts">
import WeatherCard from "./WeatherCard.svelte";
import type { WeatherPeriod } from "$lib/types/weather.js";
import { onMount } from "svelte";
interface Props {
periods: WeatherPeriod[];
}
let { periods }: Props = $props();
let cardStack = $state([...periods]);
let currentCardIndex = $state(0);
let isDragging = false;
let startX = 0;
let startY = 0;
let currentX = 0;
let currentY = 0;
let topCardRef: HTMLDivElement | undefined = $state();
const SWIPE_THRESHOLD = 100;
const ROTATION_FACTOR = 0.1;
const MAX_ROTATION = 15;
function handleTouchStart(e: TouchEvent) {
if (!topCardRef) return;
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
isDragging = true;
// Enable hardware acceleration and disable transitions smoothly
topCardRef.style.willChange = "transform, opacity";
topCardRef.style.transition = "none";
}
function handleTouchMove(e: TouchEvent) {
if (!isDragging || !topCardRef) return;
currentX = e.touches[0].clientX;
currentY = e.touches[0].clientY;
const deltaX = currentX - startX;
const deltaY = currentY - startY;
const rotation = Math.min(
Math.max(deltaX * ROTATION_FACTOR, -MAX_ROTATION),
MAX_ROTATION,
);
// Use translate3d for hardware acceleration
topCardRef.style.transform = `translate3d(${deltaX}px, ${deltaY}px, 0) rotate(${rotation}deg)`;
topCardRef.style.opacity = `${1 - Math.abs(deltaX) / 300}`;
}
function handleTouchEnd() {
if (!isDragging || !topCardRef) return;
isDragging = false;
const deltaX = currentX - startX;
if (Math.abs(deltaX) > SWIPE_THRESHOLD) {
// Card swiped away - animate it off screen
const direction = deltaX > 0 ? 1 : -1;
topCardRef.style.transition =
"transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)";
topCardRef.style.transform = `translate3d(${direction * window.innerWidth}px, ${currentY - startY}px, 0) rotate(${direction * MAX_ROTATION * 2}deg)`;
topCardRef.style.opacity = "0";
// Remove the card after animation
setTimeout(() => {
removeTopCard();
}, 400);
} else {
// Snap back to center with smooth spring-like animation
topCardRef.style.transition =
"transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease-out";
topCardRef.style.transform = "translate3d(0px, 0px, 0) rotate(0deg)";
topCardRef.style.opacity = "1";
// Clean up will-change after animation
setTimeout(() => {
if (topCardRef) {
topCardRef.style.willChange = "auto";
}
}, 500);
}
}
function removeTopCard() {
if (cardStack.length === 0) return;
// Remove the top card and add it to the bottom of the stack
const removedCard = cardStack[currentCardIndex];
cardStack = [
...cardStack.slice(0, currentCardIndex),
...cardStack.slice(currentCardIndex + 1),
removedCard,
];
// Reset the top card reference
if (topCardRef) {
topCardRef.style.willChange = "auto";
topCardRef.style.transition = "none";
topCardRef.style.transform = "translate3d(0px, 0px, 0) rotate(0deg)";
topCardRef.style.opacity = "1";
}
}
function jumpToNow() {
if (isDragging) return; // Don't jump while dragging
const now = new Date();
const currentHour = now.getHours();
// Find the period that represents the current time or closest future time
const nowIndex = periods.findIndex(period => {
const periodStart = new Date(period.startTime);
return periodStart.getHours() >= currentHour;
});
if (nowIndex !== -1) {
// Reorder the card stack to put the "now" period at the front
const beforeNow = periods.slice(0, nowIndex);
const fromNow = periods.slice(nowIndex);
cardStack = [...fromNow, ...beforeNow];
currentCardIndex = 0;
// Wait a frame to ensure DOM updates, then reset the top card
setTimeout(() => {
if (topCardRef) {
topCardRef.style.willChange = "auto";
topCardRef.style.transition = "none";
topCardRef.style.transform = "translate3d(0px, 0px, 0) rotate(0deg)";
topCardRef.style.opacity = "1";
}
}, 0);
}
}
// Handle mouse events for desktop testing
function handleMouseDown(e: MouseEvent) {
if (!topCardRef) return;
startX = e.clientX;
startY = e.clientY;
isDragging = true;
// Enable hardware acceleration and disable transitions smoothly
topCardRef.style.willChange = "transform, opacity";
topCardRef.style.transition = "none";
e.preventDefault();
}
function handleMouseMove(e: MouseEvent) {
if (!isDragging || !topCardRef) return;
currentX = e.clientX;
currentY = e.clientY;
const deltaX = currentX - startX;
const deltaY = currentY - startY;
const rotation = Math.min(
Math.max(deltaX * ROTATION_FACTOR, -MAX_ROTATION),
MAX_ROTATION,
);
// Use translate3d for hardware acceleration
topCardRef.style.transform = `translate3d(${deltaX}px, ${deltaY}px, 0) rotate(${rotation}deg)`;
topCardRef.style.opacity = `${1 - Math.abs(deltaX) / 300}`;
}
function handleMouseUp() {
if (!isDragging || !topCardRef) return;
isDragging = false;
const deltaX = currentX - startX;
if (Math.abs(deltaX) > SWIPE_THRESHOLD) {
// Card swiped away
const direction = deltaX > 0 ? 1 : -1;
topCardRef.style.transition =
"transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)";
topCardRef.style.transform = `translate3d(${direction * window.innerWidth}px, ${currentY - startY}px, 0) rotate(${direction * MAX_ROTATION * 2}deg)`;
topCardRef.style.opacity = "0";
setTimeout(() => {
removeTopCard();
}, 400);
} else {
// Snap back with smooth spring-like animation
topCardRef.style.transition =
"transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease-out";
topCardRef.style.transform = "translate3d(0px, 0px, 0) rotate(0deg)";
topCardRef.style.opacity = "1";
// Clean up will-change after animation
setTimeout(() => {
if (topCardRef) {
topCardRef.style.willChange = "auto";
}
}, 500);
}
}
onMount(() => {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
});
</script>
<!-- Desktop view - keep original grid layout -->
<div class="hidden md:grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{#each periods as period (period.number)}
<WeatherCard {period} />
{/each}
</div>
<!-- Mobile view - card stack -->
<div class="md:hidden">
<div class="relative h-[360px] flex items-center justify-center">
{#each cardStack.slice(0, 3) as period, index (period.number)}
<div
class="absolute w-full max-w-sm"
style="
z-index: {3 - index};
transform: translate3d({index * (index % 2 === 0 ? 6 : -6)}px, {index * 8}px, 0) rotate({index * (index % 2 === 0 ? 2 : -2)}deg) scale({1 - index * 0.03});
will-change: {index === 0 ? 'transform, opacity' : 'auto'};
opacity: {1 - index * 0.15};
"
>
{#if index === 0}
<div
bind:this={topCardRef}
class="cursor-grab active:cursor-grabbing"
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
onmousedown={handleMouseDown}
role="button"
aria-label="Swipe to dismiss card"
tabindex="0"
>
<WeatherCard {period} />
</div>
{:else}
<WeatherCard {period} />
{/if}
</div>
{/each}
</div>
<!-- Jump to now button -->
<div class="text-center mt-4">
<button
class="text-sm text-blue-500 hover:text-blue-700 underline cursor-pointer"
onclick={jumpToNow}
>
jump to now
</button>
</div>
<!-- Instructions -->
<div class="text-center mt-2 text-xs text-muted-foreground">
Swipe left or right to dismiss
</div>
</div>