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.