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:
@@ -6,6 +6,14 @@ export default [
|
||||
ignores: ['build/', '.svelte-kit/', 'static/', 'node_modules/']
|
||||
},
|
||||
...svelteConfig,
|
||||
{
|
||||
rules: {
|
||||
// This is a static, client-only portfolio whose links point at external
|
||||
// URLs (projects, resume, mailto). The resolve() rule targets SvelteKit
|
||||
// internal navigation, so it only produces false positives here.
|
||||
'svelte/no-navigation-without-resolve': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
// Service worker runs in a ServiceWorkerGlobalScope, not the browser/node defaults.
|
||||
files: ['static/service-worker.js'],
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// ==========================================================================
|
||||
// CONFIG — non-colour tunables (colours live in `theme.ts`).
|
||||
//
|
||||
// Centralises the magic numbers that were previously scattered through
|
||||
// SkillTree.svelte so behaviour can be tuned in one place.
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Viewport width (px) below which the mobile layout/behaviour kicks in.
|
||||
* NOTE: JS treats `< 768` as mobile; CSS media queries use `max-width: 767px`.
|
||||
* Both make 768px the first desktop width — keep that pairing if you change this.
|
||||
*/
|
||||
export const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
/** Pan/zoom limits and step sizes. */
|
||||
export const ZOOM = {
|
||||
min: 0.3,
|
||||
max: 2.5,
|
||||
keyStep: 0.1,
|
||||
wheelIn: 1.1,
|
||||
wheelOut: 0.9
|
||||
} as const;
|
||||
|
||||
/** Background parallax lag factors (fraction of tree movement). */
|
||||
export const PARALLAX = {
|
||||
star: 0.1,
|
||||
nebula: 0.07,
|
||||
gradient: 0.05
|
||||
} as const;
|
||||
|
||||
/** Node radii by size (desktop px); mobile multiplies by MOBILE_RADIUS_FACTOR. */
|
||||
export const NODE_RADII: Record<string, number> = {
|
||||
'x-large': 75,
|
||||
large: 30,
|
||||
medium: 25,
|
||||
small: 20,
|
||||
default: 14
|
||||
};
|
||||
export const MOBILE_RADIUS_FACTOR = 0.8;
|
||||
|
||||
/** Keyboard pan distance per arrow-key press (px). */
|
||||
export const PAN_SPEED = 50;
|
||||
|
||||
/** Background particle counts (desktop / mobile). */
|
||||
export const PARTICLE_COUNTS = {
|
||||
distantStars: { desktop: 200, mobile: 100 },
|
||||
mediumStars: { desktop: 60, mobile: 30 },
|
||||
brightStars: { desktop: 15, mobile: 8 },
|
||||
dust: { desktop: 30, mobile: 15 }
|
||||
} as const;
|
||||
|
||||
/** Entrance-animation timings (seconds), split by device. */
|
||||
export const TIMINGS = {
|
||||
titleDuration: { desktop: 2.5, mobile: 1.5 },
|
||||
zoomDuration: { desktop: 4.5, mobile: 2.5 },
|
||||
nodeStagger: { desktop: 0.08, mobile: 0.05 }
|
||||
} as const;
|
||||
|
||||
/** Helper: pick a desktop/mobile value. */
|
||||
export function byDevice<T>(pair: { desktop: T; mobile: T }, isMobile: boolean): T {
|
||||
return isMobile ? pair.mobile : pair.desktop;
|
||||
}
|
||||
+4
-9
@@ -300,7 +300,7 @@ export const treeNodes: TreeNode[] = [
|
||||
icon: '/icons/python.svg',
|
||||
techStack: ['Python', 'REST API', 'PyPI', 'GitHub Actions', 'Unit Testing'],
|
||||
repoUrl: 'https://github.com/TehRiehlDeal/tmdbAPI',
|
||||
pypiUrl: 'https://pypi.org/project/tvdbAPI/',
|
||||
pypiUrl: 'https://pypi.org/project/tmdbAPI/',
|
||||
status: 'maintained'
|
||||
},
|
||||
{
|
||||
@@ -895,11 +895,6 @@ export const treeNodes: TreeNode[] = [
|
||||
// },
|
||||
];
|
||||
|
||||
// Colors for each category (PoE-inspired)
|
||||
export const categoryColors: Record<TreeNode['category'], number> = {
|
||||
origin: 0xffd700, // Gold
|
||||
experience: 0x4ecdc4, // Teal
|
||||
projects: 0xff6b6b, // Coral red
|
||||
skills: 0xf9a825, // Amber orange
|
||||
education: 0xdda0dd // Plum purple
|
||||
};
|
||||
// Category colours now live in `$lib/theme` (single source of truth).
|
||||
// Re-exported here for back-compat with existing `$lib/data` imports.
|
||||
export { categoryColors } from './theme';
|
||||
|
||||
+12
-1
@@ -1 +1,12 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
// Public `$lib` surface for the skill-tree portfolio.
|
||||
export { default as SkillTree } from './components/SkillTree.svelte';
|
||||
export { treeNodes, type TreeNode } from './data';
|
||||
export {
|
||||
categoryColors,
|
||||
categoryColorHex,
|
||||
nebulaPalette,
|
||||
uiTheme,
|
||||
cssVars,
|
||||
toHex,
|
||||
type Category
|
||||
} from './theme';
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// ==========================================================================
|
||||
// THEME — single source of truth for colours.
|
||||
//
|
||||
// Category colours live here as PixiJS-friendly numbers (0xRRGGBB) and are
|
||||
// derived to CSS hex strings (#rrggbb). The CSS custom properties in
|
||||
// `src/app.css` mirror the `cssVars` map below — keep them in sync.
|
||||
// ==========================================================================
|
||||
|
||||
export type Category = 'origin' | 'experience' | 'projects' | 'skills' | 'education';
|
||||
|
||||
/** Convert a PixiJS colour number (0xRRGGBB) to a CSS hex string (#rrggbb). */
|
||||
export function toHex(color: number): string {
|
||||
return '#' + color.toString(16).padStart(6, '0');
|
||||
}
|
||||
|
||||
/** Category colours for PixiJS graphics (PoE-inspired palette). */
|
||||
export const categoryColors: Record<Category, number> = {
|
||||
origin: 0xffd700, // Gold
|
||||
experience: 0x4ecdc4, // Teal
|
||||
projects: 0xff6b6b, // Coral red
|
||||
skills: 0xf9a825, // Amber orange
|
||||
education: 0xdda0dd // Plum purple
|
||||
};
|
||||
|
||||
/** Same category colours as CSS hex strings, derived from `categoryColors`. */
|
||||
export const categoryColorHex: Record<Category, string> = {
|
||||
origin: toHex(categoryColors.origin),
|
||||
experience: toHex(categoryColors.experience),
|
||||
projects: toHex(categoryColors.projects),
|
||||
skills: toHex(categoryColors.skills),
|
||||
education: toHex(categoryColors.education)
|
||||
};
|
||||
|
||||
/** Nebula cloud / dust particle palette (background atmosphere). */
|
||||
export const nebulaPalette: Array<{ color: number; name: string }> = [
|
||||
{ color: categoryColors.experience, name: 'teal' },
|
||||
{ color: categoryColors.projects, name: 'coral' },
|
||||
{ color: categoryColors.skills, name: 'amber' },
|
||||
{ color: categoryColors.education, name: 'plum' },
|
||||
{ color: 0x6b5b95, name: 'purple' } // Extra accent
|
||||
];
|
||||
|
||||
/** Semantic UI colours used across the canvas and the DOM overlays. */
|
||||
export const uiTheme = {
|
||||
gold: '#ffd700',
|
||||
bgSpace: '#05050a',
|
||||
panelFrom: '#1a1a2e',
|
||||
panelTo: '#0f0f1a',
|
||||
nodeFill: 0x12121a,
|
||||
lineCore: 0x4a4a5a,
|
||||
textMuted: '#a8a8b8'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Flat map of CSS custom property name -> value. `src/app.css` declares these
|
||||
* on `:root`; this object documents the canonical values they mirror.
|
||||
*/
|
||||
export const cssVars: Record<string, string> = {
|
||||
'--color-gold': categoryColorHex.origin,
|
||||
'--color-experience': categoryColorHex.experience,
|
||||
'--color-projects': categoryColorHex.projects,
|
||||
'--color-skills': categoryColorHex.skills,
|
||||
'--color-education': categoryColorHex.education,
|
||||
'--bg-space': uiTheme.bgSpace,
|
||||
'--panel-from': uiTheme.panelFrom,
|
||||
'--panel-to': uiTheme.panelTo,
|
||||
'--text-muted': uiTheme.textMuted
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
BRANCH_IDS,
|
||||
BRANCH_LABELS,
|
||||
aggregateSkills,
|
||||
countByProficiency,
|
||||
getPlayerClass
|
||||
} from './stats';
|
||||
import { treeNodes } from '$lib/data';
|
||||
|
||||
describe('aggregateSkills', () => {
|
||||
const result = aggregateSkills();
|
||||
|
||||
it('has an entry for every branch id', () => {
|
||||
for (const id of BRANCH_IDS) {
|
||||
expect(result[id]).toBeDefined();
|
||||
expect(result[id].label).toBe(BRANCH_LABELS[id]);
|
||||
}
|
||||
});
|
||||
|
||||
it('only groups skill nodes under their parent branch', () => {
|
||||
for (const id of BRANCH_IDS) {
|
||||
for (const skill of result[id].skills) {
|
||||
expect(skill.category).toBe('skills');
|
||||
expect(skill.parentId).toBe(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('sorts each branch by proficiency (expert first)', () => {
|
||||
const rank = { expert: 0, advanced: 1, intermediate: 2, beginner: 3 } as const;
|
||||
for (const id of BRANCH_IDS) {
|
||||
const skills = result[id].skills;
|
||||
for (let i = 1; i < skills.length; i++) {
|
||||
const prev = rank[skills[i - 1].proficiency ?? 'beginner'];
|
||||
const cur = rank[skills[i].proficiency ?? 'beginner'];
|
||||
expect(prev).toBeLessThanOrEqual(cur);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('countByProficiency', () => {
|
||||
it('sums to the total number of nodes that have a proficiency', () => {
|
||||
const counts = countByProficiency();
|
||||
const total = Object.values(counts).reduce((sum, n) => sum + n, 0);
|
||||
const expected = treeNodes.filter((n) => n.proficiency).length;
|
||||
expect(total).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlayerClass', () => {
|
||||
it("returns the origin node's job title", () => {
|
||||
const origin = treeNodes.find((n) => n.id === 'origin');
|
||||
expect(getPlayerClass()).toBe(origin?.jobTitle);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// ==========================================================================
|
||||
// STATS UTILS — pure aggregation for the RPG-style character panel.
|
||||
// ==========================================================================
|
||||
|
||||
import { treeNodes, type TreeNode } from '$lib/data';
|
||||
|
||||
/** Skill branch ids and their display labels (single source of truth). */
|
||||
export const BRANCH_IDS = [
|
||||
'concepts',
|
||||
'tools-devops',
|
||||
'frontend',
|
||||
'backend-real-time',
|
||||
'languages'
|
||||
] as const;
|
||||
|
||||
export const BRANCH_LABELS: Record<string, string> = {
|
||||
concepts: 'Concepts',
|
||||
'tools-devops': 'Tools & DevOps',
|
||||
frontend: 'Frontend',
|
||||
'backend-real-time': 'Backend & Real-Time',
|
||||
languages: 'Languages'
|
||||
};
|
||||
|
||||
/** Proficiency sort order (best first). */
|
||||
export const PROFICIENCY_ORDER: Record<string, number> = {
|
||||
expert: 0,
|
||||
advanced: 1,
|
||||
intermediate: 2,
|
||||
beginner: 3
|
||||
};
|
||||
|
||||
/** Character "level" derived from years since the first job started. */
|
||||
export function calculatePlayerLevel(): number {
|
||||
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);
|
||||
}
|
||||
|
||||
/** Character "class" — the origin node's job title. */
|
||||
export function getPlayerClass(): string {
|
||||
const originNode = treeNodes.find((node) => node.id === 'origin');
|
||||
return originNode?.jobTitle || 'Software Engineer';
|
||||
}
|
||||
|
||||
/** Group skill nodes by branch, sorted by proficiency. */
|
||||
export function aggregateSkills(): Record<string, { label: string; skills: TreeNode[] }> {
|
||||
const result: Record<string, { label: string; skills: TreeNode[] }> = {};
|
||||
|
||||
BRANCH_IDS.forEach((branchId) => {
|
||||
const skills = treeNodes.filter(
|
||||
(node) => node.parentId === branchId && node.category === 'skills'
|
||||
);
|
||||
|
||||
skills.sort((a, b) => {
|
||||
const aOrder = PROFICIENCY_ORDER[a.proficiency || 'beginner'];
|
||||
const bOrder = PROFICIENCY_ORDER[b.proficiency || 'beginner'];
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
|
||||
result[branchId] = { label: BRANCH_LABELS[branchId], skills };
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Count all skill nodes by proficiency tier. */
|
||||
export function countByProficiency(): {
|
||||
expert: number;
|
||||
advanced: number;
|
||||
intermediate: number;
|
||||
beginner: number;
|
||||
} {
|
||||
const counts = { expert: 0, advanced: 0, intermediate: 0, beginner: 0 };
|
||||
treeNodes.forEach((node) => {
|
||||
if (node.proficiency) {
|
||||
counts[node.proficiency]++;
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getNodeById, getNodeRadius, getDistanceFromOrigin, getBeamIntensity } from './tree';
|
||||
import { NODE_RADII, MOBILE_RADIUS_FACTOR } from '$lib/config';
|
||||
import type { TreeNode } from '$lib/data';
|
||||
|
||||
function makeNode(partial: Partial<TreeNode>): TreeNode {
|
||||
return {
|
||||
id: 'test',
|
||||
label: 'Test',
|
||||
description: '',
|
||||
x: 0,
|
||||
y: 0,
|
||||
parentId: null,
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
...partial
|
||||
} as TreeNode;
|
||||
}
|
||||
|
||||
describe('getNodeById', () => {
|
||||
it('finds the origin node', () => {
|
||||
expect(getNodeById('origin')?.id).toBe('origin');
|
||||
});
|
||||
|
||||
it('returns undefined for an unknown id', () => {
|
||||
expect(getNodeById('does-not-exist')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeRadius', () => {
|
||||
it('returns the desktop base radius for each size', () => {
|
||||
expect(getNodeRadius('x-large', false)).toBe(NODE_RADII['x-large']);
|
||||
expect(getNodeRadius('small', false)).toBe(NODE_RADII.small);
|
||||
});
|
||||
|
||||
it('scales down on mobile', () => {
|
||||
expect(getNodeRadius('large', true)).toBe(NODE_RADII.large * MOBILE_RADIUS_FACTOR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDistanceFromOrigin', () => {
|
||||
it('computes Euclidean distance', () => {
|
||||
expect(getDistanceFromOrigin(makeNode({ x: 3, y: 4 }))).toBe(5);
|
||||
expect(getDistanceFromOrigin(makeNode({ x: 0, y: 0 }))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBeamIntensity', () => {
|
||||
it('is 1.0 for origin', () => {
|
||||
expect(getBeamIntensity(makeNode({ category: 'origin' }))).toBe(1.0);
|
||||
});
|
||||
|
||||
it('is 0.7 for experience', () => {
|
||||
expect(getBeamIntensity(makeNode({ category: 'experience' }))).toBe(0.7);
|
||||
});
|
||||
|
||||
it('is 0.5 for projects/education', () => {
|
||||
expect(getBeamIntensity(makeNode({ category: 'projects' }))).toBe(0.5);
|
||||
expect(getBeamIntensity(makeNode({ category: 'education' }))).toBe(0.5);
|
||||
});
|
||||
|
||||
it('scales skills by years and caps at 1.0', () => {
|
||||
expect(getBeamIntensity(makeNode({ category: 'skills' }))).toBe(0.5); // no years -> default tier
|
||||
expect(getBeamIntensity(makeNode({ category: 'skills', yearsOfExperience: 5 }))).toBeCloseTo(
|
||||
0.65
|
||||
);
|
||||
expect(getBeamIntensity(makeNode({ category: 'skills', yearsOfExperience: 100 }))).toBe(1.0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// ==========================================================================
|
||||
// TREE UTILS — pure helpers over the tree node data (no PixiJS, no DOM).
|
||||
// ==========================================================================
|
||||
|
||||
import { treeNodes, type TreeNode } from '$lib/data';
|
||||
import { NODE_RADII, MOBILE_RADIUS_FACTOR } from '$lib/config';
|
||||
|
||||
/** Look up a node by id. */
|
||||
export function getNodeById(id: string): TreeNode | undefined {
|
||||
return treeNodes.find((node) => node.id === id);
|
||||
}
|
||||
|
||||
/** Rendered radius for a node size, scaled down on mobile. */
|
||||
export function getNodeRadius(size: TreeNode['size'], isMobile: boolean): number {
|
||||
const base = NODE_RADII[size] ?? NODE_RADII.default;
|
||||
return base * (isMobile ? MOBILE_RADIUS_FACTOR : 1);
|
||||
}
|
||||
|
||||
/** Euclidean distance of a node from the tree centre (0, 0). */
|
||||
export function getDistanceFromOrigin(node: TreeNode): number {
|
||||
return Math.sqrt(node.x * node.x + node.y * node.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plasma-beam intensity for the connection feeding a node. Skills scale with
|
||||
* years of experience (0.3–1.0); other categories use fixed tiers.
|
||||
*/
|
||||
export function getBeamIntensity(node: TreeNode): number {
|
||||
if (node.category === 'skills' && node.yearsOfExperience) {
|
||||
return Math.min(0.3 + (node.yearsOfExperience / 10) * 0.7, 1.0);
|
||||
}
|
||||
if (node.category === 'experience') return 0.7;
|
||||
if (node.category === 'origin') return 1.0;
|
||||
return 0.5; // projects and education
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import favicon from '$lib/assets/favicon.ico';
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
const { children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
// Register service worker for PWA support
|
||||
@@ -11,7 +11,9 @@
|
||||
navigator.serviceWorker
|
||||
.register('/service-worker.js')
|
||||
.then((registration) => {
|
||||
console.log('✨ Service Worker registered successfully:', registration.scope);
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('✨ Service Worker registered successfully:', registration.scope);
|
||||
}
|
||||
|
||||
// Check for updates periodically
|
||||
registration.addEventListener('updatefound', () => {
|
||||
@@ -19,7 +21,9 @@
|
||||
if (newWorker) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
console.log('🔄 New service worker available. Refresh to update.');
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔄 New service worker available. Refresh to update.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,30 +14,22 @@ const PRECACHE_ASSETS = [
|
||||
|
||||
// Install event - precache critical assets
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[Service Worker] Installing...');
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => {
|
||||
console.log('[Service Worker] Precaching assets');
|
||||
return cache.addAll(PRECACHE_ASSETS);
|
||||
})
|
||||
.then((cache) => cache.addAll(PRECACHE_ASSETS))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[Service Worker] Activating...');
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME && name !== RUNTIME_CACHE)
|
||||
.map((name) => {
|
||||
console.log('[Service Worker] Deleting old cache:', name);
|
||||
return caches.delete(name);
|
||||
})
|
||||
.map((name) => caches.delete(name))
|
||||
);
|
||||
})
|
||||
.then(() => self.clients.claim())
|
||||
|
||||
Reference in New Issue
Block a user