Merge pull request 'Refactor/cohesion pass' (#1) from refactor/cohesion-pass into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
build/
|
||||
.svelte-kit/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
static/
|
||||
*.md
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import svelteConfig from '@sveltejs/eslint-config';
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
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'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
self: 'readonly',
|
||||
caches: 'readonly',
|
||||
fetch: 'readonly'
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
Generated
+2048
-5
File diff suppressed because it is too large
Load Diff
+16
-2
@@ -9,17 +9,31 @@
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"og:image": "./scripts/generate-og-image.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/eslint-config": "^10.0.1",
|
||||
"@sveltejs/kit": "^2.49.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-plugin-svelte": "^3.19.0",
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-svelte": "^4.1.0",
|
||||
"svelte": "^5.45.6",
|
||||
"svelte-check": "^4.3.4",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6"
|
||||
"typescript-eslint": "^8.60.1",
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"gsap": "^3.14.2",
|
||||
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Generate the Open Graph / Twitter link-preview card.
|
||||
#
|
||||
# Produces a 1200x630 branded card (the summary_large_image spec) from the
|
||||
# headshot + site theme, written to static/og-preview.jpg. Re-run this whenever
|
||||
# the headshot or branding changes, then redeploy and re-scrape the social
|
||||
# crawlers (see the OG meta tags in src/routes/+layout.svelte).
|
||||
#
|
||||
# Requires ImageMagick 7 (`magick`). Run from anywhere; paths are repo-relative.
|
||||
#
|
||||
# Usage: ./scripts/generate-og-image.sh
|
||||
set -euo pipefail
|
||||
|
||||
# --- locate repo + inputs ---------------------------------------------------
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
SRC_HEADSHOT="static/headshot.jpg"
|
||||
OUT="static/og-preview.jpg"
|
||||
|
||||
if ! command -v magick >/dev/null 2>&1; then
|
||||
echo "error: ImageMagick 7 (magick) not found on PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$SRC_HEADSHOT" ]]; then
|
||||
echo "error: $SRC_HEADSHOT not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- pick a bold + regular serif font (with fallbacks) ----------------------
|
||||
pick_font() {
|
||||
for f in "$@"; do
|
||||
[[ -f "$f" ]] && { echo "$f"; return 0; }
|
||||
done
|
||||
# Last resort: ask fontconfig for any serif.
|
||||
fc-match -f '%{file}' serif 2>/dev/null || true
|
||||
}
|
||||
SERIF_B="$(pick_font \
|
||||
/usr/share/fonts/TTF/DejaVuSerif-Bold.ttf \
|
||||
/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf \
|
||||
/usr/share/fonts/liberation/LiberationSerif-Bold.ttf)"
|
||||
SERIF="$(pick_font \
|
||||
/usr/share/fonts/TTF/DejaVuSerif.ttf \
|
||||
/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf \
|
||||
/usr/share/fonts/liberation/LiberationSerif-Regular.ttf)"
|
||||
: "${SERIF_B:?no bold serif font found}"
|
||||
: "${SERIF:?no serif font found}"
|
||||
|
||||
# --- theme colours (mirror src/lib/theme.ts) --------------------------------
|
||||
GOLD='#ffd700'
|
||||
GOLD_SOFT='#e8c95a'
|
||||
MUTED='#a8a8b8'
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# 1) Dark radial-gradient base (matches the #05050a space theme)
|
||||
magick -size 1200x630 radial-gradient:'#16162c'-'#05050a' "$TMP/base.png"
|
||||
|
||||
# 2) Circular-crop the headshot to 300x300
|
||||
magick "$SRC_HEADSHOT" -resize 300x300^ -gravity center -extent 300x300 \
|
||||
\( -size 300x300 xc:none -fill white -draw "circle 150,150 150,0" \) \
|
||||
-alpha set -compose CopyOpacity -composite "$TMP/headshot_circle.png"
|
||||
|
||||
# 3) Composite headshot (centre 330,315) + gold ring + faint outer ring
|
||||
magick "$TMP/base.png" "$TMP/headshot_circle.png" -geometry +180+165 -compose over -composite \
|
||||
-fill none -stroke "$GOLD" -strokewidth 5 -draw "circle 330,315 330,165" \
|
||||
-stroke "$GOLD" -strokewidth 2 -draw "fill-opacity 0.4 circle 330,315 330,157" \
|
||||
"$TMP/with_headshot.png"
|
||||
|
||||
# 4) Text block (right side) + divider rule
|
||||
magick "$TMP/with_headshot.png" \
|
||||
-font "$SERIF_B" -fill "$GOLD" -pointsize 92 -gravity NorthWest -annotate +540+205 'Kevin Riehl' \
|
||||
-stroke "$GOLD" -strokewidth 2 -draw "line 545,330 980,330" -stroke none \
|
||||
-font "$SERIF" -fill "$GOLD_SOFT" -pointsize 34 -kerning 6 -annotate +547+360 'THE ATLAS OF SKILLS' \
|
||||
-font "$SERIF" -fill "$MUTED" -pointsize 26 -kerning 0 -annotate +547+420 'Interactive skill-tree portfolio' \
|
||||
-fill "$MUTED" -pointsize 26 -annotate +547+455 'Experience · Projects · Skills' \
|
||||
"$TMP/card.png"
|
||||
|
||||
# 5) Flatten to an optimised, progressive JPG
|
||||
magick "$TMP/card.png" -strip -interlace Plane -quality 90 "$OUT"
|
||||
|
||||
echo "wrote $OUT ($(magick identify -format '%wx%h, %B bytes' "$OUT"))"
|
||||
+39
-4
@@ -1,14 +1,49 @@
|
||||
*, *::before, *::after {
|
||||
/*
|
||||
* Theme tokens — single source of truth for colours is src/lib/theme.ts.
|
||||
* These custom properties mirror its `cssVars` map; keep the two in sync.
|
||||
*/
|
||||
:root {
|
||||
--color-gold: #ffd700;
|
||||
--color-experience: #4ecdc4;
|
||||
--color-projects: #ff6b6b;
|
||||
--color-skills: #f9a825;
|
||||
--color-education: #dda0dd;
|
||||
--bg-space: #05050a;
|
||||
--panel-from: #1a1a2e;
|
||||
--panel-to: #0f0f1a;
|
||||
--text-muted: #a8a8b8;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: #05050a;
|
||||
}
|
||||
background: var(--bg-space);
|
||||
}
|
||||
|
||||
/*
|
||||
* Visually-hidden but available to screen readers and crawlers. Used for the
|
||||
* text version of the portfolio (the interactive content is canvas-rendered).
|
||||
*/
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<title>Kevin Riehl | Interactive Skill Tree Portfolio</title>
|
||||
<title>Kevin Riehl — Software Engineer (React, Node.js, TypeScript) | Portfolio</title>
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<!-- Loading spinner shown during PixiJS init (also serves as Lighthouse LCP element). -->
|
||||
<div class="loading-indicator">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading Atlas of Skills...</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.loading-indicator {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
z-index: 1000;
|
||||
color: var(--color-gold);
|
||||
}
|
||||
|
||||
.loading-indicator p {
|
||||
font-family: 'Cinzel Decorative', 'Trajan Pro', serif;
|
||||
font-size: 1.2rem;
|
||||
margin-top: 1rem;
|
||||
color: var(--color-gold);
|
||||
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid rgba(255, 215, 0, 0.2);
|
||||
border-top: 4px solid var(--color-gold);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,542 @@
|
||||
<script lang="ts">
|
||||
import type { TreeNode } from '$lib/data';
|
||||
import { categoryColorHex } from '$lib/theme';
|
||||
|
||||
const { node, visible, onClose }: { node: TreeNode; visible: boolean; onClose: () => void } =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
class:visible
|
||||
onclick={onClose}
|
||||
onkeydown={(e) => e.key === 'Escape' && onClose()}
|
||||
role="presentation"
|
||||
aria-label="Modal backdrop. Click to close or press Escape."
|
||||
>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||
<div
|
||||
class="modal"
|
||||
class:visible
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
aria-describedby="modal-description"
|
||||
style="--node-color: {categoryColorHex[node.category]}"
|
||||
>
|
||||
<button class="close-btn" onclick={onClose} aria-label="Close dialog">✕</button>
|
||||
|
||||
<h2 id="modal-title">
|
||||
{#if node.icon}
|
||||
<img src={node.icon} alt="" class="modal-icon" />
|
||||
{/if}
|
||||
{node.label}
|
||||
</h2>
|
||||
|
||||
<span class="category-badge">{node.category}</span>
|
||||
|
||||
<div id="modal-description">
|
||||
<!-- Origin-specific content -->
|
||||
{#if node.category === 'origin'}
|
||||
{#if node.jobTitle}
|
||||
<div class="origin-title">{node.jobTitle}</div>
|
||||
{/if}
|
||||
{#if node.location}
|
||||
<div class="origin-location">📍 {node.location}</div>
|
||||
{/if}
|
||||
{#if node.aboutMe}
|
||||
<p class="about-me">{node.aboutMe}</p>
|
||||
{/if}
|
||||
{#if node.email || node.linkedIn || node.github || node.website}
|
||||
<div class="social-links">
|
||||
{#if node.email}
|
||||
<a href="mailto:{node.email}" title="Email" aria-label="Send email">✉️ Email</a>
|
||||
{/if}
|
||||
{#if node.linkedIn}
|
||||
<a
|
||||
href={node.linkedIn}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="LinkedIn"
|
||||
aria-label="LinkedIn profile (opens in new tab)">💼 LinkedIn</a
|
||||
>
|
||||
{/if}
|
||||
{#if node.github}
|
||||
<a
|
||||
href={node.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="GitHub"
|
||||
aria-label="GitHub profile (opens in new tab)">💻 GitHub</a
|
||||
>
|
||||
{/if}
|
||||
{#if node.website}
|
||||
<a
|
||||
href={node.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Website"
|
||||
aria-label="Personal website (opens in new tab)">🌐 Website</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if node.resumeUrl}
|
||||
<a href={node.resumeUrl} download class="resume-button"> 📄 Download Resume </a>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Experience-specific content -->
|
||||
{#if node.category === 'experience' && node.company}
|
||||
<div class="meta-info">
|
||||
<span class="company">{node.company}</span>
|
||||
{#if node.startDate}
|
||||
<span class="dates">{node.startDate} — {node.endDate || 'Present'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Education-specific content -->
|
||||
{#if node.category === 'education' && node.institution}
|
||||
<div class="meta-info">
|
||||
<span class="company">{node.institution}</span>
|
||||
{#if node.degree}
|
||||
<span class="dates">{node.degree} in {node.field || 'N/A'}</span>
|
||||
{/if}
|
||||
{#if node.graduationYear}
|
||||
<span class="dates">Graduated {node.graduationYear}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Skill-specific content -->
|
||||
{#if node.category === 'skills' && node.proficiency}
|
||||
<div class="meta-info">
|
||||
<span class="proficiency-badge {node.proficiency}">{node.proficiency}</span>
|
||||
{#if node.yearsOfExperience}
|
||||
<span class="dates">{node.yearsOfExperience} years experience</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if node.category !== 'origin'}
|
||||
<p>{node.description}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Highlights (for experience) -->
|
||||
{#if node.category === 'experience' && node.highlights && node.highlights.length > 0}
|
||||
<ul class="highlights">
|
||||
{#each node.highlights as highlight (highlight)}
|
||||
<li>{highlight}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- Tech stack (for projects) -->
|
||||
{#if node.category === 'projects' && node.techStack && node.techStack.length > 0}
|
||||
<div class="tech-stack">
|
||||
{#each node.techStack as tech (tech)}
|
||||
<span class="tech-tag">{tech}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Project status badge -->
|
||||
{#if node.category === 'projects' && node.status}
|
||||
<div class="project-status">
|
||||
<span class="status-badge {node.status}">{node.status}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Links (for projects) -->
|
||||
{#if node.category === 'projects' && (node.projectUrl || node.repoUrl || node.pypiUrl || node.npmUrl)}
|
||||
<div class="links">
|
||||
{#if node.projectUrl}
|
||||
<a
|
||||
href={node.projectUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Live site (opens in new tab)">🔗 Live Site</a
|
||||
>
|
||||
{/if}
|
||||
{#if node.repoUrl}
|
||||
<a
|
||||
href={node.repoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Code repository (opens in new tab)">💻 Repository</a
|
||||
>
|
||||
{/if}
|
||||
{#if node.pypiUrl}
|
||||
<a
|
||||
href={node.pypiUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Python package on PyPI (opens in new tab)">📦 PyPI</a
|
||||
>
|
||||
{/if}
|
||||
{#if node.npmUrl}
|
||||
<a
|
||||
href={node.npmUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="npm package (opens in new tab)">📦 npm</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
transition: background 0.2s ease;
|
||||
backdrop-filter: blur(0px);
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-backdrop.visible {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
|
||||
border: 1px solid var(--node-color);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
box-shadow:
|
||||
0 0 40px color-mix(in srgb, var(--node-color) 30%, transparent),
|
||||
0 0 80px color-mix(in srgb, var(--node-color) 15%, transparent),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(20px);
|
||||
transition: all 0.25s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
.modal.visible {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
color: var(--node-color);
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.3rem;
|
||||
text-shadow: 0 0 20px var(--node-color);
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
width: 1.4em;
|
||||
height: 1.4em;
|
||||
vertical-align: middle;
|
||||
margin-right: 0.5rem;
|
||||
filter: drop-shadow(0 0 8px var(--node-color));
|
||||
}
|
||||
|
||||
.category-badge {
|
||||
display: inline-block;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--node-color),
|
||||
color-mix(in srgb, var(--node-color) 70%, black)
|
||||
);
|
||||
color: #0a0a0f;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal p {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
transition: all 0.15s;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: white;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Origin-specific styles */
|
||||
.origin-title {
|
||||
color: var(--node-color);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
text-shadow: 0 0 10px var(--node-color);
|
||||
}
|
||||
|
||||
.origin-location {
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.about-me {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.7;
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.social-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.social-links a {
|
||||
color: #ccc;
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.social-links a:hover {
|
||||
color: var(--node-color);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: var(--node-color);
|
||||
}
|
||||
|
||||
/* Meta info styles */
|
||||
.meta-info {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.meta-info .company {
|
||||
display: block;
|
||||
color: var(--node-color);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.meta-info .dates {
|
||||
display: block;
|
||||
color: #888;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.proficiency-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.proficiency-badge.beginner {
|
||||
background: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.proficiency-badge.intermediate {
|
||||
background: #2b6cb0;
|
||||
color: #bee3f8;
|
||||
}
|
||||
|
||||
.proficiency-badge.advanced {
|
||||
background: #2f855a;
|
||||
color: #c6f6d5;
|
||||
}
|
||||
|
||||
.proficiency-badge.expert {
|
||||
background: #b7791f;
|
||||
color: #fefcbf;
|
||||
}
|
||||
|
||||
.highlights {
|
||||
margin: 0.75rem 0 0 0;
|
||||
padding-left: 1.2rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.highlights li {
|
||||
margin-bottom: 0.4rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tech-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.tech-tag {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ccc;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: var(--node-color);
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.links a:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.resume-button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--node-color),
|
||||
color-mix(in srgb, var(--node-color) 70%, black)
|
||||
);
|
||||
color: #0a0a0f;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--node-color) 30%, transparent);
|
||||
}
|
||||
|
||||
.resume-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0 30px color-mix(in srgb, var(--node-color) 50%, transparent);
|
||||
}
|
||||
|
||||
.resume-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.project-status {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background: #2f855a;
|
||||
color: #c6f6d5;
|
||||
}
|
||||
|
||||
.status-badge.maintained {
|
||||
background: #2b6cb0;
|
||||
color: #bee3f8;
|
||||
}
|
||||
|
||||
.status-badge.completed {
|
||||
background: #6b5b95;
|
||||
color: #e2d8f0;
|
||||
}
|
||||
|
||||
.status-badge.archived {
|
||||
background: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Mobile-specific styles */
|
||||
@media (max-width: 767px) {
|
||||
.modal {
|
||||
padding: 1.25rem;
|
||||
max-height: 85vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
align-items: flex-end;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.social-links {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.social-links a {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
// Screen-reader / crawler-only rendition of the portfolio.
|
||||
//
|
||||
// The interactive tree lives entirely on a PixiJS canvas, which search
|
||||
// engines and assistive tech can't read. This component renders the same
|
||||
// content (sourced from data.ts — the single source of truth) as real,
|
||||
// prerendered semantic HTML, visually hidden via the global `.sr-only` class.
|
||||
import {
|
||||
treeNodes,
|
||||
type ExperienceNode,
|
||||
type ProjectNode,
|
||||
type EducationNode,
|
||||
type OriginNode
|
||||
} from '$lib/data';
|
||||
import { aggregateSkills, BRANCH_IDS } from '$lib/utils/stats';
|
||||
|
||||
const origin = treeNodes.find((n): n is OriginNode => n.category === 'origin');
|
||||
|
||||
// Leaf nodes only — container/branch nodes lack these category fields.
|
||||
const jobs = treeNodes.filter(
|
||||
(n): n is ExperienceNode => n.category === 'experience' && !!n.company
|
||||
);
|
||||
const projects = treeNodes.filter(
|
||||
(n): n is ProjectNode => n.category === 'projects' && !!n.techStack?.length
|
||||
);
|
||||
const education = treeNodes.filter(
|
||||
(n): n is EducationNode => n.category === 'education' && !!n.institution
|
||||
);
|
||||
|
||||
// Newest experience first (reverse-chronological, résumé convention).
|
||||
const jobsNewestFirst = [...jobs].reverse();
|
||||
|
||||
const skillsByBranch = aggregateSkills();
|
||||
</script>
|
||||
|
||||
{#if origin}
|
||||
<section class="sr-only" aria-label="Portfolio (text version)">
|
||||
<h1>{origin.fullName} — {origin.jobTitle}</h1>
|
||||
{#if origin.aboutMe}<p>{origin.aboutMe}</p>{/if}
|
||||
{#if origin.location}<p>Location: {origin.location}</p>{/if}
|
||||
|
||||
<nav aria-label="Contact and profiles">
|
||||
<ul>
|
||||
{#if origin.email}<li><a href="mailto:{origin.email}">Email: {origin.email}</a></li>{/if}
|
||||
{#if origin.linkedIn}<li><a href={origin.linkedIn}>LinkedIn</a></li>{/if}
|
||||
{#if origin.github}<li><a href={origin.github}>GitHub</a></li>{/if}
|
||||
{#if origin.website}<li><a href={origin.website}>Website</a></li>{/if}
|
||||
{#if origin.resumeUrl}<li><a href={origin.resumeUrl}>Résumé (PDF)</a></li>{/if}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<section aria-label="Work experience">
|
||||
<h2>Work Experience</h2>
|
||||
{#each jobsNewestFirst as job (job.id)}
|
||||
<article>
|
||||
<h3>{job.jobTitle} — {job.company}</h3>
|
||||
{#if job.startDate}<p>{job.startDate} – {job.endDate || 'Present'}</p>{/if}
|
||||
{#if job.highlights?.length}
|
||||
<ul>
|
||||
{#each job.highlights as highlight (highlight)}
|
||||
<li>{highlight}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section aria-label="Projects">
|
||||
<h2>Projects</h2>
|
||||
{#each projects as project (project.id)}
|
||||
<article>
|
||||
<h3>{project.label}</h3>
|
||||
<p>{project.description}</p>
|
||||
{#if project.techStack?.length}
|
||||
<p>Tech: {project.techStack.join(', ')}</p>
|
||||
{/if}
|
||||
<ul>
|
||||
{#if project.projectUrl}<li><a href={project.projectUrl}>Live site</a></li>{/if}
|
||||
{#if project.repoUrl}<li><a href={project.repoUrl}>Source code</a></li>{/if}
|
||||
{#if project.pypiUrl}<li><a href={project.pypiUrl}>PyPI package</a></li>{/if}
|
||||
{#if project.npmUrl}<li><a href={project.npmUrl}>npm package</a></li>{/if}
|
||||
</ul>
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section aria-label="Technical skills">
|
||||
<h2>Technical Skills</h2>
|
||||
{#each BRANCH_IDS as branchId (branchId)}
|
||||
{@const branch = skillsByBranch[branchId]}
|
||||
{#if branch && branch.skills.length}
|
||||
<h3>{branch.label}</h3>
|
||||
<ul>
|
||||
{#each branch.skills as skill (skill.id)}
|
||||
<li>
|
||||
{skill.label}{#if skill.proficiency}
|
||||
({skill.proficiency}{#if skill.yearsOfExperience}, {skill.yearsOfExperience} years{/if})
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section aria-label="Education">
|
||||
<h2>Education</h2>
|
||||
{#each education as edu (edu.id)}
|
||||
<article>
|
||||
<h3>
|
||||
{edu.degree}{#if edu.field}
|
||||
in {edu.field}{/if}
|
||||
</h3>
|
||||
<p>
|
||||
{edu.institution}{#if edu.graduationYear}, {edu.graduationYear}{/if}
|
||||
</p>
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
</section>
|
||||
{/if}
|
||||
+102
-2281
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,501 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition';
|
||||
import { treeNodes, type SkillNode } from '$lib/data';
|
||||
|
||||
const {
|
||||
visible,
|
||||
playerLevel,
|
||||
playerClass,
|
||||
skillCounts,
|
||||
totalSkills,
|
||||
skillsByBranch,
|
||||
expandedBranches,
|
||||
onToggle,
|
||||
onToggleBranch
|
||||
}: {
|
||||
visible: boolean;
|
||||
playerLevel: number;
|
||||
playerClass: string;
|
||||
skillCounts: { expert: number; advanced: number; intermediate: number; beginner: number };
|
||||
totalSkills: number;
|
||||
skillsByBranch: Record<string, { label: string; skills: SkillNode[] }>;
|
||||
expandedBranches: Set<string>;
|
||||
onToggle: () => void;
|
||||
onToggleBranch: (branchId: string) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<!-- Stats Panel Toggle Button -->
|
||||
<button
|
||||
class="stats-panel-toggle"
|
||||
class:open={visible}
|
||||
onclick={onToggle}
|
||||
title="Character Stats (C)"
|
||||
aria-label="Toggle Character Stats Panel (Keyboard shortcut: C)"
|
||||
aria-expanded={visible}
|
||||
aria-controls="stats-panel"
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
|
||||
<!-- Stats Panel Backdrop -->
|
||||
{#if visible}
|
||||
<div
|
||||
class="stats-panel-backdrop visible"
|
||||
onclick={onToggle}
|
||||
onkeydown={(e) => e.key === 'Escape' && onToggle()}
|
||||
role="presentation"
|
||||
aria-label="Stats panel backdrop. Click to close or press Escape."
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Stats Panel -->
|
||||
<div
|
||||
id="stats-panel"
|
||||
class="stats-panel"
|
||||
class:visible
|
||||
role="complementary"
|
||||
aria-label="Character Statistics"
|
||||
aria-hidden={!visible}
|
||||
>
|
||||
<!-- Character Card -->
|
||||
<div class="character-card">
|
||||
<img src="/headshot.jpg" alt="Character Portrait" class="character-portrait" />
|
||||
|
||||
<div class="character-stats-bar">
|
||||
<div class="stat-item">
|
||||
<span>⚔️</span>
|
||||
<span class="stat-label">LVL</span>
|
||||
<span>{playerLevel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="character-name">Kevin Riehl</h1>
|
||||
<p class="character-title">{playerClass}</p>
|
||||
</div>
|
||||
|
||||
<!-- Skills Section -->
|
||||
<div class="skills-section">
|
||||
<!-- Proficiency Summary -->
|
||||
<div class="proficiency-summary">
|
||||
<h3>⚡ Skill Mastery</h3>
|
||||
<div class="proficiency-row">
|
||||
<span class="proficiency-label">⭐⭐⭐ Expert</span>
|
||||
<span class="proficiency-count">{skillCounts.expert}</span>
|
||||
</div>
|
||||
<div class="proficiency-row">
|
||||
<span class="proficiency-label">⭐⭐ Advanced</span>
|
||||
<span class="proficiency-count">{skillCounts.advanced}</span>
|
||||
</div>
|
||||
<div class="proficiency-row">
|
||||
<span class="proficiency-label">⭐ Intermediate</span>
|
||||
<span class="proficiency-count">{skillCounts.intermediate}</span>
|
||||
</div>
|
||||
<div class="proficiency-row">
|
||||
<span class="proficiency-label">Beginner</span>
|
||||
<span class="proficiency-count">{skillCounts.beginner}</span>
|
||||
</div>
|
||||
<div
|
||||
class="proficiency-row"
|
||||
style="border-top: 1px solid rgba(255,255,255,0.1); margin-top: 0.5rem; padding-top: 0.5rem;"
|
||||
>
|
||||
<span class="proficiency-label">Total Skills</span>
|
||||
<span class="proficiency-count">{totalSkills}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skill Branches -->
|
||||
{#each Object.entries(skillsByBranch) as [branchId, branch] (branchId)}
|
||||
<div class="skill-branch">
|
||||
<div
|
||||
class="branch-header"
|
||||
onclick={() => onToggleBranch(branchId)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onToggleBranch(branchId)}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-expanded={expandedBranches.has(branchId)}
|
||||
>
|
||||
<img
|
||||
src={treeNodes.find((n) => n.id === branchId)?.icon || ''}
|
||||
alt=""
|
||||
class="branch-icon"
|
||||
/>
|
||||
<span class="branch-title">{branch.label}</span>
|
||||
<span class="branch-count">({branch.skills.length})</span>
|
||||
<span class="expand-icon" class:expanded={expandedBranches.has(branchId)}>▼</span>
|
||||
</div>
|
||||
|
||||
{#if expandedBranches.has(branchId)}
|
||||
<div class="skill-list" transition:slide={{ duration: 300 }}>
|
||||
{#each branch.skills as skill (skill.id)}
|
||||
<div class="skill-item">
|
||||
{#if skill.icon}
|
||||
<img src={skill.icon} alt="" class="skill-item-icon" />
|
||||
{/if}
|
||||
<span class="skill-name">{skill.label}</span>
|
||||
<span class="skill-proficiency">
|
||||
{#if skill.proficiency === 'expert'}
|
||||
<span class="proficiency-stars">⭐⭐⭐</span>
|
||||
{:else if skill.proficiency === 'advanced'}
|
||||
<span class="proficiency-stars">⭐⭐</span>
|
||||
{:else if skill.proficiency === 'intermediate'}
|
||||
<span class="proficiency-stars">⭐</span>
|
||||
{/if}
|
||||
<span>{skill.proficiency || ''}</span>
|
||||
</span>
|
||||
{#if skill.yearsOfExperience}
|
||||
<span class="skill-years">{skill.yearsOfExperience}y</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ========== STATS PANEL STYLES ========== */
|
||||
|
||||
/* Toggle Button */
|
||||
.stats-panel-toggle {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
|
||||
border: 2px solid var(--color-gold);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
z-index: 950;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: var(--color-gold);
|
||||
box-shadow:
|
||||
0 0 20px rgba(255, 215, 0, 0.3),
|
||||
0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.stats-panel-toggle:hover:not(.open) {
|
||||
transform: scale(1.05);
|
||||
box-shadow:
|
||||
0 0 30px rgba(255, 215, 0, 0.5),
|
||||
0 4px 16px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.stats-panel-toggle.open:hover {
|
||||
box-shadow:
|
||||
0 0 30px rgba(255, 215, 0, 0.5),
|
||||
0 4px 16px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.stats-panel-toggle.open {
|
||||
transform: translateX(380px) rotate(180deg);
|
||||
}
|
||||
|
||||
/* Panel and Backdrop */
|
||||
.stats-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 380px;
|
||||
height: 100vh;
|
||||
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
|
||||
border-right: 2px solid var(--color-gold);
|
||||
box-shadow:
|
||||
4px 0 40px rgba(255, 215, 0, 0.3),
|
||||
8px 0 80px rgba(255, 215, 0, 0.15);
|
||||
z-index: 900;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styling for stats panel */
|
||||
.stats-panel::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.stats-panel::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-left: 1px solid rgba(255, 215, 0, 0.1);
|
||||
}
|
||||
|
||||
.stats-panel::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, var(--color-gold) 0%, #b7941f 100%);
|
||||
border-radius: 5px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.stats-panel::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, #ffed4e 0%, #d4af37 100%);
|
||||
}
|
||||
|
||||
.stats-panel.visible {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.stats-panel-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 899;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* Character Card */
|
||||
.character-card {
|
||||
padding: 2rem 1.5rem;
|
||||
border-bottom: 2px solid rgba(255, 215, 0, 0.2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.character-portrait {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--color-gold);
|
||||
box-shadow: 0 0 30px rgba(255, 215, 0, 0.5);
|
||||
margin: 0 auto 1rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.character-stats-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-weight: bold;
|
||||
color: var(--color-gold);
|
||||
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
|
||||
}
|
||||
|
||||
.character-name {
|
||||
font-size: 1.4rem;
|
||||
font-weight: bold;
|
||||
color: var(--color-gold);
|
||||
text-shadow: 0 0 20px rgba(255, 215, 0, 0.5);
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.character-title {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Skills Section */
|
||||
.skills-section {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.proficiency-summary {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 215, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.proficiency-summary h3 {
|
||||
color: var(--color-gold);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.proficiency-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.proficiency-label {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.proficiency-count {
|
||||
color: var(--color-gold);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.skill-branch {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.branch-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.branch-header:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
|
||||
.branch-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
filter: drop-shadow(0 0 8px rgba(249, 168, 37, 0.5));
|
||||
}
|
||||
|
||||
.branch-title {
|
||||
flex: 1;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
color: var(--color-skills);
|
||||
text-shadow: 0 0 10px rgba(249, 168, 37, 0.3);
|
||||
}
|
||||
|
||||
.branch-count {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.expand-icon.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.skill-list {
|
||||
margin-top: 0.5rem;
|
||||
padding-left: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skill-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.skill-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-left-color: var(--color-skills);
|
||||
}
|
||||
|
||||
.skill-item-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.skill-name {
|
||||
flex: 1;
|
||||
font-size: 0.85rem;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.skill-proficiency {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.proficiency-stars {
|
||||
color: var(--color-gold);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.skill-years {
|
||||
font-size: 0.7rem;
|
||||
color: #666;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 767px) {
|
||||
.stats-panel-toggle {
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
top: auto;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.stats-panel-toggle.open {
|
||||
transform: translateY(-85dvh) rotate(180deg);
|
||||
}
|
||||
|
||||
.stats-panel {
|
||||
width: 100%;
|
||||
height: 85dvh;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-top: 2px solid var(--color-gold);
|
||||
border-radius: 16px 16px 0 0;
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
transform: translateY(100%);
|
||||
box-shadow:
|
||||
0 -4px 40px rgba(255, 215, 0, 0.3),
|
||||
0 -8px 80px rgba(255, 215, 0, 0.15);
|
||||
}
|
||||
|
||||
.stats-panel.visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.character-portrait {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.character-name {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.character-stats-bar {
|
||||
gap: 1rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import type { TreeNode } from '$lib/data';
|
||||
|
||||
const { node, style, colorHex }: { node: TreeNode; style: string; colorHex: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="tooltip" role="tooltip" aria-live="polite" style="{style} --node-color: {colorHex};">
|
||||
<h3>
|
||||
{#if node.icon}
|
||||
<img src={node.icon} alt="" class="tooltip-icon" />
|
||||
{/if}
|
||||
{node.label}
|
||||
</h3>
|
||||
<p>{node.description}</p>
|
||||
<span class="hint">Click for details</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
background: linear-gradient(145deg, var(--panel-from) 0%, #12121a 100%);
|
||||
border: 1px solid var(--node-color);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 1rem;
|
||||
max-width: 280px;
|
||||
pointer-events: none;
|
||||
z-index: 500;
|
||||
box-shadow:
|
||||
0 0 20px color-mix(in srgb, var(--node-color) 25%, transparent),
|
||||
0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
animation: tooltipFadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes tooltipFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip h3 {
|
||||
color: var(--node-color);
|
||||
margin: 0 0 0.35rem 0;
|
||||
font-size: 0.95rem;
|
||||
text-shadow: 0 0 10px var(--node-color);
|
||||
}
|
||||
|
||||
.tooltip p {
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tooltip .hint {
|
||||
color: #666;
|
||||
font-size: 0.7rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tooltip-icon {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
vertical-align: middle;
|
||||
margin-right: 0.4rem;
|
||||
filter: drop-shadow(0 0 4px var(--node-color));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<!-- Welcome title card shown during the entrance animation.
|
||||
Decorative + transient (fades out), so it's a <div>, not the page <h1>
|
||||
(the canonical <h1> lives in SeoContent for SEO). -->
|
||||
<div class="welcome-title">
|
||||
<div class="welcome-name">Kevin Riehl</div>
|
||||
<p>The Atlas of Skills</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.welcome-title {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
animation: titleFadeInOut 2.5s ease-in-out forwards;
|
||||
}
|
||||
|
||||
.welcome-title .welcome-name {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--color-gold);
|
||||
margin: 0;
|
||||
text-shadow:
|
||||
0 0 20px rgba(255, 215, 0, 0.8),
|
||||
0 0 40px rgba(255, 215, 0, 0.5),
|
||||
0 0 60px rgba(255, 215, 0, 0.3);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.welcome-title p {
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0.5rem 0 0 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3em;
|
||||
}
|
||||
|
||||
@keyframes titleFadeInOut {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
85% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.welcome-title {
|
||||
animation: titleFadeInOutMobile 1.5s ease-in-out forwards;
|
||||
}
|
||||
|
||||
.welcome-title .welcome-name {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.welcome-title p {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes titleFadeInOutMobile {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
}
|
||||
25% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
75% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(1.05);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
}
|
||||
+184
-138
@@ -1,49 +1,20 @@
|
||||
export interface TreeNode {
|
||||
// Fields shared by every node, regardless of category.
|
||||
interface BaseNode {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
x: number;
|
||||
y: number;
|
||||
parentId: string | null; // null means it's the root or connects to center
|
||||
category: 'origin' | 'experience' | 'projects' | 'skills' | 'education';
|
||||
parentId: string | null; // null means it's the root or connects to center
|
||||
size: 'x-large' | 'large' | 'medium' | 'small';
|
||||
icon?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
// ========== EXPERIENCE-SPECIFIC FIELDS ==========
|
||||
company?: string;
|
||||
jobTitle?: string;
|
||||
startDate?: string;
|
||||
endDate?: string; // Use "Present" for current role
|
||||
highlights?: string[]; // Key achievements or responsibilities
|
||||
|
||||
// ========== CONTRACT-SPECIFIC FIELDS ==========
|
||||
contractName?: string;
|
||||
client?: string;
|
||||
contractStart?: string;
|
||||
contractEnd?: string;
|
||||
|
||||
// ========== PROJECT-SPECIFIC FIELDS (extended) ==========
|
||||
techStack?: string[];
|
||||
projectUrl?: string;
|
||||
repoUrl?: string;
|
||||
npmUrl?: string; // For npm packages
|
||||
pypiUrl?: string; // For PyPI packages
|
||||
downloads?: string; // "1,000+ downloads"
|
||||
stars?: number; // GitHub stars
|
||||
status?: 'active' | 'maintained' | 'archived' | 'completed';
|
||||
|
||||
// ========== SKILL-SPECIFIC FIELDS ==========
|
||||
proficiency?: 'beginner' | 'intermediate' | 'advanced' | 'expert';
|
||||
yearsOfExperience?: number;
|
||||
|
||||
// ========== EDUCATION-SPECIFIC FIELDS ==========
|
||||
institution?: string;
|
||||
degree?: string;
|
||||
field?: string;
|
||||
graduationYear?: string;
|
||||
|
||||
// ========== ORIGIN-SPECIFIC FIELDS ==========
|
||||
// Center node with profile/contact info.
|
||||
export interface OriginNode extends BaseNode {
|
||||
category: 'origin';
|
||||
fullName?: string;
|
||||
jobTitle?: string;
|
||||
aboutMe?: string;
|
||||
location?: string;
|
||||
email?: string;
|
||||
@@ -53,6 +24,52 @@ export interface TreeNode {
|
||||
resumeUrl?: string;
|
||||
}
|
||||
|
||||
// Jobs and contract roles.
|
||||
export interface ExperienceNode extends BaseNode {
|
||||
category: 'experience';
|
||||
company?: string;
|
||||
jobTitle?: string;
|
||||
startDate?: string;
|
||||
endDate?: string; // Use "Present" for current role
|
||||
highlights?: string[]; // Key achievements or responsibilities
|
||||
contractName?: string;
|
||||
client?: string;
|
||||
contractStart?: string;
|
||||
contractEnd?: string;
|
||||
}
|
||||
|
||||
// Personal projects / packages.
|
||||
export interface ProjectNode extends BaseNode {
|
||||
category: 'projects';
|
||||
techStack?: string[];
|
||||
projectUrl?: string;
|
||||
repoUrl?: string;
|
||||
npmUrl?: string; // For npm packages
|
||||
pypiUrl?: string; // For PyPI packages
|
||||
downloads?: string; // "1,000+ downloads"
|
||||
stars?: number; // GitHub stars
|
||||
status?: 'active' | 'maintained' | 'archived' | 'completed';
|
||||
}
|
||||
|
||||
// Technical skills with proficiency.
|
||||
export interface SkillNode extends BaseNode {
|
||||
category: 'skills';
|
||||
proficiency?: 'beginner' | 'intermediate' | 'advanced' | 'expert';
|
||||
yearsOfExperience?: number;
|
||||
}
|
||||
|
||||
// Academic background.
|
||||
export interface EducationNode extends BaseNode {
|
||||
category: 'education';
|
||||
institution?: string;
|
||||
degree?: string;
|
||||
field?: string;
|
||||
graduationYear?: string;
|
||||
}
|
||||
|
||||
// Discriminated union over `category` — narrow on it to access category fields.
|
||||
export type TreeNode = OriginNode | ExperienceNode | ProjectNode | SkillNode | EducationNode;
|
||||
|
||||
// The center of our tree is at (0, 0)
|
||||
// Positive X = right, Negative X = left
|
||||
// Positive Y = down, Negative Y = up
|
||||
@@ -62,7 +79,8 @@ export const treeNodes: TreeNode[] = [
|
||||
{
|
||||
id: 'origin',
|
||||
label: 'Kevin Riehl',
|
||||
description: 'Welcome to my skill tree! Click on nodes to explore my experience, projects, and skills.',
|
||||
description:
|
||||
'Welcome to my skill tree! Click on nodes to explore my experience, projects, and skills.',
|
||||
x: 0,
|
||||
y: 0,
|
||||
parentId: null,
|
||||
@@ -71,13 +89,14 @@ export const treeNodes: TreeNode[] = [
|
||||
icon: '',
|
||||
fullName: 'Kevin Riehl',
|
||||
jobTitle: 'Software Development - Lead Associate',
|
||||
aboutMe: 'Intermediate Software Engineer with 5+ years of experience architecting full-stack applications using React, Node.js (Hapi), and SQL. Expertise includes modernizing legacy codebases, implementing rigorous testing standards, and leading frontend architecture improvements. Passionate about leveraging modern tools like ShadCN and Docker to streamline development workflows.',
|
||||
aboutMe:
|
||||
'Intermediate Software Engineer with 5+ years of experience architecting full-stack applications using React, Node.js (Hapi), and SQL. Expertise includes modernizing legacy codebases, implementing rigorous testing standards, and leading frontend architecture improvements. Passionate about leveraging modern tools like ShadCN and Docker to streamline development workflows.',
|
||||
location: 'Kent, WA',
|
||||
email: 'kevinriehl@gmail.com',
|
||||
linkedIn: 'https://www.linkedin.com/in/kevinriehl',
|
||||
github: 'https://github.com/TehRiehlDeal',
|
||||
website: 'https://tehriehldeal.com',
|
||||
resumeUrl: '/kevin-riehl-resume.pdf',
|
||||
resumeUrl: '/kevin-riehl-resume.pdf'
|
||||
},
|
||||
|
||||
// ========== EXPERIENCE (Upper Right - Contract-Based) ==========
|
||||
@@ -90,14 +109,15 @@ export const treeNodes: TreeNode[] = [
|
||||
parentId: 'origin',
|
||||
category: 'experience',
|
||||
size: 'large',
|
||||
icon: '/icons/experience.svg',
|
||||
icon: '/icons/experience.svg'
|
||||
},
|
||||
|
||||
|
||||
// Contract Parent Node
|
||||
{
|
||||
id: 'exp-contract',
|
||||
label: 'DHA - Web and Mobile Technology',
|
||||
description: 'Continuous work supporting the same government client through multiple contracting companies as the contract was re-competed and awarded.',
|
||||
description:
|
||||
'Continuous work supporting the same government client through multiple contracting companies as the contract was re-competed and awarded.',
|
||||
x: 320,
|
||||
y: -220,
|
||||
parentId: 'exp-branch',
|
||||
@@ -107,9 +127,9 @@ export const treeNodes: TreeNode[] = [
|
||||
contractName: 'DHA WMT',
|
||||
client: 'Defense Health Agency',
|
||||
contractStart: 'October 2020',
|
||||
contractEnd: 'Present',
|
||||
contractEnd: 'Present'
|
||||
},
|
||||
|
||||
|
||||
// Job 1 - First company (starting point of the arc)
|
||||
{
|
||||
id: 'exp-job1',
|
||||
@@ -127,10 +147,10 @@ export const treeNodes: TreeNode[] = [
|
||||
endDate: 'April 2021',
|
||||
highlights: [
|
||||
'Refactored legacy PHP endpoints into a robust Hapi.js architecture, aligning the project with modern JavaScript standards and improving API response times',
|
||||
'Executed a critical database migration from MySQL to MSSQL, ensuring data integrity during the transfer and optimizing schema definitions for the new environment',
|
||||
],
|
||||
'Executed a critical database migration from MySQL to MSSQL, ensuring data integrity during the transfer and optimizing schema definitions for the new environment'
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
// Job 2 - Arc continues upward
|
||||
{
|
||||
id: 'exp-job2',
|
||||
@@ -149,10 +169,10 @@ export const treeNodes: TreeNode[] = [
|
||||
highlights: [
|
||||
'Architected and built a comprehensive data analytics search engine using React and Hapi.js (TypeScript), enabling users to filter complex event datasets by location, type, and date for real-time analysis',
|
||||
'Facilitated successful production releases by managing SQL database migration scripts and executing rigorous regression testing to ensure critical features remained stable during updates',
|
||||
'Supported engineering team growth by conducting code reviews and leading the onboarding process for new developers, ensuring smooth knowledge transfer and adherence to coding standards',
|
||||
],
|
||||
'Supported engineering team growth by conducting code reviews and leading the onboarding process for new developers, ensuring smooth knowledge transfer and adherence to coding standards'
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
// Job 3 - Peak of the arc
|
||||
{
|
||||
id: 'exp-job3',
|
||||
@@ -171,10 +191,10 @@ export const treeNodes: TreeNode[] = [
|
||||
highlights: [
|
||||
"Championed the adoption of ShadCN by conducting a live technical demonstration on the team's starter template, proving the framework's ability to accelerate development velocity and ensure UI consistency across projects",
|
||||
'Developed a Web Bluetooth interface for a biometric monitoring proof-of-concept, enabling real-time data ingestion and aggregation from multiple wearable sensors (accelerometers and smartwatches) for sleep analysis',
|
||||
"Supported the engineering team's migration to containerized workflows by configuring, testing, and troubleshooting custom Docker environments, ensuring consistent application behavior across development and production",
|
||||
],
|
||||
"Supported the engineering team's migration to containerized workflows by configuring, testing, and troubleshooting custom Docker environments, ensuring consistent application behavior across development and production"
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
// Job 4 - Continuing upward and right
|
||||
{
|
||||
id: 'exp-job4',
|
||||
@@ -193,10 +213,10 @@ export const treeNodes: TreeNode[] = [
|
||||
highlights: [
|
||||
'Architected a secure telehealth video conferencing application using React and WebRTC, designing the core signaling class to manage complex peer-to-peer connections (SDP offers/answers) and media stream constraints',
|
||||
'Implemented Google MediaPipe to develop a real-time gesture recognition feature, enabling accessibility for speech-impaired users by translating hand signs (e.g., thumbs up) into animated in-chat reactions',
|
||||
'Engineered adaptive network logic that monitors packet loss in real-time and dynamically adjusts video stream quality, ensuring connection stability and optimal user experience during bandwidth fluctuations',
|
||||
],
|
||||
'Engineered adaptive network logic that monitors packet loss in real-time and dynamically adjusts video stream quality, ensuring connection stability and optimal user experience during bandwidth fluctuations'
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
// Job 5 - Current (highest point, prominent)
|
||||
{
|
||||
id: 'exp-job5',
|
||||
@@ -212,9 +232,7 @@ export const treeNodes: TreeNode[] = [
|
||||
jobTitle: 'Lead Associate',
|
||||
startDate: 'January 2026',
|
||||
endDate: 'Present',
|
||||
highlights: [
|
||||
'The journey has just begun',
|
||||
],
|
||||
highlights: ['The journey has just begun']
|
||||
},
|
||||
|
||||
// ========== PROJECTS (Upper Left Quadrant) ==========
|
||||
@@ -227,7 +245,7 @@ export const treeNodes: TreeNode[] = [
|
||||
parentId: 'origin',
|
||||
category: 'projects',
|
||||
size: 'large',
|
||||
icon: '/icons/projects.svg',
|
||||
icon: '/icons/projects.svg'
|
||||
},
|
||||
|
||||
// Open Source Libraries
|
||||
@@ -240,7 +258,7 @@ export const treeNodes: TreeNode[] = [
|
||||
parentId: 'proj-branch',
|
||||
category: 'projects',
|
||||
size: 'medium',
|
||||
icon: '/icons/opensource.svg',
|
||||
icon: '/icons/opensource.svg'
|
||||
},
|
||||
|
||||
// Personal Tools
|
||||
@@ -253,7 +271,7 @@ export const treeNodes: TreeNode[] = [
|
||||
parentId: 'proj-branch',
|
||||
category: 'projects',
|
||||
size: 'medium',
|
||||
icon: '/icons/tools.svg',
|
||||
icon: '/icons/tools.svg'
|
||||
},
|
||||
|
||||
// Games
|
||||
@@ -266,14 +284,15 @@ export const treeNodes: TreeNode[] = [
|
||||
parentId: 'proj-branch',
|
||||
category: 'projects',
|
||||
size: 'medium',
|
||||
icon: '/icons/games.svg',
|
||||
icon: '/icons/games.svg'
|
||||
},
|
||||
|
||||
// ========== OPEN SOURCE CHILDREN ==========
|
||||
{
|
||||
id: 'proj-tvdbapi',
|
||||
label: 'tvdbAPI',
|
||||
description: 'Python library for accessing The TVDB API. Fetch show info, episodes, actors, and images with a simple interface. Published on PyPI.',
|
||||
description:
|
||||
'Python library for accessing The TVDB API. Fetch show info, episodes, actors, and images with a simple interface. Published on PyPI.',
|
||||
x: -420,
|
||||
y: -310,
|
||||
parentId: 'proj-opensource',
|
||||
@@ -288,7 +307,8 @@ export const treeNodes: TreeNode[] = [
|
||||
{
|
||||
id: 'proj-tmdbapi',
|
||||
label: 'tmdbAPI',
|
||||
description: 'Python library for The Movie Database API. Search for movies, TV shows, actors, and collections with comprehensive query options.',
|
||||
description:
|
||||
'Python library for The Movie Database API. Search for movies, TV shows, actors, and collections with comprehensive query options.',
|
||||
x: -500,
|
||||
y: -250,
|
||||
parentId: 'proj-opensource',
|
||||
@@ -297,13 +317,14 @@ 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'
|
||||
},
|
||||
{
|
||||
id: 'proj-image-resize',
|
||||
label: 'Image Resize Tool',
|
||||
description: 'GUI-based batch image processor with intelligent whitespace trimming and automated resizing workflows. Features folder-based processing for brand-specific transformations and maintains aspect ratios.',
|
||||
description:
|
||||
'GUI-based batch image processor with intelligent whitespace trimming and automated resizing workflows. Features folder-based processing for brand-specific transformations and maintains aspect ratios.',
|
||||
x: -460,
|
||||
y: -180,
|
||||
parentId: 'proj-opensource',
|
||||
@@ -319,7 +340,8 @@ export const treeNodes: TreeNode[] = [
|
||||
{
|
||||
id: 'proj-renameshow',
|
||||
label: 'Rename Show',
|
||||
description: 'Desktop application for batch renaming TV show files using metadata from TVDB and TMDB. Features undo support, multi-episode detection, and automatic show searching.',
|
||||
description:
|
||||
'Desktop application for batch renaming TV show files using metadata from TVDB and TMDB. Features undo support, multi-episode detection, and automatic show searching.',
|
||||
x: -475,
|
||||
y: -135,
|
||||
parentId: 'proj-tools',
|
||||
@@ -327,12 +349,13 @@ export const treeNodes: TreeNode[] = [
|
||||
size: 'small',
|
||||
icon: '/icons/desktop.svg',
|
||||
techStack: ['Python', 'Tkinter', 'tvdbAPI', 'tmdbAPI', 'PIL'],
|
||||
repoUrl: 'https://github.com/TehRiehlDeal/python-file-rename',
|
||||
repoUrl: 'https://github.com/TehRiehlDeal/python-file-rename'
|
||||
},
|
||||
{
|
||||
id: 'proj-portfolio',
|
||||
label: 'Skill Tree Portfolio',
|
||||
description: 'This interactive portfolio you\'re exploring right now! A Path of Exile inspired skill tree built with modern web technologies.',
|
||||
description:
|
||||
"This interactive portfolio you're exploring right now! A Path of Exile inspired skill tree built with modern web technologies.",
|
||||
x: -480,
|
||||
y: -60,
|
||||
parentId: 'proj-tools',
|
||||
@@ -341,12 +364,13 @@ export const treeNodes: TreeNode[] = [
|
||||
icon: '/icons/svelte.svg',
|
||||
techStack: ['SvelteKit', 'TypeScript', 'PixiJS', 'GSAP', 'Tailwind CSS'],
|
||||
projectUrl: 'https://tehriehldeal.com',
|
||||
repoUrl: 'https://github.com/yourusername/skill-tree-portfolio',
|
||||
repoUrl: 'https://github.com/yourusername/skill-tree-portfolio'
|
||||
},
|
||||
{
|
||||
id: 'proj-character-builder',
|
||||
label: 'Pathfinder 2E Character Builder',
|
||||
description: 'Web-based character creation tool for Pathfinder 2E tabletop RPG. Features modern component-based architecture with comprehensive testing and code quality tooling.',
|
||||
description:
|
||||
'Web-based character creation tool for Pathfinder 2E tabletop RPG. Features modern component-based architecture with comprehensive testing and code quality tooling.',
|
||||
x: -490,
|
||||
y: 15,
|
||||
parentId: 'proj-tools',
|
||||
@@ -360,14 +384,26 @@ export const treeNodes: TreeNode[] = [
|
||||
{
|
||||
id: 'proj-budget',
|
||||
label: 'TehRiehlBudget',
|
||||
description: 'Self-hosted personal finance app tracking spending, balances, and net worth across checking, savings, credit, loans, and investments. Features field-level AES-256-GCM encryption, receipt uploads, audit logging, CSV export, and a local Ollama-powered AI advisor.',
|
||||
description:
|
||||
'Self-hosted personal finance app tracking spending, balances, and net worth across checking, savings, credit, loans, and investments. Features field-level AES-256-GCM encryption, receipt uploads, audit logging, CSV export, and a local Ollama-powered AI advisor.',
|
||||
x: -440,
|
||||
y: 75,
|
||||
parentId: 'proj-tools',
|
||||
category: 'projects',
|
||||
size: 'small',
|
||||
icon: '/icons/react.svg',
|
||||
techStack: ['React', 'TypeScript', 'NestJS', 'Prisma', 'PostgreSQL', 'TailwindCSS', 'ShadCN', 'Zustand', 'Supabase', 'Ollama'],
|
||||
techStack: [
|
||||
'React',
|
||||
'TypeScript',
|
||||
'NestJS',
|
||||
'Prisma',
|
||||
'PostgreSQL',
|
||||
'TailwindCSS',
|
||||
'ShadCN',
|
||||
'Zustand',
|
||||
'Supabase',
|
||||
'Ollama'
|
||||
],
|
||||
projectUrl: 'https://budget.tehriehldeal.com/',
|
||||
repoUrl: 'https://git.tehriehldeal.com/TehRiehlDeal/TehRiehlBudget',
|
||||
status: 'active'
|
||||
@@ -377,7 +413,8 @@ export const treeNodes: TreeNode[] = [
|
||||
{
|
||||
id: 'proj-movieloop',
|
||||
label: 'MovieLoop',
|
||||
description: 'Daily challenge game where players chain movies through shared actors — a "six degrees of Kevin Bacon" reimagined for the web. Features leaderboards, daily and freeplay modes, undo support, and PWA install with push notifications.',
|
||||
description:
|
||||
'Daily challenge game where players chain movies through shared actors — a "six degrees of Kevin Bacon" reimagined for the web. Features leaderboards, daily and freeplay modes, undo support, and PWA install with push notifications.',
|
||||
x: -260,
|
||||
y: -390,
|
||||
parentId: 'proj-games',
|
||||
@@ -400,7 +437,7 @@ export const treeNodes: TreeNode[] = [
|
||||
parentId: 'origin',
|
||||
category: 'skills',
|
||||
size: 'large',
|
||||
icon: '/icons/skills.svg',
|
||||
icon: '/icons/skills.svg'
|
||||
},
|
||||
|
||||
// Concepts - upper right
|
||||
@@ -413,7 +450,7 @@ export const treeNodes: TreeNode[] = [
|
||||
parentId: 'skills-branch',
|
||||
category: 'skills',
|
||||
size: 'medium',
|
||||
icon: '/icons/concepts.svg',
|
||||
icon: '/icons/concepts.svg'
|
||||
},
|
||||
|
||||
// Tools & DevOps - moved up slightly
|
||||
@@ -426,7 +463,7 @@ export const treeNodes: TreeNode[] = [
|
||||
parentId: 'skills-branch',
|
||||
category: 'skills',
|
||||
size: 'medium',
|
||||
icon: '/icons/devops.svg',
|
||||
icon: '/icons/devops.svg'
|
||||
},
|
||||
|
||||
// Frontend - lower right
|
||||
@@ -439,7 +476,7 @@ export const treeNodes: TreeNode[] = [
|
||||
parentId: 'skills-branch',
|
||||
category: 'skills',
|
||||
size: 'medium',
|
||||
icon: '/icons/frontend.svg',
|
||||
icon: '/icons/frontend.svg'
|
||||
},
|
||||
|
||||
// Backend & Real-Time - pulled closer to Skills
|
||||
@@ -452,7 +489,7 @@ export const treeNodes: TreeNode[] = [
|
||||
parentId: 'skills-branch',
|
||||
category: 'skills',
|
||||
size: 'medium',
|
||||
icon: '/icons/backend.svg',
|
||||
icon: '/icons/backend.svg'
|
||||
},
|
||||
|
||||
// Languages - left side
|
||||
@@ -465,33 +502,35 @@ export const treeNodes: TreeNode[] = [
|
||||
parentId: 'skills-branch',
|
||||
category: 'skills',
|
||||
size: 'medium',
|
||||
icon: '/icons/languages.svg',
|
||||
icon: '/icons/languages.svg'
|
||||
},
|
||||
|
||||
// ========== CONCEPTS CHILDREN (4) ==========
|
||||
{
|
||||
id: 'concept-oop',
|
||||
label: 'Object Oriented Programming',
|
||||
description: 'Object-Oriented Programming principles including encapsulation, inheritance, and polymorphism.',
|
||||
description:
|
||||
'Object-Oriented Programming principles including encapsulation, inheritance, and polymorphism.',
|
||||
x: 380,
|
||||
y: -120,
|
||||
parentId: 'concepts',
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/oop.svg',
|
||||
proficiency: 'expert',
|
||||
proficiency: 'expert'
|
||||
},
|
||||
{
|
||||
id: 'concept-data-structure',
|
||||
label: 'Data Structures & Algorithms',
|
||||
description: 'Fundamental building blocks of efficient software—organizing data and solving problems with optimal time and space complexity.',
|
||||
description:
|
||||
'Fundamental building blocks of efficient software—organizing data and solving problems with optimal time and space complexity.',
|
||||
x: 430,
|
||||
y: -75,
|
||||
parentId: 'concepts',
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/datastructure.svg',
|
||||
proficiency: 'advanced',
|
||||
proficiency: 'advanced'
|
||||
},
|
||||
{
|
||||
id: 'concept-tdd',
|
||||
@@ -503,7 +542,7 @@ export const treeNodes: TreeNode[] = [
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/test.svg',
|
||||
proficiency: 'intermediate',
|
||||
proficiency: 'intermediate'
|
||||
},
|
||||
{
|
||||
id: 'concept-agile',
|
||||
@@ -515,7 +554,7 @@ export const treeNodes: TreeNode[] = [
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/agile.svg',
|
||||
proficiency: 'advanced',
|
||||
proficiency: 'advanced'
|
||||
},
|
||||
|
||||
// ========== TOOLS & DEVOPS CHILDREN (5) ==========
|
||||
@@ -529,19 +568,20 @@ export const treeNodes: TreeNode[] = [
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/git.svg',
|
||||
proficiency: 'expert',
|
||||
proficiency: 'expert'
|
||||
},
|
||||
{
|
||||
id: 'tool-docker',
|
||||
label: 'Docker',
|
||||
description: 'Containerization with Docker for consistent development and deployment environments.',
|
||||
description:
|
||||
'Containerization with Docker for consistent development and deployment environments.',
|
||||
x: 560,
|
||||
y: 95,
|
||||
parentId: 'tools-devops',
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/docker.svg',
|
||||
proficiency: 'advanced',
|
||||
proficiency: 'advanced'
|
||||
},
|
||||
{
|
||||
id: 'tool-cicd',
|
||||
@@ -553,19 +593,20 @@ export const treeNodes: TreeNode[] = [
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/cicd.svg',
|
||||
proficiency: 'intermediate',
|
||||
proficiency: 'intermediate'
|
||||
},
|
||||
{
|
||||
id: 'tool-vscod',
|
||||
label: 'VSCode',
|
||||
description: 'Powerful, lightweight code editor by Microsoft with built-in Git support, debugging, and a vast extension ecosystem.',
|
||||
description:
|
||||
'Powerful, lightweight code editor by Microsoft with built-in Git support, debugging, and a vast extension ecosystem.',
|
||||
x: 520,
|
||||
y: 205,
|
||||
parentId: 'tools-devops',
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/vscode-plain.svg',
|
||||
proficiency: 'advanced',
|
||||
proficiency: 'advanced'
|
||||
},
|
||||
{
|
||||
id: 'tool-linux',
|
||||
@@ -577,7 +618,7 @@ export const treeNodes: TreeNode[] = [
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/linux.svg',
|
||||
proficiency: 'advanced',
|
||||
proficiency: 'advanced'
|
||||
},
|
||||
|
||||
// ========== FRONTEND CHILDREN (6) ==========
|
||||
@@ -591,31 +632,33 @@ export const treeNodes: TreeNode[] = [
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/react.svg',
|
||||
proficiency: 'advanced',
|
||||
proficiency: 'advanced'
|
||||
},
|
||||
{
|
||||
id: 'fe-redux',
|
||||
label: 'Redux',
|
||||
description: 'Predictable state management library for JavaScript applications, centralizing app state in a single store.',
|
||||
description:
|
||||
'Predictable state management library for JavaScript applications, centralizing app state in a single store.',
|
||||
x: 450,
|
||||
y: 350,
|
||||
parentId: 'frontend',
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/redux.svg',
|
||||
proficiency: 'intermediate',
|
||||
proficiency: 'intermediate'
|
||||
},
|
||||
{
|
||||
id: 'fe-zustand',
|
||||
label: 'Zustand',
|
||||
description: 'Lightweight state management for React using a simple hooks-based API with minimal boilerplate.',
|
||||
description:
|
||||
'Lightweight state management for React using a simple hooks-based API with minimal boilerplate.',
|
||||
x: 440,
|
||||
y: 405,
|
||||
parentId: 'frontend',
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/zustand-plain.svg',
|
||||
proficiency: 'intermediate',
|
||||
proficiency: 'intermediate'
|
||||
},
|
||||
{
|
||||
id: 'fe-tailwind',
|
||||
@@ -627,31 +670,33 @@ export const treeNodes: TreeNode[] = [
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/tailwindcss.svg',
|
||||
proficiency: 'advanced',
|
||||
proficiency: 'advanced'
|
||||
},
|
||||
{
|
||||
id: 'fe-shadcn',
|
||||
label: 'ShadCN/UI',
|
||||
description: 'Beautifully designed, accessible UI components that you copy and paste into your project. Built on Radix UI and Tailwind CSS.',
|
||||
description:
|
||||
'Beautifully designed, accessible UI components that you copy and paste into your project. Built on Radix UI and Tailwind CSS.',
|
||||
x: 340,
|
||||
y: 500,
|
||||
parentId: 'frontend',
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/shadcnui.svg',
|
||||
proficiency: 'advanced',
|
||||
proficiency: 'advanced'
|
||||
},
|
||||
{
|
||||
id: 'fe-pwa',
|
||||
label: 'PWA',
|
||||
description: 'Progressive Web Apps—web applications that deliver native app-like experiences with offline support, push notifications, and home screen installation.',
|
||||
description:
|
||||
'Progressive Web Apps—web applications that deliver native app-like experiences with offline support, push notifications, and home screen installation.',
|
||||
x: 270,
|
||||
y: 520,
|
||||
parentId: 'frontend',
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/pwa.svg',
|
||||
proficiency: 'intermediate',
|
||||
proficiency: 'intermediate'
|
||||
},
|
||||
|
||||
// ========== BACKEND & REAL-TIME CHILDREN (6) ==========
|
||||
@@ -665,67 +710,72 @@ export const treeNodes: TreeNode[] = [
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/nodedotjs.svg',
|
||||
proficiency: 'advanced',
|
||||
proficiency: 'advanced'
|
||||
},
|
||||
{
|
||||
id: 'be-hapi',
|
||||
label: 'Hapi.js',
|
||||
description: 'Enterprise-grade Node.js framework for building scalable APIs and services with built-in validation, authentication, and caching.',
|
||||
description:
|
||||
'Enterprise-grade Node.js framework for building scalable APIs and services with built-in validation, authentication, and caching.',
|
||||
x: 175,
|
||||
y: 370,
|
||||
parentId: 'backend-real-time',
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/hapi.svg',
|
||||
proficiency: 'advanced',
|
||||
proficiency: 'advanced'
|
||||
},
|
||||
{
|
||||
id: 'be-nestjs',
|
||||
label: 'NestJS',
|
||||
description: 'Progressive Node.js framework for building efficient, scalable server-side applications with TypeScript and Angular-inspired architecture.',
|
||||
description:
|
||||
'Progressive Node.js framework for building efficient, scalable server-side applications with TypeScript and Angular-inspired architecture.',
|
||||
x: 115,
|
||||
y: 390,
|
||||
parentId: 'backend-real-time',
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/nestjs.svg',
|
||||
proficiency: 'intermediate',
|
||||
proficiency: 'intermediate'
|
||||
},
|
||||
{
|
||||
id: 'be-webrtc',
|
||||
label: 'WebRTC',
|
||||
description: 'Real-time communication protocol enabling peer-to-peer audio, video, and data streaming directly in the browser without plugins.',
|
||||
description:
|
||||
'Real-time communication protocol enabling peer-to-peer audio, video, and data streaming directly in the browser without plugins.',
|
||||
x: 50,
|
||||
y: 385,
|
||||
parentId: 'backend-real-time',
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/webrtc.svg',
|
||||
proficiency: 'intermediate',
|
||||
proficiency: 'intermediate'
|
||||
},
|
||||
{
|
||||
id: 'be-socketio',
|
||||
label: 'Socket.io',
|
||||
description: 'Real-time bidirectional event-based communication library for Node.js and browsers with automatic fallback support.',
|
||||
description:
|
||||
'Real-time bidirectional event-based communication library for Node.js and browsers with automatic fallback support.',
|
||||
x: 0,
|
||||
y: 355,
|
||||
parentId: 'backend-real-time',
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/socketdotio.svg',
|
||||
proficiency: 'advanced',
|
||||
proficiency: 'advanced'
|
||||
},
|
||||
{
|
||||
id: 'be-restfulapi',
|
||||
label: 'RESTful APIs',
|
||||
description: 'Architectural style for designing networked applications using stateless HTTP methods to perform CRUD operations on resources.',
|
||||
description:
|
||||
'Architectural style for designing networked applications using stateless HTTP methods to perform CRUD operations on resources.',
|
||||
x: -25,
|
||||
y: 310,
|
||||
parentId: 'backend-real-time',
|
||||
category: 'skills',
|
||||
size: 'small',
|
||||
icon: '/icons/openapi.svg',
|
||||
proficiency: 'advanced',
|
||||
proficiency: 'advanced'
|
||||
},
|
||||
|
||||
// ========== LANGUAGES CHILDREN (7) ==========
|
||||
@@ -740,7 +790,7 @@ export const treeNodes: TreeNode[] = [
|
||||
size: 'small',
|
||||
icon: '/icons/javascript.svg',
|
||||
proficiency: 'expert',
|
||||
yearsOfExperience: 5,
|
||||
yearsOfExperience: 5
|
||||
},
|
||||
{
|
||||
id: 'lang-typescript',
|
||||
@@ -753,7 +803,7 @@ export const treeNodes: TreeNode[] = [
|
||||
size: 'small',
|
||||
icon: '/icons/typescript.svg',
|
||||
proficiency: 'expert',
|
||||
yearsOfExperience: 5,
|
||||
yearsOfExperience: 5
|
||||
},
|
||||
{
|
||||
id: 'lang-python',
|
||||
@@ -766,12 +816,13 @@ export const treeNodes: TreeNode[] = [
|
||||
size: 'small',
|
||||
icon: '/icons/python.svg',
|
||||
proficiency: 'intermediate',
|
||||
yearsOfExperience: 2,
|
||||
yearsOfExperience: 2
|
||||
},
|
||||
{
|
||||
id: 'lang-rust',
|
||||
label: 'Rust',
|
||||
description: 'Systems programming language focused on safety, speed, and concurrency—guaranteeing memory safety without a garbage collector.',
|
||||
description:
|
||||
'Systems programming language focused on safety, speed, and concurrency—guaranteeing memory safety without a garbage collector.',
|
||||
x: -190,
|
||||
y: 355,
|
||||
parentId: 'languages',
|
||||
@@ -779,12 +830,13 @@ export const treeNodes: TreeNode[] = [
|
||||
size: 'small',
|
||||
icon: '/icons/rust.svg',
|
||||
proficiency: 'beginner',
|
||||
yearsOfExperience: 1,
|
||||
yearsOfExperience: 1
|
||||
},
|
||||
{
|
||||
id: 'lang-php',
|
||||
label: 'PHP',
|
||||
description: 'Server-side scripting language powering much of the web—from WordPress to Laravel, built for dynamic web content.',
|
||||
description:
|
||||
'Server-side scripting language powering much of the web—from WordPress to Laravel, built for dynamic web content.',
|
||||
x: -165,
|
||||
y: 405,
|
||||
parentId: 'languages',
|
||||
@@ -792,7 +844,7 @@ export const treeNodes: TreeNode[] = [
|
||||
size: 'small',
|
||||
icon: '/icons/php.svg',
|
||||
proficiency: 'intermediate',
|
||||
yearsOfExperience: 2,
|
||||
yearsOfExperience: 2
|
||||
},
|
||||
{
|
||||
id: 'lang-sql',
|
||||
@@ -805,7 +857,7 @@ export const treeNodes: TreeNode[] = [
|
||||
size: 'small',
|
||||
icon: '/icons/mysql.svg',
|
||||
proficiency: 'intermediate',
|
||||
yearsOfExperience: 2,
|
||||
yearsOfExperience: 2
|
||||
},
|
||||
{
|
||||
id: 'lang-bash',
|
||||
@@ -818,7 +870,7 @@ export const treeNodes: TreeNode[] = [
|
||||
size: 'small',
|
||||
icon: '/icons/gnubash.svg',
|
||||
proficiency: 'intermediate',
|
||||
yearsOfExperience: 2,
|
||||
yearsOfExperience: 2
|
||||
},
|
||||
|
||||
// ========== EDUCATION (Bottom Left Quadrant) ==========
|
||||
@@ -836,19 +888,18 @@ export const treeNodes: TreeNode[] = [
|
||||
{
|
||||
id: 'edu-degree',
|
||||
label: 'University',
|
||||
description: 'Bachelor\'s degree in Computer Science.',
|
||||
description: "Bachelor's degree in Computer Science.",
|
||||
x: -300,
|
||||
y: 80,
|
||||
parentId: 'edu-branch',
|
||||
category: 'education',
|
||||
size: 'small',
|
||||
institution: 'Green River College',
|
||||
degree: 'Bachelor\'s Degree of Applied Science',
|
||||
degree: "Bachelor's Degree of Applied Science",
|
||||
field: 'IT/Software Development',
|
||||
graduationYear: '2017',
|
||||
icon: '/icons/greenriver.png'
|
||||
|
||||
},
|
||||
}
|
||||
// {
|
||||
// id: 'edu-cert',
|
||||
// label: 'Certifications',
|
||||
@@ -861,11 +912,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,222 @@
|
||||
// Sequenced entrance animation: title → origin → zoom-out → nodes → lines,
|
||||
// then continuous ambient pulsing. Must run AFTER nodes exist — it re-enables
|
||||
// node interactivity (eventMode 'static') once each node finishes appearing.
|
||||
import { Container } from 'pixi.js';
|
||||
import { gsap } from 'gsap';
|
||||
import { getDistanceFromOrigin } from '$lib/utils/tree';
|
||||
import { updateParallax } from './interaction';
|
||||
import type { SkillTreeController } from './controller';
|
||||
|
||||
interface EntranceOpts {
|
||||
isMobile: boolean;
|
||||
/** Called when the welcome title should be removed (matches its CSS fade). */
|
||||
onTitleDone: () => void;
|
||||
}
|
||||
|
||||
export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceOpts) {
|
||||
const { isMobile } = opts;
|
||||
const { treeContainer, nodeObjects, lineObjects, endScale } = ctrl;
|
||||
|
||||
// Responsive animation timing
|
||||
const titleDuration = isMobile ? 1.5 : 2.5;
|
||||
const zoomDuration = isMobile ? 2.5 : 4.5;
|
||||
const nodeStagger = isMobile ? 0.05 : 0.08;
|
||||
|
||||
// Hide all lines and plasma beams initially
|
||||
lineObjects.forEach((line) => {
|
||||
line.graphics.alpha = 0;
|
||||
line.glowGraphics.alpha = 0;
|
||||
line.plasmaBeam.alpha = 0;
|
||||
});
|
||||
|
||||
// Hide all nodes initially
|
||||
nodeObjects.forEach((obj) => {
|
||||
obj.graphics.alpha = 0;
|
||||
obj.graphics.scale.set(0.5);
|
||||
obj.glowGraphics.alpha = 0;
|
||||
});
|
||||
|
||||
// Sort by distance from origin
|
||||
const sortedNodes = [...nodeObjects].sort(
|
||||
(a, b) => getDistanceFromOrigin(a.node) - getDistanceFromOrigin(b.node)
|
||||
);
|
||||
const sortedLines = [...lineObjects].sort(
|
||||
(a, b) => getDistanceFromOrigin(a.node) - getDistanceFromOrigin(b.node)
|
||||
);
|
||||
|
||||
// STEP 1: After title fades, show origin node first (overlap on mobile)
|
||||
const originNode = sortedNodes.find((obj) => obj.node.id === 'origin');
|
||||
const originDelay = isMobile ? titleDuration - 0.5 : titleDuration;
|
||||
const originDuration = isMobile ? 0.3 : 0.6;
|
||||
|
||||
if (originNode && isMobile) {
|
||||
originNode.graphics.scale.set(0.8); // Start slightly larger
|
||||
}
|
||||
|
||||
if (originNode) {
|
||||
gsap.to(originNode.graphics, {
|
||||
alpha: 1,
|
||||
duration: originDuration,
|
||||
delay: originDelay,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
gsap.to(originNode.graphics.scale, {
|
||||
x: 1,
|
||||
y: 1,
|
||||
duration: originDuration,
|
||||
delay: originDelay,
|
||||
ease: 'back.out(1.7)',
|
||||
onComplete: () => {
|
||||
if (originNode.graphics instanceof Container) {
|
||||
originNode.graphics.eventMode = 'static';
|
||||
}
|
||||
}
|
||||
});
|
||||
gsap.to(originNode.glowGraphics, {
|
||||
alpha: 1,
|
||||
duration: originDuration,
|
||||
delay: originDelay,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
}
|
||||
|
||||
// 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(ctrl)
|
||||
});
|
||||
|
||||
// STEP 3: Animate in the rest of the nodes (excluding origin)
|
||||
const otherNodes = sortedNodes.filter((obj) => obj.node.id !== 'origin');
|
||||
const nodeStartDelay = isMobile ? titleDuration - 0.2 : titleDuration + 0.5;
|
||||
|
||||
otherNodes.forEach((obj, index) => {
|
||||
const delay = nodeStartDelay + index * nodeStagger;
|
||||
|
||||
gsap.to(obj.graphics, {
|
||||
alpha: 1,
|
||||
duration: 0.5,
|
||||
delay,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
gsap.to(obj.graphics.scale, {
|
||||
x: 1,
|
||||
y: 1,
|
||||
duration: 0.5,
|
||||
delay,
|
||||
ease: 'back.out(1.7)',
|
||||
onComplete: () => {
|
||||
if (obj.graphics instanceof Container) {
|
||||
obj.graphics.eventMode = 'static';
|
||||
}
|
||||
}
|
||||
});
|
||||
gsap.to(obj.glowGraphics, {
|
||||
alpha: 1,
|
||||
duration: 0.5,
|
||||
delay,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
|
||||
// Start ambient pulse after node appears
|
||||
gsap.to(obj.glowGraphics.scale, {
|
||||
x: 1.4,
|
||||
y: 1.4,
|
||||
duration: 2 + Math.random() * 1,
|
||||
delay: delay + 0.5,
|
||||
ease: 'sine.inOut',
|
||||
yoyo: true,
|
||||
repeat: -1
|
||||
});
|
||||
});
|
||||
|
||||
// Start origin pulse too
|
||||
if (originNode) {
|
||||
gsap.to(originNode.glowGraphics.scale, {
|
||||
x: 1.4,
|
||||
y: 1.4,
|
||||
duration: 2 + Math.random() * 1,
|
||||
delay: titleDuration + 1,
|
||||
ease: 'sine.inOut',
|
||||
yoyo: true,
|
||||
repeat: -1
|
||||
});
|
||||
}
|
||||
|
||||
// STEP 4: Animate lines and plasma beams as nodes appear
|
||||
const lineStartDelay = isMobile ? titleDuration - 0.3 : titleDuration + 0.4;
|
||||
|
||||
sortedLines.forEach((line, index) => {
|
||||
const delay = lineStartDelay + index * nodeStagger;
|
||||
|
||||
// Core line appears first
|
||||
gsap.to(line.graphics, {
|
||||
alpha: 1,
|
||||
duration: 0.4,
|
||||
delay,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
|
||||
// Base glow appears with line
|
||||
gsap.to(line.glowGraphics, {
|
||||
alpha: 1,
|
||||
duration: 0.6,
|
||||
delay: delay + 0.1,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
|
||||
// Plasma beam fades in, then breathes continuously
|
||||
gsap.to(line.plasmaBeam, {
|
||||
alpha: 1,
|
||||
duration: 0.8,
|
||||
delay: delay + 0.2,
|
||||
ease: 'power2.out',
|
||||
onComplete: () => {
|
||||
const pulseDuration = 2.0 + Math.random() * 1.0; // Vary pulse speed
|
||||
const minAlpha = 0.6 * line.intensity; // Minimum brightness - stays visible
|
||||
|
||||
gsap.to(line.plasmaBeam, {
|
||||
alpha: minAlpha,
|
||||
duration: pulseDuration,
|
||||
ease: 'sine.inOut',
|
||||
yoyo: true,
|
||||
repeat: -1
|
||||
});
|
||||
|
||||
const glowMinAlpha = 0.12 * line.intensity;
|
||||
gsap.to(line.glowGraphics, {
|
||||
alpha: glowMinAlpha,
|
||||
duration: pulseDuration * 1.2,
|
||||
ease: 'sine.inOut',
|
||||
yoyo: true,
|
||||
repeat: -1
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up title card after animation (matches titleDuration)
|
||||
setTimeout(
|
||||
() => {
|
||||
opts.onTitleDone();
|
||||
},
|
||||
isMobile ? 1500 : 2500
|
||||
);
|
||||
|
||||
// Fade in stats panel toggle button with the tree
|
||||
const toggleButton = document.querySelector('.stats-panel-toggle');
|
||||
if (toggleButton) {
|
||||
gsap.to(toggleButton, {
|
||||
opacity: 1,
|
||||
duration: 0.5,
|
||||
delay: nodeStartDelay + otherNodes.length * nodeStagger * 0.5, // midway through node animation
|
||||
ease: 'power2.out'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// Multi-layer space background: gradient → nebula → stars → dust → vignette.
|
||||
// Each layer is added to the stage here; the three parallax containers are
|
||||
// returned so the interaction module can offset them while panning.
|
||||
import { Application, Graphics, Container } from 'pixi.js';
|
||||
import { gsap } from 'gsap';
|
||||
import { nebulaPalette } from '$lib/theme';
|
||||
import { PARTICLE_COUNTS, byDevice } from '$lib/config';
|
||||
import type { BackgroundLayers } from './types';
|
||||
|
||||
export function createBackground(app: Application, isMobile: boolean): BackgroundLayers {
|
||||
const deepBackgroundContainer = new Container();
|
||||
const nebulaContainer = new Container();
|
||||
const starContainer = new Container();
|
||||
|
||||
app.stage.addChild(deepBackgroundContainer);
|
||||
app.stage.addChild(nebulaContainer);
|
||||
app.stage.addChild(starContainer);
|
||||
|
||||
const screenWidth = app.screen.width;
|
||||
const screenHeight = app.screen.height;
|
||||
const centerX = screenWidth / 2;
|
||||
const centerY = screenHeight / 2;
|
||||
|
||||
// ========== DEEP SPACE GRADIENT ==========
|
||||
const spaceGradient = new Graphics();
|
||||
const gradientRadius = Math.max(screenWidth, screenHeight);
|
||||
|
||||
const gradientSteps = 15;
|
||||
for (let i = gradientSteps; i >= 0; i--) {
|
||||
const ratio = i / gradientSteps;
|
||||
const radius = gradientRadius * (0.3 + ratio * 0.7);
|
||||
|
||||
const r = Math.floor(8 * (1 - ratio));
|
||||
const g = Math.floor(6 * (1 - ratio));
|
||||
const b = Math.floor(20 * (1 - ratio));
|
||||
const color = (r << 16) + (g << 8) + b;
|
||||
|
||||
spaceGradient.circle(centerX, centerY, radius).fill({ color, alpha: 0.8 });
|
||||
}
|
||||
deepBackgroundContainer.addChild(spaceGradient);
|
||||
|
||||
// ========== NEBULA CLOUDS ==========
|
||||
const nebulaColors = nebulaPalette;
|
||||
|
||||
nebulaColors.forEach((nebula, index) => {
|
||||
const angle = (index / nebulaColors.length) * Math.PI * 2 + Math.PI / 4;
|
||||
const distance = Math.min(screenWidth, screenHeight) * 0.35;
|
||||
const baseX = centerX + Math.cos(angle) * distance;
|
||||
const baseY = centerY + Math.sin(angle) * distance;
|
||||
|
||||
const nebulaCloud = new Graphics();
|
||||
|
||||
// Outer very faint glow
|
||||
const outerSize = 200 + Math.random() * 150;
|
||||
for (let layer = 5; layer >= 0; layer--) {
|
||||
const layerRatio = layer / 5;
|
||||
const size = outerSize * (0.4 + layerRatio * 0.6);
|
||||
const alpha = 0.02 * (1 - layerRatio);
|
||||
nebulaCloud.circle(0, 0, size).fill({ color: nebula.color, alpha });
|
||||
}
|
||||
|
||||
// Irregular blobs for a more organic look
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const offsetX = (Math.random() - 0.5) * 100;
|
||||
const offsetY = (Math.random() - 0.5) * 100;
|
||||
const blobSize = 60 + Math.random() * 80;
|
||||
|
||||
for (let layer = 3; layer >= 0; layer--) {
|
||||
const layerRatio = layer / 3;
|
||||
const size = blobSize * (0.5 + layerRatio * 0.5);
|
||||
const alpha = 0.03 * (1 - layerRatio);
|
||||
nebulaCloud.circle(offsetX, offsetY, size).fill({ color: nebula.color, alpha });
|
||||
}
|
||||
}
|
||||
|
||||
nebulaCloud.x = baseX;
|
||||
nebulaCloud.y = baseY;
|
||||
nebulaContainer.addChild(nebulaCloud);
|
||||
|
||||
// Animate nebula pulsing
|
||||
gsap.to(nebulaCloud, {
|
||||
alpha: 0.6,
|
||||
duration: 3 + Math.random() * 2,
|
||||
ease: 'sine.inOut',
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
delay: Math.random() * 2
|
||||
});
|
||||
|
||||
gsap.to(nebulaCloud.scale, {
|
||||
x: 1.1,
|
||||
y: 1.1,
|
||||
duration: 4 + Math.random() * 2,
|
||||
ease: 'sine.inOut',
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
delay: Math.random() * 2
|
||||
});
|
||||
});
|
||||
|
||||
// ========== DISTANT STAR FIELD ==========
|
||||
// Layer 1: Tiny distant stars (many, dim)
|
||||
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 });
|
||||
|
||||
star.x = Math.random() * screenWidth;
|
||||
star.y = Math.random() * screenHeight;
|
||||
starContainer.addChild(star);
|
||||
|
||||
gsap.to(star, {
|
||||
alpha: alpha * 0.3,
|
||||
duration: 1 + Math.random() * 2,
|
||||
ease: 'sine.inOut',
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
delay: Math.random() * 3
|
||||
});
|
||||
}
|
||||
|
||||
// Layer 2: Medium stars with colour tints
|
||||
const mediumStarCount = byDevice(PARTICLE_COUNTS.mediumStars, isMobile);
|
||||
const starTints = [0xffffff, 0xfff8e7, 0xe7f0ff, 0xffe7e7, 0xe7ffe7];
|
||||
|
||||
for (let i = 0; i < mediumStarCount; i++) {
|
||||
const star = new Graphics();
|
||||
const size = Math.random() * 1.5 + 1;
|
||||
const alpha = Math.random() * 0.5 + 0.3;
|
||||
const tint = starTints[Math.floor(Math.random() * starTints.length)];
|
||||
|
||||
star.circle(0, 0, size + 2).fill({ color: tint, alpha: alpha * 0.2 });
|
||||
star.circle(0, 0, size).fill({ color: tint, alpha });
|
||||
|
||||
star.x = Math.random() * screenWidth;
|
||||
star.y = Math.random() * screenHeight;
|
||||
starContainer.addChild(star);
|
||||
|
||||
gsap.to(star, {
|
||||
alpha: alpha * 0.4,
|
||||
duration: 0.5 + Math.random() * 1.5,
|
||||
ease: 'sine.inOut',
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
delay: Math.random() * 2
|
||||
});
|
||||
}
|
||||
|
||||
// Layer 3: Bright feature stars (few, prominent)
|
||||
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;
|
||||
const tint = starTints[Math.floor(Math.random() * starTints.length)];
|
||||
|
||||
star.circle(0, 0, size + 6).fill({ color: tint, alpha: 0.05 });
|
||||
star.circle(0, 0, size + 3).fill({ color: tint, alpha: 0.1 });
|
||||
star.circle(0, 0, size).fill({ color: 0xffffff, alpha: 0.9 });
|
||||
|
||||
star.x = Math.random() * screenWidth;
|
||||
star.y = Math.random() * screenHeight;
|
||||
starContainer.addChild(star);
|
||||
|
||||
gsap.to(star.scale, {
|
||||
x: 1.3,
|
||||
y: 1.3,
|
||||
duration: 2 + Math.random() * 2,
|
||||
ease: 'sine.inOut',
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
delay: Math.random() * 2
|
||||
});
|
||||
}
|
||||
|
||||
// ========== FLOATING DUST PARTICLES ==========
|
||||
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();
|
||||
const size = Math.random() * 2 + 1;
|
||||
const colorIndex = Math.floor(Math.random() * nebulaColors.length);
|
||||
const color = nebulaColors[colorIndex].color;
|
||||
|
||||
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;
|
||||
starContainer.addChild(dust);
|
||||
|
||||
dustParticles.push({
|
||||
graphics: dust,
|
||||
vx: (Math.random() - 0.5) * 0.2,
|
||||
vy: (Math.random() - 0.5) * 0.2
|
||||
});
|
||||
|
||||
gsap.to(dust, {
|
||||
alpha: 0.3,
|
||||
duration: 3 + Math.random() * 2,
|
||||
ease: 'sine.inOut',
|
||||
yoyo: true,
|
||||
repeat: -1,
|
||||
delay: Math.random() * 2
|
||||
});
|
||||
}
|
||||
|
||||
// Animate dust particles drifting
|
||||
app.ticker.add(() => {
|
||||
dustParticles.forEach((p) => {
|
||||
p.graphics.x += p.vx;
|
||||
p.graphics.y += p.vy;
|
||||
|
||||
if (p.graphics.x < -20) p.graphics.x = screenWidth + 20;
|
||||
if (p.graphics.x > screenWidth + 20) p.graphics.x = -20;
|
||||
if (p.graphics.y < -20) p.graphics.y = screenHeight + 20;
|
||||
if (p.graphics.y > screenHeight + 20) p.graphics.y = -20;
|
||||
});
|
||||
});
|
||||
|
||||
// ========== VIGNETTE OVERLAY ==========
|
||||
const vignette = new Graphics();
|
||||
const vignetteSize = Math.max(screenWidth, screenHeight) * 1.2;
|
||||
|
||||
for (let i = 12; i >= 0; i--) {
|
||||
const ratio = i / 12;
|
||||
const radius = vignetteSize * (0.4 + ratio * 0.6);
|
||||
const alpha = ratio * ratio * 0.4;
|
||||
vignette.circle(centerX, centerY, radius).fill({ color: 0x000000, alpha });
|
||||
}
|
||||
app.stage.addChild(vignette);
|
||||
|
||||
return { deepBackgroundContainer, nebulaContainer, starContainer };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Mutable controller object shared across the PixiJS modules.
|
||||
//
|
||||
// IMPORTANT: this must NOT be wrapped in Svelte's `$state` — the deep proxy
|
||||
// breaks PixiJS's internal object identity checks. Keep it a plain object so
|
||||
// the interaction, node, and animation modules can read/write the same
|
||||
// mutable pan/zoom state by reference (the same single source of truth the
|
||||
// original onMount closure relied on).
|
||||
import type { Application, Container } from 'pixi.js';
|
||||
import type { BackgroundLayers, LineObject, NodeObject } from './types';
|
||||
|
||||
export interface SkillTreeController {
|
||||
app: Application;
|
||||
treeContainer: Container;
|
||||
bg: BackgroundLayers;
|
||||
nodeObjects: NodeObject[];
|
||||
lineObjects: LineObject[];
|
||||
|
||||
// Pan/zoom mutable state (was closure vars in onMount).
|
||||
isDragging: boolean;
|
||||
hasMoved: boolean;
|
||||
dragStartX: number;
|
||||
dragStartY: number;
|
||||
containerStartX: number;
|
||||
containerStartY: number;
|
||||
lastTouchDistance: number;
|
||||
isTouchZooming: boolean;
|
||||
|
||||
// Parallax baseline (re-set on view reset) + final zoom scale.
|
||||
initialTreeX: number;
|
||||
initialTreeY: number;
|
||||
endScale: number;
|
||||
}
|
||||
|
||||
export function createController(
|
||||
app: Application,
|
||||
treeContainer: Container,
|
||||
bg: BackgroundLayers,
|
||||
endScale: number
|
||||
): SkillTreeController {
|
||||
return {
|
||||
app,
|
||||
treeContainer,
|
||||
bg,
|
||||
nodeObjects: [],
|
||||
lineObjects: [],
|
||||
isDragging: false,
|
||||
hasMoved: false,
|
||||
dragStartX: 0,
|
||||
dragStartY: 0,
|
||||
containerStartX: 0,
|
||||
containerStartY: 0,
|
||||
lastTouchDistance: 0,
|
||||
isTouchZooming: false,
|
||||
initialTreeX: treeContainer.x,
|
||||
initialTreeY: treeContainer.y,
|
||||
endScale
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Pan, zoom, parallax, and keyboard navigation for the tree.
|
||||
//
|
||||
// All shared mutable state lives on the controller (see controller.ts); these
|
||||
// handlers read/write it by reference. attach* functions return a cleanup that
|
||||
// removes the window-level listeners they added.
|
||||
import { gsap } from 'gsap';
|
||||
import { ZOOM, PARALLAX, PAN_SPEED } from '$lib/config';
|
||||
import type { SkillTreeController } from './controller';
|
||||
|
||||
/** Offset the background layers behind the tree to create parallax depth. */
|
||||
export function updateParallax(ctrl: SkillTreeController) {
|
||||
const treeDeltaX = ctrl.treeContainer.x - ctrl.initialTreeX;
|
||||
const treeDeltaY = ctrl.treeContainer.y - ctrl.initialTreeY;
|
||||
|
||||
ctrl.bg.starContainer.x = treeDeltaX * PARALLAX.star;
|
||||
ctrl.bg.starContainer.y = treeDeltaY * PARALLAX.star;
|
||||
|
||||
ctrl.bg.nebulaContainer.x = treeDeltaX * PARALLAX.nebula;
|
||||
ctrl.bg.nebulaContainer.y = treeDeltaY * PARALLAX.nebula;
|
||||
|
||||
ctrl.bg.deepBackgroundContainer.x = treeDeltaX * PARALLAX.gradient;
|
||||
ctrl.bg.deepBackgroundContainer.y = treeDeltaY * PARALLAX.gradient;
|
||||
// No scale parallax - backgrounds keep scale 1.0
|
||||
}
|
||||
|
||||
interface PanZoomOpts {
|
||||
onTooltipMove: (x: number, y: number) => void;
|
||||
}
|
||||
|
||||
/** Wire mouse/touch/wheel pan + zoom. Returns a cleanup for window listeners. */
|
||||
export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): () => void {
|
||||
const { app, treeContainer } = ctrl;
|
||||
const minZoom = ZOOM.min;
|
||||
const maxZoom = ZOOM.max;
|
||||
|
||||
// ===== MOUSE PANNING =====
|
||||
app.canvas.addEventListener('mousedown', (e: MouseEvent) => {
|
||||
ctrl.isDragging = true;
|
||||
ctrl.hasMoved = false;
|
||||
ctrl.dragStartX = e.clientX;
|
||||
ctrl.dragStartY = e.clientY;
|
||||
ctrl.containerStartX = treeContainer.x;
|
||||
ctrl.containerStartY = treeContainer.y;
|
||||
app.canvas.style.cursor = 'grabbing';
|
||||
});
|
||||
|
||||
const onWindowMouseMove = (e: MouseEvent) => {
|
||||
if (!ctrl.isDragging) return;
|
||||
const dx = e.clientX - ctrl.dragStartX;
|
||||
const dy = e.clientY - ctrl.dragStartY;
|
||||
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
|
||||
ctrl.hasMoved = true;
|
||||
}
|
||||
treeContainer.x = ctrl.containerStartX + dx;
|
||||
treeContainer.y = ctrl.containerStartY + dy;
|
||||
updateParallax(ctrl);
|
||||
};
|
||||
window.addEventListener('mousemove', onWindowMouseMove);
|
||||
|
||||
const onWindowMouseUp = () => {
|
||||
ctrl.isDragging = false;
|
||||
app.canvas.style.cursor = 'grab';
|
||||
};
|
||||
window.addEventListener('mouseup', onWindowMouseUp);
|
||||
|
||||
app.canvas.style.cursor = 'grab';
|
||||
|
||||
// Track mouse position for tooltip
|
||||
app.canvas.addEventListener('mousemove', (e: MouseEvent) => {
|
||||
opts.onTooltipMove(e.clientX, e.clientY);
|
||||
});
|
||||
|
||||
// ===== TOUCH PANNING & PINCH ZOOM =====
|
||||
app.canvas.addEventListener(
|
||||
'touchstart',
|
||||
(e: TouchEvent) => {
|
||||
if (e.touches.length === 1) {
|
||||
ctrl.isDragging = true;
|
||||
ctrl.hasMoved = false;
|
||||
ctrl.isTouchZooming = false;
|
||||
ctrl.dragStartX = e.touches[0].clientX;
|
||||
ctrl.dragStartY = e.touches[0].clientY;
|
||||
ctrl.containerStartX = treeContainer.x;
|
||||
ctrl.containerStartY = treeContainer.y;
|
||||
} else if (e.touches.length === 2) {
|
||||
ctrl.isDragging = false;
|
||||
ctrl.isTouchZooming = true;
|
||||
ctrl.hasMoved = true; // Pinch zoom counts as movement
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
ctrl.lastTouchDistance = Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
app.canvas.addEventListener(
|
||||
'touchmove',
|
||||
(e: TouchEvent) => {
|
||||
if (e.touches.length === 1 && ctrl.isDragging && !ctrl.isTouchZooming) {
|
||||
const dx = e.touches[0].clientX - ctrl.dragStartX;
|
||||
const dy = e.touches[0].clientY - ctrl.dragStartY;
|
||||
|
||||
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
|
||||
ctrl.hasMoved = true;
|
||||
treeContainer.x = ctrl.containerStartX + dx;
|
||||
treeContainer.y = ctrl.containerStartY + dy;
|
||||
updateParallax(ctrl);
|
||||
}
|
||||
} else if (e.touches.length === 2 && ctrl.isTouchZooming) {
|
||||
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (ctrl.lastTouchDistance > 0) {
|
||||
const scale = distance / ctrl.lastTouchDistance;
|
||||
let newScale = treeContainer.scale.x * scale;
|
||||
newScale = Math.max(minZoom, Math.min(maxZoom, newScale));
|
||||
|
||||
// Zoom centered on origin node (same as mouse wheel zoom)
|
||||
treeContainer.scale.set(newScale);
|
||||
updateParallax(ctrl);
|
||||
}
|
||||
|
||||
ctrl.lastTouchDistance = distance;
|
||||
ctrl.hasMoved = true;
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
app.canvas.addEventListener(
|
||||
'touchend',
|
||||
(e: TouchEvent) => {
|
||||
if (e.touches.length === 0) {
|
||||
ctrl.isDragging = false;
|
||||
ctrl.isTouchZooming = false;
|
||||
ctrl.lastTouchDistance = 0;
|
||||
} else if (e.touches.length === 1) {
|
||||
ctrl.isTouchZooming = false;
|
||||
ctrl.isDragging = true;
|
||||
ctrl.hasMoved = true; // Consider this movement since we were zooming
|
||||
ctrl.dragStartX = e.touches[0].clientX;
|
||||
ctrl.dragStartY = e.touches[0].clientY;
|
||||
ctrl.containerStartX = treeContainer.x;
|
||||
ctrl.containerStartY = treeContainer.y;
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
// ===== WHEEL ZOOM =====
|
||||
app.canvas.addEventListener('wheel', (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const oldScale = treeContainer.scale.x;
|
||||
const zoomFactor = e.deltaY > 0 ? ZOOM.wheelOut : ZOOM.wheelIn;
|
||||
const newScale = Math.max(minZoom, Math.min(maxZoom, oldScale * zoomFactor));
|
||||
|
||||
// Zoom centered on origin node (0,0 in tree space): origin sits at
|
||||
// (treeContainer.x, treeContainer.y), so scaling keeps it anchored.
|
||||
treeContainer.scale.set(newScale);
|
||||
updateParallax(ctrl);
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onWindowMouseMove);
|
||||
window.removeEventListener('mouseup', onWindowMouseUp);
|
||||
};
|
||||
}
|
||||
|
||||
interface KeyboardOpts {
|
||||
onToggleStats: () => void;
|
||||
onCloseModal: () => void;
|
||||
isModalVisible: () => boolean;
|
||||
isStatsVisible: () => boolean;
|
||||
}
|
||||
|
||||
/** Wire keyboard navigation (pan/zoom/reset/stats/escape). Returns cleanup. */
|
||||
export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts): () => void {
|
||||
const { app, treeContainer } = ctrl;
|
||||
const minZoom = ZOOM.min;
|
||||
const maxZoom = ZOOM.max;
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Toggle stats panel with 'C' key
|
||||
if (e.key === 'c' || e.key === 'C') {
|
||||
if (!opts.isModalVisible()) {
|
||||
opts.onToggleStats();
|
||||
}
|
||||
}
|
||||
|
||||
// Escape key priority: modal > stats panel
|
||||
if (e.key === 'Escape') {
|
||||
if (opts.isModalVisible()) {
|
||||
opts.onCloseModal();
|
||||
} else if (opts.isStatsVisible()) {
|
||||
opts.onToggleStats();
|
||||
}
|
||||
}
|
||||
|
||||
const panSpeed = PAN_SPEED;
|
||||
const zoomSpeed = ZOOM.keyStep;
|
||||
|
||||
// Arrow keys for panning
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
treeContainer.y += panSpeed;
|
||||
updateParallax(ctrl);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
treeContainer.y -= panSpeed;
|
||||
updateParallax(ctrl);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
treeContainer.x += panSpeed;
|
||||
updateParallax(ctrl);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
treeContainer.x -= panSpeed;
|
||||
updateParallax(ctrl);
|
||||
}
|
||||
|
||||
// + and = for zoom in
|
||||
if (e.key === '+' || e.key === '=') {
|
||||
e.preventDefault();
|
||||
const currentScale = treeContainer.scale.x;
|
||||
const newScale = Math.min(maxZoom, currentScale + zoomSpeed);
|
||||
treeContainer.scale.set(newScale);
|
||||
updateParallax(ctrl);
|
||||
}
|
||||
|
||||
// - for zoom out
|
||||
if (e.key === '-') {
|
||||
e.preventDefault();
|
||||
const currentScale = treeContainer.scale.x;
|
||||
const newScale = Math.max(minZoom, currentScale - zoomSpeed);
|
||||
treeContainer.scale.set(newScale);
|
||||
updateParallax(ctrl);
|
||||
}
|
||||
|
||||
// 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(ctrl),
|
||||
onComplete: () => {
|
||||
// Update parallax baseline after reset
|
||||
ctrl.initialTreeX = treeContainer.x;
|
||||
ctrl.initialTreeY = treeContainer.y;
|
||||
}
|
||||
});
|
||||
gsap.to(treeContainer.scale, {
|
||||
x: ctrl.endScale,
|
||||
y: ctrl.endScale,
|
||||
duration: 0.5,
|
||||
ease: 'power2.out',
|
||||
onUpdate: () => updateParallax(ctrl)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
return () => window.removeEventListener('keydown', handleKeydown);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Parent→child connections drawn as a glow + core line + plasma beam.
|
||||
// Brightness scales with beam intensity (see $lib/utils/tree).
|
||||
import { Graphics, Container } from 'pixi.js';
|
||||
import { treeNodes } from '$lib/data';
|
||||
import { categoryColors } from '$lib/theme';
|
||||
import { getNodeById, getBeamIntensity } from '$lib/utils/tree';
|
||||
import type { LineObject } from './types';
|
||||
|
||||
export function createLines(treeContainer: Container): LineObject[] {
|
||||
const lineObjects: LineObject[] = [];
|
||||
|
||||
for (const node of treeNodes) {
|
||||
if (node.parentId) {
|
||||
const parent = getNodeById(node.parentId);
|
||||
if (parent) {
|
||||
const intensity = getBeamIntensity(node);
|
||||
const color = categoryColors[node.category];
|
||||
|
||||
// Base glow layer (wide, soft)
|
||||
const glowGraphics = new Graphics();
|
||||
glowGraphics.alpha = 0;
|
||||
glowGraphics
|
||||
.moveTo(parent.x, parent.y)
|
||||
.lineTo(node.x, node.y)
|
||||
.stroke({ width: 12, color, alpha: 0.1 * intensity });
|
||||
treeContainer.addChild(glowGraphics);
|
||||
|
||||
// Core line (thin, solid)
|
||||
const lineGraphics = new Graphics();
|
||||
lineGraphics.alpha = 0;
|
||||
lineGraphics
|
||||
.moveTo(parent.x, parent.y)
|
||||
.lineTo(node.x, node.y)
|
||||
.stroke({ width: 2, color: 0x4a4a5a });
|
||||
treeContainer.addChild(lineGraphics);
|
||||
|
||||
// Plasma energy beam (medium, bright, animated later)
|
||||
const plasmaBeam = new Graphics();
|
||||
plasmaBeam.alpha = 0;
|
||||
plasmaBeam
|
||||
.moveTo(parent.x, parent.y)
|
||||
.lineTo(node.x, node.y)
|
||||
.stroke({ width: 4, color, alpha: 0.6 * intensity });
|
||||
treeContainer.addChild(plasmaBeam);
|
||||
|
||||
lineObjects.push({
|
||||
graphics: lineGraphics,
|
||||
glowGraphics,
|
||||
plasmaBeam,
|
||||
node,
|
||||
parent,
|
||||
intensity
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lineObjects;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Tree node rendering: the origin profile node and regular icon nodes.
|
||||
// Nodes start non-interactive (eventMode 'none') until the entrance animation
|
||||
// enables them; see animation.ts.
|
||||
import { Graphics, Container, Assets, Sprite } from 'pixi.js';
|
||||
import { gsap } from 'gsap';
|
||||
import { treeNodes, type TreeNode } from '$lib/data';
|
||||
import { categoryColors } from '$lib/theme';
|
||||
import { getNodeRadius } from '$lib/utils/tree';
|
||||
import type { NodeObject } from './types';
|
||||
import type { SkillTreeController } from './controller';
|
||||
|
||||
interface NodeOpts {
|
||||
isMobile: boolean;
|
||||
isTouchDevice: boolean;
|
||||
controller: SkillTreeController;
|
||||
onOpen: (node: TreeNode) => void;
|
||||
onHover: (node: TreeNode | null) => void;
|
||||
}
|
||||
|
||||
/** Shared pointer wiring for both the origin and regular nodes. */
|
||||
function attachNodeInteraction(
|
||||
target: Container,
|
||||
glowGraphics: Graphics,
|
||||
node: TreeNode,
|
||||
opts: NodeOpts
|
||||
) {
|
||||
target.on('pointerdown', () => {
|
||||
opts.controller.hasMoved = false;
|
||||
});
|
||||
|
||||
target.on('pointerup', () => {
|
||||
if (!opts.controller.hasMoved) {
|
||||
opts.onOpen(node);
|
||||
}
|
||||
});
|
||||
|
||||
target.on('pointerover', () => {
|
||||
gsap.to(target.scale, { x: 1.2, y: 1.2, duration: 0.2 });
|
||||
gsap.to(glowGraphics, { alpha: 1.5, duration: 0.2 });
|
||||
if (!opts.isTouchDevice) opts.onHover(node);
|
||||
});
|
||||
|
||||
target.on('pointerout', () => {
|
||||
gsap.to(target.scale, { x: 1, y: 1, duration: 0.2 });
|
||||
gsap.to(glowGraphics, { alpha: 1, duration: 0.2 });
|
||||
if (!opts.isTouchDevice) opts.onHover(null);
|
||||
});
|
||||
}
|
||||
|
||||
export async function createNodes(treeContainer: Container, opts: NodeOpts): Promise<NodeObject[]> {
|
||||
const { isMobile } = opts;
|
||||
const nodeObjects: NodeObject[] = [];
|
||||
|
||||
const profileTexture = await Assets.load('/headshot.jpg');
|
||||
|
||||
for (const node of treeNodes) {
|
||||
const radius = getNodeRadius(node.size, isMobile);
|
||||
const color = categoryColors[node.category];
|
||||
|
||||
const glowGraphics = new Graphics();
|
||||
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;
|
||||
treeContainer.addChild(glowGraphics);
|
||||
|
||||
if (node.id === 'origin') {
|
||||
const profileContainer = new Container();
|
||||
profileContainer.x = node.x;
|
||||
profileContainer.y = node.y;
|
||||
|
||||
const maskGraphics = new Graphics();
|
||||
maskGraphics.circle(0, 0, radius).fill({ color: 0xffffff });
|
||||
profileContainer.addChild(maskGraphics);
|
||||
|
||||
const profileSprite = new Sprite(profileTexture);
|
||||
const scale = (radius * 2) / Math.min(profileTexture.width, profileTexture.height);
|
||||
profileSprite.scale.set(scale);
|
||||
profileSprite.anchor.set(0.5);
|
||||
profileSprite.mask = maskGraphics;
|
||||
profileContainer.addChild(profileSprite);
|
||||
|
||||
const borderGraphics = new Graphics();
|
||||
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;
|
||||
profileContainer.scale.set(0.5);
|
||||
|
||||
// Disable interactivity until animation completes
|
||||
profileContainer.eventMode = 'none';
|
||||
profileContainer.cursor = 'pointer';
|
||||
|
||||
attachNodeInteraction(profileContainer, glowGraphics, node, opts);
|
||||
|
||||
treeContainer.addChild(profileContainer);
|
||||
nodeObjects.push({ graphics: profileContainer, node, glowGraphics });
|
||||
} else {
|
||||
const nodeContainer = new Container();
|
||||
nodeContainer.x = node.x;
|
||||
nodeContainer.y = node.y;
|
||||
|
||||
const nodeGraphics = new Graphics();
|
||||
nodeGraphics.circle(0, 0, radius).fill({ color: 0x12121a }).stroke({ width: 2, color });
|
||||
nodeGraphics.circle(0, 0, radius * 0.75).stroke({ width: 1, color, alpha: 0.2 });
|
||||
nodeContainer.addChild(nodeGraphics);
|
||||
|
||||
if (node.icon) {
|
||||
try {
|
||||
const iconTexture = await Assets.load(node.icon);
|
||||
const iconSprite = new Sprite(iconTexture);
|
||||
|
||||
const iconSize = radius * 1.4;
|
||||
const scale = iconSize / Math.max(iconTexture.width, iconTexture.height);
|
||||
iconSprite.scale.set(scale);
|
||||
iconSprite.anchor.set(0.5);
|
||||
iconSprite.tint = color;
|
||||
|
||||
nodeContainer.addChild(iconSprite);
|
||||
} catch {
|
||||
console.warn(`Could not load icon for ${node.id}:`, node.icon);
|
||||
}
|
||||
}
|
||||
|
||||
nodeContainer.alpha = 0;
|
||||
nodeContainer.scale.set(0.5);
|
||||
|
||||
// Disable interactivity until animation completes
|
||||
nodeContainer.eventMode = 'none';
|
||||
nodeContainer.cursor = 'pointer';
|
||||
|
||||
attachNodeInteraction(nodeContainer, glowGraphics, node, opts);
|
||||
|
||||
treeContainer.addChild(nodeContainer);
|
||||
nodeObjects.push({ graphics: nodeContainer, node, glowGraphics });
|
||||
}
|
||||
}
|
||||
|
||||
return nodeObjects;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Shared structural types for the PixiJS skill-tree rendering modules.
|
||||
import type { Graphics, Container } from 'pixi.js';
|
||||
import type { TreeNode } from '$lib/data';
|
||||
|
||||
/** A parent→child connection: glow + core line + animated plasma beam. */
|
||||
export interface LineObject {
|
||||
graphics: Graphics;
|
||||
glowGraphics: Graphics;
|
||||
plasmaBeam: Graphics;
|
||||
node: TreeNode;
|
||||
parent: TreeNode;
|
||||
intensity: number;
|
||||
}
|
||||
|
||||
/** A rendered node (origin profile or regular icon node) plus its glow. */
|
||||
export interface NodeObject {
|
||||
graphics: Graphics | Container;
|
||||
node: TreeNode;
|
||||
glowGraphics: Graphics;
|
||||
}
|
||||
|
||||
/** The three parallax background layers (vignette lives on the stage). */
|
||||
export interface BackgroundLayers {
|
||||
deepBackgroundContainer: Container;
|
||||
nebulaContainer: Container;
|
||||
starContainer: Container;
|
||||
}
|
||||
@@ -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.category === 'skills' && n.proficiency).length;
|
||||
expect(total).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlayerClass', () => {
|
||||
it("returns the origin node's job title", () => {
|
||||
const origin = treeNodes.find((n) => n.category === 'origin');
|
||||
expect(getPlayerClass()).toBe(origin?.jobTitle);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// ==========================================================================
|
||||
// STATS UTILS — pure aggregation for the RPG-style character panel.
|
||||
// ==========================================================================
|
||||
|
||||
import { treeNodes, type SkillNode } 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.category === 'origin');
|
||||
return originNode?.jobTitle || 'Software Engineer';
|
||||
}
|
||||
|
||||
/** Group skill nodes by branch, sorted by proficiency. */
|
||||
export function aggregateSkills(): Record<string, { label: string; skills: SkillNode[] }> {
|
||||
const result: Record<string, { label: string; skills: SkillNode[] }> = {};
|
||||
|
||||
BRANCH_IDS.forEach((branchId) => {
|
||||
const skills = treeNodes.filter(
|
||||
(node): node is SkillNode => 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.category === 'skills' && 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
|
||||
}
|
||||
+85
-13
@@ -2,8 +2,45 @@
|
||||
import { onMount } from 'svelte';
|
||||
import favicon from '$lib/assets/favicon.ico';
|
||||
import '../app.css';
|
||||
import { treeNodes, type OriginNode, type SkillNode, type EducationNode } from '$lib/data';
|
||||
|
||||
let { children } = $props();
|
||||
const { children } = $props();
|
||||
|
||||
// schema.org Person structured data, generated from data.ts (single source of
|
||||
// truth) so it stays in sync. Ties this domain to the LinkedIn/GitHub identity.
|
||||
const origin = treeNodes.find((n): n is OriginNode => n.category === 'origin');
|
||||
const knowsAbout = treeNodes
|
||||
.filter((n): n is SkillNode => n.category === 'skills' && !!n.proficiency)
|
||||
.map((n) => n.label);
|
||||
const edu = treeNodes.find(
|
||||
(n): n is EducationNode => n.category === 'education' && !!n.institution
|
||||
);
|
||||
|
||||
const personJsonLd = JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
name: origin?.fullName,
|
||||
jobTitle: origin?.jobTitle,
|
||||
description: origin?.aboutMe,
|
||||
url: 'https://tehriehldeal.com',
|
||||
image: 'https://tehriehldeal.com/headshot.jpg',
|
||||
email: origin?.email,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: 'Kent',
|
||||
addressRegion: 'WA',
|
||||
addressCountry: 'US'
|
||||
},
|
||||
alumniOf: edu ? { '@type': 'CollegeOrUniversity', name: edu.institution } : undefined,
|
||||
knowsAbout,
|
||||
sameAs: [origin?.linkedIn, origin?.github].filter(Boolean)
|
||||
// Escape '<' so the JSON can't break out of the <script> element.
|
||||
}).replace(/</g, '\\u003c');
|
||||
|
||||
// Wrap in the <script> tag here (close tag split across a concat) so the
|
||||
// literal closing tag never appears verbatim in source — that would either
|
||||
// terminate this block early or trip the Svelte ESLint parser.
|
||||
const personLdScript = '<script type="application/ld+json">' + personJsonLd + '</' + 'script>';
|
||||
|
||||
onMount(() => {
|
||||
// Register service worker for PWA support
|
||||
@@ -11,7 +48,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 +58,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.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -56,25 +97,56 @@
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/icons/pwa-512x512.png" />
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
<meta name="description" content="Interactive portfolio featuring a Path of Exile-inspired skill tree visualization showcasing Kevin Riehl's professional experience, projects, and technical skills." />
|
||||
<meta name="keywords" content="portfolio, web developer, software engineer, interactive resume, skill tree" />
|
||||
<link rel="canonical" href="https://tehriehldeal.com/" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Kevin Riehl — Software Engineer with 5+ years building full-stack apps in React, Node.js, and TypeScript. Explore his experience, projects, and skills in an interactive skill-tree portfolio."
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="Kevin Riehl, software engineer, full-stack developer, React, Node.js, TypeScript, portfolio, interactive resume, skill tree"
|
||||
/>
|
||||
<meta name="author" content="Kevin Riehl" />
|
||||
|
||||
<!-- Open Graph Meta Tags for social sharing -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Kevin Riehl - The Atlas of Skills" />
|
||||
<meta property="og:description" content="Interactive portfolio featuring a Path of Exile-inspired skill tree visualization." />
|
||||
<meta
|
||||
property="og:title"
|
||||
content="Kevin Riehl — Software Engineer (React, Node.js, TypeScript)"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Software Engineer with 5+ years building full-stack apps in React, Node.js, and TypeScript — shown as an interactive Path of Exile-inspired skill tree."
|
||||
/>
|
||||
<meta property="og:url" content="https://tehriehldeal.com" />
|
||||
<meta property="og:site_name" content="Atlas of Skills" />
|
||||
<!-- Add og:image when screenshots are available -->
|
||||
<!-- <meta property="og:image" content="https://tehriehldeal.com/screenshots/preview.png" /> -->
|
||||
<meta property="og:image" content="https://tehriehldeal.com/og-preview.jpg" />
|
||||
<meta property="og:image:secure_url" content="https://tehriehldeal.com/og-preview.jpg" />
|
||||
<meta property="og:image:type" content="image/jpeg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="Kevin Riehl - The Atlas of Skills interactive portfolio" />
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Kevin Riehl - The Atlas of Skills" />
|
||||
<meta name="twitter:description" content="Interactive portfolio featuring a Path of Exile-inspired skill tree visualization." />
|
||||
<!-- Add twitter:image when screenshots are available -->
|
||||
<!-- <meta name="twitter:image" content="https://tehriehldeal.com/screenshots/preview.png" /> -->
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content="Kevin Riehl — Software Engineer (React, Node.js, TypeScript)"
|
||||
/>
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Software Engineer with 5+ years building full-stack apps in React, Node.js, and TypeScript — shown as an interactive Path of Exile-inspired skill tree."
|
||||
/>
|
||||
<meta name="twitter:image" content="https://tehriehldeal.com/og-preview.jpg" />
|
||||
<meta
|
||||
name="twitter:image:alt"
|
||||
content="Kevin Riehl - The Atlas of Skills interactive portfolio"
|
||||
/>
|
||||
|
||||
<!-- Structured data (schema.org Person). Safe: built from our own data.ts
|
||||
with '<' escaped; not user input. -->
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html personLdScript}
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const prerender = true;
|
||||
export const prerender = true;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script lang="ts">
|
||||
import SkillTree from '$lib/components/SkillTree.svelte';
|
||||
import SeoContent from '$lib/components/SeoContent.svelte';
|
||||
</script>
|
||||
|
||||
<!-- Crawlable / screen-reader text version (visually hidden, prerendered). -->
|
||||
<SeoContent />
|
||||
|
||||
<main>
|
||||
<SkillTree />
|
||||
</main>
|
||||
@@ -14,4 +18,4 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
@@ -1,3 +1,5 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
|
||||
Sitemap: https://tehriehldeal.com/sitemap.xml
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Service Worker for PWA offline support
|
||||
const CACHE_NAME = 'atlas-of-skills-v1';
|
||||
const RUNTIME_CACHE = 'atlas-runtime-v1';
|
||||
const CACHE_NAME = 'atlas-of-skills-v2';
|
||||
const RUNTIME_CACHE = 'atlas-runtime-v2';
|
||||
|
||||
// Assets to cache on install
|
||||
const PRECACHE_ASSETS = [
|
||||
@@ -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())
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://tehriehldeal.com/</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
+1
-1
@@ -16,4 +16,4 @@ const config = {
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
export default config;
|
||||
|
||||
+7
-1
@@ -1,6 +1,12 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
plugins: [sveltekit()],
|
||||
test: {
|
||||
// Pure-logic unit tests (utils, data). No DOM needed.
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.ts']
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user