From 0ed2a6b3539274b8b99bc849f60858d5c572cefa Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:26:07 +0700 Subject: [PATCH] fix(calendar-lab): stabilize center fit and wheel direction --- .../calendar/lab/CrmCalendarZoomLab.vue | 108 +++++++++++++----- 1 file changed, 81 insertions(+), 27 deletions(-) diff --git a/frontend/app/components/workspace/calendar/lab/CrmCalendarZoomLab.vue b/frontend/app/components/workspace/calendar/lab/CrmCalendarZoomLab.vue index ed17c78..456ae7c 100644 --- a/frontend/app/components/workspace/calendar/lab/CrmCalendarZoomLab.vue +++ b/frontend/app/components/workspace/calendar/lab/CrmCalendarZoomLab.vue @@ -45,8 +45,9 @@ const primeFocusId = ref(""); const wheelPrimeDirection = ref<"" | Direction>(""); const wheelPrimeTicks = ref(0); -let animationTimer: ReturnType | null = null; let primeTimer: ReturnType | null = null; +let animationFrameId: number | null = null; +let animationToken = 0; const currentLevelIndex = computed(() => LEVELS.indexOf(currentLevel.value)); const displayLevel = computed(() => transitionTarget.value ?? currentLevel.value); @@ -105,11 +106,22 @@ function getRectInScene(element: HTMLElement, scene: HTMLElement) { }; } -function applyCameraToElement(element: HTMLElement, animate: boolean) { +function easing(t: number) { + return 1 - (1 - t) ** 3; +} + +function stopCameraAnimation() { + animationToken += 1; + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } +} + +function computeTransformForElement(element: HTMLElement) { const viewport = viewportRef.value; const scene = sceneRef.value; - const panzoom = panzoomRef.value; - if (!viewport || !scene || !panzoom) return; + if (!viewport || !scene) return null; const targetRect = getRectInScene(element, scene); @@ -122,14 +134,68 @@ function applyCameraToElement(element: HTMLElement, animate: boolean) { const x = VIEWPORT_PADDING + (safeWidth - targetRect.width * scale) / 2 - targetRect.x * scale; const y = VIEWPORT_PADDING + (safeHeight - targetRect.height * scale) / 2 - targetRect.y * scale; - panzoom.zoom(scale, { animate, force: true }); - panzoom.pan(x, y, { animate, force: true }); + return { x, y, scale }; } -function applyCameraToLevel(level: Level, animate: boolean) { +function applyTransform(transform: { x: number; y: number; scale: number }) { + const panzoom = panzoomRef.value; + if (!panzoom) return; + panzoom.zoom(transform.scale, { animate: false, force: true }); + panzoom.pan(transform.x, transform.y, { animate: false, force: true }); +} + +async function applyCameraToElement(element: HTMLElement, animate: boolean) { + const panzoom = panzoomRef.value; + if (!panzoom) return; + const target = computeTransformForElement(element); + if (!target) return; + + if (!animate) { + stopCameraAnimation(); + applyTransform(target); + return; + } + + stopCameraAnimation(); + const localToken = animationToken; + const startPan = panzoom.getPan(); + const startScale = panzoom.getScale(); + const startAt = performance.now(); + + await new Promise((resolve) => { + const step = (now: number) => { + if (localToken !== animationToken) { + resolve(); + return; + } + + const elapsed = now - startAt; + const t = Math.max(0, Math.min(1, elapsed / ZOOM_ANIMATION_MS)); + const k = easing(t); + + applyTransform({ + x: startPan.x + (target.x - startPan.x) * k, + y: startPan.y + (target.y - startPan.y) * k, + scale: startScale + (target.scale - startScale) * k, + }); + + if (t < 1) { + animationFrameId = requestAnimationFrame(step); + return; + } + + animationFrameId = null; + resolve(); + }; + + animationFrameId = requestAnimationFrame(step); + }); +} + +async function applyCameraToLevel(level: Level, animate: boolean) { const element = findFocusElement(level); if (!element) return; - applyCameraToElement(element, animate); + await applyCameraToElement(element, animate); } function startPrime(focusId: string) { @@ -202,21 +268,11 @@ async function animateToLevel(level: Level) { transitionTarget.value = level; await nextTick(); - applyCameraToLevel(level, true); + await applyCameraToLevel(level, true); - if (animationTimer) { - clearTimeout(animationTimer); - animationTimer = null; - } - - await new Promise((resolve) => { - animationTimer = setTimeout(() => { - currentLevel.value = level; - transitionTarget.value = null; - isAnimating.value = false; - resolve(); - }, ZOOM_ANIMATION_MS + 40); - }); + currentLevel.value = level; + transitionTarget.value = null; + isAnimating.value = false; } async function zoom(direction: Direction) { @@ -232,7 +288,7 @@ async function zoom(direction: Direction) { function onWheel(event: WheelEvent) { if (isAnimating.value) return; - const direction: Direction = event.deltaY > 0 ? "in" : "out"; + const direction: Direction = event.deltaY < 0 ? "in" : "out"; const target = prepareZoomTarget(direction); if (!target) return; @@ -304,9 +360,7 @@ onMounted(async () => { if (sceneRef.value) { panzoomRef.value = Panzoom(sceneRef.value, { - animate: true, - duration: ZOOM_ANIMATION_MS, - easing: "cubic-bezier(0.2, 0.8, 0.2, 1)", + animate: false, maxScale: 24, minScale: 0.08, disablePan: true, @@ -326,8 +380,8 @@ onMounted(async () => { }); onBeforeUnmount(() => { - if (animationTimer) clearTimeout(animationTimer); if (primeTimer) clearTimeout(primeTimer); + stopCameraAnimation(); resizeObserver.value?.disconnect(); panzoomRef.value?.destroy(); panzoomRef.value = null;