From 5657da13c1272039620a02cde4ef95ef754942d3 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:36:22 +0700 Subject: [PATCH] feat(calendar-lab): add hover-targeted zoom with progressive tension and zoom slider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Zoom animation now targets the hovered cell (not a fixed demo block) - Progressive "pull" tension: cell scales 5%→10% over 2 scroll ticks before the full flying-rect animation triggers (400ms decay timeout) - Added zoom slider in top-right toolbar matching production design (range 0-3 with dot markers, drives step-by-step zoom animations) - Slider handles mid-drag target updates via sliderTarget variable Co-Authored-By: Claude Opus 4.6 --- .../calendar/lab/CrmCalendarZoomCanvasLab.vue | 238 ++++++++++++++---- 1 file changed, 189 insertions(+), 49 deletions(-) diff --git a/frontend/app/components/workspace/calendar/lab/CrmCalendarZoomCanvasLab.vue b/frontend/app/components/workspace/calendar/lab/CrmCalendarZoomCanvasLab.vue index 92e944d..d44b7b2 100644 --- a/frontend/app/components/workspace/calendar/lab/CrmCalendarZoomCanvasLab.vue +++ b/frontend/app/components/workspace/calendar/lab/CrmCalendarZoomCanvasLab.vue @@ -29,6 +29,8 @@ const MONTH_LABELS = [ const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const ZOOM_PRIME_STEPS = 2; +const PRIME_SCALE_MAX = 0.10; +const PRIME_DECAY_MS = 400; const FLY_DURATION = 0.65; const FADE_DURATION = 0.18; const EASE = "power3.inOut"; @@ -48,7 +50,6 @@ const contentVisible = ref(true); const flyVisible = ref(false); const flyLabel = ref(""); -// Reactive viewport dimensions (updated by ResizeObserver) const vpWidth = ref(0); const vpHeight = ref(0); @@ -60,12 +61,14 @@ const hoveredMonth = ref(0); const hoveredWeek = ref(0); const hoveredDay = ref(0); -const primeFocusId = ref(""); +const primeCellIndex = ref(-1); +const primeProgress = ref(0); const wheelPrimeDirection = ref<"" | Direction>(""); const wheelPrimeTicks = ref(0); let primeTimer: ReturnType | null = null; let activeTweens: gsap.core.Tween[] = []; +let sliderTarget = -1; /* ------------------------------------------------------------------ */ /* Computed */ @@ -74,6 +77,15 @@ let activeTweens: gsap.core.Tween[] = []; const currentLevelIndex = computed(() => LEVELS.indexOf(currentLevel.value)); const canZoomIn = computed(() => currentLevelIndex.value < LEVELS.length - 1); +const hoveredCellIndex = computed(() => { + switch (currentLevel.value) { + case "year": return hoveredMonth.value; + case "month": return hoveredWeek.value; + case "week": return hoveredDay.value; + default: return 0; + } +}); + /* ------------------------------------------------------------------ */ /* Grid definitions */ /* ------------------------------------------------------------------ */ @@ -133,7 +145,6 @@ function computeGridRects(level: Level, vw: number, vh: number) { }); } -// Reactive: recomputes when currentLevel or vpWidth/vpHeight change const gridRects = computed(() => { if (vpWidth.value <= 0 || vpHeight.value <= 0) return []; return computeGridRects(currentLevel.value, vpWidth.value, vpHeight.value); @@ -143,7 +154,6 @@ const gridRects = computed(() => { /* GSAP helpers */ /* ------------------------------------------------------------------ */ -/** Promise wrapper around gsap.to */ function tweenTo(target: gsap.TweenTarget, vars: gsap.TweenVars): Promise { return new Promise((resolve) => { const t = gsap.to(target, { @@ -157,29 +167,54 @@ function tweenTo(target: gsap.TweenTarget, vars: gsap.TweenVars): Promise }); } -/** Kill all running tweens */ function killAllTweens() { for (const t of activeTweens) t.kill(); activeTweens = []; } -/** Reset opacity of content and grid to safe state */ -function resetOpacity() { - const c = contentRef.value; - const g = gridLayerRef.value; - if (c) gsap.set(c, { opacity: 1 }); - if (g) gsap.set(g, { opacity: 1 }); +/* ------------------------------------------------------------------ */ +/* Prime (tension) helpers */ +/* ------------------------------------------------------------------ */ + +function advancePrime(cellIndex: number) { + if (primeTimer) { + clearTimeout(primeTimer); + primeTimer = null; + } + primeCellIndex.value = cellIndex; + primeProgress.value = Math.min(primeProgress.value + 1, ZOOM_PRIME_STEPS); + + primeTimer = setTimeout(() => { + primeCellIndex.value = -1; + primeProgress.value = 0; + wheelPrimeDirection.value = ""; + wheelPrimeTicks.value = 0; + }, PRIME_DECAY_MS); +} + +function resetPrime() { + if (primeTimer) { + clearTimeout(primeTimer); + primeTimer = null; + } + primeCellIndex.value = -1; + primeProgress.value = 0; +} + +function getCellPrimeScale(idx: number): number { + if (primeCellIndex.value !== idx || primeProgress.value <= 0) return 1; + return 1 + (primeProgress.value / ZOOM_PRIME_STEPS) * PRIME_SCALE_MAX; } /* ------------------------------------------------------------------ */ /* Zoom In */ /* ------------------------------------------------------------------ */ -async function zoomIn() { +async function zoomIn(overrideIndex?: number) { if (isAnimating.value) return; if (currentLevelIndex.value >= LEVELS.length - 1) return; - const hovIdx = getHoveredIndex(); + const hovIdx = overrideIndex ?? hoveredCellIndex.value; const rects = gridRects.value; const targetRect = rects[hovIdx]; if (!targetRect) return; @@ -191,6 +226,7 @@ async function zoomIn() { isAnimating.value = true; killAllTweens(); + resetPrime(); const vw = vpWidth.value; const vh = vpHeight.value; @@ -280,7 +316,6 @@ async function zoomOut() { const vh = vpHeight.value; const pad = 8; - // Figure out which child index we came from const prevIdx = currentLevelIndex.value - 1; const parentLevel = LEVELS[prevIdx]!; let fromIdx = 0; @@ -361,20 +396,17 @@ async function resetToYear() { isAnimating.value = true; killAllTweens(); - // 1. Fade out await Promise.all([ tweenTo(contentEl, { opacity: 0, duration: 0.2, ease: "power2.in" }), tweenTo(gridEl, { opacity: 0, duration: 0.2, ease: "power2.in" }), ]); - // 2. Switch level currentLevel.value = "year"; contentVisible.value = true; gsap.set(contentEl, { opacity: 0 }); gsap.set(gridEl, { opacity: 0 }); await nextTick(); - // 3. Fade in await Promise.all([ tweenTo(contentEl, { opacity: 1, duration: 0.3, ease: "power2.out" }), tweenTo(gridEl, { opacity: 1, duration: 0.3, ease: "power2.out" }), @@ -390,26 +422,7 @@ async function resetToYear() { function resetWheelPrime() { wheelPrimeDirection.value = ""; wheelPrimeTicks.value = 0; -} - -function startPrime(focusId: string) { - if (primeTimer) { - clearTimeout(primeTimer); - primeTimer = null; - } - primeFocusId.value = focusId; - primeTimer = setTimeout(() => { - primeFocusId.value = ""; - }, 170); -} - -function getHoveredIndex(): number { - switch (currentLevel.value) { - case "year": return hoveredMonth.value; - case "month": return hoveredWeek.value; - case "week": return hoveredDay.value; - default: return 0; - } + resetPrime(); } function onWheel(event: WheelEvent) { @@ -424,17 +437,14 @@ function onWheel(event: WheelEvent) { if (wheelPrimeDirection.value !== direction) { wheelPrimeDirection.value = direction; wheelPrimeTicks.value = 0; + resetPrime(); } if (wheelPrimeTicks.value < ZOOM_PRIME_STEPS) { wheelPrimeTicks.value += 1; if (direction === "in") { - const idx = getHoveredIndex(); - const rects = gridRects.value; - if (rects[idx]) { - startPrime(rects[idx].id); - } + advancePrime(hoveredCellIndex.value); } return; } @@ -453,8 +463,30 @@ function onDoubleClick() { void resetToYear(); } -function isPrime(id: string) { - return primeFocusId.value === id; +/* ------------------------------------------------------------------ */ +/* Zoom slider */ +/* ------------------------------------------------------------------ */ + +async function onSliderInput(event: Event) { + const value = Number((event.target as HTMLInputElement)?.value ?? NaN); + if (!Number.isFinite(value)) return; + + const targetIndex = Math.max(0, Math.min(3, Math.round(value))); + sliderTarget = targetIndex; + + if (isAnimating.value) return; + if (targetIndex === currentLevelIndex.value) return; + + for (let i = 0; i < 3; i++) { + if (currentLevelIndex.value === sliderTarget) break; + if (sliderTarget > currentLevelIndex.value) { + await zoomIn(0); + } else { + await zoomOut(); + } + } + + sliderTarget = -1; } /* ------------------------------------------------------------------ */ @@ -464,7 +496,6 @@ function isPrime(id: string) { let resizeObserver: ResizeObserver | null = null; onMounted(() => { - // Initial measurement if (viewportRef.value) { vpWidth.value = viewportRef.value.clientWidth; vpHeight.value = viewportRef.value.clientHeight; @@ -494,6 +525,27 @@ onBeforeUnmount(() => {

{{ LEVEL_LABELS[currentLevel] }}

+ +
+ + +
{ v-for="(rect, idx) in gridRects" :key="rect.id" class="canvas-cell" - :class="[isPrime(rect.id) ? 'canvas-cell-prime' : '']" + :class="[primeCellIndex === idx ? 'canvas-cell-priming' : '']" :style="{ left: `${rect.x}px`, top: `${rect.y}px`, width: `${rect.w}px`, height: `${rect.h}px`, + transform: primeCellIndex === idx && primeProgress > 0 + ? `scale(${getCellPrimeScale(idx)})` + : undefined, }" @mouseenter=" currentLevel === 'year' ? (hoveredMonth = idx) : @@ -592,6 +647,7 @@ onBeforeUnmount(() => { .canvas-lab-toolbar { display: flex; align-items: center; + justify-content: space-between; gap: 8px; } @@ -600,6 +656,90 @@ onBeforeUnmount(() => { color: color-mix(in oklab, var(--color-base-content) 72%, transparent); } +/* ---- Zoom slider ---- */ + +.canvas-lab-zoom-control { + position: relative; + display: flex; + align-items: center; + width: 128px; + height: 22px; + padding: 0 10px; +} + +.canvas-lab-zoom-slider { + width: 100%; + height: 18px; + margin: 0; + background: transparent; + -webkit-appearance: none; + appearance: none; + cursor: pointer; +} + +.canvas-lab-zoom-slider:focus-visible { + outline: none; +} + +.canvas-lab-zoom-slider::-webkit-slider-runnable-track { + height: 2px; + border-radius: 999px; + background: color-mix(in oklab, var(--color-base-content) 22%, transparent); +} + +.canvas-lab-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; +} + +.canvas-lab-zoom-slider::-moz-range-track { + height: 2px; + border-radius: 999px; + background: color-mix(in oklab, var(--color-base-content) 22%, transparent); +} + +.canvas-lab-zoom-slider::-moz-range-progress { + height: 2px; + border-radius: 999px; + background: transparent; +} + +.canvas-lab-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); +} + +.canvas-lab-zoom-marks { + position: absolute; + inset: 0 10px; + display: flex; + align-items: center; + justify-content: space-between; + pointer-events: none; +} + +.canvas-lab-zoom-mark { + width: 4px; + height: 4px; + border-radius: 999px; + background: color-mix(in oklab, var(--color-base-content) 35%, transparent); +} + +.canvas-lab-zoom-mark-active { + background: color-mix(in oklab, var(--color-base-content) 85%, transparent); +} + +/* ---- Viewport ---- */ + .canvas-lab-viewport { position: relative; flex: 1; @@ -622,7 +762,7 @@ onBeforeUnmount(() => { border-radius: 12px; border: 1px solid color-mix(in oklab, var(--color-base-content) 20%, transparent); background: color-mix(in oklab, var(--color-base-200) 50%, transparent); - transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; + transition: border-color 140ms ease, box-shadow 140ms ease, transform 180ms ease; display: flex; align-items: center; justify-content: center; @@ -634,8 +774,8 @@ onBeforeUnmount(() => { box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 20%, transparent) inset; } -.canvas-cell-prime { - transform: scale(1.03); +.canvas-cell-priming { + z-index: 2; border-color: color-mix(in oklab, var(--color-primary) 80%, transparent); box-shadow: 0 0 0 3px color-mix(in oklab, var(--color-primary) 36%, transparent) inset; }