feat(calendar-lab): switch zoom scene to panzoom engine

This commit is contained in:
Ruslan Bakiev
2026-02-23 18:12:05 +07:00
parent 49c4757490
commit 6ab3b374a2
3 changed files with 54 additions and 28 deletions

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import Panzoom from "@panzoom/panzoom";
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue"; import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
type Level = "year" | "month" | "week" | "day"; type Level = "year" | "month" | "week" | "day";
@@ -24,12 +25,12 @@ const VIEWPORT_PADDING = 20;
const viewportRef = ref<HTMLDivElement | null>(null); const viewportRef = ref<HTMLDivElement | null>(null);
const sceneRef = ref<HTMLDivElement | null>(null); const sceneRef = ref<HTMLDivElement | null>(null);
const panzoomRef = ref<ReturnType<typeof Panzoom> | 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 isAnimating = ref(false); const isAnimating = ref(false);
const useTransition = ref(false);
const selectedMonth = ref(0); const selectedMonth = ref(0);
const selectedWeek = ref(0); const selectedWeek = ref(0);
@@ -41,11 +42,6 @@ const hoveredDay = ref(0);
const primeFocusId = ref(""); const primeFocusId = ref("");
const cameraX = ref(0);
const cameraY = ref(0);
const cameraScaleX = ref(1);
const cameraScaleY = ref(1);
const wheelPrimeDirection = ref<"" | Direction>(""); const wheelPrimeDirection = ref<"" | Direction>("");
const wheelPrimeTicks = ref(0); const wheelPrimeTicks = ref(0);
@@ -59,12 +55,6 @@ const displayLevelIndex = computed(() => LEVELS.indexOf(displayLevel.value));
const canZoomIn = computed(() => currentLevelIndex.value < LEVELS.length - 1); const canZoomIn = computed(() => currentLevelIndex.value < LEVELS.length - 1);
const canZoomOut = computed(() => currentLevelIndex.value > 0); 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) { function getFocusId(level: Level) {
if (level === "year") return "focus-year"; if (level === "year") return "focus-year";
if (level === "month") return `focus-month-${selectedMonth.value}`; if (level === "month") return `focus-month-${selectedMonth.value}`;
@@ -79,30 +69,44 @@ function findFocusElement(level: Level) {
return scene.querySelector<HTMLElement>(`[data-focus-id="${id}"]`); return scene.querySelector<HTMLElement>(`[data-focus-id="${id}"]`);
} }
function getRectInScene(element: HTMLElement, scene: HTMLElement) {
let x = 0;
let y = 0;
let node: HTMLElement | null = element;
while (node && node !== scene) {
x += node.offsetLeft;
y += node.offsetTop;
node = node.offsetParent as HTMLElement | null;
}
return {
x,
y,
width: Math.max(1, element.offsetWidth),
height: Math.max(1, element.offsetHeight),
};
}
function applyCameraToElement(element: HTMLElement, animate: boolean) { function applyCameraToElement(element: HTMLElement, animate: boolean) {
const viewport = viewportRef.value; const viewport = viewportRef.value;
const scene = sceneRef.value; const scene = sceneRef.value;
if (!viewport || !scene) return; const panzoom = panzoomRef.value;
if (!viewport || !scene || !panzoom) return;
const targetRect = getRectInScene(element, scene);
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 - VIEWPORT_PADDING * 2); const safeWidth = Math.max(1, viewportWidth - VIEWPORT_PADDING * 2);
const safeHeight = Math.max(1, viewportHeight - VIEWPORT_PADDING * 2); const safeHeight = Math.max(1, viewportHeight - VIEWPORT_PADDING * 2);
const scale = Math.min(safeWidth / targetRect.width, safeHeight / targetRect.height);
const sceneRect = scene.getBoundingClientRect(); const x = VIEWPORT_PADDING + (safeWidth - targetRect.width * scale) / 2 - targetRect.x * scale;
const elementRect = element.getBoundingClientRect(); const y = VIEWPORT_PADDING + (safeHeight - targetRect.height * scale) / 2 - targetRect.y * scale;
const localX = elementRect.left - sceneRect.left; panzoom.zoom(scale, { animate, force: true });
const localY = elementRect.top - sceneRect.top; panzoom.pan(x, y, { animate, force: true });
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) { function applyCameraToLevel(level: Level, animate: boolean) {
@@ -190,7 +194,6 @@ async function animateToLevel(level: Level) {
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
animationTimer = setTimeout(() => { animationTimer = setTimeout(() => {
useTransition.value = false;
currentLevel.value = level; currentLevel.value = level;
transitionTarget.value = null; transitionTarget.value = null;
isAnimating.value = false; isAnimating.value = false;
@@ -281,6 +284,19 @@ function showDayContent(index: number) {
onMounted(async () => { onMounted(async () => {
await nextTick(); await nextTick();
if (sceneRef.value) {
panzoomRef.value = Panzoom(sceneRef.value, {
animate: true,
duration: ZOOM_ANIMATION_MS,
easing: "cubic-bezier(0.2, 0.8, 0.2, 1)",
maxScale: 24,
minScale: 0.08,
disablePan: true,
origin: "0 0",
});
}
applyCameraToLevel("year", false); applyCameraToLevel("year", false);
resizeObserver.value = new ResizeObserver(() => { resizeObserver.value = new ResizeObserver(() => {
@@ -296,6 +312,8 @@ onBeforeUnmount(() => {
if (animationTimer) clearTimeout(animationTimer); if (animationTimer) clearTimeout(animationTimer);
if (primeTimer) clearTimeout(primeTimer); if (primeTimer) clearTimeout(primeTimer);
resizeObserver.value?.disconnect(); resizeObserver.value?.disconnect();
panzoomRef.value?.destroy();
panzoomRef.value = null;
}); });
</script> </script>
@@ -334,7 +352,7 @@ onBeforeUnmount(() => {
:class="canZoomIn ? 'cursor-zoom-in' : 'cursor-zoom-out'" :class="canZoomIn ? 'cursor-zoom-in' : 'cursor-zoom-out'"
@wheel.prevent="onWheel" @wheel.prevent="onWheel"
> >
<div ref="sceneRef" class="calendar-lab-scene" :style="sceneStyle"> <div ref="sceneRef" class="calendar-lab-scene">
<article class="calendar-year" data-focus-id="focus-year"> <article class="calendar-year" data-focus-id="focus-year">
<div class="calendar-year-grid"> <div class="calendar-year-grid">
<div <div

View File

@@ -14,6 +14,7 @@
"@langchain/openai": "^0.6.9", "@langchain/openai": "^0.6.9",
"@nuxt/eslint": "^1.15.1", "@nuxt/eslint": "^1.15.1",
"@nuxtjs/apollo": "^5.0.0-alpha.15", "@nuxtjs/apollo": "^5.0.0-alpha.15",
"@panzoom/panzoom": "^4.6.1",
"@prisma/client": "^6.16.1", "@prisma/client": "^6.16.1",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tiptap/extension-collaboration": "^2.27.2", "@tiptap/extension-collaboration": "^2.27.2",
@@ -6476,6 +6477,12 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
} }
}, },
"node_modules/@panzoom/panzoom": {
"version": "4.6.1",
"resolved": "https://registry.npmjs.org/@panzoom/panzoom/-/panzoom-4.6.1.tgz",
"integrity": "sha512-ogf/KhHHjj+DYAvHfaf3TXMQ8OE36pJtKpabLlx1OmpjcgtpCvkUoCiNONA8kuVRPGJdLiqMf0n8LRFXj1OyuA==",
"license": "MIT"
},
"node_modules/@parcel/watcher": { "node_modules/@parcel/watcher": {
"version": "2.5.6", "version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",

View File

@@ -26,6 +26,7 @@
"@langchain/openai": "^0.6.9", "@langchain/openai": "^0.6.9",
"@nuxt/eslint": "^1.15.1", "@nuxt/eslint": "^1.15.1",
"@nuxtjs/apollo": "^5.0.0-alpha.15", "@nuxtjs/apollo": "^5.0.0-alpha.15",
"@panzoom/panzoom": "^4.6.1",
"@prisma/client": "^6.16.1", "@prisma/client": "^6.16.1",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tiptap/extension-collaboration": "^2.27.2", "@tiptap/extension-collaboration": "^2.27.2",