1// ============================================
2// SPLIT COMPARE - Reusable before/after split-screen effect
3// ============================================
4
5/**
6 * Initialize split comparison effect on a container
7 * @param {HTMLElement} container - The container element with .split-container inside
8 * @param {Object} options - Configuration options
9 */
10export function initSplitCompare(container, options = {}) {
11 const {
12 defaultPosition = 70,
13 skewAngle = 10, // Degrees — matches CSS skewX(-10deg) on .split-divider
14 lerpSpeed = 0.15,
15 animationThreshold = 40, // Re-trigger animations when crossing this threshold
16 onCrossThreshold = null // Callback when crossing threshold toward "after" side
17 } = options;
18
19 const splitContainer = container.querySelector('.split-container');
20 const splitAfter = container.querySelector('.split-after');
21 const splitDivider = container.querySelector('.split-divider');
22
23 if (!splitContainer || !splitAfter || !splitDivider) return null;
24
25 // Compute skewOffset from container dimensions so clip-path angle matches CSS skewX
26 const tanAngle = Math.tan(skewAngle * Math.PI / 180);
27 let skewOffset = 8; // fallback
28
29 function recalcSkewOffset() {
30 const rect = splitContainer.getBoundingClientRect();
31 if (rect.width > 0 && rect.height > 0) {
32 skewOffset = 50 * rect.height * tanAngle / rect.width;
33 }
34 }
35 recalcSkewOffset();
36
37 const resizeObserver = new ResizeObserver(recalcSkewOffset);
38 resizeObserver.observe(splitContainer);
39
40 let minPosition = -skewOffset;
41 let maxPosition = 100 + skewOffset;
42 if (options.minPosition != null) minPosition = options.minPosition;
43 if (options.maxPosition != null) maxPosition = options.maxPosition;
44
45 let isHovering = false;
46 let currentX = defaultPosition;
47 let targetX = defaultPosition;
48 let animationId = null;
49 let wasAboveThreshold = defaultPosition > animationThreshold;
50
51 function updateSplit(percent) {
52 const minPos = options.minPosition != null ? minPosition : -skewOffset;
53 const maxPos = options.maxPosition != null ? maxPosition : 100 + skewOffset;
54 const clampedX = Math.max(minPos, Math.min(maxPos, percent));
55
56 // Check if we crossed the threshold toward the "after" side (moving left)
57 const isAboveThreshold = clampedX > animationThreshold;
58 if (wasAboveThreshold && !isAboveThreshold) {
59 // Crossed threshold - re-trigger animations
60 retriggerAnimations();
61 if (onCrossThreshold) onCrossThreshold(clampedX);
62 }
63 wasAboveThreshold = isAboveThreshold;
64
65 // Angled clip-path matching divider's skewX — offset computed from actual dimensions
66 splitAfter.style.clipPath = `polygon(${clampedX + skewOffset}% 0%, 100% 0%, 100% 100%, ${clampedX - skewOffset}% 100%)`;
67 splitDivider.style.left = `${clampedX}%`;
68 }
69
70 function retriggerAnimations() {
71 // Re-trigger CSS animations in the "after" content.
72 // If there's a canvas (e.g. overdrive shader), we can't clone-and-replace
73 // because that destroys JS-driven animations. In that case, retrigger
74 // individual elements. Otherwise, use the fast clone approach.
75 const afterContent = splitAfter.querySelector('.split-content');
76 if (!afterContent) return;
77
78 const hasCanvas = afterContent.querySelector('canvas, .od-burn, .od-sparks');
79 if (hasCanvas) {
80 // Safe path: retrigger CSS animations individually, skip canvas
81 afterContent.querySelectorAll('*').forEach(el => {
82 if (el.tagName === 'CANVAS') return;
83 const anim = getComputedStyle(el).animationName;
84 if (anim && anim !== 'none') {
85 el.style.animation = 'none';
86 el.offsetHeight;
87 el.style.animation = '';
88 }
89 });
90 } else {
91 // Fast path: clone and replace to restart all CSS animations
92 const clone = afterContent.cloneNode(true);
93 afterContent.parentNode.replaceChild(clone, afterContent);
94 }
95 }
96
97 function animate() {
98 const diff = targetX - currentX;
99 if (Math.abs(diff) > 0.1) {
100 currentX += diff * lerpSpeed;
101 updateSplit(currentX);
102 animationId = requestAnimationFrame(animate);
103 } else {
104 currentX = targetX;
105 updateSplit(currentX);
106 animationId = null;
107 }
108 }
109
110 function startAnimation() {
111 if (!animationId) {
112 animationId = requestAnimationFrame(animate);
113 }
114 }
115
116 function handleMouseEnter() {
117 isHovering = true;
118 }
119
120 function handleMouseLeave() {
121 isHovering = false;
122 targetX = defaultPosition;
123 startAnimation();
124 }
125
126 function handleMouseMove(e) {
127 if (isHovering) {
128 const rect = splitContainer.getBoundingClientRect();
129 const range = 100 + 2 * skewOffset;
130 targetX = ((e.clientX - rect.left) / rect.width) * range - skewOffset;
131 startAnimation();
132 }
133 }
134
135 let touchStartX = 0;
136 let touchStartY = 0;
137 let isDragging = false;
138 const DRAG_THRESHOLD = 10; // Minimum horizontal movement to start dragging
139
140 function handleTouchStart(e) {
141 const touch = e.touches[0];
142 touchStartX = touch.clientX;
143 touchStartY = touch.clientY;
144 isDragging = false;
145 isHovering = true;
146 }
147
148 function handleTouchEnd() {
149 isHovering = false;
150 isDragging = false;
151 targetX = defaultPosition;
152 startAnimation();
153 }
154
155 function handleTouchMove(e) {
156 const touch = e.touches[0];
157 const deltaX = Math.abs(touch.clientX - touchStartX);
158 const deltaY = Math.abs(touch.clientY - touchStartY);
159
160 // Only start dragging if horizontal movement is greater than vertical
161 // This allows vertical scrolling to pass through
162 if (!isDragging) {
163 if (deltaX > DRAG_THRESHOLD && deltaX > deltaY) {
164 isDragging = true;
165 } else if (deltaY > DRAG_THRESHOLD) {
166 // User is scrolling vertically, don't interfere
167 return;
168 } else {
169 // Not enough movement yet
170 return;
171 }
172 }
173
174 // Only prevent default when actively dragging horizontally
175 if (isDragging) {
176 e.preventDefault();
177 const rect = splitContainer.getBoundingClientRect();
178 targetX = ((touch.clientX - rect.left) / rect.width) * 100;
179 startAnimation();
180 }
181 }
182
183 // Use the parent container for mouse events to create a larger hit area
184 const hitArea = splitContainer.parentElement || splitContainer;
185
186 // Attach listeners — mouse events on the wider hit area
187 hitArea.addEventListener('mouseenter', handleMouseEnter);
188 hitArea.addEventListener('mouseleave', handleMouseLeave);
189 hitArea.addEventListener('mousemove', handleMouseMove);
190 splitContainer.addEventListener('touchstart', handleTouchStart);
191 splitContainer.addEventListener('touchend', handleTouchEnd);
192 splitContainer.addEventListener('touchmove', handleTouchMove, { passive: false });
193
194 // Initialize
195 updateSplit(defaultPosition);
196
197 // Return cleanup function
198 return {
199 destroy() {
200 hitArea.removeEventListener('mouseenter', handleMouseEnter);
201 hitArea.removeEventListener('mouseleave', handleMouseLeave);
202 hitArea.removeEventListener('mousemove', handleMouseMove);
203 splitContainer.removeEventListener('touchstart', handleTouchStart);
204 splitContainer.removeEventListener('touchend', handleTouchEnd);
205 splitContainer.removeEventListener('touchmove', handleTouchMove);
206 if (animationId) cancelAnimationFrame(animationId);
207 resizeObserver.disconnect();
208 },
209 setPosition(percent) {
210 targetX = percent;
211 startAnimation();
212 }
213 };
214}
215
216/**
217 * Initialize all split comparisons on the page
218 */
219export function initAllSplitCompare(selector = '.split-comparison', options = {}) {
220 const containers = document.querySelectorAll(selector);
221 const instances = [];
222
223 containers.forEach(container => {
224 const instance = initSplitCompare(container, options);
225 if (instance) instances.push(instance);
226 });
227
228 return instances;
229}