-
+
+
+ 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
+
+ Loading GraphQL week payload…
+ Data ready
-
-
- D{{ day }}
-
-
+
+
+ D{{ day }}
+
+
+
+
+ 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
+
+ Loading GraphQL year payload…
+ Data ready
-
-
- {{ month }}
-
-
+
+
+ {{ month }}
+
+
+
+
+ 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(() => {