PageSourceSearch

https://otakiroku.com/about.js

js otakiroku.com collected 2026-10-02 01:38:57 UTC 65,792 bytes, 1,640 lines download raw bytes

1(function() {
2    'use strict';
3
4    // ===========================================
5    // Performance Utilities
6    // ===========================================
7    const isMobileDevice = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
8    const TARGET_FPS = isMobileDevice ? 30 : 60;
9    const FRAME_INTERVAL = 1000 / TARGET_FPS;
10
11    // Throttled animation frame for mobile
12    function throttledRAF(callback) {
13        if (!isMobileDevice) {
14            return requestAnimationFrame(callback);
15        }
16
17        let lastTime = 0;
18        const animate = (currentTime) => {
19            const elapsed = currentTime - lastTime;
20            if (elapsed >= FRAME_INTERVAL) {
21                lastTime = currentTime - (elapsed % FRAME_INTERVAL);
22                callback(currentTime);
23            } else {
24                requestAnimationFrame(animate);
25            }
26        };
27        return requestAnimationFrame(animate);
28    }
29
30    // ===========================================
31    // Create Floating Particles
32    // ===========================================
33    function createParticles() {
34        const container = document.getElementById('particles');
35        if (!container) return;
36
37        // Reduce particles on mobile for performance
38        const isMobile = window.innerWidth <= 768;
39        const particleCount = isMobile ? 10 : 30;
40
41        for (let i = 0; i < particleCount; i++) {
42            const particle = document.createElement('div');
43            particle.className = 'particle';
44
45            // Random position
46            particle.style.left = Math.random() * 100 + '%';
47
48            // Random size
49            const size = Math.random() * 4 + 2;
50            particle.style.width = size + 'px';
51            particle.style.height = size + 'px';
52
53            // Random opacity
54            particle.style.opacity = Math.random() * 0.5 + 0.2;
55
56            // Random animation duration
57            const duration = Math.random() * 20 + 15;
58            particle.style.animationDuration = duration + 's';
59
60            // Random delay
61            particle.style.animationDelay = Math.random() * -duration + 's';
62
63            // Random color variation
64            const hue = Math.random() > 0.7 ? '280' : '270'; // Purple variations
65            particle.style.background = `hsla(${hue}, 70%, 60%, 0.4)`;
66
67            container.appendChild(particle);
68        }
69    }
70
71    // ===========================================
72    // Neural Network Animation with Anime Cycling
73    // ===========================================
74    function initNeuralNetwork() {
75        const canvas = document.getElementById('neural-canvas');
76        const animeEl = document.getElementById('current-anime');
77        const matchEl = document.getElementById('match-value');
78        if (!canvas) return;
79
80        const ctx = canvas.getContext('2d');
81        let animationId;
82        let nodes = [];
83        let connections = [];
84        let processingProgress = 0;
85        let currentAnimeIndex = 0;
86        let displayedMatch = 0;
87        let targetMatch = 0;
88        let isProcessing = false;
89        let cycleTimer = null;
90
91        // Anime list with match scores, covers, and findings
92        const animeList = [
93            {
94                name: 'Frieren',
95                match: 94,
96                cover: 'https://cdn.myanimelist.net/images/anime/1015/138006.jpg',
97                findings: ['Fantasy', 'Slow Pace', 'Melancholic', 'Adventure', 'High Quality']
98            },
99            {
100                name: 'Vinland Saga',
101                match: 87,
102                cover: 'https://cdn.myanimelist.net/images/anime/1500/103005.jpg',
103                findings: ['Historical', 'Action', 'Dark Tone', 'Character Growth', 'Violent']
104            },
105            {
106                name: 'Spy x Family',
107                match: 72,
108                cover: 'https://cdn.myanimelist.net/images/anime/1441/122795.jpg',
109                findings: ['Comedy', 'Family', 'Light Tone', 'Action', 'Wholesome']
110            },
111            {
112                name: 'Chainsaw Man',
113                match: 81,
114                cover: 'https://cdn.myanimelist.net/images/anime/1806/126216.jpg',
115                findings: ['Action', 'Dark Tone', 'Gore', 'Supernatural', 'Fast Pace']
116            },
117            {
118                name: 'Bocchi the Rock!',
119                match: 68,
120                cover: 'https://cdn.myanimelist.net/images/anime/1448/127956.jpg',
121                findings: ['Comedy', 'Music', 'Slice of Life', 'Anxiety Rep', 'Cute']
122            }
123        ];
124
125        const coverEl = document.getElementById('anime-cover');
126        const findingsListEl = document.getElementById('findings-list');
127
128        // Set canvas size (responsive)
129        function resizeCanvas() {
130            const container = canvas.parentElement;
131            const rect = container.getBoundingClientRect();
132            const dpr = window.devicePixelRatio || 1;
133
134            // Responsive height based on viewport
135            // Mobile gets shorter canvas to fit 3-column layout
136            let canvasHeight = 280;
137            if (window.innerWidth <= 480) {
138                canvasHeight = 160;
139            } else if (window.innerWidth <= 768) {
140                canvasHeight = 220;
141            }
142
143            canvas.width = rect.width * dpr;
144            canvas.height = canvasHeight * dpr;
145            canvas.style.width = rect.width + 'px';
146            canvas.style.height = canvasHeight + 'px';
147            ctx.setTransform(1, 0, 0, 1, 0, 0);
148            ctx.scale(dpr, dpr);
149            initNodes();
150        }
151
152        // Initialize structured node grid
153        function initNodes() {
154            nodes = [];
155            connections = [];
156
157            const width = canvas.width / (window.devicePixelRatio || 1);
158            const height = canvas.height / (window.devicePixelRatio || 1);
159
160            // Responsive layer configuration - 1 input, 1 output
161            const isMobile = window.innerWidth <= 480;
162            const layers = isMobile ? [1, 3, 5, 3, 1] : [1, 5, 8, 10, 8, 5, 1];
163            const nodeRadius = isMobile ? 3 : 5;
164
165            // Always horizontal (left to right) orientation
166            // Minimal padding on edges, distribute layers across width
167            const edgePadding = width * 0.05; // 5% padding on each side
168            const usableWidth = width - (edgePadding * 2);
169            const layerGap = layers.length > 1 ? usableWidth / (layers.length - 1) : 0;
170
171            layers.forEach((count, layerIndex) => {
172                const x = edgePadding + (layerGap * layerIndex);
173                const nodeGap = height / (count + 1);
174
175                for (let i = 0; i < count; i++) {
176                    const y = nodeGap * (i + 1);
177                    nodes.push({
178                        x: x,
179                        y: y,
180                        baseX: x,
181                        baseY: y,
182                        radius: nodeRadius,
183                        layer: layerIndex,
184                        index: i,
185                        activation: 0,
186                        targetActivation: 0
187                    });
188                }
189            });
190
191            // Connect adjacent layers - more liberal for single input/output
192            nodes.forEach((node, i) => {
193                nodes.forEach((otherNode, j) => {
194                    if (otherNode.layer === node.layer + 1) {
195                        const yDist = Math.abs(node.y - otherNode.y);
196                        // Connect all nodes if either is input (layer 0) or output (last layer)
197                        const isInputOrOutput = node.layer === 0 || otherNode.layer === layers.length - 1;
198                        const threshold = isInputOrOutput ? height * 0.9 : height * 0.4;
199
200                        if (yDist < threshold) {
201                            connections.push({
202                                from: i,
203                                to: j,
204                                weight: Math.random() * 0.5 + 0.5,
205                                signal: 0,
206                                signalProgress: -1
207                            });
208                        }
209                    }
210                });
211            });
212        }
213
214        // Start processing a new anime
215        function processAnime(index) {
216            currentAnimeIndex = index;
217            const anime = animeList[index];
218
219            // Update anime info
220            if (animeEl) animeEl.textContent = anime.name;
221            if (matchEl) matchEl.textContent = '...';
222            if (coverEl) coverEl.style.backgroundImage = `url(${anime.cover})`;
223
224            // Clear and prepare findings
225            if (findingsListEl) {
226                findingsListEl.innerHTML = '';
227                anime.findings.forEach((finding, i) => {
228                    const tag = document.createElement('div');
229                    tag.className = 'finding-tag';
230                    tag.textContent = finding;
231                    if (i === anime.findings.length - 1) tag.classList.add('highlight');
232                    findingsListEl.appendChild(tag);
233                });
234            }
235
236            processingProgress = 0;
237            targetMatch = anime.match;
238            displayedMatch = 0;
239            isProcessing = true;
240            findingsRevealed = 0;
241
242            // Reset all activations
243            nodes.forEach(node => {
244                node.activation = 0;
245                node.targetActivation = 0;
246            });
247
248            // Activate input layer
249            nodes.filter(n => n.layer === 0).forEach(node => {
250                node.targetActivation = Math.random() * 0.5 + 0.5;
251            });
252        }
253
254        let findingsRevealed = 0;
255
256        // Animation loop
257        function animate() {
258            const width = canvas.width / (window.devicePixelRatio || 1);
259            const height = canvas.height / (window.devicePixelRatio || 1);
260            const maxLayer = 5;
261
262            ctx.clearRect(0, 0, width, height);
263
264            // Update processing progress
265            if (isProcessing) {
266                processingProgress += 0.008;
267
268                // Propagate activation through layers based on progress
269                const activeLayer = Math.floor(processingProgress * (maxLayer + 1));
270
271                nodes.forEach(node => {
272                    if (node.layer <= activeLayer) {
273                        node.targetActivation = Math.random() * 0.3 + 0.7;
274                    }
275                    if (node.layer < activeLayer - 1) {
276                        node.targetActivation = Math.random() * 0.2 + 0.1;
277                    }
278                });
279
280                // Fire signals along connections
281                connections.forEach(conn => {
282                    const fromNode = nodes[conn.from];
283                    if (fromNode.layer === activeLayer && conn.signalProgress < 0 && Math.random() > 0.7) {
284                        conn.signalProgress = 0;
285                    }
286                });
287
288                // Reveal findings progressively
289                const anime = animeList[currentAnimeIndex];
290                const findingsToReveal = Math.floor(processingProgress * anime.findings.length);
291                if (findingsToReveal > findingsRevealed && findingsListEl) {
292                    const tags = findingsListEl.querySelectorAll('.finding-tag');
293                    if (tags[findingsRevealed]) {
294                        tags[findingsRevealed].classList.add('visible');
295                    }
296                    findingsRevealed = findingsToReveal;
297                }
298
299                // Update match display during processing
300                if (processingProgress > 0.6 && matchEl) {
301                    const progress = (processingProgress - 0.6) * 2.5;
302                    displayedMatch = Math.floor(targetMatch * Math.min(progress, 1));
303                    matchEl.textContent = displayedMatch + '%';
304                }
305
306                // Processing complete
307                if (processingProgress >= 1) {
308                    isProcessing = false;
309                    if (matchEl) matchEl.textContent = targetMatch + '%';
310
311                    // Reveal any remaining findings
312                    if (findingsListEl) {
313                        findingsListEl.querySelectorAll('.finding-tag').forEach(tag => {
314                            tag.classList.add('visible');
315                        });
316                    }
317
318                    // Schedule next anime
319                    cycleTimer = setTimeout(() => {
320                        const nextIndex = (currentAnimeIndex + 1) % animeList.length;
321                        processAnime(nextIndex);
322                    }, 2500);
323                }
324            }
325
326            // Responsive sizing
327            const isMobile = window.innerWidth <= 480;
328            const lineWidth = isMobile ? 0.8 : 1;
329            const signalRadius = isMobile ? 2.5 : 3;
330
331            // Draw connections
332            connections.forEach(conn => {
333                const from = nodes[conn.from];
334                const to = nodes[conn.to];
335                const avgActivation = (from.activation + to.activation) / 2;
336
337                ctx.beginPath();
338                ctx.moveTo(from.x, from.y);
339                ctx.lineTo(to.x, to.y);
340                ctx.strokeStyle = `rgba(139, 92, 246, ${0.08 + avgActivation * 0.15})`;
341                ctx.lineWidth = lineWidth;
342                ctx.stroke();
343
344                // Update and draw signal
345                if (conn.signalProgress >= 0 && conn.signalProgress < 1) {
346                    conn.signalProgress += 0.04;
347                    const px = from.x + (to.x - from.x) * conn.signalProgress;
348                    const py = from.y + (to.y - from.y) * conn.signalProgress;
349
350                    ctx.beginPath();
351                    ctx.arc(px, py, signalRadius, 0, Math.PI * 2);
352                    ctx.fillStyle = 'rgba(139, 92, 246, 0.9)';
353                    ctx.fill();
354                }
355
356                if (conn.signalProgress >= 1) {
357                    conn.signalProgress = -1;
358                }
359            });
360
361            // Draw and update nodes
362            nodes.forEach(node => {
363                // Smooth activation
364                node.activation += (node.targetActivation - node.activation) * 0.1;
365
366                const isOutput = node.layer === maxLayer;
367                const baseColor = isOutput ?
368                    (node.activation > 0.5 ? [239, 68, 68] : [156, 163, 175]) :
369                    [59, 130, 246];
370
371                // Outer glow for active nodes (disabled on mobile for performance)
372                if (!isMobile && node.activation > 0.3) {
373                    const glowRadius = node.radius + 6 + node.activation * 4;
374                    const gradient = ctx.createRadialGradient(
375                        node.x, node.y, 0,
376                        node.x, node.y, glowRadius
377                    );
378                    gradient.addColorStop(0, `rgba(${baseColor.join(',')}, ${node.activation * 0.4})`);
379                    gradient.addColorStop(1, 'transparent');
380
381                    ctx.beginPath();
382                    ctx.arc(node.x, node.y, glowRadius, 0, Math.PI * 2);
383                    ctx.fillStyle = gradient;
384                    ctx.fill();
385                }
386
387                // Main node
388                ctx.beginPath();
389                ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2);
390                ctx.fillStyle = `rgba(${baseColor.join(',')}, ${0.3 + node.activation * 0.7})`;
391                ctx.fill();
392
393                // Bright center for active nodes
394                if (node.activation > 0.5) {
395                    ctx.beginPath();
396                    ctx.arc(node.x, node.y, node.radius * 0.4, 0, Math.PI * 2);
397                    ctx.fillStyle = `rgba(255, 255, 255, ${node.activation * 0.5})`;
398                    ctx.fill();
399                }
400            });
401
402            animationId = requestAnimationFrame(animate);
403        }
404
405        // Start
406        resizeCanvas();
407        processAnime(0);
408        animate();
409
410        // Handle resize
411        window.addEventListener('resize', () => {
412            cancelAnimationFrame(animationId);
413            resizeCanvas();
414            animate();
415        });
416
417        // Pause when not visible - more aggressive on mobile
418        const observerOptions = isMobileDevice ? { threshold: 0.3 } : { threshold: 0.1 };
419        const observer = new IntersectionObserver((entries) => {
420            entries.forEach(entry => {
421                if (entry.isIntersecting) {
422                    if (!animationId) {
423                        processAnime(currentAnimeIndex);
424                        animate();
425                    }
426                } else {
427                    cancelAnimationFrame(animationId);
428                    animationId = null;
429                    if (cycleTimer) clearTimeout(cycleTimer);
430                }
431            });
432        }, observerOptions);
433        observer.observe(canvas);
434    }
435
436    // ===========================================
437    // Franchise Flowchart Animation
438    // ===========================================
439    function initFranchiseAnimation() {
440        const canvas = document.getElementById('franchise-canvas');
441        const nameEl = document.getElementById('franchise-name');
442        const totalEl = document.getElementById('franchise-total');
443        const completedEl = document.getElementById('franchise-completed');
444        if (!canvas) return;
445
446        const ctx = canvas.getContext('2d');
447        let animationId;
448        let time = 0;
449        let currentFranchise = 0;
450        let activeNode = 0;
451        let transitionProgress = 0;
452
453        const franchises = [
454            {
455                name: 'Fate Series',
456                total: 12,
457                completed: 8,
458                nodes: [
459                    { title: 'Fate/Zero', order: 1, status: 'complete' },
460                    { title: 'Stay Night', order: 2, status: 'complete' },
461                    { title: 'UBW', order: 3, status: 'complete' },
462                    { title: "Heaven's Feel", order: 4, status: 'watching' },
463                    { title: 'Apocrypha', order: 5, status: 'pending' }
464                ]
465            },
466            {
467                name: 'Monogatari',
468                total: 28,
469                completed: 15,
470                nodes: [
471                    { title: 'Bakemonogatari', order: 1, status: 'complete' },
472                    { title: 'Nisemonogatari', order: 2, status: 'complete' },
473                    { title: 'Second Season', order: 3, status: 'complete' },
474                    { title: 'Owarimonogatari', order: 4, status: 'watching' },
475                    { title: 'Zoku Owari', order: 5, status: 'pending' }
476                ]
477            },
478            {
479                name: 'Attack on Titan',
480                total: 4,
481                completed: 4,
482                nodes: [
483                    { title: 'Season 1', order: 1, status: 'complete' },
484                    { title: 'Season 2', order: 2, status: 'complete' },
485                    { title: 'Season 3', order: 3, status: 'complete' },
486                    { title: 'Final Season', order: 4, status: 'complete' },
487                    { title: '', status: 'none' }
488                ]
489            }
490        ];
491
492        function resizeCanvas() {
493            const container = canvas.parentElement;
494            const rect = container.getBoundingClientRect();
495            const dpr = window.devicePixelRatio || 1;
496
497            // Responsive height
498            let canvasHeight = 200;
499            if (window.innerWidth <= 480) {
500                canvasHeight = 160;
501            } else if (window.innerWidth <= 768) {
502                canvasHeight = 190;
503            }
504
505            canvas.width = rect.width * dpr;
506            canvas.height = canvasHeight * dpr;
507            canvas.style.width = rect.width + 'px';
508            canvas.style.height = canvasHeight + 'px';
509            ctx.setTransform(1, 0, 0, 1, 0, 0);
510            ctx.scale(dpr, dpr);
511        }
512
513        function drawCard(x, y, w, h, node, scale, alpha, fontSize) {
514            ctx.save();
515            ctx.globalAlpha = alpha;
516
517            // Card background gradient
518            const gradient = ctx.createLinearGradient(x, y, x, y + h);
519            if (node.status === 'complete') {
520                gradient.addColorStop(0, 'rgba(34, 197, 94, 0.18)');
521                gradient.addColorStop(1, 'rgba(34, 197, 94, 0.05)');
522            } else if (node.status === 'watching') {
523                gradient.addColorStop(0, 'rgba(59, 130, 246, 0.22)');
524                gradient.addColorStop(1, 'rgba(59, 130, 246, 0.08)');
525            } else {
526                gradient.addColorStop(0, 'rgba(255, 255, 255, 0.1)');
527                gradient.addColorStop(1, 'rgba(255, 255, 255, 0.03)');
528            }
529
530            // Card with shadow
531            ctx.shadowColor = 'rgba(0, 0, 0, 0.4)';
532            ctx.shadowBlur = 12 * scale;
533            ctx.shadowOffsetY = 4 * scale;
534
535            ctx.fillStyle = gradient;
536            ctx.beginPath();
537            ctx.roundRect(x, y, w, h, 8 * scale);
538            ctx.fill();
539
540            // Border
541            ctx.shadowColor = 'transparent';
542            ctx.strokeStyle = node.status === 'complete' ? 'rgba(34, 197, 94, 0.6)' :
543                             node.status === 'watching' ? 'rgba(59, 130, 246, 0.7)' :
544                             'rgba(255, 255, 255, 0.2)';
545            ctx.lineWidth = 2 * scale;
546            ctx.stroke();
547
548            // Order badge (top left)
549            const badgeSize = 26 * scale;
550            const badgeX = x + 14 * scale;
551            const badgeY = y + 14 * scale;
552            ctx.fillStyle = node.status === 'complete' ? '#22c55e' :
553                           node.status === 'watching' ? '#3b82f6' : '#71717a';
554            ctx.beginPath();
555            ctx.arc(badgeX, badgeY, badgeSize / 2, 0, Math.PI * 2);
556            ctx.fill();
557            ctx.fillStyle = '#000';
558            ctx.font = `700 ${Math.max(10, fontSize * 0.75)}px 'Space Grotesk', sans-serif`;
559            ctx.textAlign = 'center';
560            ctx.textBaseline = 'middle';
561            ctx.fillText(node.order || '?', badgeX, badgeY);
562
563            // Title (center)
564            ctx.fillStyle = '#ffffff';
565            ctx.font = `700 ${fontSize}px 'Space Grotesk', sans-serif`;
566            ctx.textAlign = 'center';
567            ctx.textBaseline = 'middle';
568            ctx.fillText(node.title, x + w / 2, y + h / 2);
569
570            // Status badge (bottom right) - only show on larger cards
571            if (w > 100 && node.status === 'complete') {
572                const badgeW = 56 * scale;
573                const badgeH = 18 * scale;
574                ctx.fillStyle = 'rgba(34, 197, 94, 0.25)';
575                ctx.strokeStyle = '#22c55e';
576                ctx.lineWidth = 1.5 * scale;
577                ctx.beginPath();
578                ctx.roundRect(x + w - badgeW - 12 * scale, y + h - badgeH - 10 * scale, badgeW, badgeH, 4 * scale);
579                ctx.fill();
580                ctx.stroke();
581                ctx.fillStyle = '#22c55e';
582                ctx.font = `600 ${Math.max(8, fontSize * 0.625)}px 'Space Grotesk', sans-serif`;
583                ctx.textAlign = 'center';
584                ctx.fillText('DONE', x + w - (badgeW / 2) - 12 * scale, y + h - (badgeH / 2) - 10 * scale);
585            } else if (w > 100 && node.status === 'watching') {
586                const badgeW = 73 * scale;
587                const badgeH = 18 * scale;
588                ctx.fillStyle = 'rgba(59, 130, 246, 0.25)';
589                ctx.strokeStyle = '#3b82f6';
590                ctx.lineWidth = 1.5 * scale;
591                ctx.beginPath();
592                ctx.roundRect(x + w - badgeW - 12 * scale, y + h - badgeH - 10 * scale, badgeW, badgeH, 4 * scale);
593                ctx.fill();
594                ctx.stroke();
595                ctx.fillStyle = '#3b82f6';
596                ctx.font = `600 ${Math.max(8, fontSize * 0.625)}px 'Space Grotesk', sans-serif`;
597                ctx.textAlign = 'center';
598                ctx.fillText('WATCHING', x + w - (badgeW / 2) - 12 * scale, y + h - (badgeH / 2) - 10 * scale);
599            }
600
601            ctx.restore();
602        }
603
604        function animate() {
605            const width = canvas.width / (window.devicePixelRatio || 1);
606            const height = canvas.height / (window.devicePixelRatio || 1);
607            const franchise = franchises[currentFranchise];
608            const nodes = franchise.nodes.filter(n => n.status !== 'none');
609
610            ctx.clearRect(0, 0, width, height);
611            time += 0.01;
612
613            // Progress bar at top (responsive)
614            const barY = width <= 480 ? 18 : 22;
615            const barMargin = width <= 480 ? 40 : 60;
616            const barWidth = width - (barMargin * 2);
617            const barHeight = width <= 480 ? 6 : 8;
618            const barX = barMargin;
619
620            // Background bar
621            ctx.fillStyle = 'rgba(255, 255, 255, 0.08)';
622            ctx.beginPath();
623            ctx.roundRect(barX, barY, barWidth, barHeight, 4);
624            ctx.fill();
625
626            // Progress fill
627            const completed = nodes.filter(n => n.status === 'complete').length;
628            const progress = completed / nodes.length;
629            ctx.fillStyle = '#22c55e';
630            ctx.beginPath();
631            ctx.roundRect(barX, barY, barWidth * progress, barHeight, 4);
632            ctx.fill();
633
634            // Progress text (responsive font size)
635            ctx.fillStyle = '#a78bfa';
636            const progressFontSize = width <= 480 ? 9 : 11;
637            ctx.font = `700 ${progressFontSize}px 'Space Grotesk', sans-serif`;
638            ctx.textAlign = 'center';
639            ctx.textBaseline = 'top';
640            ctx.fillText(`${completed}/${nodes.length} COMPLETED`, width / 2, barY - (width <= 480 ? 12 : 14));
641
642            // Card carousel animation
643            transitionProgress += 0.01;
644            if (transitionProgress > 1) {
645                transitionProgress = 0;
646                activeNode = (activeNode + 1) % nodes.length;
647            }
648
649            // Responsive card sizing
650            let cardWidth, cardHeight, fontSize, showSideCards;
651            if (width <= 480) {
652                // Mobile: show only center card, make it larger
653                cardWidth = Math.min(240, width * 0.85);
654                cardHeight = 75;
655                fontSize = 14;
656                showSideCards = false;
657            } else if (width <= 768) {
658                // Tablet: smaller cards
659                cardWidth = Math.min(200, width * 0.55);
660                cardHeight = 70;
661                fontSize = 13;
662                showSideCards = true;
663            } else {
664                // Desktop: full size
665                cardWidth = 240;
666                cardHeight = 85;
667                fontSize = 16;
668                showSideCards = true;
669            }
670
671            const cardCenterY = height / 2 + 35;
672
673            // Draw cards
674            if (showSideCards) {
675                // Draw 3 cards: left (previous), center (current), right (next)
676                for (let offset = -1; offset <= 1; offset++) {
677                    const nodeIndex = (activeNode + offset + nodes.length) % nodes.length;
678                    const node = nodes[nodeIndex];
679
680                    let cardX, scale, alpha, cardY;
681                    if (offset === 0) {
682                        // Center card (current/active)
683                        cardX = width / 2 - cardWidth / 2;
684                        scale = 1;
685                        alpha = 1;
686                        cardY = cardCenterY - cardHeight / 2;
687                    } else if (offset === -1) {
688                        // Left card (previous)
689                        cardX = width / 2 - cardWidth * 1.35;
690                        scale = 0.8;
691                        alpha = 0.35;
692                        cardY = cardCenterY - (cardHeight * scale) / 2;
693                    } else {
694                        // Right card (next)
695                        cardX = width / 2 + cardWidth * 0.35;
696                        scale = 0.8;
697                        alpha = 0.35;
698                        cardY = cardCenterY - (cardHeight * scale) / 2;
699                    }
700
701                    drawCard(cardX, cardY, cardWidth * scale, cardHeight * scale, node, scale, alpha, fontSize * scale);
702                }
703            } else {
704                // Mobile: show only center card
705                const node = nodes[activeNode];
706                const cardX = width / 2 - cardWidth / 2;
707                const cardY = cardCenterY - cardHeight / 2;
708                drawCard(cardX, cardY, cardWidth, cardHeight, node, 1, 1, fontSize);
709            }
710
711            // Cycle franchises every 8 seconds
712            if (time > 8) {
713                time = 0;
714                activeNode = 0;
715                transitionProgress = 0;
716                currentFranchise = (currentFranchise + 1) % franchises.length;
717                const f = franchises[currentFranchise];
718                if (nameEl) nameEl.textContent = f.name;
719                if (totalEl) totalEl.textContent = f.total;
720                if (completedEl) completedEl.textContent = f.completed;
721            }
722
723            animationId = requestAnimationFrame(animate);
724        }
725
726        resizeCanvas();
727        animate();
728
729        window.addEventListener('resize', () => {
730            cancelAnimationFrame(animationId);
731            resizeCanvas();
732            animate();
733        });
734
735        // Pause when not visible - more aggressive on mobile
736        const observerOptions = isMobileDevice ? { threshold: 0.3 } : { threshold: 0.1 };
737        const observer = new IntersectionObserver((entries) => {
738            entries.forEach(entry => {
739                if (entry.isIntersecting) {
740                    if (!animationId) animate();
741                } else {
742                    cancelAnimationFrame(animationId);
743                    animationId = null;
744                }
745            });
746        }, observerOptions);
747        observer.observe(canvas);
748    }
749
750    // ===========================================
751    // Rating Protection Animation
752    // ===========================================
753    function initRatingAnimation() {
754        const canvas = document.getElementById('rating-canvas');
755        const othersScoreEl = document.getElementById('others-score');
756        const okuScoreEl = document.getElementById('oku-score');
757        const othersStatusEl = document.getElementById('others-status');
758        const okuStatusEl = document.getElementById('oku-status');
759        if (!canvas) return;
760
761        const ctx = canvas.getContext('2d');
762        let animationFrame = null;
763        let running = false;
764        let time = 0;
765
766        // Animation state
767        let leftScore = 8.2;
768        let okuScore = 8.2;
769        let okuTargetScore = 8.2; // OKU moves slightly but stays stable
770        let leftTargetScore = 8.2;
771        let phase = 'bomb'; // Start with bomb, then normal, then inflate
772        let phaseTime = 0;
773        let blockedCount = 0;
774        let particles = [];
775
776        function resizeCanvas() {
777            const rect = canvas.parentElement.getBoundingClientRect();
778            const dpr = window.devicePixelRatio || 1;
779
780            // Responsive height
781            let canvasHeight = 200;
782            if (window.innerWidth <= 480) {
783                canvasHeight = 160;
784            } else if (window.innerWidth <= 768) {
785                canvasHeight = 180;
786            }
787
788            canvas.width = rect.width * dpr;
789            canvas.height = canvasHeight * dpr;
790            canvas.style.width = rect.width + 'px';
791            canvas.style.height = canvasHeight + 'px';
792            ctx.setTransform(1, 0, 0, 1, 0, 0);
793            ctx.scale(dpr, dpr);
794        }
795
796        function drawRatingBar(x, y, width, height, score, maxScore, color, label, isMobile) {
797            // Background bar
798            ctx.fillStyle = 'rgba(255, 255, 255, 0.05)';
799            ctx.beginPath();
800            ctx.roundRect(x, y, width, height, 4);
801            ctx.fill();
802
803            // Filled portion
804            const fillWidth = (score / maxScore) * width;
805            const gradient = ctx.createLinearGradient(x, y, x + fillWidth, y);
806            gradient.addColorStop(0, color);
807            gradient.addColorStop(1, color.replace('1)', '0.7)'));
808            ctx.fillStyle = gradient;
809            ctx.beginPath();
810            ctx.roundRect(x, y, fillWidth, height, 4);
811            ctx.fill();
812
813            // Score text (responsive)
814            ctx.fillStyle = '#fafafa';
815            const fontSize = isMobile ? 11 : 14;
816            const textOffset = isMobile ? 28 : 35;
817            ctx.font = `800 ${fontSize}px 'Space Grotesk', sans-serif`;
818            ctx.textAlign = 'right';
819            ctx.textBaseline = 'middle';
820            ctx.fillText(score.toFixed(1), x + width + textOffset, y + height / 2);
821        }
822
823        function spawnParticle(side, isSuspicious, currentPhase, isMobile) {
824            const width = canvas.width / (window.devicePixelRatio || 1);
825            const baseX = side === 'left' ? width * 0.25 : width * 0.75;
826
827            // Determine rating based on phase and suspiciousness
828            let rating;
829            let isLow = false;
830            if (isSuspicious) {
831                if (currentPhase === 'bomb') {
832                    // Review bombing - all low scores
833                    rating = Math.floor(Math.random() * 2) + 1; // 1-2
834                    isLow = true;
835                } else if (currentPhase === 'inflate') {
836                    // Inflated ratings - all perfect 10s
837                    rating = 10;
838                    isLow = false;
839                } else {
840                    // Normal phase shouldn't have suspicious votes, but just in case
841                    rating = Math.floor(Math.random() * 3) + 6;
842                }
843            } else {
844                rating = Math.floor(Math.random() * 3) + 6; // Legitimate: 6-8
845            }
846
847            particles.push({
848                x: baseX + (Math.random() - 0.5) * (isMobile ? 40 : 60),
849                y: -10,
850                vy: (isMobile ? 1.5 : 2) + Math.random() * (isMobile ? 1 : 1.5),
851                side: side,
852                isSuspicious: isSuspicious,
853                isLow: isLow,
854                rating: rating,
855                blocked: false,
856                alpha: 1,
857                size: isMobile ? 6 : 8
858            });
859        }
860
861        function loop() {
862            if (!running) return;
863
864            const width = canvas.width / (window.devicePixelRatio || 1);
865            const height = canvas.height / (window.devicePixelRatio || 1);
866            const isMobile = window.innerWidth <= 480;
867
868            ctx.clearRect(0, 0, width, height);
869            time++;
870            phaseTime++;
871
872            // Phase management: bomb -> normal -> inflate -> bomb
873            // Longer phases (350 frames = ~5.8 seconds each) so attacks are clearly visible
874            if (phaseTime > 350) {
875                phaseTime = 0;
876                blockedCount = 0;
877                if (phase === 'bomb') {
878                    phase = 'normal';
879                    leftTargetScore = 8.2; // Reset after bombing
880                } else if (phase === 'normal') {
881                    phase = 'inflate';
882                } else {
883                    phase = 'bomb';
884                    leftTargetScore = 8.2; // Reset after inflation
885                }
886            }
887
888            // Smooth score interpolation
889            leftScore += (leftTargetScore - leftScore) * 0.05;
890            // OKU moves very slightly - small natural fluctuation around 8.2
891            okuTargetScore = 8.2 + (Math.sin(time * 0.02) * 0.15);
892            okuScore += (okuTargetScore - okuScore) * 0.1;
893
894            // Responsive layout
895            const leftX = width * 0.05;
896            const rightX = width * 0.55;
897            const barWidth = width * (isMobile ? 0.35 : 0.32);
898            const barHeight = isMobile ? 10 : 12;
899            const barY = height - (isMobile ? 40 : 50);
900
901            // Draw backgrounds
902            ctx.fillStyle = 'rgba(239, 68, 68, 0.03)';
903            ctx.fillRect(0, 0, width / 2 - 1, height);
904            ctx.fillStyle = 'rgba(34, 197, 94, 0.03)';
905            ctx.fillRect(width / 2 + 1, 0, width / 2, height);
906
907            // Center divider
908            ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
909            ctx.lineWidth = 1;
910            ctx.beginPath();
911            ctx.moveTo(width / 2, 0);
912            ctx.lineTo(width / 2, height);
913            ctx.stroke();
914
915            // Labels at top (responsive)
916            const topLabelSize = isMobile ? 9 : 11;
917            ctx.font = `700 ${topLabelSize}px 'Space Grotesk', sans-serif`;
918            ctx.textAlign = 'center';
919            ctx.textBaseline = 'top';
920            ctx.fillStyle = '#ef4444';
921            ctx.fillText('NO PROTECTION', width * 0.25, isMobile ? 8 : 10);
922            ctx.fillStyle = '#22c55e';
923            ctx.fillText('OKU PROTECTED', width * 0.75, isMobile ? 8 : 10);
924
925            // Shield line for OKU side (responsive)
926            const shieldY = isMobile ? 50 : 65;
927            ctx.strokeStyle = 'rgba(34, 197, 94, 0.6)';
928            ctx.lineWidth = isMobile ? 2 : 3;
929            ctx.beginPath();
930            ctx.moveTo(width * 0.52, shieldY);
931            ctx.lineTo(width * 0.98, shieldY);
932            ctx.stroke();
933
934            // Shield label (responsive)
935            ctx.fillStyle = '#22c55e';
936            const shieldLabelSize = isMobile ? 7 : 9;
937            ctx.font = `600 ${shieldLabelSize}px 'Space Grotesk', sans-serif`;
938            ctx.textBaseline = 'middle';
939            ctx.fillText('CREDIBILITY ALGORITHM', width * 0.75, shieldY);
940
941            // Spawn particles - more during attack phases (fewer on mobile for performance)
942            const isAttackPhase = phase === 'bomb' || phase === 'inflate';
943            const baseSpawnRate = isMobile ? 0.08 : 0.18;
944            const spawnRate = isAttackPhase ? baseSpawnRate : (isMobile ? 0.02 : 0.05);
945            if (Math.random() < spawnRate) {
946                const isSuspicious = isAttackPhase && Math.random() < 0.7;
947                spawnParticle('left', isSuspicious, phase, isMobile);
948                spawnParticle('right', isSuspicious, phase, isMobile);
949            }
950
951            // Update and draw particles
952            particles = particles.filter(p => {
953                p.y += p.vy;
954
955                // Check shield collision for right side suspicious votes
956                if (p.side === 'right' && p.isSuspicious && !p.blocked && p.y >= shieldY - 5) {
957                    p.blocked = true;
958                    p.vy = 0;
959                    blockedCount++;
960                }
961
962                // Check if reached bottom
963                if (!p.blocked && p.y >= barY - 15) {
964                    // Apply to score - only left side is affected, OKU stays stable
965                    if (p.side === 'left') {
966                        // Left side vulnerable - moves towards the rating
967                        leftTargetScore = Math.max(1, Math.min(10, leftTargetScore + (p.rating - leftTargetScore) * 0.12));
968                    }
969                    // Right side (OKU) - legitimate votes counted but score stays stable due to algorithm
970                    return false;
971                }
972
973                // Fade out blocked particles
974                if (p.blocked) {
975                    p.alpha -= 0.05;
976                    p.size += 0.5;
977                    if (p.alpha <= 0) return false;
978                }
979
980                // Draw particle - red for low bombs, orange for inflated, green for legit
981                let color;
982                if (p.isSuspicious) {
983                    color = p.isLow ? [239, 68, 68] : [249, 115, 22]; // Red for bombs, orange for inflated
984                } else {
985                    color = [34, 197, 94]; // Green for legitimate
986                }
987
988                if (p.blocked) {
989                    // Blocked effect - expanding ring
990                    ctx.strokeStyle = `rgba(${color.join(',')}, ${p.alpha * 0.5})`;
991                    ctx.lineWidth = 2;
992                    ctx.beginPath();
993                    ctx.arc(p.x, shieldY, p.size, 0, Math.PI * 2);
994                    ctx.stroke();
995
996                    // X mark
997                    ctx.strokeStyle = `rgba(239, 68, 68, ${p.alpha})`;
998                    ctx.lineWidth = 2;
999                    ctx.beginPath();
1000                    ctx.moveTo(p.x - 4, shieldY - 4);
1001                    ctx.lineTo(p.x + 4, shieldY + 4);
1002                    ctx.moveTo(p.x + 4, shieldY - 4);
1003                    ctx.lineTo(p.x - 4, shieldY + 4);
1004                    ctx.stroke();
1005                } else {
1006                    // Normal particle
1007                    ctx.beginPath();
1008                    ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
1009                    ctx.fillStyle = `rgba(${color.join(',')}, 0.9)`;
1010                    ctx.fill();
1011
1012                    // Rating number (responsive)
1013                    ctx.fillStyle = '#fff';
1014                    const particleFontSize = isMobile ? 7 : 9;
1015                    ctx.font = `700 ${particleFontSize}px 'Space Grotesk', sans-serif`;
1016                    ctx.textAlign = 'center';
1017                    ctx.textBaseline = 'middle';
1018                    ctx.fillText(p.rating.toString(), p.x, p.y);
1019                }
1020
1021                return true;
1022            });
1023
1024            // Draw rating bars (responsive)
1025            drawRatingBar(leftX, barY, barWidth, barHeight, leftScore, 10, 'rgba(239, 68, 68, 1)', 'Score', isMobile);
1026            drawRatingBar(rightX, barY, barWidth, barHeight, okuScore, 10, 'rgba(34, 197, 94, 1)', 'Score', isMobile);
1027
1028            // Bar labels (responsive)
1029            const barLabelSize = isMobile ? 7 : 9;
1030            ctx.font = `600 ${barLabelSize}px 'Space Grotesk', sans-serif`;
1031            ctx.textAlign = 'left';
1032            ctx.textBaseline = 'bottom';
1033            ctx.fillStyle = '#71717a';
1034            ctx.fillText('SCORE', leftX, barY - (isMobile ? 3 : 5));
1035            ctx.fillText('SCORE', rightX, barY - (isMobile ? 3 : 5));
1036
1037            // Attack warning (responsive)
1038            const isAttack = phase === 'bomb' || phase === 'inflate';
1039            if (isAttack) {
1040                const pulseAlpha = 0.6 + Math.sin(time * 0.15) * 0.2;
1041                const warningColor = phase === 'bomb' ? 'rgba(239, 68, 68,' : 'rgba(249, 115, 22,';
1042                const warningText = phase === 'bomb' ? '⚠ REVIEW BOMBING DETECTED' : '⚠ INFLATED RATINGS DETECTED';
1043                ctx.fillStyle = `${warningColor}${pulseAlpha})`;
1044                const warningSize = isMobile ? 8 : 10;
1045                ctx.font = `700 ${warningSize}px 'Space Grotesk', sans-serif`;
1046                ctx.textAlign = 'center';
1047                ctx.textBaseline = 'top';
1048                ctx.fillText(warningText, width / 2, height - (isMobile ? 18 : 22));
1049            }
1050
1051            // Update DOM elements
1052            if (othersScoreEl) othersScoreEl.textContent = leftScore.toFixed(1);
1053            if (okuScoreEl) okuScoreEl.textContent = okuScore.toFixed(1);
1054
1055            if (isAttack) {
1056                const change = Math.abs(8.2 - leftScore).toFixed(1);
1057                const direction = leftScore > 8.2 ? '↑' : '↓';
1058                if (othersStatusEl) othersStatusEl.textContent = `${direction}${change} manipulated`;
1059                if (okuStatusEl) okuStatusEl.textContent = `${blockedCount} filtered`;
1060            } else {
1061                if (othersStatusEl) othersStatusEl.textContent = 'Vulnerable';
1062                if (okuStatusEl) okuStatusEl.textContent = 'Stable';
1063            }
1064
1065            animationFrame = requestAnimationFrame(loop);
1066        }
1067
1068        function start() {
1069            if (!running) {
1070                running = true;
1071                loop();
1072            }
1073        }
1074
1075        function stop() {
1076            running = false;
1077            if (animationFrame) {
1078                cancelAnimationFrame(animationFrame);
1079                animationFrame = null;
1080            }
1081        }
1082
1083        resizeCanvas();
1084        start();
1085
1086        window.addEventListener('resize', resizeCanvas);
1087
1088        // More aggressive threshold on mobile
1089        const observerOptions = { threshold: isMobileDevice ? 0.3 : 0.1 };
1090        const observer = new IntersectionObserver((entries) => {
1091            entries.forEach(entry => {
1092                if (entry.isIntersecting) {
1093                    start();
1094                } else {
1095                    stop();
1096                }
1097            });
1098        }, observerOptions);
1099        observer.observe(canvas);
1100    }
1101
1102    // ===========================================
1103    // Portability Canvas Animation
1104    // ===========================================
1105    function initPortabilityAnimation() {
1106        const canvas = document.getElementById('portability-canvas');
1107        const statusEl = document.getElementById('port-status');
1108        if (!canvas) return;
1109
1110        const ctx = canvas.getContext('2d');
1111        let animationId;
1112        let particles = [];
1113        let phase = 'import'; // 'import' or 'export'
1114        let phaseProgress = 0;
1115
1116        const statuses = {
1117            import: ['Connecting to source...', 'Fetching list data...', 'Importing 342 entries...', 'Import complete!'],
1118            export: ['Preparing export...', 'Converting to JSON...', 'Packaging data...', 'Export ready!']
1119        };
1120
1121        function resizeCanvas() {
1122            const container = canvas.parentElement;
1123            const rect = container.getBoundingClientRect();
1124            const dpr = window.devicePixelRatio || 1;
1125
1126            // Responsive height
1127            let canvasHeight = 220;
1128            if (window.innerWidth <= 480) {
1129                canvasHeight = 160;
1130            } else if (window.innerWidth <= 768) {
1131                canvasHeight = 180;
1132            }
1133
1134            canvas.width = rect.width * dpr;
1135            canvas.height = canvasHeight * dpr;
1136            canvas.style.width = rect.width + 'px';
1137            canvas.style.height = canvasHeight + 'px';
1138            ctx.setTransform(1, 0, 0, 1, 0, 0);
1139            ctx.scale(dpr, dpr);
1140        }
1141
1142        // Create a data particle
1143        function createParticle(startX, startY, endX, endY, color) {
1144            return {
1145                x: startX,
1146                y: startY,
1147                startX,
1148                startY,
1149                endX,
1150                endY,
1151                progress: 0,
1152                speed: 0.01 + Math.random() * 0.01,
1153                size: 3 + Math.random() * 3,
1154                color
1155            };
1156        }
1157
1158        function animate() {
1159            const width = canvas.width / (window.devicePixelRatio || 1);
1160            const height = canvas.height / (window.devicePixelRatio || 1);
1161            const isMobile = window.innerWidth <= 480;
1162            const centerX = width / 2;
1163            const centerY = height / 2;
1164
1165            // Responsive sizing - match CSS positioning
1166            const sideMargin = isMobile ? 30 : 30; // Match .port-labels padding
1167            const labelTop = isMobile ? 20 : 20;
1168            const boxHeight = isMobile ? 22 : 24;
1169            const boxGap = isMobile ? 6 : 8;
1170            // Calculate centers of label boxes
1171            const nodeVerticalSpacing = boxHeight + boxGap;
1172            const firstLabelY = labelTop + (boxHeight / 2);
1173            const secondLabelY = firstLabelY + nodeVerticalSpacing;
1174
1175            const centerRadius = isMobile ? 28 : 35;
1176            const centerInnerRadius = isMobile ? 16 : 20;
1177            const lineOffset = isMobile ? 32 : 40;
1178
1179            ctx.clearRect(0, 0, width, height);
1180
1181            // Draw connection lines
1182            ctx.strokeStyle = 'rgba(139, 92, 246, 0.15)';
1183            ctx.lineWidth = isMobile ? 1.5 : 2;
1184
1185            // Left side line (source) - single line from Other Sites to center
1186            const leftSourceY = (firstLabelY + secondLabelY) / 2; // Center between where the two labels used to be
1187            ctx.beginPath();
1188            ctx.moveTo(sideMargin, leftSourceY);
1189            ctx.lineTo(centerX - lineOffset, centerY);
1190            ctx.stroke();
1191
1192            // Right side lines (destinations) - connect to label box centers
1193            const rightY1 = firstLabelY;
1194            const rightY2 = secondLabelY;
1195            ctx.beginPath();
1196            ctx.moveTo(centerX + lineOffset, centerY);
1197            ctx.lineTo(width - sideMargin, rightY1);
1198            ctx.stroke();
1199            ctx.beginPath();
1200            ctx.moveTo(centerX + lineOffset, centerY);
1201            ctx.lineTo(width - sideMargin, rightY2);
1202            ctx.stroke();
1203
1204            // Draw center node (OKU)
1205            const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, centerRadius);
1206            gradient.addColorStop(0, 'rgba(139, 92, 246, 0.4)');
1207            gradient.addColorStop(1, 'rgba(139, 92, 246, 0)');
1208            ctx.beginPath();
1209            ctx.arc(centerX, centerY, centerRadius, 0, Math.PI * 2);
1210            ctx.fillStyle = gradient;
1211            ctx.fill();
1212
1213            ctx.beginPath();
1214            ctx.arc(centerX, centerY, centerInnerRadius, 0, Math.PI * 2);
1215            ctx.fillStyle = 'rgba(139, 92, 246, 0.3)';
1216            ctx.strokeStyle = 'rgba(139, 92, 246, 0.6)';
1217            ctx.lineWidth = isMobile ? 1.5 : 2;
1218            ctx.fill();
1219            ctx.stroke();
1220
1221            // Update phase progress
1222            phaseProgress += 0.003;
1223
1224            // Spawn new particles (responsive) - use label positions
1225            // Much lower spawn rate on mobile for performance
1226            const spawnRate = isMobile ? 0.95 : 0.92;
1227            if (Math.random() > spawnRate) {
1228                if (phase === 'import') {
1229                    // Single source (Other Sites)
1230                    const sourceY = (firstLabelY + secondLabelY) / 2;
1231                    particles.push(createParticle(sideMargin, sourceY, centerX, centerY, [59, 130, 246]));
1232                } else {
1233                    // Two destinations (XML, JSON)
1234                    const destY = Math.random() > 0.5 ? firstLabelY : secondLabelY;
1235                    particles.push(createParticle(centerX, centerY, width - sideMargin, destY, [34, 197, 94]));
1236                }
1237            }
1238
1239            // Update and draw particles (responsive sizing)
1240            const particleSizeMultiplier = isMobile ? 0.7 : 1;
1241            particles = particles.filter(p => {
1242                p.progress += p.speed;
1243
1244                // Easing
1245                const eased = 1 - Math.pow(1 - p.progress, 3);
1246                p.x = p.startX + (p.endX - p.startX) * eased;
1247                p.y = p.startY + (p.endY - p.startY) * eased;
1248
1249                if (p.progress < 1) {
1250                    const actualSize = p.size * particleSizeMultiplier;
1251
1252                    // Draw glow (disabled on mobile for performance)
1253                    if (!isMobile) {
1254                        const glowSize = 6;
1255                        const glowGrad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, actualSize + glowSize);
1256                        glowGrad.addColorStop(0, `rgba(${p.color.join(',')}, 0.5)`);
1257                        glowGrad.addColorStop(1, 'transparent');
1258                        ctx.beginPath();
1259                        ctx.arc(p.x, p.y, actualSize + glowSize, 0, Math.PI * 2);
1260                        ctx.fillStyle = glowGrad;
1261                        ctx.fill();
1262                    }
1263
1264                    // Draw particle
1265                    ctx.beginPath();
1266                    ctx.arc(p.x, p.y, actualSize, 0, Math.PI * 2);
1267                    ctx.fillStyle = `rgba(${p.color.join(',')}, 0.9)`;
1268                    ctx.fill();
1269
1270                    return true;
1271                }
1272                return false;
1273            });
1274
1275            // Update status text
1276            if (statusEl) {
1277                const statusList = statuses[phase];
1278                const statusIndex = Math.min(Math.floor(phaseProgress * statusList.length), statusList.length - 1);
1279                statusEl.textContent = statusList[statusIndex];
1280            }
1281
1282            // Switch phase
1283            if (phaseProgress >= 1) {
1284                phaseProgress = 0;
1285                phase = phase === 'import' ? 'export' : 'import';
1286            }
1287
1288            animationId = requestAnimationFrame(animate);
1289        }
1290
1291        resizeCanvas();
1292        animate();
1293
1294        window.addEventListener('resize', () => {
1295            cancelAnimationFrame(animationId);
1296            resizeCanvas();
1297            animate();
1298        });
1299
1300        // Pause when not visible - more aggressive on mobile
1301        const observerOptions = isMobileDevice ? { threshold: 0.3 } : { threshold: 0.1 };
1302        const observer = new IntersectionObserver((entries) => {
1303            entries.forEach(entry => {
1304                if (entry.isIntersecting) {
1305                    if (!animationId) animate();
1306                } else {
1307                    cancelAnimationFrame(animationId);
1308                    animationId = null;
1309                }
1310            });
1311        }, observerOptions);
1312        observer.observe(canvas);
1313    }
1314
1315    // ===========================================
1316    // Fetch Real Stats from API (GraphQL)
1317    // ===========================================
1318    async function fetchStats() {
1319        try {
1320            // Use GraphQL to fetch both anime and series counts in a single request
1321            const stats = await window.GraphQL.getHomeStats();
1322
1323            // Update anime count
1324            const animeCountEl = document.getElementById('anime-count');
1325            if (animeCountEl && stats.animeCount !== undefined) {
1326                animeCountEl.dataset.count = stats.animeCount;
1327            }
1328
1329            // Update series count
1330            const seriesCountEl = document.getElementById('series-count');
1331            if (seriesCountEl && stats.seriesCount !== undefined) {
1332                seriesCountEl.dataset.count = stats.seriesCount;
1333            }
1334        } catch (err) {
1335            console.warn('Could not fetch stats via GraphQL:', err);
1336        }
1337
1338        // Apply fallback values for any that weren't set
1339        const animeCountEl = document.getElementById('anime-count');
1340        if (animeCountEl && (!animeCountEl.dataset.count || animeCountEl.dataset.count === '0')) {
1341            animeCountEl.dataset.count = '25000';
1342        }
1343
1344        const seriesCountEl = document.getElementById('series-count');
1345        if (seriesCountEl && (!seriesCountEl.dataset.count || seriesCountEl.dataset.count === '0')) {
1346            seriesCountEl.dataset.count = '800';
1347        }
1348    }
1349
1350    // ===========================================
1351    // Intersection Observer for Scroll Reveals
1352    // ===========================================
1353    const observerOptions = {
1354        root: null,
1355        rootMargin: '0px 0px -100px 0px',
1356        threshold: 0.1
1357    };
1358
1359    let revealObserver = null;
1360
1361    function setupObserver() {
1362        revealObserver = new IntersectionObserver((entries) => {
1363            entries.forEach(entry => {
1364                if (entry.isIntersecting) {
1365                    entry.target.classList.add('visible');
1366
1367                    // Trigger counter animation for numbers section
1368                    if (entry.target.classList.contains('numbers-section')) {
1369                        animateCounters();
1370                    }
1371                }
1372            });
1373        }, observerOptions);
1374
1375        // Observe all animatable sections
1376        document.querySelectorAll('.feature-section, .comparison-section, .numbers-section, .cta-section').forEach(el => {
1377            revealObserver.observe(el);
1378        });
1379    }
1380
1381    // ===========================================
1382    // Counter Animation for Stats
1383    // ===========================================
1384    let countersAnimated = false;
1385
1386    function animateCounters() {
1387        if (countersAnimated) return;
1388
1389        const counters = document.querySelectorAll('.number-value[data-count]');
1390        let allReady = true;
1391
1392        counters.forEach(counter => {
1393            const target = parseInt(counter.dataset.count, 10);
1394            if (target === 0) allReady = false;
1395        });
1396
1397        // If data isn't loaded yet, retry in 100ms
1398        if (!allReady) {
1399            setTimeout(animateCounters, 100);
1400            return;
1401        }
1402
1403        countersAnimated = true;
1404
1405        counters.forEach(counter => {
1406            const target = parseInt(counter.dataset.count, 10);
1407
1408            const duration = 2000;
1409            const startTime = performance.now();
1410
1411            function updateCounter(currentTime) {
1412                const elapsed = currentTime - startTime;
1413                const progress = Math.min(elapsed / duration, 1);
1414
1415                // Easing function (ease-out cubic)
1416                const easeOut = 1 - Math.pow(1 - progress, 3);
1417                const current = Math.floor(easeOut * target);
1418
1419                counter.textContent = current.toLocaleString();
1420
1421                if (progress < 1) {
1422                    requestAnimationFrame(updateCounter);
1423                } else {
1424                    counter.textContent = target.toLocaleString();
1425                }
1426            }
1427
1428            requestAnimationFrame(updateCounter);
1429        });
1430    }
1431
1432    // ===========================================
1433    // Smooth Scroll for "See what's different"
1434    // ===========================================
1435    const scrollBtn = document.querySelector('.hero-btn.secondary');
1436    if (scrollBtn) {
1437        scrollBtn.addEventListener('click', (e) => {
1438            e.preventDefault();
1439            const target = document.querySelector('#features');
1440            if (target) {
1441                target.scrollIntoView({ behavior: 'smooth', block: 'start' });
1442            }
1443        });
1444    }
1445
1446    // ===========================================
1447    // Scroll-Based Parallax and Effects
1448    // ===========================================
1449    let ticking = false;
1450    const heroOrbs = document.querySelectorAll('.hero-orb');
1451    const heroSection = document.querySelector('.hero');
1452    const bgGradients = document.querySelectorAll('.bg-gradient-1, .bg-gradient-2, .bg-gradient-3');
1453
1454    function updateScrollEffects() {
1455        const scrollY = window.scrollY;
1456        const windowHeight = window.innerHeight;
1457        const heroHeight = heroSection ? heroSection.offsetHeight : windowHeight;
1458
1459        // Hero orb parallax (only in hero section)
1460        if (scrollY < heroHeight && heroOrbs.length) {
1461            const orb1 = heroOrbs[0];
1462            const orb2 = heroOrbs[1];
1463            const orb3 = heroOrbs[2];
1464
1465            if (orb1) orb1.style.transform = `translate(${scrollY * 0.04}px, ${scrollY * 0.1}px) scale(1)`;
1466            if (orb2) orb2.style.transform = `translate(${-scrollY * 0.03}px, ${scrollY * 0.06}px) scale(1)`;
1467            if (orb3) orb3.style.transform = `translate(${scrollY * 0.05}px, ${-scrollY * 0.04}px) scale(1)`;
1468        }
1469
1470        // Background gradients parallax
1471        bgGradients.forEach((gradient, index) => {
1472            const speed = 0.05 + (index * 0.02);
1473            const yOffset = scrollY * speed;
1474            gradient.style.transform = `translateY(${-yOffset}px)`;
1475        });
1476
1477        // Hero content fade out on scroll
1478        const heroContent = document.querySelector('.hero-content');
1479        if (heroContent && scrollY < heroHeight) {
1480            const fadeStart = heroHeight * 0.3;
1481            const fadeEnd = heroHeight * 0.7;
1482            if (scrollY > fadeStart) {
1483                const fadeProgress = (scrollY - fadeStart) / (fadeEnd - fadeStart);
1484                heroContent.style.opacity = 1 - Math.min(fadeProgress, 1);
1485                heroContent.style.transform = `translateY(${scrollY * 0.3}px)`;
1486            } else {
1487                heroContent.style.opacity = 1;
1488                heroContent.style.transform = 'translateY(0)';
1489            }
1490        }
1491
1492        // Feature visuals scale effect based on scroll position
1493        document.querySelectorAll('.feature-visual').forEach(visual => {
1494            const rect = visual.getBoundingClientRect();
1495            const centerY = rect.top + rect.height / 2;
1496            const screenCenter = windowHeight / 2;
1497            const distance = Math.abs(centerY - screenCenter);
1498            const maxDistance = windowHeight / 2;
1499
1500            if (distance < maxDistance) {
1501                const scale = 1 - (distance / maxDistance) * 0.05;
1502                visual.style.transform = `scale(${Math.max(scale, 0.95)})`;
1503            }
1504        });
1505    }
1506
1507    // ===========================================
1508    // Combined Scroll Handler
1509    // ===========================================
1510    function onScroll() {
1511        if (!ticking) {
1512            requestAnimationFrame(() => {
1513                updateScrollEffects();
1514                ticking = false;
1515            });
1516            ticking = true;
1517        }
1518    }
1519
1520    window.addEventListener('scroll', onScroll, { passive: true });
1521
1522    // ===========================================
1523    // Mouse Move Parallax for Hero
1524    // ===========================================
1525    let mouseX = 0;
1526    let mouseY = 0;
1527    let targetX = 0;
1528    let targetY = 0;
1529
1530    document.addEventListener('mousemove', (e) => {
1531        mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
1532        mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
1533    });
1534
1535    function animateMouseParallax() {
1536        // Smooth lerp
1537        targetX += (mouseX - targetX) * 0.05;
1538        targetY += (mouseY - targetY) * 0.05;
1539
1540        const heroOrb1 = heroOrbs[0];
1541        const heroOrb2 = heroOrbs[1];
1542        const heroOrb3 = heroOrbs[2];
1543
1544        // Only apply mouse parallax when near top of page
1545        if (window.scrollY < window.innerHeight) {
1546            if (heroOrb1) {
1547                const currentTransform = heroOrb1.style.transform || '';
1548                const scrollOffset = currentTransform.match(/translate\(([^,]+),\s*([^)]+)\)/);
1549                const scrollX = scrollOffset ? parseFloat(scrollOffset[1]) : 0;
1550                const scrollY = scrollOffset ? parseFloat(scrollOffset[2]) : 0;
1551                heroOrb1.style.transform = `translate(${scrollX + targetX * 30}px, ${scrollY + targetY * 30}px) scale(1)`;
1552            }
1553            if (heroOrb2) {
1554                const currentTransform = heroOrb2.style.transform || '';
1555                const scrollOffset = currentTransform.match(/translate\(([^,]+),\s*([^)]+)\)/);
1556                const scrollX = scrollOffset ? parseFloat(scrollOffset[1]) : 0;
1557                const scrollY = scrollOffset ? parseFloat(scrollOffset[2]) : 0;
1558                heroOrb2.style.transform = `translate(${scrollX - targetX * 20}px, ${scrollY - targetY * 20}px) scale(1)`;
1559            }
1560            if (heroOrb3) {
1561                const currentTransform = heroOrb3.style.transform || '';
1562                const scrollOffset = currentTransform.match(/translate\(([^,]+),\s*([^)]+)\)/);
1563                const scrollX = scrollOffset ? parseFloat(scrollOffset[1]) : 0;
1564                const scrollY = scrollOffset ? parseFloat(scrollOffset[2]) : 0;
1565                heroOrb3.style.transform = `translate(${scrollX + targetX * 15}px, ${scrollY + targetY * 15}px) scale(1)`;
1566            }
1567        }
1568
1569        requestAnimationFrame(animateMouseParallax);
1570    }
1571
1572    // ===========================================
1573    // Accessibility: Respect reduced motion
1574    // ===========================================
1575    const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
1576
1577    if (prefersReducedMotion.matches) {
1578        // Make all sections visible immediately
1579        document.querySelectorAll('.feature-section, .comparison-section, .numbers-section, .cta-section').forEach(el => {
1580            el.classList.add('visible');
1581        });
1582
1583        // Show counters at final value (after fetch)
1584        fetchStats().then(() => {
1585            document.querySelectorAll('.number-value[data-count]').forEach(counter => {
1586                counter.textContent = parseInt(counter.dataset.count, 10).toLocaleString();
1587            });
1588            countersAnimated = true;
1589        });
1590    } else {
1591        // Start mouse parallax animation
1592        animateMouseParallax();
1593    }
1594
1595    // ===========================================
1596    // Initialize on DOM ready
1597    // ===========================================
1598    document.addEventListener('DOMContentLoaded', async () => {
1599        // Fetch stats from API FIRST to ensure data is ready
1600        await fetchStats();
1601
1602        // Setup observer AFTER stats are loaded
1603        setupObserver();
1604
1605        // Create particles
1606        createParticles();
1607
1608        // Initialize neural network animation
1609        initNeuralNetwork();
1610
1611        // Initialize franchise tree animation
1612        initFranchiseAnimation();
1613
1614        // Initialize rating anti-bombing animation
1615        initRatingAnimation();
1616
1617        // Initialize portability animation
1618        initPortabilityAnimation();
1619
1620        // Initial scroll position check
1621        onScroll();
1622
1623        // Check if elements are already in view
1624        document.querySelectorAll('.feature-section, .comparison-section, .numbers-section, .cta-section').forEach(el => {
1625            const rect = el.getBoundingClientRect();
1626            if (rect.top < window.innerHeight * 0.9) {
1627                el.classList.add('visible');
1628                if (el.classList.contains('numbers-section')) {
1629                    animateCounters();
1630                }
1631            }
1632        });
1633    });
1634
1635    // Also run on load for any elements that might be in view
1636    window.addEventListener('load', () => {
1637        onScroll();
1638    });
1639
1640})();

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.