PageSourceSearch

https://www.compassap.ai/script.js

js compassap.ai collected 2026-10-05 12:02:05 UTC 8,895 bytes, 271 lines download raw bytes

1// Theme Management System
2const ThemeManager = {
3    init() {
4        this.toggle = document.getElementById('themeToggle');
5        this.root = document.documentElement;
6
7        // Check for system preference
8        const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
9        const savedTheme = localStorage.getItem('theme');
10        const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
11
12        this.setTheme(initialTheme);
13
14        // Add event listeners
15        this.toggle.addEventListener('click', () => this.toggleTheme());
16
17        // Keyboard accessibility
18        this.toggle.addEventListener('keydown', (e) => {
19            if (e.key === 'Enter' || e.key === ' ') {
20                e.preventDefault();
21                this.toggleTheme();
22            }
23        });
24
25        // Listen for system theme changes
26        window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
27            if (!localStorage.getItem('theme')) {
28                this.setTheme(e.matches ? 'dark' : 'light');
29            }
30        });
31
32    },
33
34    setTheme(theme) {
35        this.root.setAttribute('data-theme', theme);
36        localStorage.setItem('theme', theme);
37
38        // Update particles for theme
39        this.updateParticles(theme);
40
41        // Dispatch custom event
42        window.dispatchEvent(new CustomEvent('themechange', { detail: { theme } }));
43    },
44
45    toggleTheme() {
46        const currentTheme = this.root.getAttribute('data-theme') || 'dark';
47        const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
48
49        // Create ripple effect
50        this.createRipple();
51
52        this.setTheme(newTheme);
53    },
54
55    createRipple() {
56        const ripple = document.createElement('div');
57        ripple.className = 'theme-ripple';
58
59        const rect = this.toggle.getBoundingClientRect();
60        const size = Math.max(window.innerWidth, window.innerHeight) * 2;
61
62        ripple.style.width = ripple.style.height = size + 'px';
63        ripple.style.left = (rect.left + rect.width / 2 - size / 2) + 'px';
64        ripple.style.top = (rect.top + rect.height / 2 - size / 2) + 'px';
65        ripple.style.position = 'fixed';
66        ripple.style.zIndex = '999';
67
68        document.body.appendChild(ripple);
69
70        setTimeout(() => ripple.remove(), 600);
71    },
72
73    updateParticles(theme) {
74        // Particles use CSS variables, so they update automatically on theme change.
75        // We just ensure a smooth transition for the colour swap.
76        const particles = document.querySelectorAll('.particle');
77        particles.forEach(particle => {
78            particle.style.transition = 'all 0.3s ease';
79        });
80    },
81
82};
83
84// Particle System — sole ambient effect
85// Uses a DocumentFragment to batch-insert all particles in a single DOM write.
86function createParticles() {
87    // Respect user preference: skip particles entirely when reduced motion is on
88    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
89
90    const container = document.getElementById('particlesContainer');
91    if (!container) return;
92
93    const particleCount = window.innerWidth > 768 ? 35 : 15;
94    const fragment = document.createDocumentFragment();
95
96    for (let i = 0; i < particleCount; i++) {
97        const particle = document.createElement('div');
98        particle.className = 'particle';
99        particle.style.left = Math.random() * 100 + '%';
100        particle.style.animationDelay = Math.random() * 22 + 's';
101        particle.style.animationDuration = (22 + Math.random() * 14) + 's';
102        fragment.appendChild(particle);
103    }
104
105    container.appendChild(fragment);
106}
107
108// Ripple effect on CTA button click
109function initCtaRipple() {
110    const button = document.getElementById('ctaButton');
111    if (!button) return;
112
113    button.addEventListener('click', function(e) {
114        const ripple = document.createElement('span');
115        const rect = this.getBoundingClientRect();
116        const size = Math.max(rect.width, rect.height);
117        const x = e.clientX - rect.left - size / 2;
118        const y = e.clientY - rect.top - size / 2;
119
120        ripple.style.width = ripple.style.height = size + 'px';
121        ripple.style.left = x + 'px';
122        ripple.style.top = y + 'px';
123        ripple.style.position = 'absolute';
124        ripple.style.borderRadius = '50%';
125        ripple.style.background = 'rgba(255, 255, 255, 0.6)';
126        ripple.style.transform = 'scale(0)';
127        ripple.style.animation = 'rippleEffect 0.6s ease-out';
128        ripple.style.pointerEvents = 'none';
129
130        this.appendChild(ripple);
131        setTimeout(() => ripple.remove(), 600);
132    });
133}
134
135// Intersection Observer for scroll animations
136const observerOptions = {
137    threshold: 0.1,
138    rootMargin: '0px 0px -50px 0px'
139};
140
141const observer = new IntersectionObserver((entries) => {
142    entries.forEach((entry, index) => {
143        if (entry.isIntersecting) {
144            setTimeout(() => {
145                entry.target.classList.add('visible');
146            }, index * 100);
147        }
148    });
149}, observerOptions);
150
151// Parallax Scrolling Effect
152function initParallax() {
153    window.addEventListener('scroll', () => {
154        const scrolled = window.pageYOffset;
155        const parallaxElements = document.querySelectorAll('.parallax-layer');
156
157        parallaxElements.forEach((element, index) => {
158            const speed = (index + 1) * 0.5;
159            element.style.transform = `translateY(${scrolled * speed}px)`;
160        });
161    });
162}
163
164
165
166// Sticky CTA Header
167function initStickyCta() {
168    const stickyCta = document.getElementById('stickyCta');
169    const hero = document.querySelector('.hero');
170    
171    if (!stickyCta || !hero) return;
172    
173    let heroHeight = hero.offsetHeight;
174    let isVisible = false;
175    
176    function checkScroll() {
177        const scrollY = window.pageYOffset || document.documentElement.scrollTop;
178        const shouldShow = scrollY > heroHeight * 0.7;
179        
180        if (shouldShow !== isVisible) {
181            isVisible = shouldShow;
182            stickyCta.classList.toggle('visible', shouldShow);
183        }
184    }
185    
186    // Recalculate hero height on resize
187    window.addEventListener('resize', () => {
188        heroHeight = hero.offsetHeight;
189    });
190    
191    // Throttle scroll events for performance
192    let ticking = false;
193    window.addEventListener('scroll', () => {
194        if (!ticking) {
195            window.requestAnimationFrame(() => {
196                checkScroll();
197                ticking = false;
198            });
199            ticking = true;
200        }
201    });
202    
203    // Initial check
204    checkScroll();
205}
206
207// ==========================================
208// Video Modal
209// ==========================================
210function openVideoModal(embedUrl) {
211    const modal = document.getElementById('videoModal');
212    const iframe = document.getElementById('videoIframe');
213
214    // Use the original youtube.com domain — youtube-nocookie.com can trigger Error 153
215    // when the video owner hasn't explicitly allowed it.
216    const separator = embedUrl.includes('?') ? '&' : '?';
217    iframe.src = embedUrl + separator + 'autoplay=1&rel=0';
218    modal.classList.add('active');
219    document.body.style.overflow = 'hidden';
220}
221
222function closeVideoModal(event) {
223    // Only close if clicking the backdrop or the close button, not the video itself
224    if (event && event.target.closest('.video-modal-wrapper')) return;
225    const modal = document.getElementById('videoModal');
226    const iframe = document.getElementById('videoIframe');
227    modal.classList.remove('active');
228    iframe.src = ''; // Stop video playback
229    document.body.style.overflow = '';
230}
231
232// Close modal on Escape key
233document.addEventListener('keydown', (e) => {
234    if (e.key === 'Escape') {
235        const modal = document.getElementById('videoModal');
236        if (modal && modal.classList.contains('active')) {
237            closeVideoModal(e);
238        }
239    }
240});
241
242// Initialize all effects
243document.addEventListener('DOMContentLoaded', () => {
244    // Initialize theme manager
245    ThemeManager.init();
246
247    createParticles();
248    initCtaRipple();
249    initParallax();
250    initStickyCta();
251
252    // Observe fade-in elements
253    document.querySelectorAll('.fade-in').forEach(el => observer.observe(el));
254});
255
256// Cookie consent is provided by /consent/consent.js (window.cookieConsent).
257// The banner, preferences modal, and [data-consent-open] footer wiring all
258// live in that shared module — do not re-declare the API here.
259
260// Recreate particles on resize (debounced to avoid thrashing)
261let resizeTimer;
262window.addEventListener('resize', () => {
263    clearTimeout(resizeTimer);
264    resizeTimer = setTimeout(() => {
265        const container = document.getElementById('particlesContainer');
266        if (container) {
267            container.innerHTML = '';
268            createParticles();
269        }
270    }, 400);
271});

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.