refactor: centralize theme/config/utils + fix data and console bugs

Phase 1 of the cohesion refactor (behavior-preserving):

Single source of truth:
- New src/lib/theme.ts: category colors (moved from data.ts, re-exported
  for back-compat), derived hex strings, nebula palette, UI colors, CSS var map
- New src/lib/config.ts: breakpoint, zoom, parallax, node radii, particle
  counts, animation timings (previously scattered magic numbers)
- New src/lib/utils/tree.ts: getNodeById, getNodeRadius, getDistanceFromOrigin,
  getBeamIntensity (pure helpers lifted out of onMount)
- New src/lib/utils/stats.ts: branch ids/labels, proficiency order, and the
  stats-panel aggregation helpers (deduped from inline copies)
- SkillTree.svelte now imports all of the above; removed the duplicated
  getCategoryColorHex, stats helpers, branch id/label lists, and inline magic numbers
- Populated src/lib/index.ts barrel

Bug fixes:
- data.ts: tmdbAPI pypiUrl pointed at tvdbAPI -> corrected to tmdbAPI
- Removed/ gated stray console.log noise (skill-tree-loaded, SW logs)
- Removed dead vars (maxAlpha, glowMaxAlpha) and unused catch binding

Tooling: ESLint + Prettier now clean; added Vitest unit tests for the new
pure utils (14 tests). svelte-check: 0 errors. build: ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
TehRiehlDealandClaude Opus 4.8 committed 2026-06-03 09:40:27 -07:00
1 parent aa58425f1a
commit 6c652e7138
12 files changed
+491 -219

No files matched your search

+85 -196
View File
@@ -3,7 +3,22 @@
import { slide } from 'svelte/transition';
import { Application, Graphics, Container, Assets, Sprite } from 'pixi.js';
import { gsap } from 'gsap';
import { treeNodes, categoryColors, type TreeNode } from '$lib/data';
import { treeNodes, type TreeNode } from '$lib/data';
import { categoryColors, categoryColorHex, nebulaPalette } from '$lib/theme';
import { MOBILE_BREAKPOINT, ZOOM, PARALLAX, PARTICLE_COUNTS, byDevice } from '$lib/config';
import {
getNodeById,
getNodeRadius,
getDistanceFromOrigin,
getBeamIntensity
} from '$lib/utils/tree';
import {
BRANCH_IDS,
calculatePlayerLevel,
getPlayerClass,
aggregateSkills,
countByProficiency
} from '$lib/utils/stats';
let container: HTMLDivElement;
let selectedNode = $state<TreeNode | null>(null);
@@ -31,9 +46,7 @@
// Stats panel state
let statsPanelVisible = $state(false);
let expandedBranches = $state(
new Set<string>(['concepts', 'tools-devops', 'frontend', 'backend-real-time', 'languages'])
); // All expanded by default
let expandedBranches = $state(new Set<string>(BRANCH_IDS)); // All expanded by default
function openModal(node: TreeNode) {
selectedNode = node;
@@ -56,78 +69,6 @@
}, 200);
}
function getCategoryColorHex(category: TreeNode['category']): string {
const colorMap: Record<TreeNode['category'], string> = {
origin: '#ffd700',
experience: '#4ecdc4',
projects: '#ff6b6b',
skills: '#f9a825',
education: '#dda0dd'
};
return colorMap[category];
}
// Helper functions for stats panel
function calculatePlayerLevel(): number {
// Find earliest start date from experience nodes
const earliestStart = new Date('2020-10-01'); // First job
const now = new Date();
const millisPerYear = 1000 * 60 * 60 * 24 * 365.25;
const years = (now.getTime() - earliestStart.getTime()) / millisPerYear;
return Math.floor(years);
}
function getPlayerClass(): string {
const originNode = treeNodes.find((node) => node.id === 'origin');
return originNode?.jobTitle || 'Software Engineer';
}
function aggregateSkills() {
const branchIds = ['concepts', 'tools-devops', 'frontend', 'backend-real-time', 'languages'];
const branchLabels: Record<string, string> = {
concepts: 'Concepts',
'tools-devops': 'Tools & DevOps',
frontend: 'Frontend',
'backend-real-time': 'Backend & Real-Time',
languages: 'Languages'
};
const result: Record<string, { label: string; skills: TreeNode[] }> = {};
branchIds.forEach((branchId) => {
const skills = treeNodes.filter(
(node) => node.parentId === branchId && node.category === 'skills'
);
// Sort by proficiency (expert > advanced > intermediate > beginner)
const proficiencyOrder: Record<string, number> = {
expert: 0,
advanced: 1,
intermediate: 2,
beginner: 3
};
skills.sort((a, b) => {
const aOrder = proficiencyOrder[a.proficiency || 'beginner'];
const bOrder = proficiencyOrder[b.proficiency || 'beginner'];
return aOrder - bOrder;
});
result[branchId] = { label: branchLabels[branchId], skills };
});
return result;
}
function countByProficiency() {
const counts = { expert: 0, advanced: 0, intermediate: 0, beginner: 0 };
treeNodes.forEach((node) => {
if (node.proficiency) {
counts[node.proficiency]++;
}
});
return counts;
}
function toggleStatsPanel() {
statsPanelVisible = !statsPanelVisible;
if (statsPanelVisible && modalVisible) {
@@ -145,20 +86,20 @@
}
// Stats calculations (reactive)
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));
const playerLevel = $derived(calculatePlayerLevel());
const playerClass = $derived(getPlayerClass());
const skillsByBranch = $derived(aggregateSkills());
const skillCounts = $derived(countByProficiency());
const totalSkills = $derived(Object.values(skillCounts).reduce((sum, count) => sum + count, 0));
// Update screen size
function handleResize() {
windowWidth = window.innerWidth;
windowHeight = window.innerHeight;
isMobile = windowWidth < 768;
isMobile = windowWidth < MOBILE_BREAKPOINT;
}
let cleanupFunctions: (() => void)[] = [];
const cleanupFunctions: Array<() => void> = [];
onMount(async () => {
handleResize();
@@ -174,6 +115,8 @@
antialias: true
});
// PixiJS owns this canvas, not Svelte — mounting it directly is intentional.
// eslint-disable-next-line svelte/no-dom-manipulating
container.appendChild(app.canvas);
// Hide loading indicator - PixiJS is ready, welcome title will be LCP
@@ -211,26 +154,20 @@
const b = Math.floor(20 * (1 - ratio));
const color = (r << 16) + (g << 8) + b;
spaceGradient.circle(centerX, centerY, radius).fill({ color: color, alpha: 0.8 });
spaceGradient.circle(centerX, centerY, radius).fill({ color, alpha: 0.8 });
}
deepBackgroundContainer.addChild(spaceGradient);
// ========== NEBULA CLOUDS ==========
const nebulaColors = [
{ color: 0x4ecdc4, name: 'teal' }, // Experience
{ color: 0xff6b6b, name: 'coral' }, // Projects
{ color: 0xf9a825, name: 'amber' }, // Skills
{ color: 0xdda0dd, name: 'plum' }, // Education
{ color: 0x6b5b95, name: 'purple' } // Extra accent
];
const nebulaColors = nebulaPalette;
// Create large nebula cloud regions
const nebulaRegions: {
const nebulaRegions: Array<{
graphics: Graphics;
baseX: number;
baseY: number;
pulseSpeed: number;
}[] = [];
}> = [];
nebulaColors.forEach((nebula, index) => {
// Position nebulas in different quadrants, offset from center
@@ -249,7 +186,7 @@
const size = outerSize * (0.4 + layerRatio * 0.6);
const alpha = 0.02 * (1 - layerRatio);
nebulaCloud.circle(0, 0, size).fill({ color: nebula.color, alpha: alpha });
nebulaCloud.circle(0, 0, size).fill({ color: nebula.color, alpha });
}
// Add some irregular shapes for more organic look
@@ -263,7 +200,7 @@
const size = blobSize * (0.5 + layerRatio * 0.5);
const alpha = 0.03 * (1 - layerRatio);
nebulaCloud.circle(offsetX, offsetY, size).fill({ color: nebula.color, alpha: alpha });
nebulaCloud.circle(offsetX, offsetY, size).fill({ color: nebula.color, alpha });
}
}
@@ -273,8 +210,8 @@
nebulaRegions.push({
graphics: nebulaCloud,
baseX: baseX,
baseY: baseY,
baseX,
baseY,
pulseSpeed: 0.5 + Math.random() * 0.5
});
@@ -301,13 +238,13 @@
// ========== DISTANT STAR FIELD ==========
// Layer 1: Tiny distant stars (many, dim)
const distantStarCount = isMobile ? 100 : 200;
const distantStarCount = byDevice(PARTICLE_COUNTS.distantStars, isMobile);
for (let i = 0; i < distantStarCount; i++) {
const star = new Graphics();
const size = Math.random() * 1 + 0.5;
const alpha = Math.random() * 0.4 + 0.1;
star.circle(0, 0, size).fill({ color: 0xffffff, alpha: alpha });
star.circle(0, 0, size).fill({ color: 0xffffff, alpha });
star.x = Math.random() * screenWidth;
star.y = Math.random() * screenHeight;
@@ -325,7 +262,7 @@
}
// Layer 2: Medium stars with color tints
const mediumStarCount = isMobile ? 30 : 60;
const mediumStarCount = byDevice(PARTICLE_COUNTS.mediumStars, isMobile);
const starTints = [0xffffff, 0xfff8e7, 0xe7f0ff, 0xffe7e7, 0xe7ffe7];
for (let i = 0; i < mediumStarCount; i++) {
@@ -336,7 +273,7 @@
// Star with subtle glow
star.circle(0, 0, size + 2).fill({ color: tint, alpha: alpha * 0.2 });
star.circle(0, 0, size).fill({ color: tint, alpha: alpha });
star.circle(0, 0, size).fill({ color: tint, alpha });
star.x = Math.random() * screenWidth;
star.y = Math.random() * screenHeight;
@@ -354,7 +291,7 @@
}
// Layer 3: Bright feature stars (few, prominent)
const brightStarCount = isMobile ? 8 : 15;
const brightStarCount = byDevice(PARTICLE_COUNTS.brightStars, isMobile);
for (let i = 0; i < brightStarCount; i++) {
const star = new Graphics();
const size = Math.random() * 2 + 1.5;
@@ -382,8 +319,8 @@
}
// ========== FLOATING DUST PARTICLES ==========
const dustParticles: { graphics: Graphics; vx: number; vy: number }[] = [];
const dustCount = isMobile ? 15 : 30;
const dustParticles: Array<{ graphics: Graphics; vx: number; vy: number }> = [];
const dustCount = byDevice(PARTICLE_COUNTS.dust, isMobile);
for (let i = 0; i < dustCount; i++) {
const dust = new Graphics();
@@ -392,9 +329,9 @@
const color = nebulaColors[colorIndex].color;
// Soft glowing particle
dust.circle(0, 0, size + 4).fill({ color: color, alpha: 0.05 });
dust.circle(0, 0, size + 2).fill({ color: color, alpha: 0.08 });
dust.circle(0, 0, size).fill({ color: color, alpha: 0.15 });
dust.circle(0, 0, size + 4).fill({ color, alpha: 0.05 });
dust.circle(0, 0, size + 2).fill({ color, alpha: 0.08 });
dust.circle(0, 0, size).fill({ color, alpha: 0.15 });
dust.x = Math.random() * screenWidth;
dust.y = Math.random() * screenHeight;
@@ -440,7 +377,7 @@
const radius = vignetteSize * (0.4 + ratio * 0.6);
const alpha = ratio * ratio * 0.4;
vignette.circle(centerX, centerY, radius).fill({ color: 0x000000, alpha: alpha });
vignette.circle(centerX, centerY, radius).fill({ color: 0x000000, alpha });
}
app.stage.addChild(vignette);
@@ -475,8 +412,8 @@
let lastTouchDistance = 0;
let isTouchZooming = false;
const minZoom = 0.3;
const maxZoom = 2.5;
const minZoom = ZOOM.min;
const maxZoom = ZOOM.max;
// ========== MOUSE EVENTS FOR PANNING ==========
app.canvas.addEventListener('mousedown', (e: MouseEvent) => {
@@ -604,7 +541,7 @@
e.preventDefault();
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 newScale = Math.max(minZoom, Math.min(maxZoom, oldScale * zoomFactor));
// 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)
@@ -613,84 +550,43 @@
updateParallax();
});
// ========== HELPER FUNCTIONS ==========
function getNodeById(id: string): TreeNode | undefined {
return treeNodes.find((node) => node.id === id);
}
function getNodeRadius(size: TreeNode['size']): number {
const mobileFactor = isMobile ? 0.8 : 1;
switch (size) {
case 'x-large':
return 75 * mobileFactor;
case 'large':
return 30 * mobileFactor;
case 'medium':
return 25 * mobileFactor;
case 'small':
return 20 * mobileFactor;
default:
return 14 * mobileFactor;
}
}
function getDistanceFromOrigin(node: TreeNode): number {
return Math.sqrt(node.x * node.x + node.y * node.y);
}
// ========== PARALLAX UPDATE ==========
// (Pure tree helpers — getNodeById, getNodeRadius, getDistanceFromOrigin,
// getBeamIntensity — now live in $lib/utils/tree.)
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.1;
const nebulaFactor = 0.07;
const gradientFactor = 0.05;
// Update positions - backgrounds lag behind tree movement
starContainer.x = treeDeltaX * starFactor;
starContainer.y = treeDeltaY * starFactor;
starContainer.x = treeDeltaX * PARALLAX.star;
starContainer.y = treeDeltaY * PARALLAX.star;
nebulaContainer.x = treeDeltaX * nebulaFactor;
nebulaContainer.y = treeDeltaY * nebulaFactor;
nebulaContainer.x = treeDeltaX * PARALLAX.nebula;
nebulaContainer.y = treeDeltaY * PARALLAX.nebula;
deepBackgroundContainer.x = treeDeltaX * gradientFactor;
deepBackgroundContainer.y = treeDeltaY * gradientFactor;
deepBackgroundContainer.x = treeDeltaX * PARALLAX.gradient;
deepBackgroundContainer.y = treeDeltaY * PARALLAX.gradient;
// No scale parallax - backgrounds keep scale 1.0
}
// ========== DRAW GLOWING LINES WITH PLASMA BEAMS ==========
const lineObjects: {
const lineObjects: Array<{
graphics: Graphics;
glowGraphics: Graphics;
plasmaBeam: Graphics;
node: TreeNode;
parent: TreeNode;
intensity: number;
}[] = [];
}> = [];
for (const node of treeNodes) {
if (node.parentId) {
const parent = getNodeById(node.parentId);
if (parent) {
// Calculate beam intensity based on years of experience (for skill nodes)
let intensity = 0.3; // Default base intensity
if (node.category === 'skills' && node.yearsOfExperience) {
// Scale from 0.3 (beginner) to 1.0 (10+ years)
intensity = Math.min(0.3 + (node.yearsOfExperience / 10) * 0.7, 1.0);
} else if (node.category === 'experience') {
// Experience nodes get medium-high intensity
intensity = 0.7;
} else if (node.category === 'origin') {
// Origin connections are always bright
intensity = 1.0;
} else {
// Projects and education get medium intensity
intensity = 0.5;
}
// Beam intensity scales with experience (see $lib/utils/tree).
const intensity = getBeamIntensity(node);
const color = categoryColors[node.category];
@@ -700,7 +596,7 @@
glowGraphics
.moveTo(parent.x, parent.y)
.lineTo(node.x, node.y)
.stroke({ width: 12, color: color, alpha: 0.1 * intensity });
.stroke({ width: 12, color, alpha: 0.1 * intensity });
treeContainer.addChild(glowGraphics);
// Core line (thin, solid)
@@ -718,7 +614,7 @@
plasmaBeam
.moveTo(parent.x, parent.y)
.lineTo(node.x, node.y)
.stroke({ width: 4, color: color, alpha: 0.6 * intensity });
.stroke({ width: 4, color, alpha: 0.6 * intensity });
treeContainer.addChild(plasmaBeam);
lineObjects.push({
@@ -734,21 +630,21 @@
}
// ========== DRAW NODES ==========
const nodeObjects: {
const nodeObjects: Array<{
graphics: Graphics | Container;
node: TreeNode;
glowGraphics: Graphics;
}[] = [];
}> = [];
const profileTexture = await Assets.load('/headshot.jpg');
for (const node of treeNodes) {
const radius = getNodeRadius(node.size);
const radius = getNodeRadius(node.size, isMobile);
const color = categoryColors[node.category];
const glowGraphics = new Graphics();
glowGraphics.circle(0, 0, radius + 8).fill({ color: color, alpha: 0.1 });
glowGraphics.circle(0, 0, radius + 4).fill({ color: color, alpha: 0.15 });
glowGraphics.circle(0, 0, radius + 8).fill({ color, alpha: 0.1 });
glowGraphics.circle(0, 0, radius + 4).fill({ color, alpha: 0.15 });
glowGraphics.x = node.x;
glowGraphics.y = node.y;
glowGraphics.alpha = 0;
@@ -771,8 +667,8 @@
profileContainer.addChild(profileSprite);
const borderGraphics = new Graphics();
borderGraphics.circle(0, 0, radius + 2).stroke({ width: 3, color: color });
borderGraphics.circle(0, 0, radius + 6).stroke({ width: 2, color: color, alpha: 0.5 });
borderGraphics.circle(0, 0, radius + 2).stroke({ width: 3, color });
borderGraphics.circle(0, 0, radius + 6).stroke({ width: 2, color, alpha: 0.5 });
profileContainer.addChild(borderGraphics);
profileContainer.alpha = 0;
@@ -813,12 +709,9 @@
const nodeGraphics = new Graphics();
nodeGraphics
.circle(0, 0, radius)
.fill({ color: 0x12121a })
.stroke({ width: 2, color: color });
nodeGraphics.circle(0, 0, radius).fill({ color: 0x12121a }).stroke({ width: 2, color });
nodeGraphics.circle(0, 0, radius * 0.75).stroke({ width: 1, color: color, alpha: 0.2 });
nodeGraphics.circle(0, 0, radius * 0.75).stroke({ width: 1, color, alpha: 0.2 });
nodeContainer.addChild(nodeGraphics);
@@ -834,7 +727,7 @@
iconSprite.tint = color;
nodeContainer.addChild(iconSprite);
} catch (e) {
} catch {
console.warn(`Could not load icon for ${node.id}:`, node.icon);
}
}
@@ -970,14 +863,14 @@
gsap.to(obj.graphics, {
alpha: 1,
duration: 0.5,
delay: delay,
delay,
ease: 'power2.out'
});
gsap.to(obj.graphics.scale, {
x: 1,
y: 1,
duration: 0.5,
delay: delay,
delay,
ease: 'back.out(1.7)',
onComplete: () => {
// Enable interactivity after animation completes
@@ -989,7 +882,7 @@
gsap.to(obj.glowGraphics, {
alpha: 1,
duration: 0.5,
delay: delay,
delay,
ease: 'power2.out'
});
@@ -1028,7 +921,7 @@
gsap.to(line.graphics, {
alpha: 1,
duration: 0.4,
delay: delay,
delay,
ease: 'power2.out'
});
@@ -1049,7 +942,6 @@
onComplete: () => {
// After beam appears, start continuous pulsing animation
const pulseDuration = 2.0 + Math.random() * 1.0; // Vary pulse speed
const maxAlpha = 0.9 * line.intensity; // Base alpha on intensity
const minAlpha = 0.6 * line.intensity; // Minimum brightness - stays visible
// Pulsing alpha animation (breathing effect)
@@ -1062,7 +954,6 @@
});
// Glow layer also pulses
const glowMaxAlpha = 0.2 * line.intensity;
const glowMinAlpha = 0.12 * line.intensity;
gsap.to(line.glowGraphics, {
alpha: glowMinAlpha,
@@ -1139,7 +1030,7 @@
if (e.key === '+' || e.key === '=') {
e.preventDefault();
const currentScale = treeContainer.scale.x;
let newScale = Math.min(maxZoom, currentScale + zoomSpeed);
const newScale = Math.min(maxZoom, currentScale + zoomSpeed);
treeContainer.scale.set(newScale);
updateParallax();
}
@@ -1148,7 +1039,7 @@
if (e.key === '-') {
e.preventDefault();
const currentScale = treeContainer.scale.x;
let newScale = Math.max(minZoom, currentScale - zoomSpeed);
const newScale = Math.max(minZoom, currentScale - zoomSpeed);
treeContainer.scale.set(newScale);
updateParallax();
}
@@ -1180,8 +1071,6 @@
window.addEventListener('keydown', handleKeydown);
console.log('✨ Responsive skill tree loaded!');
// Store cleanup functions
cleanupFunctions.push(() => {
window.removeEventListener('resize', handleResize);
@@ -1197,7 +1086,7 @@
});
// Calculate tooltip position to keep it on screen
let tooltipStyle = $derived.by(() => {
const tooltipStyle = $derived.by(() => {
let x = tooltipX + 15;
let y = tooltipY + 15;
@@ -1249,7 +1138,7 @@
class="tooltip"
role="tooltip"
aria-live="polite"
style="{tooltipStyle} --node-color: {getCategoryColorHex(hoveredNode.category)};"
style="{tooltipStyle} --node-color: {categoryColorHex[hoveredNode.category]};"
>
<h3>
{#if hoveredNode.icon}
@@ -1282,7 +1171,7 @@
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
style="--node-color: {getCategoryColorHex(selectedNode.category)}"
style="--node-color: {categoryColorHex[selectedNode.category]}"
>
<button class="close-btn" on:click={closeModal} aria-label="Close dialog">✕</button>
@@ -1394,7 +1283,7 @@
<!-- Highlights (for experience) -->
{#if selectedNode.highlights && selectedNode.highlights.length > 0}
<ul class="highlights">
{#each selectedNode.highlights as highlight}
{#each selectedNode.highlights as highlight (highlight)}
<li>{highlight}</li>
{/each}
</ul>
@@ -1403,7 +1292,7 @@
<!-- Tech stack (for projects) -->
{#if selectedNode.techStack && selectedNode.techStack.length > 0}
<div class="tech-stack">
{#each selectedNode.techStack as tech}
{#each selectedNode.techStack as tech (tech)}
<span class="tech-tag">{tech}</span>
{/each}
</div>
@@ -1538,7 +1427,7 @@
</div>
<!-- Skill Branches -->
{#each Object.entries(skillsByBranch) as [branchId, branch]}
{#each Object.entries(skillsByBranch) as [branchId, branch] (branchId)}
<div class="skill-branch">
<div
class="branch-header"
@@ -1560,7 +1449,7 @@
{#if expandedBranches.has(branchId)}
<div class="skill-list" transition:slide={{ duration: 300 }}>
{#each branch.skills as skill}
{#each branch.skills as skill (skill.id)}
<div class="skill-item">
{#if skill.icon}
<img src={skill.icon} alt="" class="skill-item-icon" />