diff --git a/src/lib/components/SkillTree.svelte b/src/lib/components/SkillTree.svelte index 2d71eba..1bc5c38 100644 --- a/src/lib/components/SkillTree.svelte +++ b/src/lib/components/SkillTree.svelte @@ -6,31 +6,38 @@ import { treeNodes, categoryColors, type TreeNode } from '$lib/data'; let container: HTMLDivElement; - let selectedNode: TreeNode | null = null; - let modalVisible = false; - let showWelcomeTitle = true; + let selectedNode = $state(null); + let modalVisible = $state(false); + let showWelcomeTitle = $state(true); // Tooltip state - let hoveredNode: TreeNode | null = null; - let tooltipX = 0; - let tooltipY = 0; + let hoveredNode = $state(null); + let tooltipX = $state(0); + let tooltipY = $state(0); // Screen size detection - let isMobile = false; - let windowWidth = 0; - let windowHeight = 0; + let isMobile = $state(false); + let windowWidth = $state(0); + let windowHeight = $state(0); let modalOpenedAt = 0; + // Parallax state - track initial tree position for offset calculations + let initialTreeX = 0; + let initialTreeY = 0; + + // Loading state for Lighthouse LCP detection + let isLoading = $state(true); + // Stats panel state - let statsPanelVisible = false; - let expandedBranches = new Set([ + let statsPanelVisible = $state(false); + let expandedBranches = $state(new Set([ 'concepts', 'tools-devops', 'frontend', 'backend-real-time', 'languages' - ]); // All expanded by default + ])); // All expanded by default function openModal(node: TreeNode) { selectedNode = node; @@ -142,11 +149,11 @@ } // Stats calculations (reactive) - $: playerLevel = calculatePlayerLevel(); - $: playerClass = getPlayerClass(); - $: skillsByBranch = aggregateSkills(); - $: skillCounts = countByProficiency(); - $: totalSkills = Object.values(skillCounts).reduce((sum, count) => sum + count, 0); + let playerLevel = $derived(calculatePlayerLevel()); + let playerClass = $derived(getPlayerClass()); + let skillsByBranch = $derived(aggregateSkills()); + let skillCounts = $derived(countByProficiency()); + let totalSkills = $derived(Object.values(skillCounts).reduce((sum, count) => sum + count, 0)); // Update screen size function handleResize() { @@ -155,6 +162,8 @@ isMobile = windowWidth < 768; } + let cleanupFunctions: (() => void)[] = []; + onMount(async () => { handleResize(); window.addEventListener('resize', handleResize); @@ -171,6 +180,9 @@ container.appendChild(app.canvas); + // Hide loading indicator - PixiJS is ready, welcome title will be LCP + isLoading = false; + // ========== BACKGROUND LAYERS ========== // We create multiple layers for depth const deepBackgroundContainer = new Container(); @@ -465,6 +477,10 @@ treeContainer.x = app.screen.width / 2; treeContainer.y = app.screen.height / 2; + // Track initial position for parallax calculations + initialTreeX = treeContainer.x; + initialTreeY = treeContainer.y; + // Start zoomed in - will zoom out after welcome title // Mobile needs to zoom out further to show more of the tree const startScale = isMobile ? 3 : 4; @@ -507,6 +523,7 @@ } treeContainer.x = containerStartX + dx; treeContainer.y = containerStartY + dy; + updateParallax(); }); window.addEventListener('mouseup', () => { @@ -555,6 +572,7 @@ hasMoved = true; treeContainer.x = containerStartX + dx; treeContainer.y = containerStartY + dy; + updateParallax(); } } else if (e.touches.length === 2 && isTouchZooming) { // Two touches - pinch zoom @@ -567,20 +585,9 @@ let newScale = treeContainer.scale.x * scale; newScale = Math.max(minZoom, Math.min(maxZoom, newScale)); - // Zoom toward center of the two touches - const centerX = (e.touches[0].clientX + e.touches[1].clientX) / 2; - const centerY = (e.touches[0].clientY + e.touches[1].clientY) / 2; - const rect = app.canvas.getBoundingClientRect(); - const mouseX = centerX - rect.left; - const mouseY = centerY - rect.top; - - const oldScale = treeContainer.scale.x; - const worldX = (mouseX - treeContainer.x) / oldScale; - const worldY = (mouseY - treeContainer.y) / oldScale; - + // Zoom centered on origin node (same as mouse wheel zoom) treeContainer.scale.set(newScale); - treeContainer.x = mouseX - worldX * newScale; - treeContainer.y = mouseY - worldY * newScale; + updateParallax(); } lastTouchDistance = distance; @@ -608,17 +615,15 @@ // ========== SCROLL EVENT FOR ZOOMING ========== app.canvas.addEventListener('wheel', (e: WheelEvent) => { e.preventDefault(); - const rect = app.canvas.getBoundingClientRect(); - const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; const oldScale = treeContainer.scale.x; const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; let newScale = Math.max(minZoom, Math.min(maxZoom, oldScale * zoomFactor)); - const worldX = (mouseX - treeContainer.x) / oldScale; - const worldY = (mouseY - treeContainer.y) / oldScale; + + // Zoom centered on origin node (0,0 in tree space) + // Since origin is at (0,0), it's always at screen position (treeContainer.x, treeContainer.y) + // Just changing scale keeps the origin in the same screen position treeContainer.scale.set(newScale); - treeContainer.x = mouseX - worldX * newScale; - treeContainer.y = mouseY - worldY * newScale; + updateParallax(); }); // ========== HELPER FUNCTIONS ========== @@ -646,6 +651,30 @@ return Math.sqrt(node.x * node.x + node.y * node.y); } + // ========== PARALLAX UPDATE ========== + function updateParallax() { + // Calculate tree offset from initial position + const treeDeltaX = treeContainer.x - initialTreeX; + const treeDeltaY = treeContainer.y - initialTreeY; + + // Parallax position factors (subtle intensity) + const starFactor = 0.10; + const nebulaFactor = 0.07; + const gradientFactor = 0.05; + + // Update positions - backgrounds lag behind tree movement + starContainer.x = treeDeltaX * starFactor; + starContainer.y = treeDeltaY * starFactor; + + nebulaContainer.x = treeDeltaX * nebulaFactor; + nebulaContainer.y = treeDeltaY * nebulaFactor; + + deepBackgroundContainer.x = treeDeltaX * gradientFactor; + deepBackgroundContainer.y = treeDeltaY * gradientFactor; + + // No scale parallax - backgrounds keep scale 1.0 + } + // ========== DRAW GLOWING LINES WITH PLASMA BEAMS ========== const lineObjects: { graphics: Graphics; @@ -943,13 +972,14 @@ // STEP 2: Start zoom out after origin appears const zoomDelay = isMobile ? titleDuration - 0.3 : titleDuration + 0.3; - + gsap.to(treeContainer.scale, { x: endScale, y: endScale, duration: zoomDuration, delay: zoomDelay, ease: 'power1.out', + onUpdate: updateParallax }); // STEP 3: Animate in the rest of the nodes (excluding origin) @@ -1083,7 +1113,7 @@ }); } - // Keyboard event handler for stats panel + // Keyboard event handler for stats panel and navigation function handleKeydown(e: KeyboardEvent) { // Toggle stats panel with 'C' key if (e.key === 'c' || e.key === 'C') { @@ -1100,20 +1130,93 @@ toggleStatsPanel(); } } + + // Keyboard navigation for tree panning and zooming + const panSpeed = 50; + const zoomSpeed = 0.1; + + // Arrow keys for panning + if (e.key === 'ArrowUp') { + e.preventDefault(); + treeContainer.y += panSpeed; + updateParallax(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + treeContainer.y -= panSpeed; + updateParallax(); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + treeContainer.x += panSpeed; + updateParallax(); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + treeContainer.x -= panSpeed; + updateParallax(); + } + + // + and = for zoom in + if (e.key === '+' || e.key === '=') { + e.preventDefault(); + const currentScale = treeContainer.scale.x; + let newScale = Math.min(maxZoom, currentScale + zoomSpeed); + treeContainer.scale.set(newScale); + updateParallax(); + } + + // - for zoom out + if (e.key === '-') { + e.preventDefault(); + const currentScale = treeContainer.scale.x; + let newScale = Math.max(minZoom, currentScale - zoomSpeed); + treeContainer.scale.set(newScale); + updateParallax(); + } + + // R to reset view to origin + if (e.key === 'r' || e.key === 'R') { + e.preventDefault(); + gsap.to(treeContainer, { + x: app.screen.width / 2, + y: app.screen.height / 2, + duration: 0.5, + ease: 'power2.out', + onUpdate: updateParallax, + onComplete: () => { + // Update initial positions after reset + initialTreeX = treeContainer.x; + initialTreeY = treeContainer.y; + } + }); + gsap.to(treeContainer.scale, { + x: endScale, + y: endScale, + duration: 0.5, + ease: 'power2.out', + onUpdate: updateParallax + }); + } } window.addEventListener('keydown', handleKeydown); console.log('✨ Responsive skill tree loaded!'); - return () => { + // Store cleanup functions + cleanupFunctions.push(() => { window.removeEventListener('resize', handleResize); window.removeEventListener('keydown', handleKeydown); + }); + }); + + // Cleanup on component unmount + $effect(() => { + return () => { + cleanupFunctions.forEach(fn => fn()); }; }); // Calculate tooltip position to keep it on screen - $: tooltipStyle = (() => { + let tooltipStyle = $derived.by(() => { let x = tooltipX + 15; let y = tooltipY + 15; @@ -1129,12 +1232,27 @@ } return `left: ${x}px; top: ${y}px;`; - })(); + }); -
+ +
+ + +{#if isLoading} +
+
+

Loading Atlas of Skills...

+
+{/if} {#if showWelcomeTitle} @@ -1148,6 +1266,8 @@ {#if hoveredNode && !modalVisible && !isMobile} {/if} @@ -1317,17 +1445,32 @@ on:click={toggleStatsPanel} title="Character Stats (C)" aria-label="Toggle Character Stats Panel (Keyboard shortcut: C)" + aria-expanded={statsPanelVisible} + aria-controls="stats-panel" > ⚙️ {#if statsPanelVisible} -
+
e.key === 'Escape' && toggleStatsPanel()} + role="presentation" + aria-label="Stats panel backdrop. Click to close or press Escape." + >
{/if} -