feat(calendar-lab): switch to hierarchical grid zoom mechanics
This commit is contained in:
@@ -1,15 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
import CrmCalendarLabDayRect from "~~/app/components/workspace/calendar/lab/CrmCalendarLabDayRect.vue";
|
|
||||||
import CrmCalendarLabMonthRect from "~~/app/components/workspace/calendar/lab/CrmCalendarLabMonthRect.vue";
|
|
||||||
import CrmCalendarLabWeekRect from "~~/app/components/workspace/calendar/lab/CrmCalendarLabWeekRect.vue";
|
|
||||||
import CrmCalendarLabYearRect from "~~/app/components/workspace/calendar/lab/CrmCalendarLabYearRect.vue";
|
|
||||||
|
|
||||||
type Level = "year" | "month" | "week" | "day";
|
type Level = "year" | "month" | "week" | "day";
|
||||||
type LevelState = {
|
type Direction = "in" | "out";
|
||||||
loaded: boolean;
|
|
||||||
loading: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LEVELS: Level[] = ["year", "month", "week", "day"];
|
const LEVELS: Level[] = ["year", "month", "week", "day"];
|
||||||
const LEVEL_LABELS: Record<Level, string> = {
|
const LEVEL_LABELS: Record<Level, string> = {
|
||||||
@@ -19,247 +12,279 @@ const LEVEL_LABELS: Record<Level, string> = {
|
|||||||
day: "Day",
|
day: "Day",
|
||||||
};
|
};
|
||||||
|
|
||||||
const NEXT_LEVEL: Record<Level, Level | null> = {
|
const MONTH_LABELS = [
|
||||||
year: "month",
|
"Jan", "Feb", "Mar", "Apr",
|
||||||
month: "week",
|
"May", "Jun", "Jul", "Aug",
|
||||||
week: "day",
|
"Sep", "Oct", "Nov", "Dec",
|
||||||
day: null,
|
];
|
||||||
};
|
|
||||||
|
|
||||||
const PREV_LEVEL: Record<Level, Level | null> = {
|
|
||||||
year: null,
|
|
||||||
month: "year",
|
|
||||||
week: "month",
|
|
||||||
day: "week",
|
|
||||||
};
|
|
||||||
|
|
||||||
const ZOOM_PRIME_STEPS = 2;
|
const ZOOM_PRIME_STEPS = 2;
|
||||||
const ZOOM_PRIME_SCALE = 1.05;
|
const ZOOM_ANIMATION_MS = 2000;
|
||||||
const ZOOM_ANIMATION_MS = 2200;
|
const VIEWPORT_PADDING = 20;
|
||||||
const WORLD_PADDING = 20;
|
|
||||||
|
|
||||||
const WORLD_RECT_BY_LEVEL: Record<Level, { x: number; y: number; width: number; height: number }> = {
|
|
||||||
year: { x: 80, y: 50, width: 1240, height: 760 },
|
|
||||||
month: { x: 330, y: 195, width: 740, height: 430 },
|
|
||||||
week: { x: 470, y: 275, width: 460, height: 270 },
|
|
||||||
day: { x: 560, y: 330, width: 280, height: 165 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const viewportRef = ref<HTMLDivElement | null>(null);
|
const viewportRef = ref<HTMLDivElement | null>(null);
|
||||||
|
const sceneRef = ref<HTMLDivElement | null>(null);
|
||||||
const resizeObserver = ref<ResizeObserver | null>(null);
|
const resizeObserver = ref<ResizeObserver | null>(null);
|
||||||
|
|
||||||
const currentLevel = ref<Level>("year");
|
const currentLevel = ref<Level>("year");
|
||||||
const transitionTarget = ref<Level | null>(null);
|
const transitionTarget = ref<Level | null>(null);
|
||||||
const hoveredLevel = ref<Level>("month");
|
|
||||||
const isAnimating = ref(false);
|
const isAnimating = ref(false);
|
||||||
const useTransition = ref(false);
|
const useTransition = ref(false);
|
||||||
|
|
||||||
const camera = reactive({
|
const selectedMonth = ref(0);
|
||||||
x: 0,
|
const selectedWeek = ref(0);
|
||||||
y: 0,
|
const selectedDay = ref(0);
|
||||||
scaleX: 1,
|
|
||||||
scaleY: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const levelState = reactive<Record<Level, LevelState>>({
|
const hoveredMonth = ref(0);
|
||||||
year: { loaded: true, loading: false },
|
const hoveredWeek = ref(0);
|
||||||
month: { loaded: false, loading: false },
|
const hoveredDay = ref(0);
|
||||||
week: { loaded: false, loading: false },
|
|
||||||
day: { loaded: false, loading: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
const wheelPrime = reactive({
|
const primeFocusId = ref("");
|
||||||
direction: "" as "" | "in" | "out",
|
|
||||||
ticks: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pulseState = reactive({
|
const cameraX = ref(0);
|
||||||
level: "" as "" | Level,
|
const cameraY = ref(0);
|
||||||
scale: 1,
|
const cameraScaleX = ref(1);
|
||||||
});
|
const cameraScaleY = ref(1);
|
||||||
|
|
||||||
|
const wheelPrimeDirection = ref<"" | Direction>("");
|
||||||
|
const wheelPrimeTicks = ref(0);
|
||||||
|
|
||||||
let animationTimer: ReturnType<typeof setTimeout> | null = null;
|
let animationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let primeTimer: ReturnType<typeof setTimeout> | null = null;
|
let primeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let loadTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
|
const currentLevelIndex = computed(() => LEVELS.indexOf(currentLevel.value));
|
||||||
const displayLevel = computed(() => transitionTarget.value ?? currentLevel.value);
|
const displayLevel = computed(() => transitionTarget.value ?? currentLevel.value);
|
||||||
const displayIndex = computed(() => LEVELS.indexOf(displayLevel.value));
|
const displayLevelIndex = computed(() => LEVELS.indexOf(displayLevel.value));
|
||||||
const canZoomIn = computed(() => NEXT_LEVEL[currentLevel.value] !== null);
|
|
||||||
const canZoomOut = computed(() => PREV_LEVEL[currentLevel.value] !== null);
|
const canZoomIn = computed(() => currentLevelIndex.value < LEVELS.length - 1);
|
||||||
|
const canZoomOut = computed(() => currentLevelIndex.value > 0);
|
||||||
|
|
||||||
const sceneStyle = computed(() => ({
|
const sceneStyle = computed(() => ({
|
||||||
transform: `translate3d(${camera.x}px, ${camera.y}px, 0) scale(${camera.scaleX}, ${camera.scaleY})`,
|
transform: `translate3d(${cameraX.value}px, ${cameraY.value}px, 0) scale(${cameraScaleX.value}, ${cameraScaleY.value})`,
|
||||||
transformOrigin: "0 0",
|
transformOrigin: "0 0",
|
||||||
transition: useTransition.value ? `transform ${ZOOM_ANIMATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)` : "none",
|
transition: useTransition.value ? `transform ${ZOOM_ANIMATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)` : "none",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function pulseScaleFor(level: Level) {
|
function getFocusId(level: Level) {
|
||||||
return pulseState.level === level ? pulseState.scale : 1;
|
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 nextLevelOf(level: Level) {
|
function findFocusElement(level: Level) {
|
||||||
return NEXT_LEVEL[level];
|
const scene = sceneRef.value;
|
||||||
|
if (!scene) return null;
|
||||||
|
const id = getFocusId(level);
|
||||||
|
return scene.querySelector<HTMLElement>(`[data-focus-id="${id}"]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function prevLevelOf(level: Level) {
|
function applyCameraToElement(element: HTMLElement, animate: boolean) {
|
||||||
return PREV_LEVEL[level];
|
|
||||||
}
|
|
||||||
|
|
||||||
function levelByIndex(index: number): Level {
|
|
||||||
return LEVELS[Math.max(0, Math.min(LEVELS.length - 1, index))] ?? "year";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNodeVisible(level: Level) {
|
|
||||||
const currentIndex = LEVELS.indexOf(currentLevel.value);
|
|
||||||
const targetIndex = transitionTarget.value ? LEVELS.indexOf(transitionTarget.value) : currentIndex;
|
|
||||||
const maxIndex = Math.max(currentIndex, targetIndex);
|
|
||||||
return LEVELS.indexOf(level) <= Math.min(maxIndex + 1, LEVELS.length - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showLevelContent(level: Level) {
|
|
||||||
if (isAnimating.value) return false;
|
|
||||||
return currentLevel.value === level;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCameraForLevel(level: Level, animate: boolean) {
|
|
||||||
const viewport = viewportRef.value;
|
const viewport = viewportRef.value;
|
||||||
if (!viewport) return;
|
const scene = sceneRef.value;
|
||||||
|
if (!viewport || !scene) return;
|
||||||
|
|
||||||
const rect = WORLD_RECT_BY_LEVEL[level];
|
|
||||||
const viewportWidth = Math.max(1, viewport.clientWidth);
|
const viewportWidth = Math.max(1, viewport.clientWidth);
|
||||||
const viewportHeight = Math.max(1, viewport.clientHeight);
|
const viewportHeight = Math.max(1, viewport.clientHeight);
|
||||||
const safeWidth = Math.max(1, viewportWidth - WORLD_PADDING * 2);
|
const safeWidth = Math.max(1, viewportWidth - VIEWPORT_PADDING * 2);
|
||||||
const safeHeight = Math.max(1, viewportHeight - WORLD_PADDING * 2);
|
const safeHeight = Math.max(1, viewportHeight - VIEWPORT_PADDING * 2);
|
||||||
|
|
||||||
const scaleX = safeWidth / rect.width;
|
const sceneRect = scene.getBoundingClientRect();
|
||||||
const scaleY = safeHeight / rect.height;
|
const elementRect = element.getBoundingClientRect();
|
||||||
|
|
||||||
const x = WORLD_PADDING - rect.x * scaleX;
|
const localX = elementRect.left - sceneRect.left;
|
||||||
const y = WORLD_PADDING - rect.y * scaleY;
|
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;
|
useTransition.value = animate;
|
||||||
camera.x = x;
|
cameraScaleX.value = scaleX;
|
||||||
camera.y = y;
|
cameraScaleY.value = scaleY;
|
||||||
camera.scaleX = scaleX;
|
cameraX.value = VIEWPORT_PADDING - localX * scaleX;
|
||||||
camera.scaleY = scaleY;
|
cameraY.value = VIEWPORT_PADDING - localY * scaleY;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureGraphqlPayload(level: Level) {
|
function applyCameraToLevel(level: Level, animate: boolean) {
|
||||||
const state = levelState[level];
|
const element = findFocusElement(level);
|
||||||
if (state.loaded || state.loading) return;
|
if (!element) return;
|
||||||
state.loading = true;
|
applyCameraToElement(element, animate);
|
||||||
|
|
||||||
if (loadTimer) {
|
|
||||||
clearTimeout(loadTimer);
|
|
||||||
loadTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
loadTimer = setTimeout(() => {
|
|
||||||
state.loading = false;
|
|
||||||
state.loaded = true;
|
|
||||||
resolve();
|
|
||||||
}, 540);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startPulse(level: Level) {
|
function startPrime(focusId: string) {
|
||||||
if (primeTimer) {
|
if (primeTimer) {
|
||||||
clearTimeout(primeTimer);
|
clearTimeout(primeTimer);
|
||||||
primeTimer = null;
|
primeTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
pulseState.level = level;
|
primeFocusId.value = focusId;
|
||||||
pulseState.scale = ZOOM_PRIME_SCALE;
|
|
||||||
|
|
||||||
primeTimer = setTimeout(() => {
|
primeTimer = setTimeout(() => {
|
||||||
pulseState.scale = 1;
|
primeFocusId.value = "";
|
||||||
pulseState.level = "";
|
|
||||||
}, 170);
|
}, 170);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetWheelPrime() {
|
function resetWheelPrime() {
|
||||||
wheelPrime.direction = "";
|
wheelPrimeDirection.value = "";
|
||||||
wheelPrime.ticks = 0;
|
wheelPrimeTicks.value = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runZoomTo(target: Level) {
|
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;
|
if (isAnimating.value) return;
|
||||||
if (target === currentLevel.value) return;
|
|
||||||
|
|
||||||
isAnimating.value = true;
|
isAnimating.value = true;
|
||||||
transitionTarget.value = target;
|
transitionTarget.value = level;
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
updateCameraForLevel(target, true);
|
applyCameraToLevel(level, true);
|
||||||
|
|
||||||
if (animationTimer) {
|
if (animationTimer) {
|
||||||
clearTimeout(animationTimer);
|
clearTimeout(animationTimer);
|
||||||
animationTimer = null;
|
animationTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
animationTimer = setTimeout(async () => {
|
await new Promise<void>((resolve) => {
|
||||||
useTransition.value = false;
|
animationTimer = setTimeout(() => {
|
||||||
currentLevel.value = target;
|
useTransition.value = false;
|
||||||
transitionTarget.value = null;
|
currentLevel.value = level;
|
||||||
isAnimating.value = false;
|
transitionTarget.value = null;
|
||||||
hoveredLevel.value = nextLevelOf(target) ?? target;
|
isAnimating.value = false;
|
||||||
await ensureGraphqlPayload(target);
|
resolve();
|
||||||
}, ZOOM_ANIMATION_MS + 30);
|
}, 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) {
|
function onWheel(event: WheelEvent) {
|
||||||
if (isAnimating.value) return;
|
if (isAnimating.value) return;
|
||||||
|
|
||||||
const direction: "in" | "out" = event.deltaY > 0 ? "in" : "out";
|
const direction: Direction = event.deltaY > 0 ? "in" : "out";
|
||||||
const nextLevel = direction === "in" ? nextLevelOf(currentLevel.value) : prevLevelOf(currentLevel.value);
|
const target = prepareZoomTarget(direction);
|
||||||
if (!nextLevel) return;
|
if (!target) return;
|
||||||
|
|
||||||
if (wheelPrime.direction !== direction) {
|
if (wheelPrimeDirection.value !== direction) {
|
||||||
wheelPrime.direction = direction;
|
wheelPrimeDirection.value = direction;
|
||||||
wheelPrime.ticks = 0;
|
wheelPrimeTicks.value = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pulseTarget = direction === "in" ? (hoveredLevel.value === nextLevel ? hoveredLevel.value : nextLevel) : currentLevel.value;
|
if (wheelPrimeTicks.value < ZOOM_PRIME_STEPS) {
|
||||||
if (wheelPrime.ticks < ZOOM_PRIME_STEPS) {
|
wheelPrimeTicks.value += 1;
|
||||||
wheelPrime.ticks += 1;
|
startPrime(target.focusId);
|
||||||
startPulse(pulseTarget);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resetWheelPrime();
|
resetWheelPrime();
|
||||||
void runZoomTo(nextLevel);
|
void animateToLevel(target.level);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSliderInput(event: Event) {
|
async function onSliderInput(event: Event) {
|
||||||
const target = event.target as HTMLInputElement | null;
|
const target = event.target as HTMLInputElement | null;
|
||||||
if (!target) return;
|
if (!target || isAnimating.value) return;
|
||||||
|
|
||||||
const next = levelByIndex(Number(target.value));
|
|
||||||
resetWheelPrime();
|
resetWheelPrime();
|
||||||
void runZoomTo(next);
|
|
||||||
|
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 onZoomInClick() {
|
function isPrime(id: string) {
|
||||||
const next = nextLevelOf(currentLevel.value);
|
return primeFocusId.value === id;
|
||||||
if (!next) return;
|
|
||||||
resetWheelPrime();
|
|
||||||
void runZoomTo(next);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onZoomOutClick() {
|
function isMonthSelected(index: number) {
|
||||||
const prev = prevLevelOf(currentLevel.value);
|
return selectedMonth.value === index;
|
||||||
if (!prev) return;
|
}
|
||||||
resetWheelPrime();
|
|
||||||
void runZoomTo(prev);
|
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 () => {
|
onMounted(async () => {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
updateCameraForLevel(currentLevel.value, false);
|
applyCameraToLevel("year", false);
|
||||||
hoveredLevel.value = nextLevelOf(currentLevel.value) ?? currentLevel.value;
|
|
||||||
|
|
||||||
resizeObserver.value = new ResizeObserver(() => {
|
resizeObserver.value = new ResizeObserver(() => {
|
||||||
updateCameraForLevel(displayLevel.value, false);
|
applyCameraToLevel(displayLevel.value, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (viewportRef.value) {
|
if (viewportRef.value) {
|
||||||
@@ -270,7 +295,6 @@ onMounted(async () => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (animationTimer) clearTimeout(animationTimer);
|
if (animationTimer) clearTimeout(animationTimer);
|
||||||
if (primeTimer) clearTimeout(primeTimer);
|
if (primeTimer) clearTimeout(primeTimer);
|
||||||
if (loadTimer) clearTimeout(loadTimer);
|
|
||||||
resizeObserver.value?.disconnect();
|
resizeObserver.value?.disconnect();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -278,11 +302,9 @@ onBeforeUnmount(() => {
|
|||||||
<template>
|
<template>
|
||||||
<section class="calendar-lab-root">
|
<section class="calendar-lab-root">
|
||||||
<header class="calendar-lab-toolbar">
|
<header class="calendar-lab-toolbar">
|
||||||
<div class="calendar-lab-toolbar-left">
|
<p class="calendar-lab-level-text">
|
||||||
<button class="btn btn-xs" :disabled="!canZoomOut" @click="onZoomOutClick">-</button>
|
Current level: {{ LEVEL_LABELS[currentLevel] }}
|
||||||
<button class="btn btn-xs" :disabled="!canZoomIn" @click="onZoomInClick">+</button>
|
</p>
|
||||||
<span class="calendar-lab-level-text">Current: {{ LEVEL_LABELS[currentLevel] }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="calendar-zoom-inline" @click.stop>
|
<div class="calendar-zoom-inline" @click.stop>
|
||||||
<input
|
<input
|
||||||
@@ -291,7 +313,7 @@ onBeforeUnmount(() => {
|
|||||||
min="0"
|
min="0"
|
||||||
max="3"
|
max="3"
|
||||||
step="1"
|
step="1"
|
||||||
:value="displayIndex"
|
:value="displayLevelIndex"
|
||||||
aria-label="Calendar zoom level"
|
aria-label="Calendar zoom level"
|
||||||
@input="onSliderInput"
|
@input="onSliderInput"
|
||||||
>
|
>
|
||||||
@@ -300,7 +322,7 @@ onBeforeUnmount(() => {
|
|||||||
v-for="index in 4"
|
v-for="index in 4"
|
||||||
:key="`calendar-lab-zoom-mark-${index}`"
|
:key="`calendar-lab-zoom-mark-${index}`"
|
||||||
class="calendar-zoom-mark"
|
class="calendar-zoom-mark"
|
||||||
:class="displayIndex === index - 1 ? 'calendar-zoom-mark-active' : ''"
|
:class="displayLevelIndex === index - 1 ? 'calendar-zoom-mark-active' : ''"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -312,87 +334,73 @@ onBeforeUnmount(() => {
|
|||||||
:class="canZoomIn ? 'cursor-zoom-in' : 'cursor-zoom-out'"
|
:class="canZoomIn ? 'cursor-zoom-in' : 'cursor-zoom-out'"
|
||||||
@wheel.prevent="onWheel"
|
@wheel.prevent="onWheel"
|
||||||
>
|
>
|
||||||
<div class="calendar-lab-scene" :style="sceneStyle">
|
<div ref="sceneRef" class="calendar-lab-scene" :style="sceneStyle">
|
||||||
<div class="calendar-lab-world">
|
<article class="calendar-year" data-focus-id="focus-year">
|
||||||
<div
|
<div class="calendar-year-grid">
|
||||||
v-show="isNodeVisible('year')"
|
|
||||||
class="calendar-lab-node calendar-lab-node-year"
|
|
||||||
:class="[
|
|
||||||
currentLevel === 'year' ? 'calendar-lab-node-active' : '',
|
|
||||||
hoveredLevel === 'year' ? 'calendar-lab-node-hover' : '',
|
|
||||||
transitionTarget === 'year' ? 'calendar-lab-node-target' : '',
|
|
||||||
]"
|
|
||||||
@mouseenter="hoveredLevel = 'year'"
|
|
||||||
>
|
|
||||||
<CrmCalendarLabYearRect
|
|
||||||
:is-active="currentLevel === 'year'"
|
|
||||||
:is-loading="levelState.year.loading"
|
|
||||||
:is-loaded="levelState.year.loaded"
|
|
||||||
:show-content="showLevelContent('year')"
|
|
||||||
:next-label="LEVEL_LABELS.month"
|
|
||||||
:pulse-scale="pulseScaleFor('year')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-show="isNodeVisible('month')"
|
v-for="(label, monthIndex) in MONTH_LABELS"
|
||||||
class="calendar-lab-node calendar-lab-node-month"
|
:key="`month-${label}`"
|
||||||
|
class="calendar-month-card"
|
||||||
:class="[
|
:class="[
|
||||||
currentLevel === 'month' ? 'calendar-lab-node-active' : '',
|
isMonthSelected(monthIndex) ? 'calendar-month-card-selected' : '',
|
||||||
hoveredLevel === 'month' ? 'calendar-lab-node-hover' : '',
|
currentLevel === 'year' && hoveredMonth === monthIndex ? 'calendar-hover-target' : '',
|
||||||
transitionTarget === 'month' ? 'calendar-lab-node-target' : '',
|
isPrime(`focus-month-${monthIndex}`) ? 'calendar-prime-target' : '',
|
||||||
]"
|
]"
|
||||||
@mouseenter="hoveredLevel = 'month'"
|
:data-focus-id="`focus-month-${monthIndex}`"
|
||||||
|
@mouseenter="currentLevel === 'year' ? (hoveredMonth = monthIndex) : undefined"
|
||||||
>
|
>
|
||||||
<CrmCalendarLabMonthRect
|
<p class="calendar-card-label">{{ label }}</p>
|
||||||
:is-active="currentLevel === 'month'"
|
|
||||||
:is-loading="levelState.month.loading"
|
|
||||||
:is-loaded="levelState.month.loaded"
|
|
||||||
:show-content="showLevelContent('month')"
|
|
||||||
:next-label="LEVEL_LABELS.week"
|
|
||||||
:pulse-scale="pulseScaleFor('month')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
<div v-if="showMonthContent(monthIndex)" class="calendar-week-grid-wrap">
|
||||||
v-show="isNodeVisible('week')"
|
<div class="calendar-week-grid">
|
||||||
class="calendar-lab-node calendar-lab-node-week"
|
<div
|
||||||
:class="[
|
v-for="weekIndex in 6"
|
||||||
currentLevel === 'week' ? 'calendar-lab-node-active' : '',
|
:key="`week-${weekIndex - 1}`"
|
||||||
hoveredLevel === 'week' ? 'calendar-lab-node-hover' : '',
|
class="calendar-week-card"
|
||||||
transitionTarget === 'week' ? 'calendar-lab-node-target' : '',
|
:class="[
|
||||||
]"
|
isWeekSelected(weekIndex - 1) ? 'calendar-week-card-selected' : '',
|
||||||
@mouseenter="hoveredLevel = 'week'"
|
currentLevel === 'month' && hoveredWeek === weekIndex - 1 ? 'calendar-hover-target' : '',
|
||||||
>
|
isPrime(`focus-week-${weekIndex - 1}`) ? 'calendar-prime-target' : '',
|
||||||
<CrmCalendarLabWeekRect
|
]"
|
||||||
:is-active="currentLevel === 'week'"
|
:data-focus-id="`focus-week-${weekIndex - 1}`"
|
||||||
:is-loading="levelState.week.loading"
|
@mouseenter="currentLevel === 'month' ? (hoveredWeek = weekIndex - 1) : undefined"
|
||||||
:is-loaded="levelState.week.loaded"
|
>
|
||||||
:show-content="showLevelContent('week')"
|
<p class="calendar-card-label">Week {{ weekIndex }}</p>
|
||||||
:next-label="LEVEL_LABELS.day"
|
|
||||||
:pulse-scale="pulseScaleFor('week')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
<div v-if="showWeekContent(weekIndex - 1)" class="calendar-day-grid-wrap">
|
||||||
v-show="isNodeVisible('day')"
|
<div class="calendar-day-grid">
|
||||||
class="calendar-lab-node calendar-lab-node-day"
|
<div
|
||||||
:class="[
|
v-for="dayIndex in 7"
|
||||||
currentLevel === 'day' ? 'calendar-lab-node-active' : '',
|
:key="`day-${dayIndex - 1}`"
|
||||||
hoveredLevel === 'day' ? 'calendar-lab-node-hover' : '',
|
class="calendar-day-card"
|
||||||
transitionTarget === 'day' ? 'calendar-lab-node-target' : '',
|
:class="[
|
||||||
]"
|
isDaySelected(dayIndex - 1) ? 'calendar-day-card-selected' : '',
|
||||||
@mouseenter="hoveredLevel = 'day'"
|
currentLevel === 'week' && hoveredDay === dayIndex - 1 ? 'calendar-hover-target' : '',
|
||||||
>
|
isPrime(`focus-day-${dayIndex - 1}`) ? 'calendar-prime-target' : '',
|
||||||
<CrmCalendarLabDayRect
|
]"
|
||||||
:is-active="currentLevel === 'day'"
|
:data-focus-id="`focus-day-${dayIndex - 1}`"
|
||||||
:is-loading="levelState.day.loading"
|
@mouseenter="currentLevel === 'week' ? (hoveredDay = dayIndex - 1) : undefined"
|
||||||
:is-loaded="levelState.day.loaded"
|
>
|
||||||
:show-content="showLevelContent('day')"
|
<p class="calendar-card-label">Day {{ dayIndex }}</p>
|
||||||
:pulse-scale="pulseScaleFor('day')"
|
|
||||||
/>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -415,12 +423,6 @@ onBeforeUnmount(() => {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-lab-toolbar-left {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-lab-level-text {
|
.calendar-lab-level-text {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: color-mix(in oklab, var(--color-base-content) 72%, transparent);
|
color: color-mix(in oklab, var(--color-base-content) 72%, transparent);
|
||||||
@@ -444,180 +446,98 @@ onBeforeUnmount(() => {
|
|||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendar-lab-world {
|
.calendar-year {
|
||||||
width: 1400px;
|
|
||||||
height: 900px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-lab-node {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-lab-node-year {
|
|
||||||
left: 80px;
|
left: 80px;
|
||||||
top: 50px;
|
top: 50px;
|
||||||
width: 1240px;
|
width: 1240px;
|
||||||
height: 760px;
|
height: 760px;
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-lab-node-month {
|
|
||||||
left: 250px;
|
|
||||||
top: 145px;
|
|
||||||
width: 740px;
|
|
||||||
height: 430px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-lab-node-week {
|
|
||||||
left: 140px;
|
|
||||||
top: 80px;
|
|
||||||
width: 460px;
|
|
||||||
height: 270px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-lab-node-day {
|
|
||||||
left: 90px;
|
|
||||||
top: 55px;
|
|
||||||
width: 280px;
|
|
||||||
height: 165px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-lab-node-active :deep(.calendar-lab-rect),
|
|
||||||
.calendar-lab-node-target :deep(.calendar-lab-rect) {
|
|
||||||
border-color: color-mix(in oklab, var(--color-primary) 85%, transparent);
|
|
||||||
box-shadow:
|
|
||||||
0 0 0 2px color-mix(in oklab, var(--color-primary) 45%, transparent),
|
|
||||||
0 16px 36px color-mix(in oklab, var(--color-base-content) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.calendar-lab-node-hover :deep(.calendar-lab-rect) {
|
|
||||||
border-color: color-mix(in oklab, var(--color-primary) 70%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.calendar-lab-rect) {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 24%, transparent);
|
border: 1px solid color-mix(in oklab, var(--color-base-content) 24%, transparent);
|
||||||
background: color-mix(in oklab, var(--color-base-100) 96%, transparent);
|
background: color-mix(in oklab, var(--color-base-100) 96%, transparent);
|
||||||
padding: 12px;
|
padding: 14px;
|
||||||
transform-origin: center center;
|
|
||||||
transition:
|
|
||||||
transform 200ms ease,
|
|
||||||
border-color 180ms ease,
|
|
||||||
box-shadow 180ms ease,
|
|
||||||
background-color 180ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.calendar-lab-header) {
|
.calendar-year-grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: flex-start;
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
justify-content: space-between;
|
grid-template-rows: repeat(3, minmax(0, 1fr));
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
margin-bottom: 8px;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.calendar-lab-title) {
|
.calendar-month-card,
|
||||||
font-size: 0.84rem;
|
.calendar-week-card,
|
||||||
font-weight: 700;
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.calendar-lab-subtitle) {
|
.calendar-month-card-selected,
|
||||||
font-size: 0.7rem;
|
.calendar-week-card-selected,
|
||||||
color: color-mix(in oklab, var(--color-base-content) 65%, transparent);
|
.calendar-day-card-selected {
|
||||||
|
border-color: color-mix(in oklab, var(--color-primary) 70%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.calendar-lab-loading),
|
.calendar-card-label {
|
||||||
:deep(.calendar-lab-meta),
|
|
||||||
:deep(.calendar-lab-hint) {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.calendar-lab-loading) {
|
|
||||||
color: color-mix(in oklab, var(--color-warning) 85%, var(--color-base-content));
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.calendar-lab-meta) {
|
|
||||||
color: color-mix(in oklab, var(--color-success) 86%, var(--color-base-content));
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.calendar-lab-hint) {
|
|
||||||
color: color-mix(in oklab, var(--color-base-content) 68%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.calendar-lab-grid-year) {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.calendar-lab-chip),
|
|
||||||
:deep(.calendar-lab-row),
|
|
||||||
:deep(.calendar-lab-day),
|
|
||||||
:deep(.calendar-lab-event) {
|
|
||||||
border-radius: 9px;
|
|
||||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 16%, transparent);
|
|
||||||
background: color-mix(in oklab, var(--color-base-200) 78%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.calendar-lab-chip) {
|
|
||||||
min-height: 28px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 0.74rem;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: color-mix(in oklab, var(--color-base-content) 88%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.calendar-lab-grid-month) {
|
.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;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 6px;
|
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.calendar-lab-row) {
|
.calendar-day-grid {
|
||||||
min-height: 60px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 8px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.calendar-lab-grid-week) {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.calendar-lab-day) {
|
.calendar-slot-grid {
|
||||||
min-height: 70px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.calendar-lab-timeline) {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 6px;
|
grid-template-rows: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 5px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.calendar-lab-event) {
|
.calendar-slot {
|
||||||
min-height: 44px;
|
border-radius: 8px;
|
||||||
padding: 6px 8px;
|
border: 1px solid color-mix(in oklab, var(--color-base-content) 16%, transparent);
|
||||||
display: flex;
|
background: color-mix(in oklab, var(--color-base-100) 88%, transparent);
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.calendar-lab-event span) {
|
.calendar-hover-target {
|
||||||
font-weight: 700;
|
border-color: color-mix(in oklab, var(--color-primary) 70%, transparent);
|
||||||
min-width: 50px;
|
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 {
|
.calendar-zoom-inline {
|
||||||
|
|||||||
Reference in New Issue
Block a user