split-compare.js

  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}