|
2180 | 2180 | const targetSection = document.getElementById(targetId); |
2181 | 2181 | if (targetSection) { |
2182 | 2182 | targetSection.classList.add('active'); |
| 2183 | + if (typeof badgeRectsDirty !== 'undefined') badgeRectsDirty = true; |
2183 | 2184 | } |
2184 | 2185 | navItems.forEach(nav => { |
2185 | 2186 | nav.classList.remove('active'); |
|
2583 | 2584 | const cursorFollower = document.getElementById('cursorFollower'); |
2584 | 2585 | const isTouchDevice = ('ontouchstart' in window || navigator.maxTouchPoints > 0) && window.matchMedia('(hover: none)').matches; |
2585 | 2586 |
|
| 2587 | + var badgeRectsDirty = true; |
| 2588 | + |
2586 | 2589 | if (!isTouchDevice) { |
2587 | | - let mouseX = 0; |
2588 | | - let mouseY = 0; |
2589 | | - let followerX = 0; |
2590 | | - let followerY = 0; |
2591 | | - const ease = 0.15; |
| 2590 | + var mouseX = 0; |
| 2591 | + var mouseY = 0; |
| 2592 | + var followerX = 0; |
| 2593 | + var followerY = 0; |
| 2594 | + var badgeElements = []; |
| 2595 | + var activeBadge = null; |
| 2596 | + |
| 2597 | + var MAGNETIC_RADIUS = 20; |
| 2598 | + var EASE_DEFAULT = 0.15; |
| 2599 | + var EASE_ATTRACT = 0.12; |
| 2600 | + var BASE_SIZE = 12; |
| 2601 | + var SHAPE_EASE_IN = 0.07; |
| 2602 | + var SHAPE_EASE_OUT = 0.15; |
| 2603 | + |
| 2604 | + // Lerped shape state for fluid morph |
| 2605 | + var currentScaleX = 1; |
| 2606 | + var currentScaleY = 1; |
| 2607 | + var currentBorderRadius = 6; // px (6 = circle for 12px element) |
| 2608 | + |
| 2609 | + function distToRect(px, py, r) { |
| 2610 | + var dx = Math.max(r.left - px, 0, px - r.right); |
| 2611 | + var dy = Math.max(r.top - py, 0, py - r.bottom); |
| 2612 | + return Math.sqrt(dx * dx + dy * dy); |
| 2613 | + } |
| 2614 | + |
| 2615 | + function cacheBadgeElements() { |
| 2616 | + var badges = document.querySelectorAll('.stat-badge, .role-badge'); |
| 2617 | + badgeElements = []; |
| 2618 | + badges.forEach(function(badge) { |
| 2619 | + var section = badge.closest('section'); |
| 2620 | + if (section && !section.classList.contains('active')) return; |
| 2621 | + badgeElements.push(badge); |
| 2622 | + }); |
| 2623 | + badgeRectsDirty = false; |
| 2624 | + } |
2592 | 2625 |
|
2593 | | - document.addEventListener('mousemove', (e) => { |
| 2626 | + document.addEventListener('mousemove', function(e) { |
2594 | 2627 | mouseX = e.clientX; |
2595 | 2628 | mouseY = e.clientY; |
2596 | 2629 | }); |
2597 | 2630 |
|
2598 | 2631 | function animateFollower() { |
2599 | | - followerX += (mouseX - followerX) * ease; |
2600 | | - followerY += (mouseY - followerY) * ease; |
| 2632 | + if (badgeRectsDirty) cacheBadgeElements(); |
| 2633 | + |
| 2634 | + // Find closest badge (fresh rects every frame — no stale positions) |
| 2635 | + var closestDist = MAGNETIC_RADIUS; |
| 2636 | + var closestRect = null; |
| 2637 | + var closestEl = null; |
| 2638 | + for (var i = 0; i < badgeElements.length; i++) { |
| 2639 | + var rect = badgeElements[i].getBoundingClientRect(); |
| 2640 | + if (rect.width === 0 || rect.height === 0) continue; |
| 2641 | + var d = distToRect(mouseX, mouseY, rect); |
| 2642 | + if (d < closestDist) { |
| 2643 | + closestDist = d; |
| 2644 | + closestRect = rect; |
| 2645 | + closestEl = badgeElements[i]; |
| 2646 | + } |
| 2647 | + } |
| 2648 | + |
| 2649 | + // Compute magnetic pull |
| 2650 | + var targetX = mouseX; |
| 2651 | + var targetY = mouseY; |
| 2652 | + var strength = 0; |
| 2653 | + var targetScaleX = 1; |
| 2654 | + var targetScaleY = 1; |
| 2655 | + var targetRadius = 6; // px (6 = circle) |
| 2656 | + |
| 2657 | + if (closestRect) { |
| 2658 | + strength = 1 - closestDist / MAGNETIC_RADIUS; |
| 2659 | + strength = strength * strength; // quadratic ease-in |
| 2660 | + var cx = closestRect.left + closestRect.width / 2; |
| 2661 | + var cy = closestRect.top + closestRect.height / 2; |
| 2662 | + targetX = mouseX + (cx - mouseX) * strength; |
| 2663 | + targetY = mouseY + (cy - mouseY) * strength; |
| 2664 | + |
| 2665 | + // Target: fill inside the badge |
| 2666 | + targetScaleX = 1 + (closestRect.width / BASE_SIZE - 1) * strength; |
| 2667 | + targetScaleY = 1 + (closestRect.height / BASE_SIZE - 1) * strength; |
| 2668 | + // Lerp toward 2px border-radius (matches badge border-radius) |
| 2669 | + targetRadius = 6 + (2 - 6) * strength; |
| 2670 | + } |
| 2671 | + |
| 2672 | + // Lerp position |
| 2673 | + var ease = closestRect ? EASE_ATTRACT : EASE_DEFAULT; |
| 2674 | + followerX += (targetX - followerX) * ease; |
| 2675 | + followerY += (targetY - followerY) * ease; |
| 2676 | + |
| 2677 | + // Lerp shape — slow approach, fast retreat |
| 2678 | + var shapeEase = closestRect ? SHAPE_EASE_IN : SHAPE_EASE_OUT; |
| 2679 | + currentScaleX += (targetScaleX - currentScaleX) * shapeEase; |
| 2680 | + currentScaleY += (targetScaleY - currentScaleY) * shapeEase; |
| 2681 | + currentBorderRadius += (targetRadius - currentBorderRadius) * shapeEase; |
| 2682 | + |
| 2683 | + // Position (same as original — left/top) |
2601 | 2684 | cursorFollower.style.left = followerX + 'px'; |
2602 | 2685 | cursorFollower.style.top = followerY + 'px'; |
| 2686 | + |
| 2687 | + // Track proximity separately from visual morph |
| 2688 | + activeBadge = closestEl || null; |
| 2689 | + |
| 2690 | + // Shape: only apply transform/borderRadius when morphing |
| 2691 | + var isBlob = Math.abs(currentScaleX - 1) > 0.01 || Math.abs(currentScaleY - 1) > 0.01; |
| 2692 | + |
| 2693 | + if (isBlob && closestRect) { |
| 2694 | + // Near a badge: morph and suppress link hover |
| 2695 | + cursorFollower.classList.remove('hovering'); |
| 2696 | + cursorFollower.style.transform = |
| 2697 | + 'translate(-50%, -50%) scale(' + currentScaleX.toFixed(3) + ',' + currentScaleY.toFixed(3) + ')'; |
| 2698 | + cursorFollower.style.borderRadius = currentBorderRadius.toFixed(1) + 'px'; |
| 2699 | + } else if (isBlob && !closestRect && cursorFollower.classList.contains('hovering')) { |
| 2700 | + // Left badge, now on a link: snap morph to default so CSS hover works |
| 2701 | + currentScaleX = 1; |
| 2702 | + currentScaleY = 1; |
| 2703 | + currentBorderRadius = 6; |
| 2704 | + cursorFollower.style.transform = ''; |
| 2705 | + cursorFollower.style.borderRadius = ''; |
| 2706 | + } else if (isBlob) { |
| 2707 | + // Left badge, empty space: smooth retreat animation |
| 2708 | + cursorFollower.style.transform = |
| 2709 | + 'translate(-50%, -50%) scale(' + currentScaleX.toFixed(3) + ',' + currentScaleY.toFixed(3) + ')'; |
| 2710 | + cursorFollower.style.borderRadius = currentBorderRadius.toFixed(1) + 'px'; |
| 2711 | + } else { |
| 2712 | + // Not morphing: clean default |
| 2713 | + currentScaleX = 1; |
| 2714 | + currentScaleY = 1; |
| 2715 | + currentBorderRadius = 6; |
| 2716 | + cursorFollower.style.transform = ''; |
| 2717 | + cursorFollower.style.borderRadius = ''; |
| 2718 | + } |
| 2719 | + |
2603 | 2720 | requestAnimationFrame(animateFollower); |
2604 | 2721 | } |
2605 | 2722 |
|
2606 | 2723 | animateFollower(); |
2607 | 2724 |
|
2608 | | - const interactiveElements = document.querySelectorAll('a, button, .theme-toggle'); |
2609 | | - interactiveElements.forEach(el => { |
2610 | | - el.addEventListener('mouseenter', () => { |
2611 | | - cursorFollower.classList.add('hovering'); |
| 2725 | + // Link hover — original behaviour (CSS width/height transition) |
| 2726 | + var interactiveElements = document.querySelectorAll('a, button, .theme-toggle'); |
| 2727 | + interactiveElements.forEach(function(el) { |
| 2728 | + el.addEventListener('mouseenter', function() { |
| 2729 | + if (!activeBadge) cursorFollower.classList.add('hovering'); |
2612 | 2730 | }); |
2613 | | - el.addEventListener('mouseleave', () => { |
| 2731 | + el.addEventListener('mouseleave', function() { |
2614 | 2732 | cursorFollower.classList.remove('hovering'); |
2615 | 2733 | }); |
2616 | 2734 | }); |
| 2735 | + |
| 2736 | + // Invalidate badge element cache on scroll and resize |
| 2737 | + sections.forEach(function(s) { |
| 2738 | + s.addEventListener('scroll', function() { badgeRectsDirty = true; }, { passive: true }); |
| 2739 | + }); |
| 2740 | + window.addEventListener('resize', function() { badgeRectsDirty = true; }, { passive: true }); |
2617 | 2741 | } |
2618 | 2742 |
|
2619 | 2743 |
|
|
2767 | 2891 | el.innerHTML = |
2768 | 2892 | '<span class="stat-badge"><svg viewBox="0 0 16 16" fill="currentColor"><path d="' + STAR_PATH + '"/></svg> ' + stars + '</span>' + |
2769 | 2893 | '<span class="stat-badge"><svg viewBox="0 0 16 16" fill="currentColor"><path d="' + FORK_PATH + '"/></svg> ' + forks + '</span>'; |
| 2894 | + if (typeof badgeRectsDirty !== 'undefined') badgeRectsDirty = true; |
2770 | 2895 | } |
2771 | 2896 |
|
2772 | 2897 | els.forEach(function(el) { |
|
0 commit comments