PageSourceSearch

https://www.konzerthaus-dortmund.de/js/gt-parallax.js

js konzerthaus-dortmund.de collected 2026-10-03 22:30:08 UTC 17,796 bytes, 504 lines download raw bytes

1/**
2 * Vanilla JS Parallax - UIkit-compatible implementation
3 * 
4 * Mimics the behavior of UIkit's uk-parallax component without the flickering issues.
5 * Uses requestAnimationFrame for smooth animations and throttled updates for efficiency.
6 * 
7 * Anti-flicker measures:
8 * - Uses CSS will-change and transform3d for GPU acceleration
9 * - Batches DOM reads and writes
10 * - Throttles updates to avoid excessive recalculations
11 * - Uses passive event listeners
12 * 
13 * Supports:
14 * - target: CSS selector for the element to track
15 * - y: translateY animation (e.g., "200, -200" or just "-200")
16 * - x: translateX animation
17 * - scale: scale transform
18 * - rotate: rotation in degrees
19 * - opacity: opacity animation
20 * - start: animation start offset (vh, %, px)
21 * - end: animation end offset (vh, %, px)
22 * - easing: animation easing curve
23 * - media: media query for enabling/disabling
24 */
25
26(function() {
27    'use strict';
28
29    // Shared scroll handler for all instances (more efficient)
30    const instances = new Set();
31    let globalRafId = null;
32    let lastKnownScrollY = window.scrollY;
33    let ticking = false;
34
35    function globalUpdate() {
36        lastKnownScrollY = window.scrollY;
37        instances.forEach(instance => instance.update(lastKnownScrollY));
38        ticking = false;
39    }
40
41    function requestTick() {
42        if (!ticking) {
43            ticking = true;
44            globalRafId = requestAnimationFrame(globalUpdate);
45        }
46    }
47
48    // Single scroll listener for all instances
49    let scrollListenerAttached = false;
50    function attachGlobalScrollListener() {
51        if (scrollListenerAttached) return;
52        scrollListenerAttached = true;
53        
54        window.addEventListener('scroll', requestTick, { passive: true });
55        window.addEventListener('resize', () => {
56            // Debounce resize
57            clearTimeout(window._parallaxResizeTimer);
58            window._parallaxResizeTimer = setTimeout(requestTick, 100);
59        }, { passive: true });
60    }
61
62    class VanillaParallax {
63        constructor(element, options = {}) {
64            this.element = element;
65            this.options = this.parseOptions(options);
66            this.target = this.options.target 
67                ? document.querySelector(this.options.target) 
68                : element;
69            
70            this.isActive = true;
71            this.lastProgress = -1;
72            this.cachedTargetRect = null;
73            this.cacheTime = 0;
74            
75            this.init();
76        }
77
78        parseOptions(options) {
79            const defaults = {
80                target: null,
81                y: null,
82                x: null,
83                scale: null,
84                rotate: null,
85                opacity: null,
86                blur: null,
87                bgx: null,
88                bgy: null,
89                start: '0',
90                end: '0',
91                easing: 1,
92                media: false
93            };
94
95            // If options is a string (from data attribute), parse it
96            if (typeof options === 'string') {
97                options = this.parseAttributeString(options);
98            }
99
100            return { ...defaults, ...options };
101        }
102
103        parseAttributeString(str) {
104            const options = {};
105            // Parse "key: value; key2: value2" format
106            const pairs = str.split(';').map(s => s.trim()).filter(Boolean);
107            
108            pairs.forEach(pair => {
109                const colonIndex = pair.indexOf(':');
110                if (colonIndex > -1) {
111                    const key = pair.substring(0, colonIndex).trim();
112                    const value = pair.substring(colonIndex + 1).trim();
113                    options[key] = value;
114                }
115            });
116
117            return options;
118        }
119
120        parseStops(value) {
121            if (value === null || value === undefined) return null;
122            
123            const str = String(value);
124            // Parse comma-separated values like "200, -200" or just "-200"
125            const parts = str.split(',').map(s => s.trim());
126            
127            return parts.map(part => {
128                // Check for position specification like "50 10%"
129                const match = part.match(/^(-?\d+(?:\.\d+)?(?:px|vh|vw|%)?)\s*(\d+%)?$/);
130                if (match) {
131                    return {
132                        value: this.parseValue(match[1]),
133                        position: match[2] ? parseFloat(match[2]) / 100 : null
134                    };
135                }
136                return {
137                    value: this.parseValue(part),
138                    position: null
139                };
140            });
141        }
142
143        parseValue(str) {
144            if (str === null || str === undefined) return 0;
145            str = String(str).trim();
146            
147            // Handle units
148            if (str.endsWith('vh')) {
149                return parseFloat(str) * window.innerHeight / 100;
150            }
151            if (str.endsWith('vw')) {
152                return parseFloat(str) * window.innerWidth / 100;
153            }
154            if (str.endsWith('%')) {
155                return { percent: parseFloat(str) };
156            }
157            if (str.endsWith('px')) {
158                return parseFloat(str);
159            }
160            
161            return parseFloat(str) || 0;
162        }
163
164        parseOffset(str, targetHeight) {
165            if (!str || str === '0') return 0;
166            
167            str = String(str).trim();
168            let total = 0;
169            
170            // Handle expressions like "100% + 100" or "30vh"
171            const parts = str.match(/([+-]?\s*\d+(?:\.\d+)?(?:vh|vw|%|px)?)/g) || [];
172            
173            parts.forEach(part => {
174                part = part.trim();
175                if (part.endsWith('vh')) {
176                    total += parseFloat(part) * window.innerHeight / 100;
177                } else if (part.endsWith('vw')) {
178                    total += parseFloat(part) * window.innerWidth / 100;
179                } else if (part.endsWith('%')) {
180                    total += parseFloat(part) * targetHeight / 100;
181                } else if (part.endsWith('px')) {
182                    total += parseFloat(part);
183                } else {
184                    total += parseFloat(part) || 0;
185                }
186            });
187
188            return total;
189        }
190
191        init() {
192            // Check media query
193            if (!this.checkMedia()) {
194                this.isActive = false;
195                return;
196            }
197
198            // Set initial styles for GPU acceleration (prevents flickering)
199            this.element.style.willChange = 'transform, opacity';
200            this.element.style.backfaceVisibility = 'hidden';
201            this.element.style.perspective = '1000px';
202            
203            // Register with global handler
204            instances.add(this);
205            attachGlobalScrollListener();
206            
207            // Initial update
208            this.update(window.scrollY);
209        }
210
211        checkMedia() {
212            const media = this.options.media;
213            if (!media) return true;
214            
215            // Handle breakpoint shortcuts
216            const breakpoints = {
217                '@s': '(min-width: 640px)',
218                '@m': '(min-width: 960px)',
219                '@l': '(min-width: 1200px)',
220                '@xl': '(min-width: 1600px)'
221            };
222
223            let query = breakpoints[media] || media;
224            
225            // Handle numeric values
226            if (typeof media === 'number' || /^\d+$/.test(media)) {
227                query = `(min-width: ${media}px)`;
228            }
229
230            return window.matchMedia(query).matches;
231        }
232
233        update(scrollY) {
234            if (!this.isActive) return;
235            
236            // Recheck media query on each update
237            if (this.options.media && !this.checkMedia()) {
238                this.resetStyles();
239                return;
240            }
241
242            const progress = this.calculateProgress();
243            
244            // Only update if progress actually changed (prevents flickering)
245            if (Math.abs(progress - this.lastProgress) < 0.0001) return;
246            this.lastProgress = progress;
247            
248            this.applyTransforms(progress);
249        }
250
251        calculateProgress() {
252            const rect = this.target.getBoundingClientRect();
253            const targetHeight = this.target.offsetHeight;
254            const viewportHeight = window.innerHeight;
255
256            // Parse start and end offsets
257            const startOffset = this.parseOffset(this.options.start, targetHeight);
258            const endOffset = this.parseOffset(this.options.end, targetHeight);
259
260            // Calculate animation range
261            // Default: starts when target top enters viewport bottom, ends when target bottom leaves viewport top
262            const animationStart = viewportHeight + startOffset;
263            const animationEnd = -targetHeight - endOffset;
264            
265            // Current position relative to viewport
266            const currentPos = rect.top;
267            
268            // Calculate raw progress (0 to 1)
269            const totalDistance = animationStart - animationEnd;
270            let progress = (animationStart - currentPos) / totalDistance;
271            
272            // Clamp progress between 0 and 1
273            progress = Math.max(0, Math.min(1, progress));
274            
275            // Apply easing
276            progress = this.applyEasing(progress);
277
278            return progress;
279        }
280
281        applyEasing(progress) {
282            const easing = parseFloat(this.options.easing) || 1;
283            
284            if (easing === 0) {
285                return progress;
286            } else if (easing < 0) {
287                // Starts fast, slows down
288                return 1 - Math.pow(1 - progress, 1 - easing);
289            } else {
290                // Starts slow, speeds up
291                return Math.pow(progress, 1 + easing);
292            }
293        }
294
295        interpolate(stops, progress, defaultStart = 0) {
296            if (!stops || stops.length === 0) return null;
297            
298            // Single value means animate from defaultStart to that value
299            if (stops.length === 1) {
300                const endValue = typeof stops[0].value === 'object' 
301                    ? stops[0].value.percent 
302                    : stops[0].value;
303                return defaultStart + (endValue - defaultStart) * progress;
304            }
305            
306            // Multiple stops - find the two we're between
307            const positions = stops.map((stop, i) => {
308                if (stop.position !== null) return stop.position;
309                // Evenly distribute stops without explicit positions
310                return i / (stops.length - 1);
311            });
312
313            // Find which segment we're in
314            let startIdx = 0;
315            for (let i = 0; i < positions.length - 1; i++) {
316                if (progress >= positions[i] && progress <= positions[i + 1]) {
317                    startIdx = i;
318                    break;
319                }
320                if (progress > positions[i]) {
321                    startIdx = i;
322                }
323            }
324
325            const endIdx = Math.min(startIdx + 1, stops.length - 1);
326            
327            // Calculate local progress within this segment
328            const segmentStart = positions[startIdx];
329            const segmentEnd = positions[endIdx];
330            const segmentProgress = segmentEnd === segmentStart 
331                ? 1 
332                : (progress - segmentStart) / (segmentEnd - segmentStart);
333            
334            const startValue = typeof stops[startIdx].value === 'object' 
335                ? stops[startIdx].value.percent 
336                : stops[startIdx].value;
337            const endValue = typeof stops[endIdx].value === 'object' 
338                ? stops[endIdx].value.percent 
339                : stops[endIdx].value;
340
341            return startValue + (endValue - startValue) * segmentProgress;
342        }
343
344        applyTransforms(progress) {
345            let translateX = 0;
346            let translateY = 0;
347            let scale = 1;
348            let rotate = 0;
349            const filters = [];
350            let styles = {};
351
352            // Y translation
353            if (this.options.y !== null) {
354                const stops = this.parseStops(this.options.y);
355                const value = this.interpolate(stops, progress, 0);
356                if (value !== null) {
357                    translateY = value;
358                }
359            }
360
361            // X translation
362            if (this.options.x !== null) {
363                const stops = this.parseStops(this.options.x);
364                const value = this.interpolate(stops, progress, 0);
365                if (value !== null) {
366                    translateX = value;
367                }
368            }
369
370            // Scale
371            if (this.options.scale !== null) {
372                const stops = this.parseStops(this.options.scale);
373                const value = this.interpolate(stops, progress, 1);
374                if (value !== null) {
375                    scale = value;
376                }
377            }
378
379            // Rotate
380            if (this.options.rotate !== null) {
381                const stops = this.parseStops(this.options.rotate);
382                const value = this.interpolate(stops, progress, 0);
383                if (value !== null) {
384                    rotate = value;
385                }
386            }
387
388            // Opacity
389            if (this.options.opacity !== null) {
390                const stops = this.parseStops(this.options.opacity);
391                const value = this.interpolate(stops, progress, 1);
392                if (value !== null) {
393                    styles.opacity = value;
394                }
395            }
396
397            // Blur filter
398            if (this.options.blur !== null) {
399                const stops = this.parseStops(this.options.blur);
400                const value = this.interpolate(stops, progress, 0);
401                if (value !== null) {
402                    filters.push(`blur(${value}px)`);
403                }
404            }
405
406            // Background position Y
407            if (this.options.bgy !== null) {
408                const stops = this.parseStops(this.options.bgy);
409                const value = this.interpolate(stops, progress, 0);
410                if (value !== null) {
411                    const currentBgPos = window.getComputedStyle(this.element).backgroundPosition;
412                    const xPos = currentBgPos.split(' ')[0] || '50%';
413                    styles.backgroundPosition = `${xPos} calc(50% + ${value}px)`;
414                }
415            }
416
417            // Background position X
418            if (this.options.bgx !== null) {
419                const stops = this.parseStops(this.options.bgx);
420                const value = this.interpolate(stops, progress, 0);
421                if (value !== null) {
422                    const currentBgPos = window.getComputedStyle(this.element).backgroundPosition;
423                    const yPos = currentBgPos.split(' ')[1] || '50%';
424                    styles.backgroundPosition = `calc(50% + ${value}px) ${yPos}`;
425                }
426            }
427
428            // Build transform string using translate3d for GPU acceleration (anti-flicker)
429            const transforms = [];
430            transforms.push(`translate3d(${translateX}px, ${translateY}px, 0)`);
431            if (scale !== 1) {
432                transforms.push(`scale(${scale})`);
433            }
434            if (rotate !== 0) {
435                transforms.push(`rotate(${rotate}deg)`);
436            }
437
438            // Apply all transforms in one write operation (batch DOM writes)
439            this.element.style.transform = transforms.join(' ');
440
441            // Apply filters
442            if (filters.length > 0) {
443                this.element.style.filter = filters.join(' ');
444            }
445
446            // Apply other styles
447            Object.keys(styles).forEach(key => {
448                this.element.style[key] = styles[key];
449            });
450        }
451
452        resetStyles() {
453            this.element.style.transform = '';
454            this.element.style.opacity = '';
455            this.element.style.filter = '';
456            this.element.style.backgroundPosition = '';
457        }
458
459        destroy() {
460            this.isActive = false;
461            instances.delete(this);
462            this.resetStyles();
463            this.element.style.willChange = '';
464            this.element.style.backfaceVisibility = '';
465            this.element.style.perspective = '';
466        }
467    }
468
469    // Auto-initialize from data attributes
470    function initFromAttributes() {
471        // Support both data-parallax and data-gt-parallax attributes
472        const elements = document.querySelectorAll('[data-gt-parallax], [data-parallax]');
473        
474        elements.forEach(element => {
475            const options = element.dataset.gtParallax || element.dataset.parallax;
476            new VanillaParallax(element, options);
477        });
478    }
479
480    // Initialize elements with gt-parallax attribute
481    function initGtParallax() {
482        const elements = document.querySelectorAll('[gt-parallax]');
483        
484        elements.forEach(element => {
485            const options = element.getAttribute('gt-parallax');
486            new VanillaParallax(element, options);
487        });
488    }
489
490    // Export for manual use
491    window.VanillaParallax = VanillaParallax;
492
493    // Auto-init on DOM ready
494    if (document.readyState === 'loading') {
495        document.addEventListener('DOMContentLoaded', () => {
496            initGtParallax();
497            initFromAttributes();
498        });
499    } else {
500        initGtParallax();
501        initFromAttributes();
502    }
503
504})();

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.