diff --git a/frontend/app/components/workspace/calendar/lab/CrmCalendarLabDayRect.vue b/frontend/app/components/workspace/calendar/lab/CrmCalendarLabDayRect.vue index 49880d9..42e9d31 100644 --- a/frontend/app/components/workspace/calendar/lab/CrmCalendarLabDayRect.vue +++ b/frontend/app/components/workspace/calendar/lab/CrmCalendarLabDayRect.vue @@ -3,6 +3,7 @@ defineProps<{ isActive: boolean; isLoading: boolean; isLoaded: boolean; + showContent: boolean; pulseScale: number; }>(); @@ -18,22 +19,25 @@ defineProps<{

Timeline events

-

Loading GraphQL day payload…

-

Data ready

+ +

Zoom stopped. Day content will render here.

diff --git a/frontend/app/components/workspace/calendar/lab/CrmCalendarLabMonthRect.vue b/frontend/app/components/workspace/calendar/lab/CrmCalendarLabMonthRect.vue index 01fd327..2cce88a 100644 --- a/frontend/app/components/workspace/calendar/lab/CrmCalendarLabMonthRect.vue +++ b/frontend/app/components/workspace/calendar/lab/CrmCalendarLabMonthRect.vue @@ -3,6 +3,8 @@ defineProps<{ isActive: boolean; isLoading: boolean; isLoaded: boolean; + showContent: boolean; + nextLabel?: string; pulseScale: number; }>(); @@ -18,17 +20,22 @@ defineProps<{

Weeks inside one month

-

Loading GraphQL month payload…

-

Data ready

+ +

+ Zoom into {{ nextLabel ?? "Week" }} +

diff --git a/frontend/app/components/workspace/calendar/lab/CrmCalendarLabWeekRect.vue b/frontend/app/components/workspace/calendar/lab/CrmCalendarLabWeekRect.vue index 010265d..cb2a5f0 100644 --- a/frontend/app/components/workspace/calendar/lab/CrmCalendarLabWeekRect.vue +++ b/frontend/app/components/workspace/calendar/lab/CrmCalendarLabWeekRect.vue @@ -3,6 +3,8 @@ defineProps<{ isActive: boolean; isLoading: boolean; isLoaded: boolean; + showContent: boolean; + nextLabel?: string; pulseScale: number; }>(); @@ -18,17 +20,22 @@ defineProps<{

7 day columns

-

Loading GraphQL week payload…

-

Data ready

+ +

+ Zoom into {{ nextLabel ?? "Day" }} +

diff --git a/frontend/app/components/workspace/calendar/lab/CrmCalendarLabYearRect.vue b/frontend/app/components/workspace/calendar/lab/CrmCalendarLabYearRect.vue index f0cc2e6..91f28a2 100644 --- a/frontend/app/components/workspace/calendar/lab/CrmCalendarLabYearRect.vue +++ b/frontend/app/components/workspace/calendar/lab/CrmCalendarLabYearRect.vue @@ -3,6 +3,8 @@ defineProps<{ isActive: boolean; isLoading: boolean; isLoaded: boolean; + showContent: boolean; + nextLabel?: string; pulseScale: number; }>(); @@ -18,17 +20,22 @@ defineProps<{

12 months overview

-

Loading GraphQL year payload…

-

Data ready

+ +

+ Zoom into {{ nextLabel ?? "Month" }} +

diff --git a/frontend/app/components/workspace/calendar/lab/CrmCalendarZoomLab.vue b/frontend/app/components/workspace/calendar/lab/CrmCalendarZoomLab.vue index 3f356b1..bd7661b 100644 --- a/frontend/app/components/workspace/calendar/lab/CrmCalendarZoomLab.vue +++ b/frontend/app/components/workspace/calendar/lab/CrmCalendarZoomLab.vue @@ -11,7 +11,7 @@ type LevelState = { loading: boolean; }; -const CALENDAR_LEVELS: Level[] = ["year", "month", "week", "day"]; +const LEVELS: Level[] = ["year", "month", "week", "day"]; const LEVEL_LABELS: Record = { year: "Year", month: "Month", @@ -19,6 +19,20 @@ const LEVEL_LABELS: Record = { day: "Day", }; +const NEXT_LEVEL: Record = { + year: "month", + month: "week", + week: "day", + day: null, +}; + +const PREV_LEVEL: Record = { + year: null, + month: "year", + week: "month", + day: "week", +}; + const ZOOM_PRIME_STEPS = 2; const ZOOM_PRIME_SCALE = 1.05; const ZOOM_ANIMATION_MS = 2200; @@ -31,17 +45,19 @@ const WORLD_RECT_BY_LEVEL: Record("year"); -const isAnimating = ref(false); -const useTransition = ref(false); const viewportRef = ref(null); const resizeObserver = ref(null); +const currentLevel = ref("year"); +const transitionTarget = ref(null); +const hoveredLevel = ref("month"); +const isAnimating = ref(false); +const useTransition = ref(false); const camera = reactive({ x: 0, y: 0, - scale: 1, + scaleX: 1, + scaleY: 1, }); const levelState = reactive>({ @@ -51,10 +67,6 @@ const levelState = reactive>({ day: { loaded: false, loading: false }, }); -let animationTimer: ReturnType | null = null; -let primeTimer: ReturnType | null = null; -let loadTimer: ReturnType | null = null; - const wheelPrime = reactive({ direction: "" as "" | "in" | "out", ticks: 0, @@ -65,12 +77,17 @@ const pulseState = reactive({ scale: 1, }); -const activeLevel = computed(() => CALENDAR_LEVELS[zoomLevelIndex.value] ?? "year"); -const canZoomIn = computed(() => zoomLevelIndex.value < CALENDAR_LEVELS.length - 1); -const canZoomOut = computed(() => zoomLevelIndex.value > 0); +let animationTimer: ReturnType | null = null; +let primeTimer: ReturnType | null = null; +let loadTimer: ReturnType | null = null; + +const displayLevel = computed(() => transitionTarget.value ?? currentLevel.value); +const displayIndex = computed(() => LEVELS.indexOf(displayLevel.value)); +const canZoomIn = computed(() => NEXT_LEVEL[currentLevel.value] !== null); +const canZoomOut = computed(() => PREV_LEVEL[currentLevel.value] !== null); const sceneStyle = computed(() => ({ - transform: `translate3d(${camera.x}px, ${camera.y}px, 0) scale(${camera.scale})`, + transform: `translate3d(${camera.x}px, ${camera.y}px, 0) scale(${camera.scaleX}, ${camera.scaleY})`, transformOrigin: "0 0", transition: useTransition.value ? `transform ${ZOOM_ANIMATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)` : "none", })); @@ -79,6 +96,30 @@ function pulseScaleFor(level: Level) { return pulseState.level === level ? pulseState.scale : 1; } +function nextLevelOf(level: Level) { + return NEXT_LEVEL[level]; +} + +function prevLevelOf(level: Level) { + 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; if (!viewport) return; @@ -88,20 +129,23 @@ function updateCameraForLevel(level: Level, animate: boolean) { const viewportHeight = Math.max(1, viewport.clientHeight); const safeWidth = Math.max(1, viewportWidth - WORLD_PADDING * 2); const safeHeight = Math.max(1, viewportHeight - WORLD_PADDING * 2); - const scale = Math.min(safeWidth / rect.width, safeHeight / rect.height); - const x = WORLD_PADDING + (safeWidth - rect.width * scale) / 2 - rect.x * scale; - const y = WORLD_PADDING + (safeHeight - rect.height * scale) / 2 - rect.y * scale; + const scaleX = safeWidth / rect.width; + const scaleY = safeHeight / rect.height; + + const x = WORLD_PADDING - rect.x * scaleX; + const y = WORLD_PADDING - rect.y * scaleY; useTransition.value = animate; camera.x = x; camera.y = y; - camera.scale = scale; + camera.scaleX = scaleX; + camera.scaleY = scaleY; } async function ensureGraphqlPayload(level: Level) { const state = levelState[level]; - if (state.loading || state.loaded) return; + if (state.loaded || state.loading) return; state.loading = true; if (loadTimer) { @@ -114,7 +158,7 @@ async function ensureGraphqlPayload(level: Level) { state.loading = false; state.loaded = true; resolve(); - }, 520); + }, 540); }); } @@ -123,12 +167,14 @@ function startPulse(level: Level) { clearTimeout(primeTimer); primeTimer = null; } + pulseState.level = level; pulseState.scale = ZOOM_PRIME_SCALE; + primeTimer = setTimeout(() => { pulseState.scale = 1; pulseState.level = ""; - }, 160); + }, 170); } function resetWheelPrime() { @@ -136,15 +182,15 @@ function resetWheelPrime() { wheelPrime.ticks = 0; } -async function runZoomTo(targetIndex: number) { +async function runZoomTo(target: Level) { if (isAnimating.value) return; - if (targetIndex < 0 || targetIndex >= CALENDAR_LEVELS.length) return; - if (targetIndex === zoomLevelIndex.value) return; + if (target === currentLevel.value) return; isAnimating.value = true; - zoomLevelIndex.value = targetIndex; + transitionTarget.value = target; + await nextTick(); - updateCameraForLevel(activeLevel.value, true); + updateCameraForLevel(target, true); if (animationTimer) { clearTimeout(animationTimer); @@ -153,63 +199,69 @@ async function runZoomTo(targetIndex: number) { animationTimer = setTimeout(async () => { useTransition.value = false; + currentLevel.value = target; + transitionTarget.value = null; isAnimating.value = false; - await ensureGraphqlPayload(activeLevel.value); + hoveredLevel.value = nextLevelOf(target) ?? target; + await ensureGraphqlPayload(target); }, ZOOM_ANIMATION_MS + 30); } function onWheel(event: WheelEvent) { if (isAnimating.value) return; - const direction: "in" | "out" = event.deltaY > 0 ? "in" : "out"; - if (direction === "in" && !canZoomIn.value) return; - if (direction === "out" && !canZoomOut.value) return; - const pulseLevel = activeLevel.value; + const direction: "in" | "out" = event.deltaY > 0 ? "in" : "out"; + const nextLevel = direction === "in" ? nextLevelOf(currentLevel.value) : prevLevelOf(currentLevel.value); + if (!nextLevel) return; + if (wheelPrime.direction !== direction) { wheelPrime.direction = direction; wheelPrime.ticks = 0; } + const pulseTarget = direction === "in" ? (hoveredLevel.value === nextLevel ? hoveredLevel.value : nextLevel) : currentLevel.value; if (wheelPrime.ticks < ZOOM_PRIME_STEPS) { wheelPrime.ticks += 1; - startPulse(pulseLevel); + startPulse(pulseTarget); return; } resetWheelPrime(); - if (direction === "in") { - void runZoomTo(zoomLevelIndex.value + 1); - return; - } - void runZoomTo(zoomLevelIndex.value - 1); + void runZoomTo(nextLevel); } function onSliderInput(event: Event) { const target = event.target as HTMLInputElement | null; if (!target) return; - const index = Number(target.value); + + const next = levelByIndex(Number(target.value)); resetWheelPrime(); - void runZoomTo(index); + void runZoomTo(next); } function onZoomInClick() { - if (!canZoomIn.value) return; + const next = nextLevelOf(currentLevel.value); + if (!next) return; resetWheelPrime(); - void runZoomTo(zoomLevelIndex.value + 1); + void runZoomTo(next); } function onZoomOutClick() { - if (!canZoomOut.value) return; + const prev = prevLevelOf(currentLevel.value); + if (!prev) return; resetWheelPrime(); - void runZoomTo(zoomLevelIndex.value - 1); + void runZoomTo(prev); } onMounted(async () => { await nextTick(); - updateCameraForLevel(activeLevel.value, false); + updateCameraForLevel(currentLevel.value, false); + hoveredLevel.value = nextLevelOf(currentLevel.value) ?? currentLevel.value; + resizeObserver.value = new ResizeObserver(() => { - updateCameraForLevel(activeLevel.value, false); + updateCameraForLevel(displayLevel.value, false); }); + if (viewportRef.value) { resizeObserver.value.observe(viewportRef.value); } @@ -229,7 +281,7 @@ onBeforeUnmount(() => {
- Current: {{ LEVEL_LABELS[activeLevel] }} + Current: {{ LEVEL_LABELS[currentLevel] }}
@@ -239,7 +291,7 @@ onBeforeUnmount(() => { min="0" max="3" step="1" - :value="zoomLevelIndex" + :value="displayIndex" aria-label="Calendar zoom level" @input="onSliderInput" > @@ -248,7 +300,7 @@ onBeforeUnmount(() => { v-for="index in 4" :key="`calendar-lab-zoom-mark-${index}`" class="calendar-zoom-mark" - :class="zoomLevelIndex === index - 1 ? 'calendar-zoom-mark-active' : ''" + :class="displayIndex === index - 1 ? 'calendar-zoom-mark-active' : ''" />
@@ -263,62 +315,77 @@ onBeforeUnmount(() => {
@@ -415,7 +482,8 @@ onBeforeUnmount(() => { height: 165px; } -.calendar-lab-node-active :deep(.calendar-lab-rect) { +.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), @@ -460,7 +528,8 @@ onBeforeUnmount(() => { } :deep(.calendar-lab-loading), -:deep(.calendar-lab-meta) { +:deep(.calendar-lab-meta), +:deep(.calendar-lab-hint) { margin-bottom: 8px; font-size: 0.72rem; } @@ -473,6 +542,10 @@ onBeforeUnmount(() => { 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));