Compare commits

...

10 Commits

Author SHA1 Message Date
TehRiehlDeal df78dc694f feat: make portfolio content crawlable + add SEO structured data
The entire portfolio rendered only on the PixiJS canvas, so crawlers saw a
near-empty body and the site ranked poorly for non-personalized searches. Fixes:

- NEW SeoContent.svelte: screen-reader/crawler-only (.sr-only) semantic HTML
  (h1 + bio + contact + Experience/Projects/Skills/Education), generated from
  data.ts (single source of truth, reuses aggregateSkills). Prerenders into
  build/index.html — verified it now contains the job/project/education text
  that was previously canvas-only. Also a big screen-reader accessibility win.
- JSON-LD schema.org Person in +layout (name, jobTitle, alumniOf, knowsAbout,
  sameAs LinkedIn/GitHub) — strong entity signal for name searches.
- Canonical URL; static/sitemap.xml + robots.txt Sitemap reference.
- Keyword-tuned <title>, description, og/twitter titles (name + role + stack).
- Demoted the transient welcome <h1> to a <div> so SeoContent's <h1> is the
  single canonical heading. Bumped SW cache to v2.

.sr-only utility added to app.css. check/lint/test/build clean; verified no
visual regression and no runtime exceptions (background + canvas unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:16:17 -07:00
TehRiehlDeal 6f17cba9c5 chore: add scripts/generate-og-image.sh to regenerate the OG card
Reproducible ImageMagick script that rebuilds static/og-preview.jpg from the
headshot + theme colours (with serif-font fallbacks so it runs on other
machines). Exposed as `npm run og:image`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:57:36 -07:00
TehRiehlDeal 6ff99e0d51 feat: add Open Graph / Twitter preview image for link unfurls
Link previews need an og:image — the tags existed but the image was
commented out. Generated a 1200x630 branded card (static/og-preview.jpg,
~100KB: dark space gradient, circular headshot with gold ring, gold serif
title + tagline) and wired up:
- og:image (+ secure_url, type, width, height, alt)
- twitter:image (+ alt); twitter:card already summary_large_image

Absolute URLs point at https://tehriehldeal.com/og-preview.jpg so crawlers
(Slack/Discord/iMessage/Twitter/Facebook) can fetch it. Tags verified present
in the prerendered build/index.html.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:44:07 -07:00
TehRiehlDeal 84112d8e42 refactor: extract PixiJS rendering into pixi/ modules
Phase 5 (behavior-preserving): decompose the ~1000-line onMount into focused
modules coordinated by a plain (non-reactive) controller object:
- pixi/types.ts        LineObject / NodeObject / BackgroundLayers
- pixi/controller.ts   shared mutable pan/zoom state + Pixi refs (NOT $state,
                       which would break PixiJS object identity)
- pixi/background.ts   gradient/nebula/stars/dust/vignette + ambient loops
- pixi/lines.ts        plasma-beam connections
- pixi/nodes.ts        origin + regular nodes; collapses the duplicated pointer
                       handlers into one attachNodeInteraction helper
- pixi/interaction.ts  pan/zoom/parallax + keyboard nav (return cleanups)
- pixi/animation.ts    sequenced entrance animation

onMount is now a thin orchestrator. SkillTree.svelte: 1177 -> 274 lines
(2453 at the start of the refactor). Listener passivity, the hasMoved click
guard, eventMode-until-animated ordering, and the R-reset parallax rebaseline
are all preserved.

svelte-check: 0 errors/0 warnings. lint/test/build clean. Verified onMount runs
with no runtime exceptions (background + stats panel render). NOTE: pan/zoom/
node-click/keyboard need a manual real-browser check (headless can't paint nodes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:23:54 -07:00
TehRiehlDeal f08138718a refactor: extract leaf UI components from SkillTree.svelte
Phase 4 (behavior-preserving): split the monolithic component's DOM overlays
into focused children, each owning its markup + scoped CSS:
- LoadingIndicator.svelte, WelcomeTitle.svelte, Tooltip.svelte
- NodeModal.svelte (modal + ~280 lines CSS; onClose callback, category-narrowed)
- StatsPanel.svelte (toggle + backdrop + panel + ~340 lines CSS; onToggle/
  onToggleBranch callbacks). Kept the .stats-panel-toggle class name stable so
  the entrance-animation GSAP querySelector still resolves.

SkillTree.svelte drops from 2453 -> ~600 lines. Modernized the moved event
handlers (on:click -> onclick), clearing the 9 Svelte deprecation warnings.

svelte-check: 0 errors, 0 warnings. lint/test/build clean. Verified the stats
panel renders identically in-browser (LVL/mastery/branches all correct).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:10:17 -07:00
TehRiehlDeal bf235680a4 refactor: model TreeNode as a discriminated union
Phase 3 (behavior-preserving):
- Replace the 45-optional-field TreeNode interface with a tagged union over
  category: OriginNode | ExperienceNode | ProjectNode | SkillNode | EducationNode,
  sharing a BaseNode. Category-specific fields now live only on their member.
- Modal template narrows by category (category === 'experience' && ...) instead
  of bare field-presence checks; equivalent at runtime since each field is
  exclusive to its category (proven by the union compiling against the data).
- stats utils narrow correctly: aggregateSkills filters to SkillNode[] via a type
  predicate; countByProficiency and getPlayerClass guard on category.

svelte-check: 0 errors (every data node conforms). lint/test/build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:59:25 -07:00
TehRiehlDeal 6091c62654 refactor: drive theme colors through CSS custom properties
Phase 2 (behavior-preserving):
- Add :root color tokens to app.css mirroring theme.ts cssVars
- Replace solid theme hex literals in SkillTree.svelte <style> with var(--..):
  #ffd700 -> --color-gold (x17), #a8a8b8 -> --text-muted (x8),
  #1a1a2e -> --panel-from, #0f0f1a -> --panel-to, #f9a825 -> --color-skills
- body background now var(--bg-space)

Values are identical to the literals; every referenced var has a :root
definition. rgba() shadows and one-off grays/badge colors left as-is.
check/lint/build all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:54:29 -07:00
TehRiehlDeal 6c652e7138 refactor: centralize theme/config/utils + fix data and console bugs
Phase 1 of the cohesion refactor (behavior-preserving):

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:40:27 -07:00
TehRiehlDeal aa58425f1a style: apply Prettier formatting across codebase
Isolated reformat commit so subsequent logic changes have clean diffs.
No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:29:52 -07:00
TehRiehlDeal a85bd16218 chore: add ESLint, Prettier, and Vitest tooling
Add code-quality tooling the project lacked:
- ESLint (flat config via @sveltejs/eslint-config)
- Prettier (tabs, single quotes, matching existing style)
- Vitest (node env, for pure-logic unit tests)
- npm scripts: lint, format, format:check, test, test:watch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:29:28 -07:00
39 changed files with 5408 additions and 2461 deletions
+6
View File
@@ -0,0 +1,6 @@
build/
.svelte-kit/
node_modules/
package-lock.json
static/
*.md
+15
View File
@@ -0,0 +1,15 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
+28
View File
@@ -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'
}
}
}
];
+2048 -5
View File
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -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",
+84
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+542
View File
@@ -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>
+122
View File
@@ -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}
File diff suppressed because it is too large Load Diff
+501
View File
@@ -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>
+72
View File
@@ -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>
+92
View File
@@ -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>
+62
View File
@@ -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
View File
@@ -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
View File
@@ -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';
+222
View File
@@ -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'
});
}
}
+238
View File
@@ -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 };
}
+58
View File
@@ -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
};
}
+268
View File
@@ -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);
}
+59
View File
@@ -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;
}
+142
View File
@@ -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;
}
+27
View File
@@ -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;
}
+68
View File
@@ -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
};
+57
View File
@@ -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);
});
});
+82
View File
@@ -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;
}
+69
View File
@@ -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);
});
});
+35
View File
@@ -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.31.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
View File
@@ -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
View File
@@ -1 +1 @@
export const prerender = true;
export const prerender = true;
+5 -1
View File
@@ -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

+2
View File
@@ -1,3 +1,5 @@
# allow crawling everything by default
User-agent: *
Disallow:
Sitemap: https://tehriehldeal.com/sitemap.xml
+4 -12
View File
@@ -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())
+8
View File
@@ -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
View File
@@ -16,4 +16,4 @@ const config = {
}
};
export default config;
export default config;
+7 -1
View File
@@ -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']
}
});