This commit is contained in:
399
src/lib/components/TimeSpinner.svelte
Normal file
399
src/lib/components/TimeSpinner.svelte
Normal file
@@ -0,0 +1,399 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value: string;
|
||||
onchange?: (value: string) => void;
|
||||
countdownFont?: string;
|
||||
}
|
||||
|
||||
let { value, onchange, countdownFont = "" }: Props = $props();
|
||||
|
||||
// Local display values - driven by prop normally, overridden during drag/momentum
|
||||
let displayHours = $state(0);
|
||||
let displayMinutes = $state(0);
|
||||
let isAnimating = $state(false); // true during drag OR momentum
|
||||
|
||||
// Fractional offset for smooth wheel rotation (0 to <1)
|
||||
let hoursFraction = $state(0);
|
||||
let minutesFraction = $state(0);
|
||||
|
||||
// Sync display from prop when NOT dragging/animating
|
||||
$effect(() => {
|
||||
if (!isAnimating) {
|
||||
displayHours = parseInt(value.split(":")[0]) || 0;
|
||||
displayMinutes = parseInt(value.split(":")[1]) || 0;
|
||||
hoursFraction = 0;
|
||||
minutesFraction = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Drag state ──
|
||||
|
||||
let hoursDragging = $state(false);
|
||||
let hoursLastY = $state(0);
|
||||
let hoursAccumPx = $state(0);
|
||||
let hoursBaseValue = $state(0);
|
||||
let hoursVelocity = $state(0);
|
||||
let hoursLastMoveTime = $state(0);
|
||||
|
||||
let minutesDragging = $state(false);
|
||||
let minutesLastY = $state(0);
|
||||
let minutesAccumPx = $state(0);
|
||||
let minutesBaseValue = $state(0);
|
||||
let minutesVelocity = $state(0);
|
||||
let minutesLastMoveTime = $state(0);
|
||||
|
||||
// Momentum animation handle
|
||||
let momentumRaf: number | null = null;
|
||||
|
||||
const SENSITIVITY = 20; // pixels per value step
|
||||
const ITEM_ANGLE = 30; // degrees between items on the cylinder
|
||||
const WHEEL_RADIUS = 26; // cylinder radius in px
|
||||
const FRICTION = 0.93; // momentum decay per frame
|
||||
const MIN_VELOCITY = 0.3; // px/frame threshold to stop momentum
|
||||
|
||||
function wrapValue(v: number, max: number): number {
|
||||
return ((v % max) + max) % max;
|
||||
}
|
||||
|
||||
function emitValue(h: number, m: number) {
|
||||
onchange?.(`${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`);
|
||||
}
|
||||
|
||||
function stopMomentum() {
|
||||
if (momentumRaf !== null) {
|
||||
cancelAnimationFrame(momentumRaf);
|
||||
momentumRaf = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the visible items for the 3D wheel
|
||||
function getWheelItems(current: number, fraction: number, maxVal: number) {
|
||||
const items = [];
|
||||
for (let i = -2; i <= 2; i++) {
|
||||
const val = wrapValue(current + i, maxVal);
|
||||
const angle = (i - fraction) * ITEM_ANGLE;
|
||||
const absAngle = Math.abs(angle);
|
||||
// Center item bright, off-center very dim
|
||||
const isCenter = absAngle < ITEM_ANGLE * 0.5;
|
||||
const opacity = isCenter
|
||||
? Math.max(0.7, 1 - absAngle / 60)
|
||||
: Math.max(0.05, 0.35 - absAngle / 120);
|
||||
items.push({ value: val, angle, opacity });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
const hoursItems = $derived(getWheelItems(displayHours, hoursFraction, 24));
|
||||
const minutesItems = $derived(getWheelItems(displayMinutes, minutesFraction, 60));
|
||||
|
||||
// ── Shared step logic (used by both drag and momentum) ──
|
||||
|
||||
function applyAccum(
|
||||
field: "hours" | "minutes",
|
||||
accumPx: number,
|
||||
baseValue: number,
|
||||
): { newVal: number; fraction: number } {
|
||||
const totalSteps = accumPx / SENSITIVITY;
|
||||
const wholeSteps = Math.round(totalSteps);
|
||||
const fraction = totalSteps - wholeSteps;
|
||||
const maxVal = field === "hours" ? 24 : 60;
|
||||
const newVal = wrapValue(baseValue + wholeSteps, maxVal);
|
||||
return { newVal, fraction };
|
||||
}
|
||||
|
||||
// ── Hours pointer handlers ──
|
||||
|
||||
function handleHoursPointerDown(e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
stopMomentum();
|
||||
hoursDragging = true;
|
||||
isAnimating = true;
|
||||
hoursLastY = e.clientY;
|
||||
hoursAccumPx = 0;
|
||||
hoursBaseValue = displayHours;
|
||||
hoursVelocity = 0;
|
||||
hoursLastMoveTime = performance.now();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
function handleHoursPointerMove(e: PointerEvent) {
|
||||
if (!hoursDragging) return;
|
||||
e.preventDefault();
|
||||
const dy = hoursLastY - e.clientY;
|
||||
hoursAccumPx += dy;
|
||||
hoursLastY = e.clientY;
|
||||
|
||||
// Track velocity (px/ms)
|
||||
const now = performance.now();
|
||||
const dt = now - hoursLastMoveTime;
|
||||
if (dt > 0) hoursVelocity = dy / dt;
|
||||
hoursLastMoveTime = now;
|
||||
|
||||
const { newVal, fraction } = applyAccum("hours", hoursAccumPx, hoursBaseValue);
|
||||
hoursFraction = fraction;
|
||||
if (newVal !== displayHours) {
|
||||
displayHours = newVal;
|
||||
emitValue(newVal, displayMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
function handleHoursPointerUp(e: PointerEvent) {
|
||||
if (!hoursDragging) return;
|
||||
e.preventDefault();
|
||||
hoursDragging = false;
|
||||
|
||||
// Launch momentum if velocity is significant
|
||||
const velocityPxPerFrame = hoursVelocity * 16; // convert px/ms to px/frame (~16ms)
|
||||
if (Math.abs(velocityPxPerFrame) > MIN_VELOCITY) {
|
||||
startMomentum("hours", velocityPxPerFrame);
|
||||
} else {
|
||||
hoursFraction = 0;
|
||||
isAnimating = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Minutes pointer handlers ──
|
||||
|
||||
function handleMinutesPointerDown(e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
stopMomentum();
|
||||
minutesDragging = true;
|
||||
isAnimating = true;
|
||||
minutesLastY = e.clientY;
|
||||
minutesAccumPx = 0;
|
||||
minutesBaseValue = displayMinutes;
|
||||
minutesVelocity = 0;
|
||||
minutesLastMoveTime = performance.now();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
function handleMinutesPointerMove(e: PointerEvent) {
|
||||
if (!minutesDragging) return;
|
||||
e.preventDefault();
|
||||
const dy = minutesLastY - e.clientY;
|
||||
minutesAccumPx += dy;
|
||||
minutesLastY = e.clientY;
|
||||
|
||||
const now = performance.now();
|
||||
const dt = now - minutesLastMoveTime;
|
||||
if (dt > 0) minutesVelocity = dy / dt;
|
||||
minutesLastMoveTime = now;
|
||||
|
||||
const { newVal, fraction } = applyAccum("minutes", minutesAccumPx, minutesBaseValue);
|
||||
minutesFraction = fraction;
|
||||
if (newVal !== displayMinutes) {
|
||||
displayMinutes = newVal;
|
||||
emitValue(displayHours, newVal);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMinutesPointerUp(e: PointerEvent) {
|
||||
if (!minutesDragging) return;
|
||||
e.preventDefault();
|
||||
minutesDragging = false;
|
||||
|
||||
const velocityPxPerFrame = minutesVelocity * 16;
|
||||
if (Math.abs(velocityPxPerFrame) > MIN_VELOCITY) {
|
||||
startMomentum("minutes", velocityPxPerFrame);
|
||||
} else {
|
||||
minutesFraction = 0;
|
||||
isAnimating = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Momentum animation ──
|
||||
|
||||
function startMomentum(field: "hours" | "minutes", velocity: number) {
|
||||
stopMomentum();
|
||||
|
||||
function tick() {
|
||||
velocity *= FRICTION;
|
||||
|
||||
if (Math.abs(velocity) < MIN_VELOCITY) {
|
||||
// Snap to nearest value
|
||||
if (field === "hours") hoursFraction = 0;
|
||||
else minutesFraction = 0;
|
||||
isAnimating = false;
|
||||
momentumRaf = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (field === "hours") {
|
||||
hoursAccumPx += velocity;
|
||||
const { newVal, fraction } = applyAccum("hours", hoursAccumPx, hoursBaseValue);
|
||||
hoursFraction = fraction;
|
||||
if (newVal !== displayHours) {
|
||||
displayHours = newVal;
|
||||
emitValue(newVal, displayMinutes);
|
||||
}
|
||||
} else {
|
||||
minutesAccumPx += velocity;
|
||||
const { newVal, fraction } = applyAccum("minutes", minutesAccumPx, minutesBaseValue);
|
||||
minutesFraction = fraction;
|
||||
if (newVal !== displayMinutes) {
|
||||
displayMinutes = newVal;
|
||||
emitValue(displayHours, newVal);
|
||||
}
|
||||
}
|
||||
|
||||
momentumRaf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
momentumRaf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function format(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="time-spinner" style="font-family: {countdownFont ? `'${countdownFont}', monospace` : 'monospace'};">
|
||||
|
||||
<!-- Hours wheel -->
|
||||
<div
|
||||
class="wheel-field"
|
||||
class:dragging={hoursDragging}
|
||||
role="slider"
|
||||
aria-label="Hours"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={23}
|
||||
aria-valuenow={displayHours}
|
||||
tabindex={0}
|
||||
onpointerdown={handleHoursPointerDown}
|
||||
onpointermove={handleHoursPointerMove}
|
||||
onpointerup={handleHoursPointerUp}
|
||||
onpointercancel={handleHoursPointerUp}
|
||||
>
|
||||
<div class="wheel-viewport">
|
||||
<div class="wheel-cylinder">
|
||||
{#each hoursItems as item}
|
||||
<div
|
||||
class="wheel-item"
|
||||
style="
|
||||
transform: translateY(-50%) rotateX({-item.angle}deg) translateZ({WHEEL_RADIUS}px);
|
||||
opacity: {item.opacity};
|
||||
"
|
||||
>
|
||||
{format(item.value)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<span class="unit-badge">h</span>
|
||||
</div>
|
||||
|
||||
<span class="separator">:</span>
|
||||
|
||||
<!-- Minutes wheel -->
|
||||
<div
|
||||
class="wheel-field"
|
||||
class:dragging={minutesDragging}
|
||||
role="slider"
|
||||
aria-label="Minutes"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={59}
|
||||
aria-valuenow={displayMinutes}
|
||||
tabindex={0}
|
||||
onpointerdown={handleMinutesPointerDown}
|
||||
onpointermove={handleMinutesPointerMove}
|
||||
onpointerup={handleMinutesPointerUp}
|
||||
onpointercancel={handleMinutesPointerUp}
|
||||
>
|
||||
<div class="wheel-viewport">
|
||||
<div class="wheel-cylinder">
|
||||
{#each minutesItems as item}
|
||||
<div
|
||||
class="wheel-item"
|
||||
style="
|
||||
transform: translateY(-50%) rotateX({-item.angle}deg) translateZ({WHEEL_RADIUS}px);
|
||||
opacity: {item.opacity};
|
||||
"
|
||||
>
|
||||
{format(item.value)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<span class="unit-badge">m</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.time-spinner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.wheel-field {
|
||||
position: relative;
|
||||
width: 50px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
cursor: ns-resize;
|
||||
touch-action: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wheel-field.dragging {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
/* Perspective container - looking into the cylinder from outside */
|
||||
.wheel-viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
perspective: 90px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The 3D cylinder that holds number items */
|
||||
.wheel-cylinder {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
/* Individual number on the cylinder surface */
|
||||
.wheel-item {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
line-height: 1;
|
||||
backface-visibility: hidden;
|
||||
pointer-events: none;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
/* Unit label pinned to the right of the field */
|
||||
.unit-badge {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user