623 lines
17 KiB
Vue
623 lines
17 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
|
|
|
type Level = "year" | "month" | "week" | "day";
|
|
type Direction = "in" | "out";
|
|
|
|
const LEVELS: Level[] = ["year", "month", "week", "day"];
|
|
const LEVEL_LABELS: Record<Level, string> = {
|
|
year: "Year",
|
|
month: "Month",
|
|
week: "Week",
|
|
day: "Day",
|
|
};
|
|
|
|
const MONTH_LABELS = [
|
|
"Jan", "Feb", "Mar", "Apr",
|
|
"May", "Jun", "Jul", "Aug",
|
|
"Sep", "Oct", "Nov", "Dec",
|
|
];
|
|
|
|
const ZOOM_PRIME_STEPS = 2;
|
|
const ZOOM_ANIMATION_MS = 2000;
|
|
const VIEWPORT_PADDING = 20;
|
|
|
|
const viewportRef = ref<HTMLDivElement | null>(null);
|
|
const sceneRef = ref<HTMLDivElement | null>(null);
|
|
const resizeObserver = ref<ResizeObserver | null>(null);
|
|
|
|
const currentLevel = ref<Level>("year");
|
|
const transitionTarget = ref<Level | null>(null);
|
|
const isAnimating = ref(false);
|
|
const useTransition = ref(false);
|
|
|
|
const selectedMonth = ref(0);
|
|
const selectedWeek = ref(0);
|
|
const selectedDay = ref(0);
|
|
|
|
const hoveredMonth = ref(0);
|
|
const hoveredWeek = ref(0);
|
|
const hoveredDay = ref(0);
|
|
|
|
const primeFocusId = ref("");
|
|
|
|
const cameraX = ref(0);
|
|
const cameraY = ref(0);
|
|
const cameraScaleX = ref(1);
|
|
const cameraScaleY = ref(1);
|
|
|
|
const wheelPrimeDirection = ref<"" | Direction>("");
|
|
const wheelPrimeTicks = ref(0);
|
|
|
|
let animationTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let primeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const currentLevelIndex = computed(() => LEVELS.indexOf(currentLevel.value));
|
|
const displayLevel = computed(() => transitionTarget.value ?? currentLevel.value);
|
|
const displayLevelIndex = computed(() => LEVELS.indexOf(displayLevel.value));
|
|
|
|
const canZoomIn = computed(() => currentLevelIndex.value < LEVELS.length - 1);
|
|
const canZoomOut = computed(() => currentLevelIndex.value > 0);
|
|
|
|
const sceneStyle = computed(() => ({
|
|
transform: `translate3d(${cameraX.value}px, ${cameraY.value}px, 0) scale(${cameraScaleX.value}, ${cameraScaleY.value})`,
|
|
transformOrigin: "0 0",
|
|
transition: useTransition.value ? `transform ${ZOOM_ANIMATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)` : "none",
|
|
}));
|
|
|
|
function getFocusId(level: Level) {
|
|
if (level === "year") return "focus-year";
|
|
if (level === "month") return `focus-month-${selectedMonth.value}`;
|
|
if (level === "week") return `focus-week-${selectedWeek.value}`;
|
|
return `focus-day-${selectedDay.value}`;
|
|
}
|
|
|
|
function findFocusElement(level: Level) {
|
|
const scene = sceneRef.value;
|
|
if (!scene) return null;
|
|
const id = getFocusId(level);
|
|
return scene.querySelector<HTMLElement>(`[data-focus-id="${id}"]`);
|
|
}
|
|
|
|
function applyCameraToElement(element: HTMLElement, animate: boolean) {
|
|
const viewport = viewportRef.value;
|
|
const scene = sceneRef.value;
|
|
if (!viewport || !scene) return;
|
|
|
|
const viewportWidth = Math.max(1, viewport.clientWidth);
|
|
const viewportHeight = Math.max(1, viewport.clientHeight);
|
|
const safeWidth = Math.max(1, viewportWidth - VIEWPORT_PADDING * 2);
|
|
const safeHeight = Math.max(1, viewportHeight - VIEWPORT_PADDING * 2);
|
|
|
|
const sceneRect = scene.getBoundingClientRect();
|
|
const elementRect = element.getBoundingClientRect();
|
|
|
|
const localX = elementRect.left - sceneRect.left;
|
|
const localY = elementRect.top - sceneRect.top;
|
|
|
|
const scaleX = safeWidth / Math.max(1, elementRect.width);
|
|
const scaleY = safeHeight / Math.max(1, elementRect.height);
|
|
|
|
useTransition.value = animate;
|
|
cameraScaleX.value = scaleX;
|
|
cameraScaleY.value = scaleY;
|
|
cameraX.value = VIEWPORT_PADDING - localX * scaleX;
|
|
cameraY.value = VIEWPORT_PADDING - localY * scaleY;
|
|
}
|
|
|
|
function applyCameraToLevel(level: Level, animate: boolean) {
|
|
const element = findFocusElement(level);
|
|
if (!element) return;
|
|
applyCameraToElement(element, animate);
|
|
}
|
|
|
|
function startPrime(focusId: string) {
|
|
if (primeTimer) {
|
|
clearTimeout(primeTimer);
|
|
primeTimer = null;
|
|
}
|
|
|
|
primeFocusId.value = focusId;
|
|
primeTimer = setTimeout(() => {
|
|
primeFocusId.value = "";
|
|
}, 170);
|
|
}
|
|
|
|
function resetWheelPrime() {
|
|
wheelPrimeDirection.value = "";
|
|
wheelPrimeTicks.value = 0;
|
|
}
|
|
|
|
function nextLevel(level: Level): Level | null {
|
|
const idx = LEVELS.indexOf(level);
|
|
if (idx < 0 || idx >= LEVELS.length - 1) return null;
|
|
return LEVELS[idx + 1] ?? null;
|
|
}
|
|
|
|
function prevLevel(level: Level): Level | null {
|
|
const idx = LEVELS.indexOf(level);
|
|
if (idx <= 0) return null;
|
|
return LEVELS[idx - 1] ?? null;
|
|
}
|
|
|
|
function prepareZoomTarget(direction: Direction): { level: Level; focusId: string } | null {
|
|
if (direction === "in") {
|
|
if (currentLevel.value === "year") {
|
|
selectedMonth.value = hoveredMonth.value;
|
|
selectedWeek.value = 0;
|
|
selectedDay.value = 0;
|
|
const level = nextLevel(currentLevel.value);
|
|
if (!level) return null;
|
|
return { level, focusId: getFocusId(level) };
|
|
}
|
|
|
|
if (currentLevel.value === "month") {
|
|
selectedWeek.value = hoveredWeek.value;
|
|
selectedDay.value = 0;
|
|
const level = nextLevel(currentLevel.value);
|
|
if (!level) return null;
|
|
return { level, focusId: getFocusId(level) };
|
|
}
|
|
|
|
if (currentLevel.value === "week") {
|
|
selectedDay.value = hoveredDay.value;
|
|
const level = nextLevel(currentLevel.value);
|
|
if (!level) return null;
|
|
return { level, focusId: getFocusId(level) };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
const level = prevLevel(currentLevel.value);
|
|
if (!level) return null;
|
|
return { level, focusId: getFocusId(level) };
|
|
}
|
|
|
|
async function animateToLevel(level: Level) {
|
|
if (isAnimating.value) return;
|
|
|
|
isAnimating.value = true;
|
|
transitionTarget.value = level;
|
|
|
|
await nextTick();
|
|
applyCameraToLevel(level, true);
|
|
|
|
if (animationTimer) {
|
|
clearTimeout(animationTimer);
|
|
animationTimer = null;
|
|
}
|
|
|
|
await new Promise<void>((resolve) => {
|
|
animationTimer = setTimeout(() => {
|
|
useTransition.value = false;
|
|
currentLevel.value = level;
|
|
transitionTarget.value = null;
|
|
isAnimating.value = false;
|
|
resolve();
|
|
}, ZOOM_ANIMATION_MS + 40);
|
|
});
|
|
}
|
|
|
|
async function zoom(direction: Direction) {
|
|
if (isAnimating.value) return false;
|
|
|
|
const target = prepareZoomTarget(direction);
|
|
if (!target) return false;
|
|
|
|
await animateToLevel(target.level);
|
|
return true;
|
|
}
|
|
|
|
function onWheel(event: WheelEvent) {
|
|
if (isAnimating.value) return;
|
|
|
|
const direction: Direction = event.deltaY > 0 ? "in" : "out";
|
|
const target = prepareZoomTarget(direction);
|
|
if (!target) return;
|
|
|
|
if (wheelPrimeDirection.value !== direction) {
|
|
wheelPrimeDirection.value = direction;
|
|
wheelPrimeTicks.value = 0;
|
|
}
|
|
|
|
if (wheelPrimeTicks.value < ZOOM_PRIME_STEPS) {
|
|
wheelPrimeTicks.value += 1;
|
|
startPrime(target.focusId);
|
|
return;
|
|
}
|
|
|
|
resetWheelPrime();
|
|
void animateToLevel(target.level);
|
|
}
|
|
|
|
async function onSliderInput(event: Event) {
|
|
const target = event.target as HTMLInputElement | null;
|
|
if (!target || isAnimating.value) return;
|
|
|
|
resetWheelPrime();
|
|
|
|
const targetIndex = Number(target.value);
|
|
const safeTargetIndex = Math.max(0, Math.min(LEVELS.length - 1, targetIndex));
|
|
|
|
while (!isAnimating.value && currentLevelIndex.value < safeTargetIndex) {
|
|
const moved = await zoom("in");
|
|
if (!moved) break;
|
|
}
|
|
|
|
while (!isAnimating.value && currentLevelIndex.value > safeTargetIndex) {
|
|
const moved = await zoom("out");
|
|
if (!moved) break;
|
|
}
|
|
}
|
|
|
|
function isPrime(id: string) {
|
|
return primeFocusId.value === id;
|
|
}
|
|
|
|
function isMonthSelected(index: number) {
|
|
return selectedMonth.value === index;
|
|
}
|
|
|
|
function isWeekSelected(index: number) {
|
|
return selectedWeek.value === index;
|
|
}
|
|
|
|
function isDaySelected(index: number) {
|
|
return selectedDay.value === index;
|
|
}
|
|
|
|
function showMonthContent(index: number) {
|
|
return isMonthSelected(index) && currentLevel.value !== "year";
|
|
}
|
|
|
|
function showWeekContent(index: number) {
|
|
return isWeekSelected(index) && (currentLevel.value === "week" || currentLevel.value === "day");
|
|
}
|
|
|
|
function showDayContent(index: number) {
|
|
return isDaySelected(index) && currentLevel.value === "day";
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await nextTick();
|
|
applyCameraToLevel("year", false);
|
|
|
|
resizeObserver.value = new ResizeObserver(() => {
|
|
applyCameraToLevel(displayLevel.value, false);
|
|
});
|
|
|
|
if (viewportRef.value) {
|
|
resizeObserver.value.observe(viewportRef.value);
|
|
}
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
if (animationTimer) clearTimeout(animationTimer);
|
|
if (primeTimer) clearTimeout(primeTimer);
|
|
resizeObserver.value?.disconnect();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<section class="calendar-lab-root">
|
|
<header class="calendar-lab-toolbar">
|
|
<p class="calendar-lab-level-text">
|
|
Current level: {{ LEVEL_LABELS[currentLevel] }}
|
|
</p>
|
|
|
|
<div class="calendar-zoom-inline" @click.stop>
|
|
<input
|
|
class="calendar-zoom-slider"
|
|
type="range"
|
|
min="0"
|
|
max="3"
|
|
step="1"
|
|
:value="displayLevelIndex"
|
|
aria-label="Calendar zoom level"
|
|
@input="onSliderInput"
|
|
>
|
|
<div class="calendar-zoom-marks" aria-hidden="true">
|
|
<span
|
|
v-for="index in 4"
|
|
:key="`calendar-lab-zoom-mark-${index}`"
|
|
class="calendar-zoom-mark"
|
|
:class="displayLevelIndex === index - 1 ? 'calendar-zoom-mark-active' : ''"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div
|
|
ref="viewportRef"
|
|
class="calendar-lab-viewport"
|
|
:class="canZoomIn ? 'cursor-zoom-in' : 'cursor-zoom-out'"
|
|
@wheel.prevent="onWheel"
|
|
>
|
|
<div ref="sceneRef" class="calendar-lab-scene" :style="sceneStyle">
|
|
<article class="calendar-year" data-focus-id="focus-year">
|
|
<div class="calendar-year-grid">
|
|
<div
|
|
v-for="(label, monthIndex) in MONTH_LABELS"
|
|
:key="`month-${label}`"
|
|
class="calendar-month-card"
|
|
:class="[
|
|
isMonthSelected(monthIndex) ? 'calendar-month-card-selected' : '',
|
|
currentLevel === 'year' && hoveredMonth === monthIndex ? 'calendar-hover-target' : '',
|
|
isPrime(`focus-month-${monthIndex}`) ? 'calendar-prime-target' : '',
|
|
]"
|
|
:data-focus-id="`focus-month-${monthIndex}`"
|
|
@mouseenter="currentLevel === 'year' ? (hoveredMonth = monthIndex) : undefined"
|
|
>
|
|
<p class="calendar-card-label">{{ label }}</p>
|
|
|
|
<div v-if="showMonthContent(monthIndex)" class="calendar-week-grid-wrap">
|
|
<div class="calendar-week-grid">
|
|
<div
|
|
v-for="weekIndex in 6"
|
|
:key="`week-${weekIndex - 1}`"
|
|
class="calendar-week-card"
|
|
:class="[
|
|
isWeekSelected(weekIndex - 1) ? 'calendar-week-card-selected' : '',
|
|
currentLevel === 'month' && hoveredWeek === weekIndex - 1 ? 'calendar-hover-target' : '',
|
|
isPrime(`focus-week-${weekIndex - 1}`) ? 'calendar-prime-target' : '',
|
|
]"
|
|
:data-focus-id="`focus-week-${weekIndex - 1}`"
|
|
@mouseenter="currentLevel === 'month' ? (hoveredWeek = weekIndex - 1) : undefined"
|
|
>
|
|
<p class="calendar-card-label">Week {{ weekIndex }}</p>
|
|
|
|
<div v-if="showWeekContent(weekIndex - 1)" class="calendar-day-grid-wrap">
|
|
<div class="calendar-day-grid">
|
|
<div
|
|
v-for="dayIndex in 7"
|
|
:key="`day-${dayIndex - 1}`"
|
|
class="calendar-day-card"
|
|
:class="[
|
|
isDaySelected(dayIndex - 1) ? 'calendar-day-card-selected' : '',
|
|
currentLevel === 'week' && hoveredDay === dayIndex - 1 ? 'calendar-hover-target' : '',
|
|
isPrime(`focus-day-${dayIndex - 1}`) ? 'calendar-prime-target' : '',
|
|
]"
|
|
:data-focus-id="`focus-day-${dayIndex - 1}`"
|
|
@mouseenter="currentLevel === 'week' ? (hoveredDay = dayIndex - 1) : undefined"
|
|
>
|
|
<p class="calendar-card-label">Day {{ dayIndex }}</p>
|
|
|
|
<div v-if="showDayContent(dayIndex - 1)" class="calendar-slot-grid-wrap">
|
|
<div class="calendar-slot-grid">
|
|
<span
|
|
v-for="slot in 12"
|
|
:key="`slot-${slot}`"
|
|
class="calendar-slot"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.calendar-lab-root {
|
|
height: calc(100dvh - 2.5rem);
|
|
min-height: 620px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
padding: 10px;
|
|
}
|
|
|
|
.calendar-lab-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 8px;
|
|
}
|
|
|
|
.calendar-lab-level-text {
|
|
font-size: 0.78rem;
|
|
color: color-mix(in oklab, var(--color-base-content) 72%, transparent);
|
|
}
|
|
|
|
.calendar-lab-viewport {
|
|
position: relative;
|
|
flex: 1;
|
|
min-height: 0;
|
|
border-radius: 14px;
|
|
border: 1px solid color-mix(in oklab, var(--color-base-content) 18%, transparent);
|
|
background:
|
|
radial-gradient(circle at 15% 15%, color-mix(in oklab, var(--color-base-content) 6%, transparent), transparent 40%),
|
|
color-mix(in oklab, var(--color-base-100) 94%, transparent);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.calendar-lab-scene {
|
|
width: 1400px;
|
|
height: 900px;
|
|
will-change: transform;
|
|
}
|
|
|
|
.calendar-year {
|
|
position: absolute;
|
|
left: 80px;
|
|
top: 50px;
|
|
width: 1240px;
|
|
height: 760px;
|
|
border-radius: 14px;
|
|
border: 1px solid color-mix(in oklab, var(--color-base-content) 24%, transparent);
|
|
background: color-mix(in oklab, var(--color-base-100) 96%, transparent);
|
|
padding: 14px;
|
|
}
|
|
|
|
.calendar-year-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
grid-template-rows: repeat(3, minmax(0, 1fr));
|
|
gap: 10px;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.calendar-month-card,
|
|
.calendar-week-card,
|
|
.calendar-day-card {
|
|
border-radius: 12px;
|
|
border: 1px solid color-mix(in oklab, var(--color-base-content) 18%, transparent);
|
|
background: color-mix(in oklab, var(--color-base-200) 72%, transparent);
|
|
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
|
|
min-width: 0;
|
|
min-height: 0;
|
|
padding: 8px;
|
|
}
|
|
|
|
.calendar-month-card-selected,
|
|
.calendar-week-card-selected,
|
|
.calendar-day-card-selected {
|
|
border-color: color-mix(in oklab, var(--color-primary) 70%, transparent);
|
|
}
|
|
|
|
.calendar-card-label {
|
|
font-size: 0.72rem;
|
|
font-weight: 600;
|
|
color: color-mix(in oklab, var(--color-base-content) 88%, transparent);
|
|
}
|
|
|
|
.calendar-week-grid-wrap,
|
|
.calendar-day-grid-wrap,
|
|
.calendar-slot-grid-wrap {
|
|
width: 100%;
|
|
height: calc(100% - 20px);
|
|
margin-top: 6px;
|
|
}
|
|
|
|
.calendar-week-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
grid-template-rows: repeat(2, minmax(0, 1fr));
|
|
gap: 8px;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.calendar-day-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
|
gap: 6px;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.calendar-slot-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
grid-template-rows: repeat(3, minmax(0, 1fr));
|
|
gap: 5px;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.calendar-slot {
|
|
border-radius: 8px;
|
|
border: 1px solid color-mix(in oklab, var(--color-base-content) 16%, transparent);
|
|
background: color-mix(in oklab, var(--color-base-100) 88%, transparent);
|
|
}
|
|
|
|
.calendar-hover-target {
|
|
border-color: color-mix(in oklab, var(--color-primary) 70%, transparent);
|
|
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 36%, transparent) inset;
|
|
}
|
|
|
|
.calendar-prime-target {
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
.calendar-zoom-inline {
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
width: 128px;
|
|
height: 22px;
|
|
padding: 0 10px;
|
|
}
|
|
|
|
.calendar-zoom-slider {
|
|
width: 100%;
|
|
height: 18px;
|
|
margin: 0;
|
|
background: transparent;
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.calendar-zoom-slider:focus-visible {
|
|
outline: none;
|
|
}
|
|
|
|
.calendar-zoom-slider::-webkit-slider-runnable-track {
|
|
height: 2px;
|
|
border-radius: 999px;
|
|
background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
|
|
}
|
|
|
|
.calendar-zoom-slider::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
width: 10px;
|
|
height: 10px;
|
|
margin-top: -4px;
|
|
border-radius: 999px;
|
|
background: color-mix(in oklab, var(--color-base-content) 88%, transparent);
|
|
border: 0;
|
|
}
|
|
|
|
.calendar-zoom-slider::-moz-range-track {
|
|
height: 2px;
|
|
border-radius: 999px;
|
|
background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
|
|
}
|
|
|
|
.calendar-zoom-slider::-moz-range-progress {
|
|
height: 2px;
|
|
border-radius: 999px;
|
|
background: transparent;
|
|
}
|
|
|
|
.calendar-zoom-slider::-moz-range-thumb {
|
|
width: 10px;
|
|
height: 10px;
|
|
border: 0;
|
|
border-radius: 999px;
|
|
background: color-mix(in oklab, var(--color-base-content) 88%, transparent);
|
|
}
|
|
|
|
.calendar-zoom-marks {
|
|
position: absolute;
|
|
inset: 0 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.calendar-zoom-mark {
|
|
width: 4px;
|
|
height: 4px;
|
|
border-radius: 999px;
|
|
background: color-mix(in oklab, var(--color-base-content) 35%, transparent);
|
|
}
|
|
|
|
.calendar-zoom-mark-active {
|
|
background: color-mix(in oklab, var(--color-base-content) 85%, transparent);
|
|
}
|
|
</style>
|