427 lines
12 KiB
Svelte
427 lines
12 KiB
Svelte
<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");
|
|
}
|
|
|
|
// Keyboard handlers for arrow key operation
|
|
function handleHoursKeydown(e: KeyboardEvent) {
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
displayHours = wrapValue(displayHours + 1, 24);
|
|
emitValue(displayHours, displayMinutes);
|
|
} else if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
displayHours = wrapValue(displayHours - 1, 24);
|
|
emitValue(displayHours, displayMinutes);
|
|
}
|
|
}
|
|
|
|
function handleMinutesKeydown(e: KeyboardEvent) {
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
displayMinutes = wrapValue(displayMinutes + 1, 60);
|
|
emitValue(displayHours, displayMinutes);
|
|
} else if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
displayMinutes = wrapValue(displayMinutes - 1, 60);
|
|
emitValue(displayHours, displayMinutes);
|
|
}
|
|
}
|
|
</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}
|
|
onkeydown={handleHoursKeydown}
|
|
>
|
|
<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}
|
|
onkeydown={handleMinutesKeydown}
|
|
>
|
|
<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: 2px;
|
|
user-select: none;
|
|
touch-action: none;
|
|
}
|
|
|
|
.wheel-field {
|
|
position: relative;
|
|
width: 44px;
|
|
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: 6px;
|
|
}
|
|
|
|
/* Unit label pinned to the right of the field */
|
|
.unit-badge {
|
|
position: absolute;
|
|
right: 3px;
|
|
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>
|