Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a0af2afc0 | ||
|
|
564ef79d68 |
No files matched your search
@@ -27,8 +27,3 @@ Thumbs.db
|
|||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
homelab/*
|
|
||||||
# Full-res source images — kept locally, never deployed (adapter-static copies all of static/)
|
|
||||||
assets-src/
|
|
||||||
*.original
|
|
||||||
Generated
+14
-22
@@ -8,7 +8,6 @@
|
|||||||
"name": "teh-riehl-skill-tree-portfolio",
|
"name": "teh-riehl-skill-tree-portfolio",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/cinzel": "^5.3.0",
|
|
||||||
"gsap": "^3.14.2",
|
"gsap": "^3.14.2",
|
||||||
"pixi.js": "^8.15.0"
|
"pixi.js": "^8.15.0"
|
||||||
},
|
},
|
||||||
@@ -602,15 +601,6 @@
|
|||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fontsource-variable/cinzel": {
|
|
||||||
"version": "5.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/cinzel/-/cinzel-5.3.0.tgz",
|
|
||||||
"integrity": "sha512-eFcU0TwaAP5QESF2pV8VoOPJJXJ6sSGm/6mXMwqTdMBPAJMaLhfa6oISHuOtFeddsoHP9RYm4Io0j1j3mNEz2Q==",
|
|
||||||
"license": "OFL-1.1",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ayuhito"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
@@ -1175,6 +1165,7 @@
|
|||||||
"integrity": "sha512-JFtOqDoU0DI/+QSG8qnq5bKcehVb3tCHhOG4amsSYth5/KgO4EkJvi42xSAiyKmXAAULW1/Zdb6lkgGEgSxdZg==",
|
"integrity": "sha512-JFtOqDoU0DI/+QSG8qnq5bKcehVb3tCHhOG4amsSYth5/KgO4EkJvi42xSAiyKmXAAULW1/Zdb6lkgGEgSxdZg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||||
@@ -1218,6 +1209,7 @@
|
|||||||
"integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
|
"integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
|
||||||
"deepmerge": "^4.3.1",
|
"deepmerge": "^4.3.1",
|
||||||
@@ -1354,6 +1346,7 @@
|
|||||||
"integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==",
|
"integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.60.1",
|
"@typescript-eslint/scope-manager": "8.60.1",
|
||||||
"@typescript-eslint/types": "8.60.1",
|
"@typescript-eslint/types": "8.60.1",
|
||||||
@@ -1686,6 +1679,7 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1908,7 +1902,6 @@
|
|||||||
"integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==",
|
"integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"graceful-fs": "^4.2.4",
|
"graceful-fs": "^4.2.4",
|
||||||
"tapable": "^2.3.3"
|
"tapable": "^2.3.3"
|
||||||
@@ -1985,6 +1978,7 @@
|
|||||||
"integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==",
|
"integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
@@ -2041,7 +2035,6 @@
|
|||||||
"integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==",
|
"integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"semver": "^7.5.4"
|
"semver": "^7.5.4"
|
||||||
},
|
},
|
||||||
@@ -2079,7 +2072,6 @@
|
|||||||
"https://opencollective.com/eslint"
|
"https://opencollective.com/eslint"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.1.2",
|
"@eslint-community/eslint-utils": "^4.1.2",
|
||||||
"@eslint-community/regexpp": "^4.11.0",
|
"@eslint-community/regexpp": "^4.11.0",
|
||||||
@@ -2098,7 +2090,6 @@
|
|||||||
"integrity": "sha512-q3ARhk+eZRc7myR0KHx+R3/GJeOHF+Ir6PK95Pu2tEX8Sl/4BIpmmVLva2kPrjC2gCmn6WHlHm+3yeo6Rxhycw==",
|
"integrity": "sha512-q3ARhk+eZRc7myR0KHx+R3/GJeOHF+Ir6PK95Pu2tEX8Sl/4BIpmmVLva2kPrjC2gCmn6WHlHm+3yeo6Rxhycw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.5.0",
|
"@eslint-community/eslint-utils": "^4.5.0",
|
||||||
"enhanced-resolve": "^5.17.1",
|
"enhanced-resolve": "^5.17.1",
|
||||||
@@ -2135,7 +2126,6 @@
|
|||||||
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
|
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -2149,6 +2139,7 @@
|
|||||||
"integrity": "sha512-t3rNaZeXz4d2gG4uJyMEYfJCFKf22+SWbSizIIXIWKu4wM+XPLiMWuSSr/C5821JmFeN9ogK+eExbG+z+twyxw==",
|
"integrity": "sha512-t3rNaZeXz4d2gG4uJyMEYfJCFKf22+SWbSizIIXIWKu4wM+XPLiMWuSSr/C5821JmFeN9ogK+eExbG+z+twyxw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.6.1",
|
"@eslint-community/eslint-utils": "^4.6.1",
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
@@ -2471,7 +2462,6 @@
|
|||||||
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"resolve-pkg-maps": "^1.0.0"
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
},
|
},
|
||||||
@@ -2519,16 +2509,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
|
||||||
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
|
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/gsap": {
|
"node_modules/gsap": {
|
||||||
"version": "3.14.2",
|
"version": "3.14.2",
|
||||||
@@ -2939,6 +2927,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -3072,6 +3061,7 @@
|
|||||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -3126,7 +3116,6 @@
|
|||||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
}
|
}
|
||||||
@@ -3284,6 +3273,7 @@
|
|||||||
"integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==",
|
"integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.4",
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
@@ -3383,7 +3373,6 @@
|
|||||||
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
},
|
},
|
||||||
@@ -3487,6 +3476,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -3501,6 +3491,7 @@
|
|||||||
"integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==",
|
"integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "8.60.1",
|
"@typescript-eslint/eslint-plugin": "8.60.1",
|
||||||
"@typescript-eslint/parser": "8.60.1",
|
"@typescript-eslint/parser": "8.60.1",
|
||||||
@@ -3542,6 +3533,7 @@
|
|||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
"vitest": "^4.1.8"
|
"vitest": "^4.1.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/cinzel": "^5.3.0",
|
|
||||||
"gsap": "^3.14.2",
|
"gsap": "^3.14.2",
|
||||||
"pixi.js": "^8.15.0"
|
"pixi.js": "^8.15.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended",
|
||||||
|
"group:allNonMajor"
|
||||||
|
]
|
||||||
|
}
|
||||||
-166
@@ -11,62 +11,7 @@
|
|||||||
--bg-space: #05050a;
|
--bg-space: #05050a;
|
||||||
--panel-from: #1a1a2e;
|
--panel-from: #1a1a2e;
|
||||||
--panel-to: #0f0f1a;
|
--panel-to: #0f0f1a;
|
||||||
|
|
||||||
/*
|
|
||||||
* Text colours, with measured contrast against --panel-from (#1a1a2e):
|
|
||||||
* --text-primary 14.0:1
|
|
||||||
* --text-muted 7.3:1
|
|
||||||
* --text-subtle 6.1:1
|
|
||||||
* The previous #666 used for hints, counts and the modal close glyph was
|
|
||||||
* 2.97:1 — below the 4.5:1 AA threshold for normal text.
|
|
||||||
*/
|
|
||||||
--text-primary: #e8e8f0;
|
|
||||||
--text-muted: #a8a8b8;
|
--text-muted: #a8a8b8;
|
||||||
--text-subtle: #9a9aa8;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Type. Cinzel is a Trajan-style inscriptional face — display sizes only;
|
|
||||||
* body copy stays on the system stack, which costs nothing to load and is
|
|
||||||
* far more legible at 13-14px. Before this, the single font-family
|
|
||||||
* declaration in the codebase named two fonts that were never loaded, so
|
|
||||||
* every word rendered in the browser's default serif.
|
|
||||||
*/
|
|
||||||
--font-display: 'Cinzel Variable', 'Cinzel', 'Trajan Pro', Georgia, serif;
|
|
||||||
--font-body: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
|
|
||||||
/* Spacing scale */
|
|
||||||
--space-1: 0.25rem;
|
|
||||||
--space-2: 0.5rem;
|
|
||||||
--space-3: 0.75rem;
|
|
||||||
--space-4: 1rem;
|
|
||||||
--space-5: 1.5rem;
|
|
||||||
--space-6: 2rem;
|
|
||||||
|
|
||||||
/* Radii */
|
|
||||||
--radius-sm: 6px;
|
|
||||||
--radius-md: 8px;
|
|
||||||
--radius-lg: 12px;
|
|
||||||
--radius-pill: 999px;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Layering. These were ad-hoc literals scattered across seven files, with
|
|
||||||
* LoadingIndicator and the modal backdrop both sitting on 1000.
|
|
||||||
*/
|
|
||||||
--z-welcome: 100;
|
|
||||||
--z-tooltip: 500;
|
|
||||||
--z-panel-backdrop: 899;
|
|
||||||
--z-panel: 900;
|
|
||||||
--z-panel-toggle: 950;
|
|
||||||
--z-modal: 1000;
|
|
||||||
--z-toast: 1100;
|
|
||||||
--z-update-toast: 1150;
|
|
||||||
--z-tree-nav: 1200;
|
|
||||||
--z-skip-link: 2000;
|
|
||||||
|
|
||||||
/* Motion */
|
|
||||||
--ease-out: cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
--duration-fast: 0.15s;
|
|
||||||
--duration-base: 0.3s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
@@ -81,85 +26,16 @@ html,
|
|||||||
body {
|
body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-family: var(--font-body);
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg-space);
|
background: var(--bg-space);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* Focus indicators. These used to live in SkillTree.svelte's *scoped* style
|
|
||||||
* block, so Svelte's scoping meant they never reached NodeModal or StatsPanel
|
|
||||||
* children — those fell back to the UA default outline, which is low-contrast
|
|
||||||
* on this background. Global is the only place this rule works.
|
|
||||||
*/
|
|
||||||
:focus-visible {
|
|
||||||
outline: 2px solid var(--color-gold);
|
|
||||||
outline-offset: 2px;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Reduced motion. The JS side (see config.prefersReducedMotion) skips the
|
|
||||||
* entrance and never creates the ambient GSAP loops; this covers the CSS
|
|
||||||
* animations and transitions those can't reach.
|
|
||||||
*/
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
scroll-behavior: auto !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Visually-hidden but available to screen readers and crawlers. Used for the
|
* Visually-hidden but available to screen readers and crawlers. Used for the
|
||||||
* text version of the portfolio (the interactive content is canvas-rendered).
|
* text version of the portfolio (the interactive content is canvas-rendered).
|
||||||
*/
|
*/
|
||||||
/*
|
|
||||||
* ...but focusable content inside it must become visible when focused, or a
|
|
||||||
* sighted keyboard user tabs through ~20 invisible stops with no idea where
|
|
||||||
* focus is (WCAG 2.4.7). Standard sr-only-focusable treatment.
|
|
||||||
*/
|
|
||||||
.sr-only:focus-within {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: var(--z-skip-link);
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
max-width: min(90vw, 60rem);
|
|
||||||
max-height: 90vh;
|
|
||||||
margin: 0;
|
|
||||||
overflow: auto;
|
|
||||||
clip: auto;
|
|
||||||
white-space: normal;
|
|
||||||
padding: 1.5rem;
|
|
||||||
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
|
|
||||||
border: 1px solid var(--color-gold);
|
|
||||||
border-radius: 0 0 8px 0;
|
|
||||||
color: #e8e8f0;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only:focus-within a {
|
|
||||||
color: var(--color-gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only:focus-within h1,
|
|
||||||
.sr-only:focus-within h2,
|
|
||||||
.sr-only:focus-within h3 {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
color: var(--color-gold);
|
|
||||||
margin: 1rem 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only {
|
.sr-only {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
@@ -171,45 +47,3 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* Un-hides the text version when the canvas can't run — no JS, or no WebGL.
|
|
||||||
* Applied by <noscript> (see +layout.svelte) and by the render-failure path in
|
|
||||||
* SkillTree.svelte. Also restores scrolling, which is disabled globally above
|
|
||||||
* for the canvas view.
|
|
||||||
*/
|
|
||||||
.sr-only.revealed {
|
|
||||||
position: static;
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
margin: 0;
|
|
||||||
overflow: visible;
|
|
||||||
clip: auto;
|
|
||||||
white-space: normal;
|
|
||||||
max-width: 60rem;
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
color: #e8e8f0;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
html:has(.sr-only.revealed),
|
|
||||||
html:has(.sr-only.revealed) body {
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only.revealed a {
|
|
||||||
color: var(--color-gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only.revealed h1,
|
|
||||||
.sr-only.revealed h2,
|
|
||||||
.sr-only.revealed h3 {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
color: var(--color-gold);
|
|
||||||
margin: 1.25rem 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only.revealed ul {
|
|
||||||
padding-left: 1.25rem;
|
|
||||||
}
|
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
|
||||||
<title>Kevin Riehl — Lead Software Engineer (React, Node.js, WebRTC) | Portfolio</title>
|
<title>Kevin Riehl — Software Engineer (React, Node.js, TypeScript) | Portfolio</title>
|
||||||
|
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
// Controls for the allocated path: how many nodes, share, reset.
|
|
||||||
const {
|
|
||||||
count = 0,
|
|
||||||
onReset,
|
|
||||||
onShare
|
|
||||||
}: {
|
|
||||||
count?: number;
|
|
||||||
onReset: () => void;
|
|
||||||
onShare: () => Promise<boolean> | boolean;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let copied = $state(false);
|
|
||||||
|
|
||||||
async function share() {
|
|
||||||
copied = await onShare();
|
|
||||||
if (copied) setTimeout(() => (copied = false), 2000);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if count > 0}
|
|
||||||
<div class="build-bar" role="status" aria-live="polite">
|
|
||||||
<span class="count"><strong>{count}</strong> allocated</span>
|
|
||||||
<button onclick={share}>{copied ? '✓ Link copied' : 'Share build'}</button>
|
|
||||||
<button class="reset" onclick={onReset}>Reset</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.build-bar {
|
|
||||||
position: fixed;
|
|
||||||
top: var(--space-5);
|
|
||||||
right: var(--space-5);
|
|
||||||
z-index: var(--z-panel-toggle);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
border: 1px solid rgba(255, 215, 0, 0.4);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background: rgba(10, 10, 20, 0.85);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count {
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.count strong {
|
|
||||||
color: var(--color-gold);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: 0.25rem 0.6rem;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
border-color: var(--color-gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.reset {
|
|
||||||
border-color: transparent;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.build-bar {
|
|
||||||
top: auto;
|
|
||||||
bottom: calc(var(--space-5) + 3.2rem);
|
|
||||||
right: var(--space-2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
// First-run interaction hint. The tree gives no affordance that it can be
|
|
||||||
// dragged, zoomed or clicked — the icons look decorative. Shown once, then
|
|
||||||
// remembered, so it never nags a returning visitor.
|
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
import { prefersReducedMotion } from '$lib/config';
|
|
||||||
|
|
||||||
const { visible = false, onDismiss }: { visible?: boolean; onDismiss?: () => void } = $props();
|
|
||||||
|
|
||||||
const duration = prefersReducedMotion() ? 0 : 400;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if visible}
|
|
||||||
<div class="coach" transition:fade={{ duration }} role="status">
|
|
||||||
<span>Drag to explore</span>
|
|
||||||
<span class="sep" aria-hidden="true">·</span>
|
|
||||||
<span>Scroll to zoom</span>
|
|
||||||
<span class="sep" aria-hidden="true">·</span>
|
|
||||||
<span>Click a node for details</span>
|
|
||||||
<button onclick={() => onDismiss?.()} aria-label="Dismiss hint">✕</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.coach {
|
|
||||||
position: fixed;
|
|
||||||
bottom: var(--space-5);
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: var(--z-toast);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
border: 1px solid rgba(255, 215, 0, 0.35);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background: rgba(10, 10, 20, 0.88);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sep {
|
|
||||||
color: var(--color-gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.coach button {
|
|
||||||
margin-left: var(--space-1);
|
|
||||||
padding: 0 0.25rem;
|
|
||||||
border: 0;
|
|
||||||
background: none;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
font: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coach button:hover {
|
|
||||||
color: var(--color-gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.coach {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
gap: var(--space-1);
|
|
||||||
padding: var(--space-2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
// Always-visible identity.
|
|
||||||
//
|
|
||||||
// The measured problem this solves: on a cold load the site showed a black
|
|
||||||
// screen for ~5s, then a wordless constellation of icons. At 9 seconds the
|
|
||||||
// number of words on screen was zero — no name, no title, no indication of
|
|
||||||
// what any of it was. A recruiter could not learn a single fact without
|
|
||||||
// clicking. This puts the 5-second pitch on screen immediately and keeps it
|
|
||||||
// there, sourced from the same origin node as everything else.
|
|
||||||
import { treeNodes, type OriginNode } from '$lib/data';
|
|
||||||
|
|
||||||
const {
|
|
||||||
showSkip = false,
|
|
||||||
onSkip,
|
|
||||||
onStartJourney
|
|
||||||
}: {
|
|
||||||
/** True while the intro is still playing. */
|
|
||||||
showSkip?: boolean;
|
|
||||||
onSkip?: () => void;
|
|
||||||
onStartJourney?: () => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const origin = treeNodes.find((n): n is OriginNode => n.category === 'origin');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if origin}
|
|
||||||
<div class="hud">
|
|
||||||
<p class="hud-name">{origin.fullName}</p>
|
|
||||||
<p class="hud-title">{origin.jobTitle}</p>
|
|
||||||
{#if origin.tagline}
|
|
||||||
<p class="hud-tagline">{origin.tagline}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if onStartJourney && !showSkip}
|
|
||||||
<!-- Discoverable entry point; 'J' does the same thing. -->
|
|
||||||
<button class="journey-cta" onclick={() => onStartJourney()}>
|
|
||||||
▶ Take the 60-second tour
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showSkip}
|
|
||||||
<button class="skip-intro" onclick={() => onSkip?.()}>Skip intro</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.journey-cta {
|
|
||||||
margin-top: var(--space-3);
|
|
||||||
padding: 0.3rem 0.7rem;
|
|
||||||
border: 1px solid rgba(255, 215, 0, 0.5);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background: rgba(10, 10, 20, 0.7);
|
|
||||||
color: var(--color-gold);
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
pointer-events: auto;
|
|
||||||
transition: background var(--duration-fast) var(--ease-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey-cta:hover {
|
|
||||||
background: rgba(255, 215, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hud {
|
|
||||||
position: fixed;
|
|
||||||
top: var(--space-5);
|
|
||||||
left: calc(var(--space-5) + 68px); /* clears the stats toggle */
|
|
||||||
z-index: var(--z-welcome);
|
|
||||||
max-width: min(30rem, calc(100vw - 8rem));
|
|
||||||
pointer-events: none;
|
|
||||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hud-name {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 1.35rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
line-height: 1.1;
|
|
||||||
color: var(--color-gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hud-title {
|
|
||||||
margin: 0.15rem 0 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hud-tagline {
|
|
||||||
margin: 0.2rem 0 0;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skip-intro {
|
|
||||||
position: fixed;
|
|
||||||
bottom: var(--space-5);
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: var(--z-toast);
|
|
||||||
padding: var(--space-2) var(--space-4);
|
|
||||||
border: 1px solid var(--color-gold);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background: rgba(10, 10, 20, 0.85);
|
|
||||||
color: var(--color-gold);
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--duration-fast) var(--ease-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skip-intro:hover {
|
|
||||||
background: rgba(255, 215, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.hud {
|
|
||||||
top: auto;
|
|
||||||
bottom: var(--space-5);
|
|
||||||
left: var(--space-4);
|
|
||||||
right: var(--space-4);
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hud-name {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hud-tagline {
|
|
||||||
display: none; /* too little room to be worth the noise */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
// Guided career narrative — the tree tells its own story.
|
|
||||||
//
|
|
||||||
// Browsing a skill tree is self-directed: a visitor has to know what to click
|
|
||||||
// and in what order, and most won't. This flies the camera along the
|
|
||||||
// experience arc oldest-first, surfacing one or two highlights per stop, so
|
|
||||||
// the career reads as progression rather than a pile of nodes.
|
|
||||||
//
|
|
||||||
// Deep-linkable (?tour=peraton) so a specific role can be shared directly.
|
|
||||||
import { getJourneyStops, type JourneyStop } from '$lib/utils/journey';
|
|
||||||
import { prefersReducedMotion } from '$lib/config';
|
|
||||||
|
|
||||||
const {
|
|
||||||
active = false,
|
|
||||||
startIndex = 0,
|
|
||||||
onExit,
|
|
||||||
onStop
|
|
||||||
}: {
|
|
||||||
active?: boolean;
|
|
||||||
/** Stop to open on — set by the ?tour= deep link. */
|
|
||||||
startIndex?: number;
|
|
||||||
onExit: () => void;
|
|
||||||
/** Move the camera to this stop. */
|
|
||||||
onStop: (stop: JourneyStop) => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const stops = getJourneyStops();
|
|
||||||
|
|
||||||
/** Auto-advance dwell per stop. Long enough to read two highlights. */
|
|
||||||
const DWELL_MS = 9000;
|
|
||||||
|
|
||||||
let index = $state(0);
|
|
||||||
// Reduced motion starts paused: auto-advancing content that moves the camera
|
|
||||||
// is exactly what that preference is asking us not to do. The tour is still
|
|
||||||
// fully available, just driven by the user.
|
|
||||||
let paused = $state(prefersReducedMotion());
|
|
||||||
let panelEl = $state<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const stop = $derived(stops[index]);
|
|
||||||
const isLast = $derived(index === stops.length - 1);
|
|
||||||
|
|
||||||
function go(next: number) {
|
|
||||||
if (next < 0 || next >= stops.length) return;
|
|
||||||
index = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function exit() {
|
|
||||||
onExit();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open on the requested stop each time the journey starts.
|
|
||||||
$effect(() => {
|
|
||||||
if (!active) return;
|
|
||||||
index = Math.max(0, Math.min(startIndex, stops.length - 1));
|
|
||||||
paused = prefersReducedMotion();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drive the camera whenever the active stop changes.
|
|
||||||
$effect(() => {
|
|
||||||
if (!active || !stop) return;
|
|
||||||
onStop(stop);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-advance. Reduced motion still advances — the objection is to motion,
|
|
||||||
// not to pacing — but the camera jumps rather than flies (see panToNode).
|
|
||||||
$effect(() => {
|
|
||||||
if (!active || paused || isLast) return;
|
|
||||||
const current = index;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if (current === index) go(current + 1);
|
|
||||||
}, DWELL_MS);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Move focus into the panel so keys land here and screen readers follow.
|
|
||||||
$effect(() => {
|
|
||||||
if (!active || !panelEl) return;
|
|
||||||
const previous = document.activeElement as HTMLElement | null;
|
|
||||||
panelEl.focus({ preventScroll: true });
|
|
||||||
return () => previous?.focus?.({ preventScroll: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowRight':
|
|
||||||
e.preventDefault();
|
|
||||||
go(index + 1);
|
|
||||||
break;
|
|
||||||
case 'ArrowLeft':
|
|
||||||
e.preventDefault();
|
|
||||||
go(index - 1);
|
|
||||||
break;
|
|
||||||
case ' ':
|
|
||||||
e.preventDefault();
|
|
||||||
paused = !paused;
|
|
||||||
break;
|
|
||||||
case 'Escape':
|
|
||||||
e.preventDefault();
|
|
||||||
exit();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if active && stop}
|
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
||||||
<section
|
|
||||||
bind:this={panelEl}
|
|
||||||
class="journey"
|
|
||||||
tabindex="-1"
|
|
||||||
onkeydown={handleKeydown}
|
|
||||||
aria-label="Career journey"
|
|
||||||
>
|
|
||||||
<div class="progress" role="group" aria-label="Progress">
|
|
||||||
{#each stops as s, i (s.slug)}
|
|
||||||
<button
|
|
||||||
class="pip"
|
|
||||||
class:done={i <= index}
|
|
||||||
onclick={() => go(i)}
|
|
||||||
aria-label="Go to {s.title}"
|
|
||||||
aria-current={i === index ? 'step' : undefined}
|
|
||||||
></button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- aria-live so each stop is announced as the tour advances -->
|
|
||||||
<div class="body" aria-live="polite" aria-atomic="true">
|
|
||||||
<p class="kicker">{stop.kicker}</p>
|
|
||||||
<h2 class="title">{stop.title}</h2>
|
|
||||||
{#if stop.subtitle}<p class="subtitle">{stop.subtitle}</p>{/if}
|
|
||||||
{#if stop.points.length}
|
|
||||||
<ul>
|
|
||||||
{#each stop.points as point (point)}
|
|
||||||
<li>{point}</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="controls">
|
|
||||||
<span class="counter">{index + 1} / {stops.length}</span>
|
|
||||||
<button onclick={() => go(index - 1)} disabled={index === 0}>‹ Prev</button>
|
|
||||||
{#if !isLast}
|
|
||||||
<button class="primary" onclick={() => (paused = !paused)}>
|
|
||||||
{paused ? '▶ Play' : '❚❚ Pause'}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<button onclick={() => go(index + 1)} disabled={isLast}>Next ›</button>
|
|
||||||
<button class="exit" onclick={exit}>{isLast ? 'Explore the tree' : 'Exit'}</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.journey {
|
|
||||||
position: fixed;
|
|
||||||
right: var(--space-5);
|
|
||||||
bottom: var(--space-5);
|
|
||||||
left: var(--space-5);
|
|
||||||
z-index: var(--z-toast);
|
|
||||||
max-width: 34rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: var(--space-4);
|
|
||||||
border: 1px solid var(--color-gold);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
background: linear-gradient(145deg, rgba(26, 26, 46, 0.97) 0%, rgba(15, 15, 26, 0.97) 100%);
|
|
||||||
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.journey:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pip {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
height: 3px;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: rgba(255, 255, 255, 0.18);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--duration-base) var(--ease-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pip.done {
|
|
||||||
background: var(--color-gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kicker {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
margin: 0.15rem 0 0;
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 1.3rem;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
color: var(--color-gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
margin: 0.1rem 0 0;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 0.88rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
margin: var(--space-3) 0 0;
|
|
||||||
padding-left: 1.1rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.83rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
li + li {
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
margin-top: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.counter {
|
|
||||||
margin-right: auto;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls button {
|
|
||||||
padding: 0.35rem 0.7rem;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: rgba(0, 0, 0, 0.35);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls button:hover:not(:disabled) {
|
|
||||||
border-color: var(--color-gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls button:disabled {
|
|
||||||
opacity: 0.35;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls .primary {
|
|
||||||
border-color: var(--color-gold);
|
|
||||||
color: var(--color-gold);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls .exit {
|
|
||||||
border-color: transparent;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.journey {
|
|
||||||
right: var(--space-2);
|
|
||||||
bottom: var(--space-2);
|
|
||||||
left: var(--space-2);
|
|
||||||
padding: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -11,12 +11,12 @@
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
z-index: var(--z-modal);
|
z-index: 1000;
|
||||||
color: var(--color-gold);
|
color: var(--color-gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-indicator p {
|
.loading-indicator p {
|
||||||
font-family: var(--font-display);
|
font-family: 'Cinzel Decorative', 'Trajan Pro', serif;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
color: var(--color-gold);
|
color: var(--color-gold);
|
||||||
|
|||||||
@@ -2,106 +2,32 @@
|
|||||||
import type { TreeNode } from '$lib/data';
|
import type { TreeNode } from '$lib/data';
|
||||||
import { categoryColorHex } from '$lib/theme';
|
import { categoryColorHex } from '$lib/theme';
|
||||||
|
|
||||||
const {
|
const { node, visible, onClose }: { node: TreeNode; visible: boolean; onClose: () => void } =
|
||||||
node,
|
$props();
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
onDismiss,
|
|
||||||
allocated = false,
|
|
||||||
onToggleAllocate
|
|
||||||
}: {
|
|
||||||
allocated?: boolean;
|
|
||||||
/** Allocate/remove this node and its path. */
|
|
||||||
onToggleAllocate?: (id: string) => void;
|
|
||||||
node: TreeNode;
|
|
||||||
visible: boolean;
|
|
||||||
/** Explicit close (✕ button) — always immediate. */
|
|
||||||
onClose: () => void;
|
|
||||||
/** Outside/backdrop dismissal — subject to the mobile touch-bleed guard. */
|
|
||||||
onDismiss: () => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let dialogEl = $state<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const FOCUSABLE =
|
|
||||||
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move focus into the dialog on open and restore it on close.
|
|
||||||
*
|
|
||||||
* Without this, `aria-modal="true"` was lying: it tells assistive tech that
|
|
||||||
* everything outside the dialog does not exist, while focus stayed on the
|
|
||||||
* canvas behind it and Tab walked the whole hidden text version underneath.
|
|
||||||
*/
|
|
||||||
$effect(() => {
|
|
||||||
if (!visible || !dialogEl) return;
|
|
||||||
|
|
||||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
|
||||||
const first = dialogEl.querySelector<HTMLElement>(FOCUSABLE);
|
|
||||||
(first ?? dialogEl).focus({ preventScroll: true });
|
|
||||||
|
|
||||||
return () => previouslyFocused?.focus?.({ preventScroll: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Keep Tab inside the dialog while it's open. */
|
|
||||||
function trapFocus(e: KeyboardEvent) {
|
|
||||||
if (e.key !== 'Tab' || !dialogEl) return;
|
|
||||||
|
|
||||||
const items = [...dialogEl.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
|
|
||||||
(el) => el.offsetParent !== null || el === document.activeElement
|
|
||||||
);
|
|
||||||
if (!items.length) return;
|
|
||||||
|
|
||||||
const first = items[0];
|
|
||||||
const last = items[items.length - 1];
|
|
||||||
const active = document.activeElement;
|
|
||||||
|
|
||||||
if (e.shiftKey && (active === first || active === dialogEl)) {
|
|
||||||
e.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
} else if (!e.shiftKey && active === last) {
|
|
||||||
e.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
<div
|
||||||
Backdrop is presentational: click-to-close only. It previously carried an
|
class="modal-backdrop"
|
||||||
onkeydown Escape handler, but a role="presentation" div with no tabindex can
|
class:visible
|
||||||
never be a keydown target, so that handler was unreachable. Escape is handled
|
onclick={onClose}
|
||||||
by the global key listener (see pixi/interaction.ts), and Tab is trapped by
|
onkeydown={(e) => e.key === 'Escape' && onClose()}
|
||||||
the dialog below.
|
role="presentation"
|
||||||
-->
|
aria-label="Modal backdrop. Click to close or press Escape."
|
||||||
<div class="modal-backdrop" class:visible onclick={onDismiss} role="presentation">
|
>
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||||
<div
|
<div
|
||||||
bind:this={dialogEl}
|
|
||||||
class="modal"
|
class="modal"
|
||||||
class:visible
|
class:visible
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
onkeydown={trapFocus}
|
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="modal-title"
|
aria-labelledby="modal-title"
|
||||||
aria-describedby="modal-description"
|
aria-describedby="modal-description"
|
||||||
tabindex="-1"
|
|
||||||
style="--node-color: {categoryColorHex[node.category]}"
|
style="--node-color: {categoryColorHex[node.category]}"
|
||||||
>
|
>
|
||||||
<button class="close-btn" onclick={onClose} aria-label="Close dialog">✕</button>
|
<button class="close-btn" onclick={onClose} aria-label="Close dialog">✕</button>
|
||||||
|
|
||||||
{#if onToggleAllocate}
|
|
||||||
<!-- Allocation lives on a button rather than the node click, which
|
|
||||||
already opens this dialog. -->
|
|
||||||
<button
|
|
||||||
class="allocate-btn"
|
|
||||||
class:on={allocated}
|
|
||||||
onclick={() => onToggleAllocate(node.id)}
|
|
||||||
aria-pressed={allocated}
|
|
||||||
>
|
|
||||||
{allocated ? '✓ Allocated' : '⊕ Allocate path'}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<h2 id="modal-title">
|
<h2 id="modal-title">
|
||||||
{#if node.icon}
|
{#if node.icon}
|
||||||
<img src={node.icon} alt="" class="modal-icon" />
|
<img src={node.icon} alt="" class="modal-icon" />
|
||||||
@@ -217,15 +143,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Project status badge + third-party usage -->
|
<!-- Project status badge -->
|
||||||
{#if node.category === 'projects' && (node.status || node.downloads)}
|
{#if node.category === 'projects' && node.status}
|
||||||
<div class="project-status">
|
<div class="project-status">
|
||||||
{#if node.status}
|
|
||||||
<span class="status-badge {node.status}">{node.status}</span>
|
<span class="status-badge {node.status}">{node.status}</span>
|
||||||
{/if}
|
|
||||||
{#if node.downloads}
|
|
||||||
<span class="downloads-badge">↓ {node.downloads}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -281,7 +202,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: var(--z-modal);
|
z-index: 1000;
|
||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
backdrop-filter: blur(0px);
|
backdrop-filter: blur(0px);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@@ -293,33 +214,6 @@
|
|||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.allocate-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
padding: 0.3rem 0.75rem;
|
|
||||||
border: 1px solid rgba(255, 215, 0, 0.45);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-gold);
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.allocate-btn:hover {
|
|
||||||
background: rgba(255, 215, 0, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.allocate-btn.on {
|
|
||||||
background: rgba(255, 215, 0, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
|
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
|
||||||
border: 1px solid var(--node-color);
|
border: 1px solid var(--node-color);
|
||||||
@@ -345,8 +239,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal h2 {
|
.modal h2 {
|
||||||
font-family: var(--font-display);
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
color: var(--node-color);
|
color: var(--node-color);
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
@@ -392,7 +284,7 @@
|
|||||||
right: 0.75rem;
|
right: 0.75rem;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-subtle);
|
color: #666;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
@@ -416,7 +308,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.origin-location {
|
.origin-location {
|
||||||
color: var(--text-muted);
|
color: #888;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@@ -468,7 +360,7 @@
|
|||||||
|
|
||||||
.meta-info .dates {
|
.meta-info .dates {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--text-muted);
|
color: #888;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
}
|
}
|
||||||
@@ -579,18 +471,6 @@
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.2rem 0.6rem;
|
|
||||||
border: 1px solid rgba(255, 215, 0, 0.35);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(255, 215, 0, 0.08);
|
|
||||||
color: var(--color-gold);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-status {
|
.project-status {
|
||||||
margin: 0.75rem 0;
|
margin: 0.75rem 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,266 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
// Cmd/Ctrl-K search over the whole tree.
|
|
||||||
//
|
|
||||||
// The canvas is 57 wordless icons, so there was no way to answer "does he
|
|
||||||
// know NestJS?" short of clicking around. This makes the data directly
|
|
||||||
// queryable and flies the camera to whatever you pick.
|
|
||||||
//
|
|
||||||
// Built to the ARIA combobox pattern: the input keeps focus and owns
|
|
||||||
// aria-activedescendant, the list is a listbox, and nothing else steals
|
|
||||||
// focus — so arrow keys read correctly to a screen reader.
|
|
||||||
import { searchNodes, type SearchResult } from '$lib/utils/search';
|
|
||||||
import { categoryColorHex } from '$lib/theme';
|
|
||||||
import type { TreeNode } from '$lib/data';
|
|
||||||
import { prefersReducedMotion } from '$lib/config';
|
|
||||||
|
|
||||||
const {
|
|
||||||
open = false,
|
|
||||||
onClose,
|
|
||||||
onSelect
|
|
||||||
}: {
|
|
||||||
open?: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSelect: (node: TreeNode) => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let query = $state('');
|
|
||||||
let activeIndex = $state(0);
|
|
||||||
let inputEl = $state<HTMLInputElement | null>(null);
|
|
||||||
let listEl = $state<HTMLUListElement | null>(null);
|
|
||||||
|
|
||||||
const results = $derived<SearchResult[]>(searchNodes(query).slice(0, 8));
|
|
||||||
|
|
||||||
// Reset and focus each time it opens.
|
|
||||||
$effect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
query = '';
|
|
||||||
activeIndex = 0;
|
|
||||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
|
||||||
inputEl?.focus();
|
|
||||||
return () => previouslyFocused?.focus?.({ preventScroll: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
// Keep the active option scrolled into view as arrows move it.
|
|
||||||
if (!open || !listEl) return;
|
|
||||||
const el = listEl.children[activeIndex] as HTMLElement | undefined;
|
|
||||||
el?.scrollIntoView({ block: 'nearest', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
|
|
||||||
});
|
|
||||||
|
|
||||||
function choose(index: number) {
|
|
||||||
const hit = results[index];
|
|
||||||
if (!hit) return;
|
|
||||||
onSelect(hit.node);
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowDown':
|
|
||||||
e.preventDefault();
|
|
||||||
activeIndex = results.length ? (activeIndex + 1) % results.length : 0;
|
|
||||||
break;
|
|
||||||
case 'ArrowUp':
|
|
||||||
e.preventDefault();
|
|
||||||
activeIndex = results.length ? (activeIndex - 1 + results.length) % results.length : 0;
|
|
||||||
break;
|
|
||||||
case 'Enter':
|
|
||||||
e.preventDefault();
|
|
||||||
choose(activeIndex);
|
|
||||||
break;
|
|
||||||
case 'Escape':
|
|
||||||
e.preventDefault();
|
|
||||||
onClose();
|
|
||||||
break;
|
|
||||||
case 'Tab':
|
|
||||||
// Nothing else in here is focusable; keep focus on the input.
|
|
||||||
e.preventDefault();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if open}
|
|
||||||
<!-- Presentational scrim; Escape and the input own all keyboard handling. -->
|
|
||||||
<div class="palette-backdrop" onclick={onClose} role="presentation"></div>
|
|
||||||
|
|
||||||
<div class="palette" role="dialog" aria-modal="true" aria-label="Search the skill tree">
|
|
||||||
<input
|
|
||||||
bind:this={inputEl}
|
|
||||||
bind:value={query}
|
|
||||||
onkeydown={handleKeydown}
|
|
||||||
oninput={() => (activeIndex = 0)}
|
|
||||||
type="text"
|
|
||||||
class="palette-input"
|
|
||||||
placeholder="Search skills, projects, companies…"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={results.length > 0}
|
|
||||||
aria-controls="palette-results"
|
|
||||||
aria-autocomplete="list"
|
|
||||||
aria-activedescendant={results.length ? `palette-opt-${activeIndex}` : undefined}
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if query && !results.length}
|
|
||||||
<p class="palette-empty">No matches for “{query}”.</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<ul bind:this={listEl} id="palette-results" role="listbox" aria-label="Search results">
|
|
||||||
{#each results as result, i (result.node.id)}
|
|
||||||
<!--
|
|
||||||
No keyboard handler here on purpose. In the ARIA combobox pattern the
|
|
||||||
input keeps focus and drives selection via aria-activedescendant;
|
|
||||||
making options focusable would break that for screen readers. Mouse
|
|
||||||
users get click, keyboard users get the arrow keys on the input.
|
|
||||||
-->
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<li
|
|
||||||
id="palette-opt-{i}"
|
|
||||||
role="option"
|
|
||||||
aria-selected={i === activeIndex}
|
|
||||||
class:active={i === activeIndex}
|
|
||||||
style="--node-color: {categoryColorHex[result.node.category]}"
|
|
||||||
onclick={() => choose(i)}
|
|
||||||
onmousemove={() => (activeIndex = i)}
|
|
||||||
>
|
|
||||||
<span class="dot" aria-hidden="true"></span>
|
|
||||||
<span class="label">{result.node.label}</span>
|
|
||||||
{#if result.context}
|
|
||||||
<span class="context">{result.context}</span>
|
|
||||||
{/if}
|
|
||||||
<span class="category">{result.node.category}</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p class="palette-hint">
|
|
||||||
<kbd>↑</kbd><kbd>↓</kbd> to navigate · <kbd>Enter</kbd> to open · <kbd>Esc</kbd> to close
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.palette-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: var(--z-modal);
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.palette {
|
|
||||||
position: fixed;
|
|
||||||
top: 12vh;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: var(--z-modal);
|
|
||||||
width: min(36rem, 92vw);
|
|
||||||
padding: var(--space-3);
|
|
||||||
border: 1px solid var(--color-gold);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
|
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.palette-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--space-3);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: rgba(0, 0, 0, 0.35);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font: inherit;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.palette-input::placeholder {
|
|
||||||
color: var(--text-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.palette-empty {
|
|
||||||
margin: var(--space-3) var(--space-2) 0;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
max-height: 46vh;
|
|
||||||
margin: var(--space-2) 0 0;
|
|
||||||
padding: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
li.active {
|
|
||||||
background: rgba(255, 215, 0, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
align-self: center;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--node-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.category {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
margin-left: auto;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
font-size: 0.68rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.palette-hint {
|
|
||||||
margin: var(--space-3) var(--space-2) var(--space-1);
|
|
||||||
color: var(--text-subtle);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
kbd {
|
|
||||||
padding: 0.1rem 0.3rem;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: rgba(0, 0, 0, 0.4);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.palette {
|
|
||||||
top: 4vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -14,10 +14,6 @@
|
|||||||
} from '$lib/data';
|
} from '$lib/data';
|
||||||
import { aggregateSkills, BRANCH_IDS } from '$lib/utils/stats';
|
import { aggregateSkills, BRANCH_IDS } from '$lib/utils/stats';
|
||||||
|
|
||||||
// Set when the canvas can't render, so this becomes the visible page rather
|
|
||||||
// than a screen-reader-only mirror of it.
|
|
||||||
const { revealed = false }: { revealed?: boolean } = $props();
|
|
||||||
|
|
||||||
const origin = treeNodes.find((n): n is OriginNode => n.category === 'origin');
|
const origin = treeNodes.find((n): n is OriginNode => n.category === 'origin');
|
||||||
|
|
||||||
// Leaf nodes only — container/branch nodes lack these category fields.
|
// Leaf nodes only — container/branch nodes lack these category fields.
|
||||||
@@ -38,7 +34,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if origin}
|
{#if origin}
|
||||||
<section class="sr-only" class:revealed aria-label="Portfolio (text version)">
|
<section class="sr-only" aria-label="Portfolio (text version)">
|
||||||
<h1>{origin.fullName} — {origin.jobTitle}</h1>
|
<h1>{origin.fullName} — {origin.jobTitle}</h1>
|
||||||
{#if origin.aboutMe}<p>{origin.aboutMe}</p>{/if}
|
{#if origin.aboutMe}<p>{origin.aboutMe}</p>{/if}
|
||||||
{#if origin.location}<p>Location: {origin.location}</p>{/if}
|
{#if origin.location}<p>Location: {origin.location}</p>{/if}
|
||||||
@@ -79,9 +75,6 @@
|
|||||||
{#if project.techStack?.length}
|
{#if project.techStack?.length}
|
||||||
<p>Tech: {project.techStack.join(', ')}</p>
|
<p>Tech: {project.techStack.join(', ')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if project.downloads}
|
|
||||||
<p>{project.downloads}</p>
|
|
||||||
{/if}
|
|
||||||
<ul>
|
<ul>
|
||||||
{#if project.projectUrl}<li><a href={project.projectUrl}>Live site</a></li>{/if}
|
{#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.repoUrl}<li><a href={project.repoUrl}>Source code</a></li>{/if}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
|
||||||
import { Application, Container } from 'pixi.js';
|
import { Application, Container } from 'pixi.js';
|
||||||
import type { TreeNode } from '$lib/data';
|
import type { TreeNode } from '$lib/data';
|
||||||
import { categoryColorHex } from '$lib/theme';
|
import { categoryColorHex } from '$lib/theme';
|
||||||
@@ -9,16 +8,7 @@
|
|||||||
import Tooltip from './Tooltip.svelte';
|
import Tooltip from './Tooltip.svelte';
|
||||||
import NodeModal from './NodeModal.svelte';
|
import NodeModal from './NodeModal.svelte';
|
||||||
import StatsPanel from './StatsPanel.svelte';
|
import StatsPanel from './StatsPanel.svelte';
|
||||||
import TreeNavigator from './TreeNavigator.svelte';
|
import { MOBILE_BREAKPOINT } from '$lib/config';
|
||||||
import Hud from './Hud.svelte';
|
|
||||||
import CoachHint from './CoachHint.svelte';
|
|
||||||
import SearchPalette from './SearchPalette.svelte';
|
|
||||||
import JourneyMode from './JourneyMode.svelte';
|
|
||||||
import BuildBar from './BuildBar.svelte';
|
|
||||||
import { allocate, deallocate, encodeBuild, decodeBuild } from '$lib/utils/allocation';
|
|
||||||
import { updateAllocation } from '$lib/pixi/allocation';
|
|
||||||
import { getJourneyStops, findStopIndex } from '$lib/utils/journey';
|
|
||||||
import { MOBILE_BREAKPOINT, prefersReducedMotion } from '$lib/config';
|
|
||||||
import {
|
import {
|
||||||
BRANCH_IDS,
|
BRANCH_IDS,
|
||||||
calculatePlayerLevel,
|
calculatePlayerLevel,
|
||||||
@@ -27,96 +17,16 @@
|
|||||||
countByProficiency
|
countByProficiency
|
||||||
} from '$lib/utils/stats';
|
} from '$lib/utils/stats';
|
||||||
import { createBackground } from '$lib/pixi/background';
|
import { createBackground } from '$lib/pixi/background';
|
||||||
import { createController, type SkillTreeController } from '$lib/pixi/controller';
|
import { createController } from '$lib/pixi/controller';
|
||||||
import { createLines } from '$lib/pixi/lines';
|
import { createLines } from '$lib/pixi/lines';
|
||||||
import { createNodes } from '$lib/pixi/nodes';
|
import { createNodes } from '$lib/pixi/nodes';
|
||||||
import {
|
import { attachPanZoom, attachKeyboard } from '$lib/pixi/interaction';
|
||||||
attachPanZoom,
|
|
||||||
attachKeyboard,
|
|
||||||
updateParallax,
|
|
||||||
panToNode,
|
|
||||||
updateLabels
|
|
||||||
} from '$lib/pixi/interaction';
|
|
||||||
import { runEntranceAnimations } from '$lib/pixi/animation';
|
import { runEntranceAnimations } from '$lib/pixi/animation';
|
||||||
import { createLifecycle } from '$lib/pixi/lifecycle';
|
|
||||||
|
|
||||||
const { onRenderFail }: { onRenderFail?: () => void } = $props();
|
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
// Set once the canvas is up; the tree navigator uses it to pan the camera.
|
|
||||||
let controllerRef: SkillTreeController | null = null;
|
|
||||||
let selectedNode = $state<TreeNode | null>(null);
|
let selectedNode = $state<TreeNode | null>(null);
|
||||||
let modalVisible = $state(false);
|
let modalVisible = $state(false);
|
||||||
let showWelcomeTitle = $state(true);
|
let showWelcomeTitle = $state(true);
|
||||||
// Intro state. A returning visitor should land on the settled tree rather
|
|
||||||
// than sit through the entrance again.
|
|
||||||
const VISITED_KEY = 'atlas:visited';
|
|
||||||
let introPlaying = $state(false);
|
|
||||||
let showCoachHint = $state(false);
|
|
||||||
let searchOpen = $state(false);
|
|
||||||
// Guided career narrative. Deep-linkable via ?tour=peraton.
|
|
||||||
let journeyActive = $state(false);
|
|
||||||
let journeyStartIndex = $state(0);
|
|
||||||
|
|
||||||
// Allocated path. SvelteSet so mutation is tracked — a plain Set is not
|
|
||||||
// proxied by $state, the same trap that silently broke the stats panel.
|
|
||||||
const allocated = new SvelteSet<string>();
|
|
||||||
|
|
||||||
function toggleAllocate(id: string) {
|
|
||||||
const next = allocated.has(id) ? deallocate(allocated, id) : allocate(allocated, id);
|
|
||||||
allocated.clear();
|
|
||||||
for (const nodeId of next) allocated.add(nodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetBuild() {
|
|
||||||
allocated.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function shareBuild(): Promise<boolean> {
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.set('build', encodeBuild(allocated));
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(url.toString());
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
// Clipboard can be blocked (insecure origin, permissions). Put the link
|
|
||||||
// in the address bar so it is still copyable by hand.
|
|
||||||
history.replaceState(null, '', url);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startJourney(index = 0) {
|
|
||||||
journeyStartIndex = index;
|
|
||||||
journeyActive = true;
|
|
||||||
searchOpen = false;
|
|
||||||
showCoachHint = false;
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
let skipEntrance: (() => void) | null = null;
|
|
||||||
|
|
||||||
function hasVisitedBefore() {
|
|
||||||
try {
|
|
||||||
return localStorage.getItem(VISITED_KEY) === '1';
|
|
||||||
} catch {
|
|
||||||
return false; // private mode / storage disabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function markVisited() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(VISITED_KEY, '1');
|
|
||||||
} catch {
|
|
||||||
/* not important enough to surface */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function skipIntro() {
|
|
||||||
skipEntrance?.();
|
|
||||||
introPlaying = false;
|
|
||||||
showWelcomeTitle = false;
|
|
||||||
markVisited();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tooltip state
|
// Tooltip state
|
||||||
let hoveredNode = $state<TreeNode | null>(null);
|
let hoveredNode = $state<TreeNode | null>(null);
|
||||||
@@ -135,9 +45,7 @@
|
|||||||
|
|
||||||
// Stats panel state
|
// Stats panel state
|
||||||
let statsPanelVisible = $state(false);
|
let statsPanelVisible = $state(false);
|
||||||
// SvelteSet, not $state(new Set()) — Svelte 5 does not proxy a plain Set, so
|
let expandedBranches = $state(new Set<string>(BRANCH_IDS)); // All expanded by default
|
||||||
// .add()/.delete() would be untracked and the panel would never re-render.
|
|
||||||
const expandedBranches = new SvelteSet<string>(BRANCH_IDS); // All expanded by default
|
|
||||||
|
|
||||||
function openModal(node: TreeNode) {
|
function openModal(node: TreeNode) {
|
||||||
selectedNode = node;
|
selectedNode = node;
|
||||||
@@ -148,23 +56,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
|
// Prevent closing if modal just opened (within 300ms)
|
||||||
|
// This fixes touch events "bleeding through" on mobile
|
||||||
|
if (Date.now() - modalOpenedAt < 300) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
modalVisible = false;
|
modalVisible = false;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
selectedNode = null;
|
selectedNode = null;
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Backdrop//outside dismissal only. The 300ms guard exists because a tap that
|
|
||||||
* opens the modal on mobile can bleed through to the backdrop underneath —
|
|
||||||
* but it used to gate *every* close path, so pressing Escape or the ✕ right
|
|
||||||
* after opening silently did nothing.
|
|
||||||
*/
|
|
||||||
function dismissModal() {
|
|
||||||
if (Date.now() - modalOpenedAt < 300) return;
|
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleStatsPanel() {
|
function toggleStatsPanel() {
|
||||||
statsPanelVisible = !statsPanelVisible;
|
statsPanelVisible = !statsPanelVisible;
|
||||||
if (statsPanelVisible && modalVisible) {
|
if (statsPanelVisible && modalVisible) {
|
||||||
@@ -178,6 +81,7 @@
|
|||||||
} else {
|
} else {
|
||||||
expandedBranches.add(branchId);
|
expandedBranches.add(branchId);
|
||||||
}
|
}
|
||||||
|
expandedBranches = expandedBranches; // Trigger reactivity
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats calculations (reactive)
|
// Stats calculations (reactive)
|
||||||
@@ -194,54 +98,30 @@
|
|||||||
isMobile = windowWidth < MOBILE_BREAKPOINT;
|
isMobile = windowWidth < MOBILE_BREAKPOINT;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One owner for every Pixi/GSAP/DOM resource this component allocates.
|
const cleanupFunctions: Array<() => void> = [];
|
||||||
// Registered with the component's teardown BEFORE any await, so unmounting
|
|
||||||
// mid-load still releases everything (this used to be the last statement of
|
|
||||||
// the async onMount, so an early unmount cleaned up nothing at all).
|
|
||||||
const life = createLifecycle();
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
handleResize();
|
handleResize();
|
||||||
life.listen(window, 'resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
// matchMedia('(hover: hover)') rather than 'ontouchstart': the latter is
|
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||||
// true on touch-capable laptops, which permanently killed mouse hover.
|
|
||||||
const isTouchDevice = !window.matchMedia('(hover: hover)').matches;
|
|
||||||
|
|
||||||
const app = new Application();
|
const app = new Application();
|
||||||
|
|
||||||
try {
|
|
||||||
await app.init({
|
await app.init({
|
||||||
background: '#05050a',
|
background: '#05050a',
|
||||||
resizeTo: container,
|
resizeTo: container,
|
||||||
// The scene is soft glow circles — MSAA costs fill rate and buys almost
|
antialias: true
|
||||||
// nothing here. Sharpness comes from rendering at device resolution instead.
|
|
||||||
antialias: false,
|
|
||||||
resolution: Math.min(window.devicePixelRatio || 1, 2),
|
|
||||||
autoDensity: true,
|
|
||||||
powerPreference: 'high-performance'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Unmounted while the renderer was initialising — release and stop.
|
|
||||||
if (life.disposed) {
|
|
||||||
app.destroy(true, { children: true, texture: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tearing down the renderer has to happen last, so register it first
|
|
||||||
// (dispose runs newest-first).
|
|
||||||
life.add(() => app.destroy(true, { children: true, texture: true }));
|
|
||||||
|
|
||||||
// PixiJS owns this canvas, not Svelte — mounting it directly is intentional.
|
// PixiJS owns this canvas, not Svelte — mounting it directly is intentional.
|
||||||
// eslint-disable-next-line svelte/no-dom-manipulating
|
// eslint-disable-next-line svelte/no-dom-manipulating
|
||||||
container.appendChild(app.canvas);
|
container.appendChild(app.canvas);
|
||||||
|
|
||||||
// Background layers (gradient → nebula → stars → dust → vignette).
|
// Hide loading indicator - PixiJS is ready, welcome title will be LCP
|
||||||
// Its own sub-lifecycle so a resize rebuild can kill just its ~315
|
isLoading = false;
|
||||||
// ambient tweens and the dust ticker, without touching the tree's.
|
|
||||||
let bgLife = createLifecycle();
|
// Background layers (gradient → nebula → stars → dust → vignette)
|
||||||
let bg = createBackground(app, isMobile, bgLife);
|
const bg = createBackground(app, isMobile);
|
||||||
life.add(() => bgLife.dispose());
|
|
||||||
|
|
||||||
// Tree container — pan/zoom transform is applied here
|
// Tree container — pan/zoom transform is applied here
|
||||||
const treeContainer = new Container();
|
const treeContainer = new Container();
|
||||||
@@ -256,8 +136,6 @@
|
|||||||
treeContainer.alpha = 1;
|
treeContainer.alpha = 1;
|
||||||
|
|
||||||
const controller = createController(app, treeContainer, bg, endScale);
|
const controller = createController(app, treeContainer, bg, endScale);
|
||||||
controllerRef = controller;
|
|
||||||
life.add(() => (controllerRef = null));
|
|
||||||
|
|
||||||
// Build the visualisation (connections first, then nodes on top)
|
// Build the visualisation (connections first, then nodes on top)
|
||||||
controller.lineObjects = createLines(treeContainer);
|
controller.lineObjects = createLines(treeContainer);
|
||||||
@@ -265,214 +143,66 @@
|
|||||||
isMobile,
|
isMobile,
|
||||||
isTouchDevice,
|
isTouchDevice,
|
||||||
controller,
|
controller,
|
||||||
life,
|
|
||||||
onOpen: openModal,
|
onOpen: openModal,
|
||||||
onHover: (node) => {
|
onHover: (node) => {
|
||||||
hoveredNode = node;
|
hoveredNode = node;
|
||||||
// Seed the tooltip at the current pointer before it first paints.
|
|
||||||
if (node) panZoom?.flushTooltip();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Unmounted while textures were loading.
|
|
||||||
if (life.disposed) return;
|
|
||||||
|
|
||||||
// Textures are loaded and the scene graph is built — only now is the canvas
|
|
||||||
// genuinely ready. (This used to fire right after app.init(), so the spinner
|
|
||||||
// resolved onto an empty screen.)
|
|
||||||
isLoading = false;
|
|
||||||
|
|
||||||
// Pan / zoom / parallax + keyboard navigation
|
// Pan / zoom / parallax + keyboard navigation
|
||||||
const panZoom = attachPanZoom(
|
const detachPanZoom = attachPanZoom(controller, {
|
||||||
controller,
|
|
||||||
{
|
|
||||||
onTooltipMove: (x, y) => {
|
onTooltipMove: (x, y) => {
|
||||||
tooltipX = x;
|
tooltipX = x;
|
||||||
tooltipY = y;
|
tooltipY = y;
|
||||||
},
|
}
|
||||||
hasHover: () => hoveredNode !== null
|
});
|
||||||
},
|
const detachKeyboard = attachKeyboard(controller, {
|
||||||
life
|
|
||||||
);
|
|
||||||
attachKeyboard(
|
|
||||||
controller,
|
|
||||||
{
|
|
||||||
onToggleStats: toggleStatsPanel,
|
onToggleStats: toggleStatsPanel,
|
||||||
onCloseModal: closeModal,
|
onCloseModal: closeModal,
|
||||||
isModalVisible: () => modalVisible,
|
isModalVisible: () => modalVisible,
|
||||||
isStatsVisible: () => statsPanelVisible,
|
isStatsVisible: () => statsPanelVisible
|
||||||
onOpenSearch: () => (searchOpen = true),
|
|
||||||
isSearchOpen: () => searchOpen,
|
|
||||||
onStartJourney: () => startJourney(0),
|
|
||||||
isJourneyActive: () => journeyActive
|
|
||||||
},
|
|
||||||
life
|
|
||||||
);
|
|
||||||
|
|
||||||
// Every star position, the gradient radius, the vignette size and the
|
|
||||||
// dust wrap bounds are baked from app.screen at build time. Without this
|
|
||||||
// a resized window leaves an unlit, starless band.
|
|
||||||
let lastWidth = app.screen.width;
|
|
||||||
let lastHeight = app.screen.height;
|
|
||||||
let resizeFrame = -1;
|
|
||||||
|
|
||||||
function rebuildBackground() {
|
|
||||||
bgLife.dispose();
|
|
||||||
bg.deepBackgroundContainer.destroy({ children: true });
|
|
||||||
bg.nebulaContainer.destroy({ children: true });
|
|
||||||
bg.starContainer.destroy({ children: true });
|
|
||||||
bg.vignette.destroy();
|
|
||||||
|
|
||||||
bgLife = createLifecycle();
|
|
||||||
bg = createBackground(app, isMobile, bgLife);
|
|
||||||
controller.bg = bg;
|
|
||||||
|
|
||||||
// createBackground appends to the stage, so the tree would end up
|
|
||||||
// behind the new layers. Restore it to the top.
|
|
||||||
app.stage.setChildIndex(treeContainer, app.stage.children.length - 1);
|
|
||||||
updateParallax(controller);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCanvasResize() {
|
|
||||||
resizeFrame = -1;
|
|
||||||
const width = app.screen.width;
|
|
||||||
const height = app.screen.height;
|
|
||||||
if (width === lastWidth && height === lastHeight) return;
|
|
||||||
|
|
||||||
// Keep whatever the user was looking at anchored: shift by half the
|
|
||||||
// delta rather than snapping back to centre and yanking their view.
|
|
||||||
const dx = (width - lastWidth) / 2;
|
|
||||||
const dy = (height - lastHeight) / 2;
|
|
||||||
treeContainer.x += dx;
|
|
||||||
treeContainer.y += dy;
|
|
||||||
controller.initialTreeX += dx;
|
|
||||||
controller.initialTreeY += dy;
|
|
||||||
|
|
||||||
lastWidth = width;
|
|
||||||
lastHeight = height;
|
|
||||||
rebuildBackground();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Coalesce to one rebuild per frame — resize fires continuously while
|
|
||||||
// dragging a window edge, and a rebuild allocates ~300 Graphics.
|
|
||||||
life.listen(window, 'resize', () => {
|
|
||||||
if (resizeFrame === -1) resizeFrame = requestAnimationFrame(handleCanvasResize);
|
|
||||||
});
|
|
||||||
life.add(() => {
|
|
||||||
if (resizeFrame !== -1) cancelAnimationFrame(resizeFrame);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Labels start hidden below the zoom threshold; set their initial state
|
// Sequenced entrance animation
|
||||||
// so they're correct before the first zoom interaction.
|
runEntranceAnimations(controller, {
|
||||||
updateLabels(controller);
|
|
||||||
|
|
||||||
// Sequenced entrance animation. Returning visitors skip straight to the
|
|
||||||
// settled tree; first-timers get the intro plus a Skip control.
|
|
||||||
const returning = hasVisitedBefore();
|
|
||||||
introPlaying = !returning && !prefersReducedMotion();
|
|
||||||
|
|
||||||
const entrance = runEntranceAnimations(controller, {
|
|
||||||
isMobile,
|
isMobile,
|
||||||
life,
|
|
||||||
skipIntro: returning,
|
|
||||||
onTitleDone: () => {
|
onTitleDone: () => {
|
||||||
showWelcomeTitle = false;
|
showWelcomeTitle = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
skipEntrance = entrance.skip;
|
|
||||||
|
|
||||||
// Show the interaction hint once the tree is actually there to interact
|
// Store cleanup functions
|
||||||
// with — timed off the real entrance length, not a guess, so "Click a
|
cleanupFunctions.push(() => {
|
||||||
// node" never appears before the nodes do and Skip never disappears
|
window.removeEventListener('resize', handleResize);
|
||||||
// while the intro is still running.
|
detachPanZoom();
|
||||||
if (!returning) {
|
detachKeyboard();
|
||||||
const hintTimer = setTimeout(() => {
|
});
|
||||||
introPlaying = false;
|
|
||||||
showCoachHint = true;
|
|
||||||
// Only now has the visitor actually seen the intro. Marking at
|
|
||||||
// mount instead would flag someone who bounced after a second as
|
|
||||||
// having seen it, and they'd never get it.
|
|
||||||
markVisited();
|
|
||||||
}, entrance.durationMs + 300);
|
|
||||||
life.add(() => clearTimeout(hintTimer));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deep links, applied immediately and regardless of whether this is a
|
|
||||||
// first or return visit. These were previously nested inside the
|
|
||||||
// first-visit hint timer, so a shared ?build= or ?tour= link did nothing
|
|
||||||
// for a returning visitor and waited out the whole intro for everyone
|
|
||||||
// else — backwards, since a shared link is exactly the cold-arrival case.
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
|
|
||||||
// ?build=... restores a shared allocation.
|
|
||||||
const shared = decodeBuild(params.get('build'));
|
|
||||||
if (shared.size) {
|
|
||||||
for (const id of shared) allocated.add(id);
|
|
||||||
updateAllocation(controller, allocated);
|
|
||||||
|
|
||||||
// The entrance tweens every node's alpha to 1, which lands after this
|
|
||||||
// and wipes the dimming. Re-apply once it has finished, or the shared
|
|
||||||
// build shows a full-brightness tree with a counter that disagrees.
|
|
||||||
const repaint = setTimeout(
|
|
||||||
() => updateAllocation(controller, allocated),
|
|
||||||
entrance.durationMs + 100
|
|
||||||
);
|
|
||||||
life.add(() => clearTimeout(repaint));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ?tour=peraton deep-links straight into that stop of the journey.
|
|
||||||
const tourParam = params.get('tour');
|
|
||||||
if (tourParam !== null) {
|
|
||||||
const stops = getJourneyStops();
|
|
||||||
const found = findStopIndex(stops, tourParam);
|
|
||||||
// A bare ?tour starts from the beginning.
|
|
||||||
startJourney(found === -1 ? 0 : found);
|
|
||||||
// Don't make someone who followed a tour link sit through the intro.
|
|
||||||
skipIntro();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// No WebGL, or a texture failed to load. Don't strand the user on a
|
|
||||||
// spinner that never resolves — reveal the text version instead.
|
|
||||||
console.error('Skill tree failed to initialise:', err);
|
|
||||||
isLoading = false;
|
|
||||||
// Hands off to +page.svelte, which reveals the text version.
|
|
||||||
onRenderFail?.();
|
|
||||||
life.dispose();
|
|
||||||
app.destroy(true, { children: true, texture: true });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup on component unmount
|
// Cleanup on component unmount
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
return () => life.dispose();
|
return () => {
|
||||||
|
cleanupFunctions.forEach((fn) => fn());
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Repaint allocation styling whenever the build changes.
|
// Calculate tooltip position to keep it on screen
|
||||||
$effect(() => {
|
|
||||||
// Touch .size so this re-runs on every mutation of the SvelteSet.
|
|
||||||
void allocated.size;
|
|
||||||
if (controllerRef) updateAllocation(controllerRef, allocated);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Live tooltip element, so flip decisions use its real measured size rather
|
|
||||||
// than a hardcoded 280x120 guess (descriptions vary in length, so the old
|
|
||||||
// height constant mis-flipped tall tooltips near the viewport bottom).
|
|
||||||
let tooltipEl = $state<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const tooltipStyle = $derived.by(() => {
|
const tooltipStyle = $derived.by(() => {
|
||||||
const gap = 15;
|
let x = tooltipX + 15;
|
||||||
const rect = tooltipEl?.getBoundingClientRect();
|
let y = tooltipY + 15;
|
||||||
const width = rect?.width || 280;
|
|
||||||
const height = rect?.height || 120;
|
|
||||||
|
|
||||||
let x = tooltipX + gap;
|
// Keep tooltip on screen
|
||||||
let y = tooltipY + gap;
|
const tooltipWidth = 280;
|
||||||
|
const tooltipHeight = 120;
|
||||||
|
|
||||||
// Flip to the other side of the cursor rather than overflow the viewport.
|
if (x + tooltipWidth > windowWidth) {
|
||||||
if (x + width > windowWidth) x = Math.max(0, tooltipX - width - gap);
|
x = tooltipX - tooltipWidth - 15;
|
||||||
if (y + height > windowHeight) y = Math.max(0, tooltipY - height - gap);
|
}
|
||||||
|
if (y + tooltipHeight > windowHeight) {
|
||||||
|
y = tooltipY - tooltipHeight - 15;
|
||||||
|
}
|
||||||
|
|
||||||
return `transform: translate3d(${x}px, ${y}px, 0);`;
|
return `left: ${x}px; top: ${y}px;`;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -482,66 +212,16 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={container}
|
bind:this={container}
|
||||||
class="canvas-container"
|
class="canvas-container"
|
||||||
role="img"
|
role="application"
|
||||||
aria-label="Skill tree visualisation. Arrow keys pan, plus and minus zoom, R resets the view, C toggles the character stats panel. For a navigable list of every entry, use the skill tree navigator."
|
aria-label="Interactive skill tree visualization. Use arrow keys to pan, +/- to zoom, R to reset view, C to toggle character stats panel."
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!--
|
|
||||||
The keyboard/AT interface for the canvas. role="application" used to sit on
|
|
||||||
the div above, which tells screen readers to suppress their own navigation
|
|
||||||
and forward every key — appropriate only for a widget implementing a full
|
|
||||||
keyboard model, which the canvas does not. This does.
|
|
||||||
-->
|
|
||||||
<TreeNavigator
|
|
||||||
onActivate={openModal}
|
|
||||||
onFocusNode={(node) => {
|
|
||||||
if (controllerRef) panToNode(controllerRef, node.x, node.y, life);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Loading Indicator for Lighthouse LCP -->
|
<!-- Loading Indicator for Lighthouse LCP -->
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<LoadingIndicator />
|
<LoadingIndicator />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Persistent identity: name, title and the one-line hook, always on screen -->
|
|
||||||
<Hud showSkip={introPlaying} onSkip={skipIntro} onStartJourney={() => startJourney(0)} />
|
|
||||||
|
|
||||||
<BuildBar count={allocated.size} onReset={resetBuild} onShare={shareBuild} />
|
|
||||||
|
|
||||||
<JourneyMode
|
|
||||||
active={journeyActive}
|
|
||||||
startIndex={journeyStartIndex}
|
|
||||||
onExit={() => (journeyActive = false)}
|
|
||||||
onStop={(stop) => {
|
|
||||||
if (controllerRef) {
|
|
||||||
panToNode(controllerRef, stop.node.x, stop.node.y, life, {
|
|
||||||
scale: stop.scale,
|
|
||||||
duration: 1.1
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SearchPalette
|
|
||||||
open={searchOpen}
|
|
||||||
onClose={() => (searchOpen = false)}
|
|
||||||
onSelect={(node) => {
|
|
||||||
if (controllerRef) panToNode(controllerRef, node.x, node.y, life);
|
|
||||||
openModal(node);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- The hint lives in the same bottom-centre slot as the journey card, so it
|
|
||||||
must yield while the tour is running. -->
|
|
||||||
<CoachHint
|
|
||||||
visible={showCoachHint && !journeyActive}
|
|
||||||
onDismiss={() => {
|
|
||||||
showCoachHint = false;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Welcome Title Card -->
|
<!-- Welcome Title Card -->
|
||||||
{#if showWelcomeTitle}
|
{#if showWelcomeTitle}
|
||||||
<WelcomeTitle />
|
<WelcomeTitle />
|
||||||
@@ -550,7 +230,6 @@
|
|||||||
<!-- Hover Tooltip (hidden on mobile) -->
|
<!-- Hover Tooltip (hidden on mobile) -->
|
||||||
{#if hoveredNode && !modalVisible && !isMobile}
|
{#if hoveredNode && !modalVisible && !isMobile}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
bind:element={tooltipEl}
|
|
||||||
node={hoveredNode}
|
node={hoveredNode}
|
||||||
style={tooltipStyle}
|
style={tooltipStyle}
|
||||||
colorHex={categoryColorHex[hoveredNode.category]}
|
colorHex={categoryColorHex[hoveredNode.category]}
|
||||||
@@ -559,14 +238,7 @@
|
|||||||
|
|
||||||
<!-- Modal Overlay -->
|
<!-- Modal Overlay -->
|
||||||
{#if selectedNode}
|
{#if selectedNode}
|
||||||
<NodeModal
|
<NodeModal node={selectedNode} visible={modalVisible} onClose={closeModal} />
|
||||||
node={selectedNode}
|
|
||||||
visible={modalVisible}
|
|
||||||
onClose={closeModal}
|
|
||||||
onDismiss={dismissModal}
|
|
||||||
allocated={allocated.has(selectedNode.id)}
|
|
||||||
onToggleAllocate={toggleAllocate}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Character Stats Panel (toggle button + backdrop + panel) -->
|
<!-- Character Stats Panel (toggle button + backdrop + panel) -->
|
||||||
@@ -583,8 +255,12 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Focus indicators are global now — see app.css. A scoped rule here could
|
/* Global focus indicators for accessibility */
|
||||||
never reach NodeModal or StatsPanel children. */
|
*:focus-visible {
|
||||||
|
outline: 2px solid var(--color-gold);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.canvas-container {
|
.canvas-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -40,36 +40,27 @@
|
|||||||
|
|
||||||
<!-- Stats Panel Backdrop -->
|
<!-- Stats Panel Backdrop -->
|
||||||
{#if visible}
|
{#if visible}
|
||||||
<!-- Presentational click-to-close. The onkeydown this used to carry could
|
<div
|
||||||
never fire (role="presentation", no tabindex); Escape is global. -->
|
class="stats-panel-backdrop visible"
|
||||||
<div class="stats-panel-backdrop visible" onclick={onToggle} role="presentation"></div>
|
onclick={onToggle}
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && onToggle()}
|
||||||
|
role="presentation"
|
||||||
|
aria-label="Stats panel backdrop. Click to close or press Escape."
|
||||||
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Stats Panel -->
|
<!-- Stats Panel -->
|
||||||
<!--
|
|
||||||
`inert` rather than aria-hidden: the panel is only translated off-screen, so
|
|
||||||
its focusable children stayed in the tab order while announcing as nothing —
|
|
||||||
a direct axe `aria-hidden-focus` violation. inert removes it from the
|
|
||||||
accessibility tree AND the tab order together.
|
|
||||||
-->
|
|
||||||
<div
|
<div
|
||||||
id="stats-panel"
|
id="stats-panel"
|
||||||
class="stats-panel"
|
class="stats-panel"
|
||||||
class:visible
|
class:visible
|
||||||
role="complementary"
|
role="complementary"
|
||||||
aria-label="Character Statistics"
|
aria-label="Character Statistics"
|
||||||
inert={!visible}
|
aria-hidden={!visible}
|
||||||
>
|
>
|
||||||
<!-- Character Card -->
|
<!-- Character Card -->
|
||||||
<div class="character-card">
|
<div class="character-card">
|
||||||
<img
|
<img src="/headshot.jpg" alt="Character Portrait" class="character-portrait" />
|
||||||
src="/headshot.jpg"
|
|
||||||
alt=""
|
|
||||||
width="120"
|
|
||||||
height="120"
|
|
||||||
decoding="async"
|
|
||||||
class="character-portrait"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="character-stats-bar">
|
<div class="character-stats-bar">
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
@@ -79,7 +70,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="character-name">Kevin Riehl</p>
|
<h1 class="character-name">Kevin Riehl</h1>
|
||||||
<p class="character-title">{playerClass}</p>
|
<p class="character-title">{playerClass}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,14 +107,13 @@
|
|||||||
<!-- Skill Branches -->
|
<!-- Skill Branches -->
|
||||||
{#each Object.entries(skillsByBranch) as [branchId, branch] (branchId)}
|
{#each Object.entries(skillsByBranch) as [branchId, branch] (branchId)}
|
||||||
<div class="skill-branch">
|
<div class="skill-branch">
|
||||||
<!-- A real <button>: the previous div+role="button" handled Enter but
|
<div
|
||||||
not Space, which the ARIA button pattern requires. -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="branch-header"
|
class="branch-header"
|
||||||
onclick={() => onToggleBranch(branchId)}
|
onclick={() => onToggleBranch(branchId)}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && onToggleBranch(branchId)}
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
aria-expanded={expandedBranches.has(branchId)}
|
aria-expanded={expandedBranches.has(branchId)}
|
||||||
aria-controls="skill-list-{branchId}"
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={treeNodes.find((n) => n.id === branchId)?.icon || ''}
|
src={treeNodes.find((n) => n.id === branchId)?.icon || ''}
|
||||||
@@ -133,10 +123,10 @@
|
|||||||
<span class="branch-title">{branch.label}</span>
|
<span class="branch-title">{branch.label}</span>
|
||||||
<span class="branch-count">({branch.skills.length})</span>
|
<span class="branch-count">({branch.skills.length})</span>
|
||||||
<span class="expand-icon" class:expanded={expandedBranches.has(branchId)}>▼</span>
|
<span class="expand-icon" class:expanded={expandedBranches.has(branchId)}>▼</span>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
{#if expandedBranches.has(branchId)}
|
{#if expandedBranches.has(branchId)}
|
||||||
<div id="skill-list-{branchId}" class="skill-list" transition:slide={{ duration: 300 }}>
|
<div class="skill-list" transition:slide={{ duration: 300 }}>
|
||||||
{#each branch.skills as skill (skill.id)}
|
{#each branch.skills as skill (skill.id)}
|
||||||
<div class="skill-item">
|
<div class="skill-item">
|
||||||
{#if skill.icon}
|
{#if skill.icon}
|
||||||
@@ -179,7 +169,7 @@
|
|||||||
border: 2px solid var(--color-gold);
|
border: 2px solid var(--color-gold);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: var(--z-panel-toggle);
|
z-index: 950;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -221,10 +211,8 @@
|
|||||||
box-shadow:
|
box-shadow:
|
||||||
4px 0 40px rgba(255, 215, 0, 0.3),
|
4px 0 40px rgba(255, 215, 0, 0.3),
|
||||||
8px 0 80px rgba(255, 215, 0, 0.15);
|
8px 0 80px rgba(255, 215, 0, 0.15);
|
||||||
z-index: var(--z-panel);
|
z-index: 900;
|
||||||
/* Offset past the box-shadow (8px offset + 80px blur) so the gold glow
|
transform: translateX(-100%);
|
||||||
doesn't bleed down the left edge of the viewport while closed. */
|
|
||||||
transform: translateX(calc(-100% - 100px));
|
|
||||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
@@ -262,7 +250,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
z-index: var(--z-panel-backdrop);
|
z-index: 899;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +293,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.character-name {
|
.character-name {
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--color-gold);
|
color: var(--color-gold);
|
||||||
@@ -335,8 +322,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.proficiency-summary h3 {
|
.proficiency-summary h3 {
|
||||||
font-family: var(--font-display);
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: var(--color-gold);
|
color: var(--color-gold);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
@@ -375,11 +360,6 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
/* Reset the UA button defaults so this keeps the previous appearance. */
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.branch-header:hover {
|
.branch-header:hover {
|
||||||
@@ -403,12 +383,12 @@
|
|||||||
|
|
||||||
.branch-count {
|
.branch-count {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted);
|
color: #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expand-icon {
|
.expand-icon {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-subtle);
|
color: #666;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,7 +446,7 @@
|
|||||||
|
|
||||||
.skill-years {
|
.skill-years {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: var(--text-subtle);
|
color: #666;
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { TreeNode } from '$lib/data';
|
import type { TreeNode } from '$lib/data';
|
||||||
|
|
||||||
// `let`, not `const`: bind:this assigns to `element`, and a bindable prop is
|
const { node, style, colorHex }: { node: TreeNode; style: string; colorHex: string } = $props();
|
||||||
// only writable when destructured with let.
|
|
||||||
let {
|
|
||||||
node,
|
|
||||||
style,
|
|
||||||
colorHex,
|
|
||||||
element = $bindable(null)
|
|
||||||
}: {
|
|
||||||
node: TreeNode;
|
|
||||||
style: string;
|
|
||||||
colorHex: string;
|
|
||||||
element?: HTMLDivElement | null;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
<div class="tooltip" role="tooltip" aria-live="polite" style="{style} --node-color: {colorHex};">
|
||||||
aria-hidden: this is a mouse-only visual duplicate of canvas content. It used
|
|
||||||
to carry role="tooltip" + aria-live="polite", which announced the full label,
|
|
||||||
description and "Click for details" on every single hover. Nothing references
|
|
||||||
it via aria-describedby either — the trigger is a WebGL sprite with no DOM
|
|
||||||
node — so the role was inert anyway. The accessible path is the text version.
|
|
||||||
-->
|
|
||||||
<div
|
|
||||||
bind:this={element}
|
|
||||||
class="tooltip"
|
|
||||||
aria-hidden="true"
|
|
||||||
style="{style} --node-color: {colorHex};"
|
|
||||||
>
|
|
||||||
<h3>
|
<h3>
|
||||||
{#if node.icon}
|
{#if node.icon}
|
||||||
<img src={node.icon} alt="" class="tooltip-icon" />
|
<img src={node.icon} alt="" class="tooltip-icon" />
|
||||||
@@ -42,19 +18,13 @@
|
|||||||
<style>
|
<style>
|
||||||
.tooltip {
|
.tooltip {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
/* Positioned via translate3d, not left/top: moving on the compositor
|
|
||||||
avoids a layout+paint per frame on an element with two color-mix
|
|
||||||
shadows. The offsets live in the transform, so these stay at 0. */
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
will-change: transform;
|
|
||||||
background: linear-gradient(145deg, var(--panel-from) 0%, #12121a 100%);
|
background: linear-gradient(145deg, var(--panel-from) 0%, #12121a 100%);
|
||||||
border: 1px solid var(--node-color);
|
border: 1px solid var(--node-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
max-width: 280px;
|
max-width: 280px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: var(--z-tooltip);
|
z-index: 500;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 20px color-mix(in srgb, var(--node-color) 25%, transparent),
|
0 0 20px color-mix(in srgb, var(--node-color) 25%, transparent),
|
||||||
0 4px 12px rgba(0, 0, 0, 0.5);
|
0 4px 12px rgba(0, 0, 0, 0.5);
|
||||||
@@ -73,8 +43,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tooltip h3 {
|
.tooltip h3 {
|
||||||
font-family: var(--font-display);
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
color: var(--node-color);
|
color: var(--node-color);
|
||||||
margin: 0 0 0.35rem 0;
|
margin: 0 0 0.35rem 0;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
@@ -89,7 +57,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tooltip .hint {
|
.tooltip .hint {
|
||||||
color: var(--text-subtle);
|
color: #666;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
// Accessible counterpart to the canvas.
|
|
||||||
//
|
|
||||||
// The skill tree is drawn entirely in WebGL: nodes are PixiJS Containers with
|
|
||||||
// no DOM representation, so before this there was no way to reach a node by
|
|
||||||
// keyboard or have one announced. The canvas advertised role="application",
|
|
||||||
// which tells screen readers to hand every key to the app — for a widget that
|
|
||||||
// implemented no keyboard model at all.
|
|
||||||
//
|
|
||||||
// This renders the same data.ts as a real WAI-ARIA tree: roving tabindex,
|
|
||||||
// arrow-key traversal, Enter/Space to open the same modal a click opens.
|
|
||||||
// Visually hidden until focused, then shown, so it doubles as a skip-target
|
|
||||||
// for sighted keyboard users instead of ~20 invisible tab stops.
|
|
||||||
import { treeNodes, type TreeNode } from '$lib/data';
|
|
||||||
import { buildHierarchy, flattenVisible } from '$lib/utils/tree';
|
|
||||||
import { categoryColorHex } from '$lib/theme';
|
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
|
||||||
|
|
||||||
const {
|
|
||||||
onActivate,
|
|
||||||
onFocusNode
|
|
||||||
}: {
|
|
||||||
/** Enter/Space on a node — opens the same modal as a canvas click. */
|
|
||||||
onActivate: (node: TreeNode) => void;
|
|
||||||
/** Focus moved to a node — pans the canvas so the two stay in sync. */
|
|
||||||
onFocusNode?: (node: TreeNode) => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const roots = buildHierarchy(treeNodes);
|
|
||||||
|
|
||||||
// SvelteSet, not $state(new Set()) — Svelte does not proxy a plain Set, so
|
|
||||||
// in-place mutation is untracked. (This is the same trap that silently broke
|
|
||||||
// the stats panel's expand/collapse.)
|
|
||||||
const expanded = new SvelteSet<string>(roots.map((r) => r.node.id));
|
|
||||||
let activeId = $state(roots[0]?.node.id ?? '');
|
|
||||||
|
|
||||||
const rows = $derived(flattenVisible(roots, (id) => expanded.has(id)));
|
|
||||||
|
|
||||||
function setExpanded(id: string, open: boolean) {
|
|
||||||
if (open) expanded.add(id);
|
|
||||||
else expanded.delete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusRow(index: number) {
|
|
||||||
const row = rows[index];
|
|
||||||
if (!row) return;
|
|
||||||
activeId = row.branch.node.id;
|
|
||||||
document.getElementById(`tn-${row.branch.node.id}`)?.focus();
|
|
||||||
onFocusNode?.(row.branch.node);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent, index: number) {
|
|
||||||
const { branch } = rows[index];
|
|
||||||
const hasChildren = branch.children.length > 0;
|
|
||||||
const isOpen = expanded.has(branch.node.id);
|
|
||||||
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowDown':
|
|
||||||
e.preventDefault();
|
|
||||||
focusRow(Math.min(index + 1, rows.length - 1));
|
|
||||||
break;
|
|
||||||
case 'ArrowUp':
|
|
||||||
e.preventDefault();
|
|
||||||
focusRow(Math.max(index - 1, 0));
|
|
||||||
break;
|
|
||||||
case 'ArrowRight':
|
|
||||||
e.preventDefault();
|
|
||||||
if (hasChildren && !isOpen) setExpanded(branch.node.id, true);
|
|
||||||
else if (hasChildren) focusRow(index + 1);
|
|
||||||
break;
|
|
||||||
case 'ArrowLeft': {
|
|
||||||
e.preventDefault();
|
|
||||||
if (hasChildren && isOpen) {
|
|
||||||
setExpanded(branch.node.id, false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Otherwise move to the parent row.
|
|
||||||
const parentId = branch.node.parentId;
|
|
||||||
const parentIndex = rows.findIndex((r) => r.branch.node.id === parentId);
|
|
||||||
if (parentIndex !== -1) focusRow(parentIndex);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'Home':
|
|
||||||
e.preventDefault();
|
|
||||||
focusRow(0);
|
|
||||||
break;
|
|
||||||
case 'End':
|
|
||||||
e.preventDefault();
|
|
||||||
focusRow(rows.length - 1);
|
|
||||||
break;
|
|
||||||
case 'Enter':
|
|
||||||
case ' ':
|
|
||||||
e.preventDefault();
|
|
||||||
onActivate(branch.node);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="tree-nav">
|
|
||||||
<h2 id="tree-nav-label" tabindex="-1">Skill tree navigator</h2>
|
|
||||||
<p class="tree-nav-hint">
|
|
||||||
Arrow keys move between entries, Right and Left expand and collapse, Enter opens details.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul role="tree" aria-labelledby="tree-nav-label">
|
|
||||||
{#each rows as { branch, level }, index (branch.node.id)}
|
|
||||||
{@const node = branch.node}
|
|
||||||
{@const hasChildren = branch.children.length > 0}
|
|
||||||
<li
|
|
||||||
role="treeitem"
|
|
||||||
id="tn-{node.id}"
|
|
||||||
aria-level={level}
|
|
||||||
aria-expanded={hasChildren ? expanded.has(node.id) : undefined}
|
|
||||||
aria-selected={activeId === node.id}
|
|
||||||
tabindex={activeId === node.id ? 0 : -1}
|
|
||||||
style="--node-color: {categoryColorHex[node.category]}; --level: {level}"
|
|
||||||
onkeydown={(e) => handleKeydown(e, index)}
|
|
||||||
onclick={() => onActivate(node)}
|
|
||||||
onfocus={() => {
|
|
||||||
activeId = node.id;
|
|
||||||
onFocusNode?.(node);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span class="tn-dot" aria-hidden="true"></span>
|
|
||||||
<span class="tn-label">{node.label}</span>
|
|
||||||
<span class="tn-category">{node.category}</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/*
|
|
||||||
* Visually hidden until something inside it takes focus. Same idea as a skip
|
|
||||||
* link: invisible to mouse users, but a sighted keyboard user gets a real,
|
|
||||||
* visible panel instead of focus vanishing into a 1x1px clip region.
|
|
||||||
*/
|
|
||||||
.tree-nav {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
white-space: nowrap;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-nav:focus-within {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: var(--z-tree-nav);
|
|
||||||
width: min(24rem, 92vw);
|
|
||||||
height: auto;
|
|
||||||
max-height: 90vh;
|
|
||||||
margin: 0;
|
|
||||||
padding: 1rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
clip: auto;
|
|
||||||
white-space: normal;
|
|
||||||
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
|
|
||||||
border: 1px solid var(--color-gold);
|
|
||||||
border-radius: 0 0 10px 0;
|
|
||||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-nav h2 {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
margin: 0 0 0.25rem;
|
|
||||||
color: var(--color-gold);
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-nav-hint {
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.35rem 0.5rem;
|
|
||||||
padding-left: calc(0.5rem + (var(--level) - 1) * 0.85rem);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #e8e8f0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
li:hover,
|
|
||||||
li[aria-selected='true'] {
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tn-dot {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--node-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tn-label {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tn-category {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -13,13 +13,12 @@
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
z-index: var(--z-welcome);
|
z-index: 100;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
animation: titleFadeInOut 2.5s ease-in-out forwards;
|
animation: titleFadeInOut 2.5s ease-in-out forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-title .welcome-name {
|
.welcome-title .welcome-name {
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--color-gold);
|
color: var(--color-gold);
|
||||||
@@ -36,7 +35,6 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin: 0.5rem 0 0 0;
|
margin: 0.5rem 0 0 0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-family: var(--font-display);
|
|
||||||
letter-spacing: 0.3em;
|
letter-spacing: 0.3em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { describe, it, expect, afterEach } from 'vitest';
|
|
||||||
import { prefersReducedMotion, byDevice } from './config';
|
|
||||||
|
|
||||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
|
||||||
|
|
||||||
function stubMatchMedia(matches: boolean | null) {
|
|
||||||
(globalThis as { window?: unknown }).window =
|
|
||||||
matches === null
|
|
||||||
? {} // window exists but matchMedia doesn't (very old/embedded engines)
|
|
||||||
: { matchMedia: (media: string) => ({ matches, media }) };
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
(globalThis as { window?: unknown }).window = originalWindow;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('prefersReducedMotion', () => {
|
|
||||||
it('is false when there is no window (SSR / prerender)', () => {
|
|
||||||
delete (globalThis as { window?: unknown }).window;
|
|
||||||
expect(prefersReducedMotion()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is false when window has no matchMedia', () => {
|
|
||||||
stubMatchMedia(null);
|
|
||||||
expect(prefersReducedMotion()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is true when the user asked for reduced motion', () => {
|
|
||||||
stubMatchMedia(true);
|
|
||||||
expect(prefersReducedMotion()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is false when the user has not', () => {
|
|
||||||
stubMatchMedia(false);
|
|
||||||
expect(prefersReducedMotion()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reads matchMedia at call time, not module load', () => {
|
|
||||||
stubMatchMedia(false);
|
|
||||||
expect(prefersReducedMotion()).toBe(false);
|
|
||||||
stubMatchMedia(true);
|
|
||||||
expect(prefersReducedMotion()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('byDevice', () => {
|
|
||||||
it('picks the mobile value on mobile and the desktop value otherwise', () => {
|
|
||||||
expect(byDevice({ desktop: 'd', mobile: 'm' }, true)).toBe('m');
|
|
||||||
expect(byDevice({ desktop: 'd', mobile: 'm' }, false)).toBe('d');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+3
-29
@@ -38,16 +38,6 @@ export const NODE_RADII: Record<string, number> = {
|
|||||||
};
|
};
|
||||||
export const MOBILE_RADIUS_FACTOR = 0.8;
|
export const MOBILE_RADIUS_FACTOR = 0.8;
|
||||||
|
|
||||||
/**
|
|
||||||
* Zoom at which on-canvas node labels appear.
|
|
||||||
*
|
|
||||||
* Deliberately just above the settled desktop zoom (endScale = 1), so the
|
|
||||||
* default full-tree view stays a clean constellation — 57 constant-size labels
|
|
||||||
* at that zoom overlap into noise. Zooming in at all reveals them, and 'L'
|
|
||||||
* forces them on at any zoom.
|
|
||||||
*/
|
|
||||||
export const LABEL_ZOOM_THRESHOLD = 1.05;
|
|
||||||
|
|
||||||
/** Keyboard pan distance per arrow-key press (px). */
|
/** Keyboard pan distance per arrow-key press (px). */
|
||||||
export const PAN_SPEED = 50;
|
export const PAN_SPEED = 50;
|
||||||
|
|
||||||
@@ -61,27 +51,11 @@ export const PARTICLE_COUNTS = {
|
|||||||
|
|
||||||
/** Entrance-animation timings (seconds), split by device. */
|
/** Entrance-animation timings (seconds), split by device. */
|
||||||
export const TIMINGS = {
|
export const TIMINGS = {
|
||||||
titleDuration: { desktop: 1.6, mobile: 1.1 },
|
titleDuration: { desktop: 2.5, mobile: 1.5 },
|
||||||
// Was 4.5s desktop. Nothing is readable until this finishes, and it is the
|
zoomDuration: { desktop: 4.5, mobile: 2.5 },
|
||||||
// dominant term in time-to-first-information.
|
nodeStagger: { desktop: 0.08, mobile: 0.05 }
|
||||||
zoomDuration: { desktop: 2, mobile: 1.5 },
|
|
||||||
// 0.08 x 56 nodes was 4.5s of cascade on its own, dominating the entrance.
|
|
||||||
nodeStagger: { desktop: 0.045, mobile: 0.03 }
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the visitor has asked for reduced motion.
|
|
||||||
*
|
|
||||||
* This site is unusually motion-heavy — a multi-second forced zoom, a staggered
|
|
||||||
* entrance, ~480 looping tweens and a continuous dust ticker — and none of it
|
|
||||||
* was previously conditional. Read at call time, not module load, so it is
|
|
||||||
* correct under SSR/prerender and picks up an OS-level change on reload.
|
|
||||||
*/
|
|
||||||
export function prefersReducedMotion(): boolean {
|
|
||||||
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
|
||||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Helper: pick a desktop/mobile value. */
|
/** Helper: pick a desktop/mobile value. */
|
||||||
export function byDevice<T>(pair: { desktop: T; mobile: T }, isMobile: boolean): T {
|
export function byDevice<T>(pair: { desktop: T; mobile: T }, isMobile: boolean): T {
|
||||||
return isMobile ? pair.mobile : pair.desktop;
|
return isMobile ? pair.mobile : pair.desktop;
|
||||||
|
|||||||
+15
-24
@@ -15,8 +15,6 @@ export interface OriginNode extends BaseNode {
|
|||||||
category: 'origin';
|
category: 'origin';
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
/** One-line hook for the persistent HUD — the 5-second pitch. */
|
|
||||||
tagline?: string;
|
|
||||||
aboutMe?: string;
|
aboutMe?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
@@ -90,10 +88,9 @@ export const treeNodes: TreeNode[] = [
|
|||||||
size: 'x-large',
|
size: 'x-large',
|
||||||
icon: '',
|
icon: '',
|
||||||
fullName: 'Kevin Riehl',
|
fullName: 'Kevin Riehl',
|
||||||
jobTitle: 'Lead Software Engineer',
|
jobTitle: 'Software Development - Lead Associate',
|
||||||
tagline: 'WebRTC telehealth · MediaPipe accessibility · air-gapped CI',
|
|
||||||
aboutMe:
|
aboutMe:
|
||||||
"Software engineer with 5+ years building full-stack applications for Defense Health Agency programs. I've architected a WebRTC telehealth platform, shipped real-time gesture recognition that made chat usable for speech-impaired patients, and built the deployment tooling that unblocked a team after an air-gapped infrastructure migration. React, Node.js, NestJS, TypeScript, SQL.",
|
'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',
|
location: 'Kent, WA',
|
||||||
email: 'kevinriehl@gmail.com',
|
email: 'kevinriehl@gmail.com',
|
||||||
linkedIn: 'https://www.linkedin.com/in/kevinriehl',
|
linkedIn: 'https://www.linkedin.com/in/kevinriehl',
|
||||||
@@ -137,7 +134,7 @@ export const treeNodes: TreeNode[] = [
|
|||||||
{
|
{
|
||||||
id: 'exp-job1',
|
id: 'exp-job1',
|
||||||
label: 'Software Engineer',
|
label: 'Software Engineer',
|
||||||
description: 'Refactored legacy PHP endpoints onto Hapi.js and ran a MySQL to MSSQL migration.',
|
description: 'Junior developer at the start of my career.',
|
||||||
x: 420,
|
x: 420,
|
||||||
y: -280,
|
y: -280,
|
||||||
parentId: 'exp-contract',
|
parentId: 'exp-contract',
|
||||||
@@ -158,7 +155,7 @@ export const treeNodes: TreeNode[] = [
|
|||||||
{
|
{
|
||||||
id: 'exp-job2',
|
id: 'exp-job2',
|
||||||
label: 'Software Engineer',
|
label: 'Software Engineer',
|
||||||
description: 'Built a React + Hapi.js analytics search engine over complex event datasets.',
|
description: 'Growing my skills and taking on more responsibility.',
|
||||||
x: 500,
|
x: 500,
|
||||||
y: -350,
|
y: -350,
|
||||||
parentId: 'exp-job1',
|
parentId: 'exp-job1',
|
||||||
@@ -180,7 +177,7 @@ export const treeNodes: TreeNode[] = [
|
|||||||
{
|
{
|
||||||
id: 'exp-job3',
|
id: 'exp-job3',
|
||||||
label: 'Intermediate Software Engineer',
|
label: 'Intermediate Software Engineer',
|
||||||
description: 'Web Bluetooth biometric monitoring, plus a team-wide move to ShadCN and Docker.',
|
description: 'Stepping into a more senior role.',
|
||||||
x: 560,
|
x: 560,
|
||||||
y: -430,
|
y: -430,
|
||||||
parentId: 'exp-job2',
|
parentId: 'exp-job2',
|
||||||
@@ -202,8 +199,7 @@ export const treeNodes: TreeNode[] = [
|
|||||||
{
|
{
|
||||||
id: 'exp-job4',
|
id: 'exp-job4',
|
||||||
label: 'Intermediate Software Engineer',
|
label: 'Intermediate Software Engineer',
|
||||||
description:
|
description: 'Where one journey ends, another begins.',
|
||||||
'WebRTC telehealth platform and real-time gesture recognition for speech-impaired users.',
|
|
||||||
x: 600,
|
x: 600,
|
||||||
y: -510,
|
y: -510,
|
||||||
parentId: 'exp-job3',
|
parentId: 'exp-job3',
|
||||||
@@ -225,8 +221,7 @@ export const treeNodes: TreeNode[] = [
|
|||||||
{
|
{
|
||||||
id: 'exp-job5',
|
id: 'exp-job5',
|
||||||
label: 'Lead Associate',
|
label: 'Lead Associate',
|
||||||
description:
|
description: 'Where I am now.',
|
||||||
'Air-gapped deployment automation, a NestJS audit interceptor, and 90%+ test coverage.',
|
|
||||||
x: 620,
|
x: 620,
|
||||||
y: -590,
|
y: -590,
|
||||||
parentId: 'exp-job4',
|
parentId: 'exp-job4',
|
||||||
@@ -238,9 +233,9 @@ export const treeNodes: TreeNode[] = [
|
|||||||
startDate: 'January 2026',
|
startDate: 'January 2026',
|
||||||
endDate: 'Present',
|
endDate: 'Present',
|
||||||
highlights: [
|
highlights: [
|
||||||
'Proactively engineered a suite of automation tools to streamline code deployment workflows to an air-gapped Git instance following a critical infrastructure migration, eliminating manual bottlenecks and restoring team development velocity',
|
'Proactively engineered a suite of automation tools to streamline code deployment workflows to an air-gapped Git instance following a critical infrastructure migration, eliminating manual bottlenecks and restoring team development velocity.',
|
||||||
'Designed and implemented a custom NestJS API interceptor to dynamically detect and audit specific user activities, providing robust, real-time daily activity tracking for compliance and analytics',
|
'Designed and implemented a custom NestJS API interceptor to dynamically detect and audit specific user activities, providing robust, real-time daily activity tracking for compliance and analytics.',
|
||||||
'Spearheaded a rigorous testing initiative to elevate code quality, systematically architecting and executing unit tests to scale platform test coverage from a legacy deficit to a resilient 90%+ baseline'
|
'Spearheaded a rigorous testing initiative to elevate code quality, systematically architecting and executing unit tests to scale platform test coverage from a legacy deficit to a resilient 90%+ baseline.'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -311,8 +306,6 @@ export const treeNodes: TreeNode[] = [
|
|||||||
techStack: ['Python', 'REST API', 'PyPI', 'GitHub Actions', 'Unit Testing'],
|
techStack: ['Python', 'REST API', 'PyPI', 'GitHub Actions', 'Unit Testing'],
|
||||||
repoUrl: 'https://github.com/TehRiehlDeal/TVDB-API',
|
repoUrl: 'https://github.com/TehRiehlDeal/TVDB-API',
|
||||||
pypiUrl: 'https://pypi.org/project/tvdbAPI/',
|
pypiUrl: 'https://pypi.org/project/tvdbAPI/',
|
||||||
// Lifetime PyPI downloads, checked 2026-08-09 (pypistats.org).
|
|
||||||
downloads: '2,277 downloads',
|
|
||||||
status: 'maintained'
|
status: 'maintained'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -329,8 +322,6 @@ export const treeNodes: TreeNode[] = [
|
|||||||
techStack: ['Python', 'REST API', 'PyPI', 'GitHub Actions', 'Unit Testing'],
|
techStack: ['Python', 'REST API', 'PyPI', 'GitHub Actions', 'Unit Testing'],
|
||||||
repoUrl: 'https://github.com/TehRiehlDeal/tmdbAPI',
|
repoUrl: 'https://github.com/TehRiehlDeal/tmdbAPI',
|
||||||
pypiUrl: 'https://pypi.org/project/tmdbAPI/',
|
pypiUrl: 'https://pypi.org/project/tmdbAPI/',
|
||||||
// Lifetime PyPI downloads, checked 2026-08-09 (pypistats.org).
|
|
||||||
downloads: '784 downloads',
|
|
||||||
status: 'maintained'
|
status: 'maintained'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -375,9 +366,9 @@ export const treeNodes: TreeNode[] = [
|
|||||||
category: 'projects',
|
category: 'projects',
|
||||||
size: 'small',
|
size: 'small',
|
||||||
icon: '/icons/svelte.svg',
|
icon: '/icons/svelte.svg',
|
||||||
techStack: ['SvelteKit', 'TypeScript', 'PixiJS', 'GSAP', 'Vitest'],
|
techStack: ['SvelteKit', 'TypeScript', 'PixiJS', 'GSAP', 'Tailwind CSS'],
|
||||||
projectUrl: 'https://tehriehldeal.com',
|
projectUrl: 'https://tehriehldeal.com',
|
||||||
repoUrl: 'https://git.tehriehldeal.com/TehRiehlDeal/skill-tree-portfolio'
|
repoUrl: 'https://github.com/yourusername/skill-tree-portfolio'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'proj-character-builder',
|
id: 'proj-character-builder',
|
||||||
@@ -638,7 +629,7 @@ export const treeNodes: TreeNode[] = [
|
|||||||
proficiency: 'intermediate'
|
proficiency: 'intermediate'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'tool-vscode',
|
id: 'tool-vscod',
|
||||||
label: 'VSCode',
|
label: 'VSCode',
|
||||||
description:
|
description:
|
||||||
'Powerful, lightweight code editor by Microsoft with built-in Git support, debugging, and a vast extension ecosystem.',
|
'Powerful, lightweight code editor by Microsoft with built-in Git support, debugging, and a vast extension ecosystem.',
|
||||||
@@ -929,8 +920,8 @@ export const treeNodes: TreeNode[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'edu-degree',
|
id: 'edu-degree',
|
||||||
label: 'Green River College',
|
label: 'University',
|
||||||
description: "Bachelor's of Applied Science in IT and Software Development.",
|
description: "Bachelor's degree in Computer Science.",
|
||||||
x: -300,
|
x: -300,
|
||||||
y: 80,
|
y: 80,
|
||||||
parentId: 'edu-branch',
|
parentId: 'edu-branch',
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
// Visual treatment for allocated nodes.
|
|
||||||
//
|
|
||||||
// The path reads as "lit" by dimming everything else rather than brightening
|
|
||||||
// the selection — the tree is already near its brightness ceiling, so
|
|
||||||
// recession is the only headroom available.
|
|
||||||
import { isEdgeAllocated } from '$lib/utils/allocation';
|
|
||||||
import type { SkillTreeController } from './controller';
|
|
||||||
|
|
||||||
/** Alpha for nodes outside the current build. */
|
|
||||||
const DIMMED_NODE_ALPHA = 0.22;
|
|
||||||
/** Tint applied to connections outside the current build. */
|
|
||||||
const DIMMED_LINE_TINT = 0x2a2a35;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply (or clear) allocation styling.
|
|
||||||
*
|
|
||||||
* Nodes are dimmed via `alpha`, which nothing tweens after the entrance
|
|
||||||
* finishes. Connections are dimmed via `tint` instead — their alpha is driven
|
|
||||||
* by the permanent plasma-beam pulse, so writing to it here would fight the
|
|
||||||
* animation every frame.
|
|
||||||
*/
|
|
||||||
export function updateAllocation(ctrl: SkillTreeController, allocated: ReadonlySet<string>) {
|
|
||||||
const active = allocated.size > 0;
|
|
||||||
|
|
||||||
for (const obj of ctrl.nodeObjects) {
|
|
||||||
const lit = !active || allocated.has(obj.node.id);
|
|
||||||
const alpha = lit ? 1 : DIMMED_NODE_ALPHA;
|
|
||||||
obj.graphics.alpha = alpha;
|
|
||||||
obj.glowGraphics.alpha = lit ? 1 : DIMMED_NODE_ALPHA * 0.5;
|
|
||||||
if (obj.label) obj.label.alpha = alpha;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const line of ctrl.lineObjects) {
|
|
||||||
const lit = !active || isEdgeAllocated(allocated, line.node);
|
|
||||||
const tint = lit ? 0xffffff : DIMMED_LINE_TINT;
|
|
||||||
line.graphics.tint = tint;
|
|
||||||
line.glowGraphics.tint = tint;
|
|
||||||
line.plasmaBeam.tint = tint;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+8
-109
@@ -4,84 +4,24 @@
|
|||||||
import { Container } from 'pixi.js';
|
import { Container } from 'pixi.js';
|
||||||
import { gsap } from 'gsap';
|
import { gsap } from 'gsap';
|
||||||
import { getDistanceFromOrigin } from '$lib/utils/tree';
|
import { getDistanceFromOrigin } from '$lib/utils/tree';
|
||||||
import { prefersReducedMotion, TIMINGS, byDevice } from '$lib/config';
|
import { updateParallax } from './interaction';
|
||||||
import { updateParallax, updateLabels } from './interaction';
|
|
||||||
import type { SkillTreeController } from './controller';
|
import type { SkillTreeController } from './controller';
|
||||||
import type { Lifecycle } from './lifecycle';
|
|
||||||
|
|
||||||
/** Handle returned by runEntranceAnimations. */
|
|
||||||
export interface EntranceHandle {
|
|
||||||
/** Fast-forward the intro to its finished state. */
|
|
||||||
skip: () => void;
|
|
||||||
/** Total intro length in ms, so the UI can time Skip and the hint off it. */
|
|
||||||
durationMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EntranceOpts {
|
interface EntranceOpts {
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
life: Lifecycle;
|
|
||||||
/** Returning visitor — land on the settled tree instead of replaying it. */
|
|
||||||
skipIntro?: boolean;
|
|
||||||
/** Called when the welcome title should be removed (matches its CSS fade). */
|
/** Called when the welcome title should be removed (matches its CSS fade). */
|
||||||
onTitleDone: () => void;
|
onTitleDone: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runEntranceAnimations(
|
export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceOpts) {
|
||||||
ctrl: SkillTreeController,
|
|
||||||
opts: EntranceOpts
|
|
||||||
): EntranceHandle {
|
|
||||||
const { isMobile } = opts;
|
const { isMobile } = opts;
|
||||||
const { treeContainer, nodeObjects, lineObjects, endScale } = ctrl;
|
const { treeContainer, nodeObjects, lineObjects, endScale } = ctrl;
|
||||||
|
|
||||||
/** Put the tree straight into its settled, interactive state. */
|
// Responsive animation timing
|
||||||
function showFinishedTree() {
|
const titleDuration = isMobile ? 1.5 : 2.5;
|
||||||
treeContainer.scale.set(endScale);
|
const zoomDuration = isMobile ? 2.5 : 4.5;
|
||||||
updateLabels(ctrl);
|
const nodeStagger = isMobile ? 0.05 : 0.08;
|
||||||
|
|
||||||
for (const obj of nodeObjects) {
|
|
||||||
obj.graphics.alpha = 1;
|
|
||||||
obj.graphics.scale.set(1);
|
|
||||||
obj.glowGraphics.alpha = 1;
|
|
||||||
if (obj.label) obj.label.alpha = 1;
|
|
||||||
if (obj.graphics instanceof Container) obj.graphics.eventMode = 'static';
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const line of lineObjects) {
|
|
||||||
line.graphics.alpha = 1;
|
|
||||||
line.glowGraphics.alpha = 1;
|
|
||||||
line.plasmaBeam.alpha = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggle = document.querySelector<HTMLElement>('.stats-panel-toggle');
|
|
||||||
if (toggle) toggle.style.opacity = '1';
|
|
||||||
|
|
||||||
opts.onTitleDone();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reduced motion, or a returning visitor who has already seen the intro:
|
|
||||||
// no zoom, no stagger, and no `repeat: -1` pulses.
|
|
||||||
if (prefersReducedMotion() || opts.skipIntro) {
|
|
||||||
showFinishedTree();
|
|
||||||
return { skip: () => {}, durationMs: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timings come from config so there is a single place to tune them. They used
|
|
||||||
// to be duplicated here as literals, so editing config.ts did nothing.
|
|
||||||
const titleDuration = byDevice(TIMINGS.titleDuration, isMobile);
|
|
||||||
const zoomDuration = byDevice(TIMINGS.zoomDuration, isMobile);
|
|
||||||
const nodeStagger = byDevice(TIMINGS.nodeStagger, isMobile);
|
|
||||||
|
|
||||||
// The entrance gets its own nested GSAP context so Skip can enumerate exactly
|
|
||||||
// its tweens. Nested contexts register with the parent, so the lifecycle
|
|
||||||
// still tears everything down in one call.
|
|
||||||
let entranceCtx: gsap.Context | null = null;
|
|
||||||
|
|
||||||
// The entrance allocates ~340 finite tweens plus ~170 `repeat: -1` pulses,
|
|
||||||
// some created inside an onComplete. GSAP restores an animation's context
|
|
||||||
// while running its callbacks, so wrapping the whole sequence here makes
|
|
||||||
// even those late-created pulses killable via the lifecycle.
|
|
||||||
opts.life.animate(() => {
|
|
||||||
entranceCtx = gsap.context(() => {
|
|
||||||
// Hide all lines and plasma beams initially
|
// Hide all lines and plasma beams initially
|
||||||
lineObjects.forEach((line) => {
|
lineObjects.forEach((line) => {
|
||||||
line.graphics.alpha = 0;
|
line.graphics.alpha = 0;
|
||||||
@@ -138,14 +78,6 @@ export function runEntranceAnimations(
|
|||||||
delay: originDelay,
|
delay: originDelay,
|
||||||
ease: 'power2.out'
|
ease: 'power2.out'
|
||||||
});
|
});
|
||||||
if (originNode.label) {
|
|
||||||
gsap.to(originNode.label, {
|
|
||||||
alpha: 1,
|
|
||||||
duration: originDuration,
|
|
||||||
delay: originDelay,
|
|
||||||
ease: 'power2.out'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP 2: Start zoom out after origin appears
|
// STEP 2: Start zoom out after origin appears
|
||||||
@@ -157,10 +89,7 @@ export function runEntranceAnimations(
|
|||||||
duration: zoomDuration,
|
duration: zoomDuration,
|
||||||
delay: zoomDelay,
|
delay: zoomDelay,
|
||||||
ease: 'power1.out',
|
ease: 'power1.out',
|
||||||
onUpdate: () => {
|
onUpdate: () => updateParallax(ctrl)
|
||||||
updateParallax(ctrl);
|
|
||||||
updateLabels(ctrl);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// STEP 3: Animate in the rest of the nodes (excluding origin)
|
// STEP 3: Animate in the rest of the nodes (excluding origin)
|
||||||
@@ -195,12 +124,6 @@ export function runEntranceAnimations(
|
|||||||
ease: 'power2.out'
|
ease: 'power2.out'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Labels arrive with their node, never before it — otherwise they show
|
|
||||||
// over invisible nodes during the splash.
|
|
||||||
if (obj.label) {
|
|
||||||
gsap.to(obj.label, { alpha: 1, duration: 0.5, delay, ease: 'power2.out' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start ambient pulse after node appears
|
// Start ambient pulse after node appears
|
||||||
gsap.to(obj.glowGraphics.scale, {
|
gsap.to(obj.glowGraphics.scale, {
|
||||||
x: 1.4,
|
x: 1.4,
|
||||||
@@ -279,13 +202,12 @@ export function runEntranceAnimations(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Clean up title card after animation (matches titleDuration)
|
// Clean up title card after animation (matches titleDuration)
|
||||||
const titleTimer = setTimeout(
|
setTimeout(
|
||||||
() => {
|
() => {
|
||||||
opts.onTitleDone();
|
opts.onTitleDone();
|
||||||
},
|
},
|
||||||
isMobile ? 1500 : 2500
|
isMobile ? 1500 : 2500
|
||||||
);
|
);
|
||||||
opts.life.add(() => clearTimeout(titleTimer));
|
|
||||||
|
|
||||||
// Fade in stats panel toggle button with the tree
|
// Fade in stats panel toggle button with the tree
|
||||||
const toggleButton = document.querySelector('.stats-panel-toggle');
|
const toggleButton = document.querySelector('.stats-panel-toggle');
|
||||||
@@ -297,27 +219,4 @@ export function runEntranceAnimations(
|
|||||||
ease: 'power2.out'
|
ease: 'power2.out'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// When the last node/line finishes appearing. Derived rather than guessed, so
|
|
||||||
// the Skip control and the coach hint stay in step with the real animation.
|
|
||||||
const lastIndex = Math.max(nodeObjects.length, lineObjects.length) - 1;
|
|
||||||
const durationMs = Math.round(
|
|
||||||
(byDevice(TIMINGS.titleDuration, isMobile) + 0.5 + lastIndex * nodeStagger + 0.5) * 1000
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
durationMs,
|
|
||||||
skip() {
|
|
||||||
// progress(1) completes each finite tween AND fires its onComplete —
|
|
||||||
// which is what re-enables node interactivity and starts the ambient
|
|
||||||
// pulses. So skipping lands in the same state as watching it through.
|
|
||||||
// Infinite pulses are left alone; fast-forwarding them is meaningless.
|
|
||||||
for (const tween of entranceCtx?.getTweens() ?? []) {
|
|
||||||
if (tween.repeat() === -1) continue;
|
|
||||||
tween.progress(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
+21
-63
@@ -4,17 +4,11 @@
|
|||||||
import { Application, Graphics, Container } from 'pixi.js';
|
import { Application, Graphics, Container } from 'pixi.js';
|
||||||
import { gsap } from 'gsap';
|
import { gsap } from 'gsap';
|
||||||
import { nebulaPalette } from '$lib/theme';
|
import { nebulaPalette } from '$lib/theme';
|
||||||
import { PARTICLE_COUNTS, byDevice, prefersReducedMotion } from '$lib/config';
|
import { PARTICLE_COUNTS, byDevice } from '$lib/config';
|
||||||
import type { BackgroundLayers } from './types';
|
import type { BackgroundLayers } from './types';
|
||||||
import type { Lifecycle } from './lifecycle';
|
|
||||||
|
|
||||||
export function createBackground(
|
export function createBackground(app: Application, isMobile: boolean): BackgroundLayers {
|
||||||
app: Application,
|
|
||||||
isMobile: boolean,
|
|
||||||
life: Lifecycle
|
|
||||||
): BackgroundLayers {
|
|
||||||
const deepBackgroundContainer = new Container();
|
const deepBackgroundContainer = new Container();
|
||||||
let vignette!: Graphics;
|
|
||||||
const nebulaContainer = new Container();
|
const nebulaContainer = new Container();
|
||||||
const starContainer = new Container();
|
const starContainer = new Container();
|
||||||
|
|
||||||
@@ -22,21 +16,6 @@ export function createBackground(
|
|||||||
app.stage.addChild(nebulaContainer);
|
app.stage.addChild(nebulaContainer);
|
||||||
app.stage.addChild(starContainer);
|
app.stage.addChild(starContainer);
|
||||||
|
|
||||||
// Everything below allocates `repeat: -1` ambient tweens; building it inside
|
|
||||||
// the lifecycle's GSAP context makes all of them killable on teardown.
|
|
||||||
const reduceMotion = prefersReducedMotion();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every animation in this file is an infinite ambient loop (twinkle, drift,
|
|
||||||
* nebula breathing). Routing them through one helper means reduced motion
|
|
||||||
* never creates them, rather than creating and immediately killing them.
|
|
||||||
*/
|
|
||||||
function ambient(target: object, vars: gsap.TweenVars) {
|
|
||||||
if (reduceMotion) return;
|
|
||||||
ambient(target, vars);
|
|
||||||
}
|
|
||||||
|
|
||||||
life.animate(() => {
|
|
||||||
const screenWidth = app.screen.width;
|
const screenWidth = app.screen.width;
|
||||||
const screenHeight = app.screen.height;
|
const screenHeight = app.screen.height;
|
||||||
const centerX = screenWidth / 2;
|
const centerX = screenWidth / 2;
|
||||||
@@ -99,7 +78,7 @@ export function createBackground(
|
|||||||
nebulaContainer.addChild(nebulaCloud);
|
nebulaContainer.addChild(nebulaCloud);
|
||||||
|
|
||||||
// Animate nebula pulsing
|
// Animate nebula pulsing
|
||||||
ambient(nebulaCloud, {
|
gsap.to(nebulaCloud, {
|
||||||
alpha: 0.6,
|
alpha: 0.6,
|
||||||
duration: 3 + Math.random() * 2,
|
duration: 3 + Math.random() * 2,
|
||||||
ease: 'sine.inOut',
|
ease: 'sine.inOut',
|
||||||
@@ -108,7 +87,7 @@ export function createBackground(
|
|||||||
delay: Math.random() * 2
|
delay: Math.random() * 2
|
||||||
});
|
});
|
||||||
|
|
||||||
ambient(nebulaCloud.scale, {
|
gsap.to(nebulaCloud.scale, {
|
||||||
x: 1.1,
|
x: 1.1,
|
||||||
y: 1.1,
|
y: 1.1,
|
||||||
duration: 4 + Math.random() * 2,
|
duration: 4 + Math.random() * 2,
|
||||||
@@ -133,7 +112,7 @@ export function createBackground(
|
|||||||
star.y = Math.random() * screenHeight;
|
star.y = Math.random() * screenHeight;
|
||||||
starContainer.addChild(star);
|
starContainer.addChild(star);
|
||||||
|
|
||||||
ambient(star, {
|
gsap.to(star, {
|
||||||
alpha: alpha * 0.3,
|
alpha: alpha * 0.3,
|
||||||
duration: 1 + Math.random() * 2,
|
duration: 1 + Math.random() * 2,
|
||||||
ease: 'sine.inOut',
|
ease: 'sine.inOut',
|
||||||
@@ -160,7 +139,7 @@ export function createBackground(
|
|||||||
star.y = Math.random() * screenHeight;
|
star.y = Math.random() * screenHeight;
|
||||||
starContainer.addChild(star);
|
starContainer.addChild(star);
|
||||||
|
|
||||||
ambient(star, {
|
gsap.to(star, {
|
||||||
alpha: alpha * 0.4,
|
alpha: alpha * 0.4,
|
||||||
duration: 0.5 + Math.random() * 1.5,
|
duration: 0.5 + Math.random() * 1.5,
|
||||||
ease: 'sine.inOut',
|
ease: 'sine.inOut',
|
||||||
@@ -185,7 +164,7 @@ export function createBackground(
|
|||||||
star.y = Math.random() * screenHeight;
|
star.y = Math.random() * screenHeight;
|
||||||
starContainer.addChild(star);
|
starContainer.addChild(star);
|
||||||
|
|
||||||
ambient(star.scale, {
|
gsap.to(star.scale, {
|
||||||
x: 1.3,
|
x: 1.3,
|
||||||
y: 1.3,
|
y: 1.3,
|
||||||
duration: 2 + Math.random() * 2,
|
duration: 2 + Math.random() * 2,
|
||||||
@@ -220,7 +199,7 @@ export function createBackground(
|
|||||||
vy: (Math.random() - 0.5) * 0.2
|
vy: (Math.random() - 0.5) * 0.2
|
||||||
});
|
});
|
||||||
|
|
||||||
ambient(dust, {
|
gsap.to(dust, {
|
||||||
alpha: 0.3,
|
alpha: 0.3,
|
||||||
duration: 3 + Math.random() * 2,
|
duration: 3 + Math.random() * 2,
|
||||||
ease: 'sine.inOut',
|
ease: 'sine.inOut',
|
||||||
@@ -230,30 +209,21 @@ export function createBackground(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animate dust particles drifting. Named function + indexed loop: `forEach`
|
// Animate dust particles drifting
|
||||||
// with an inline arrow allocated a fresh closure on every frame.
|
app.ticker.add(() => {
|
||||||
function driftDust() {
|
dustParticles.forEach((p) => {
|
||||||
for (let i = 0; i < dustParticles.length; i++) {
|
p.graphics.x += p.vx;
|
||||||
const p = dustParticles[i];
|
p.graphics.y += p.vy;
|
||||||
const g = p.graphics;
|
|
||||||
g.x += p.vx;
|
|
||||||
g.y += p.vy;
|
|
||||||
|
|
||||||
if (g.x < -20) g.x = screenWidth + 20;
|
if (p.graphics.x < -20) p.graphics.x = screenWidth + 20;
|
||||||
else if (g.x > screenWidth + 20) g.x = -20;
|
if (p.graphics.x > screenWidth + 20) p.graphics.x = -20;
|
||||||
if (g.y < -20) g.y = screenHeight + 20;
|
if (p.graphics.y < -20) p.graphics.y = screenHeight + 20;
|
||||||
else if (g.y > screenHeight + 20) g.y = -20;
|
if (p.graphics.y > screenHeight + 20) p.graphics.y = -20;
|
||||||
}
|
});
|
||||||
}
|
});
|
||||||
// Drifting dust is continuous ambient motion — skip it entirely rather
|
|
||||||
// than run it slower.
|
|
||||||
if (!reduceMotion) {
|
|
||||||
app.ticker.add(driftDust);
|
|
||||||
life.add(() => app.ticker.remove(driftDust));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== VIGNETTE OVERLAY ==========
|
// ========== VIGNETTE OVERLAY ==========
|
||||||
vignette = new Graphics();
|
const vignette = new Graphics();
|
||||||
const vignetteSize = Math.max(screenWidth, screenHeight) * 1.2;
|
const vignetteSize = Math.max(screenWidth, screenHeight) * 1.2;
|
||||||
|
|
||||||
for (let i = 12; i >= 0; i--) {
|
for (let i = 12; i >= 0; i--) {
|
||||||
@@ -264,17 +234,5 @@ export function createBackground(
|
|||||||
}
|
}
|
||||||
app.stage.addChild(vignette);
|
app.stage.addChild(vignette);
|
||||||
|
|
||||||
// The gradient (16 fills) and vignette (13 fills) are near-full-screen and
|
return { deepBackgroundContainer, nebulaContainer, starContainer };
|
||||||
// never change — bake them to a texture instead of recompositing every frame.
|
|
||||||
deepBackgroundContainer.cacheAsTexture(true);
|
|
||||||
vignette.cacheAsTexture(true);
|
|
||||||
|
|
||||||
// Nothing in the background is clickable; skip it during hit-testing.
|
|
||||||
deepBackgroundContainer.interactiveChildren = false;
|
|
||||||
nebulaContainer.interactiveChildren = false;
|
|
||||||
starContainer.interactiveChildren = false;
|
|
||||||
vignette.eventMode = 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
return { deepBackgroundContainer, nebulaContainer, starContainer, vignette };
|
|
||||||
}
|
}
|
||||||
@@ -29,9 +29,6 @@ export interface SkillTreeController {
|
|||||||
initialTreeX: number;
|
initialTreeX: number;
|
||||||
initialTreeY: number;
|
initialTreeY: number;
|
||||||
endScale: number;
|
endScale: number;
|
||||||
|
|
||||||
/** Labels pinned on by the user ('L'), overriding the zoom threshold. */
|
|
||||||
labelsForced: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createController(
|
export function createController(
|
||||||
@@ -56,7 +53,6 @@ export function createController(
|
|||||||
isTouchZooming: false,
|
isTouchZooming: false,
|
||||||
initialTreeX: treeContainer.x,
|
initialTreeX: treeContainer.x,
|
||||||
initialTreeY: treeContainer.y,
|
initialTreeY: treeContainer.y,
|
||||||
endScale,
|
endScale
|
||||||
labelsForced: false
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+23
-310
@@ -4,110 +4,8 @@
|
|||||||
// handlers read/write it by reference. attach* functions return a cleanup that
|
// handlers read/write it by reference. attach* functions return a cleanup that
|
||||||
// removes the window-level listeners they added.
|
// removes the window-level listeners they added.
|
||||||
import { gsap } from 'gsap';
|
import { gsap } from 'gsap';
|
||||||
import { ZOOM, PARALLAX, PAN_SPEED, prefersReducedMotion, LABEL_ZOOM_THRESHOLD } from '$lib/config';
|
import { ZOOM, PARALLAX, PAN_SPEED } from '$lib/config';
|
||||||
import type { SkillTreeController } from './controller';
|
import type { SkillTreeController } from './controller';
|
||||||
import type { Lifecycle } from './lifecycle';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fade node labels in once the tree is zoomed in far enough to have room for
|
|
||||||
* them, and counter-scale so text stays a constant size on screen rather than
|
|
||||||
* ballooning with the zoom. Called on every zoom change (not per frame).
|
|
||||||
*/
|
|
||||||
/** Screen-space rectangle, used for label collision tests. */
|
|
||||||
interface Box {
|
|
||||||
left: number;
|
|
||||||
top: number;
|
|
||||||
right: number;
|
|
||||||
bottom: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function overlaps(a: Box, b: Box): boolean {
|
|
||||||
return !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bigger nodes win a contested label. */
|
|
||||||
const SIZE_PRIORITY: Record<string, number> = {
|
|
||||||
'x-large': 0,
|
|
||||||
large: 1,
|
|
||||||
medium: 2,
|
|
||||||
small: 3
|
|
||||||
};
|
|
||||||
|
|
||||||
export function updateLabels(ctrl: SkillTreeController) {
|
|
||||||
const { treeContainer, nodeObjects } = ctrl;
|
|
||||||
const scale = treeContainer.scale.x;
|
|
||||||
const visible = ctrl.labelsForced || scale >= LABEL_ZOOM_THRESHOLD;
|
|
||||||
const inverse = 1 / scale;
|
|
||||||
|
|
||||||
if (!visible) {
|
|
||||||
for (const obj of nodeObjects) if (obj.label) obj.label.visible = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every node's footprint on screen. A label may never cover a node other
|
|
||||||
// than its own.
|
|
||||||
const nodeBoxes = new Map<string, Box>();
|
|
||||||
for (const obj of nodeObjects) {
|
|
||||||
const g = obj.graphics;
|
|
||||||
const w = (g.width || 0) / 2;
|
|
||||||
const h = (g.height || 0) / 2;
|
|
||||||
const cx = treeContainer.x + obj.node.x * scale;
|
|
||||||
const cy = treeContainer.y + obj.node.y * scale;
|
|
||||||
nodeBoxes.set(obj.node.id, {
|
|
||||||
left: cx - w,
|
|
||||||
top: cy - h,
|
|
||||||
right: cx + w,
|
|
||||||
bottom: cy + h
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw labels biggest-node-first, and drop any that would collide with a
|
|
||||||
// label already placed or with someone else's node. In dense clusters this
|
|
||||||
// simply shows fewer labels until the user zooms further in.
|
|
||||||
const ordered = [...nodeObjects].sort(
|
|
||||||
(a, b) => (SIZE_PRIORITY[a.node.size] ?? 9) - (SIZE_PRIORITY[b.node.size] ?? 9)
|
|
||||||
);
|
|
||||||
|
|
||||||
const placed: Box[] = [];
|
|
||||||
const PAD = 2;
|
|
||||||
|
|
||||||
for (const obj of ordered) {
|
|
||||||
const label = obj.label;
|
|
||||||
if (!label) continue;
|
|
||||||
|
|
||||||
// Counter-scale so the text stays a constant size on screen instead of
|
|
||||||
// growing with the zoom. Alpha is deliberately NOT set here — it is owned
|
|
||||||
// by the entrance animation, so a label can't appear over a node that has
|
|
||||||
// not faded in yet.
|
|
||||||
label.scale.set(inverse);
|
|
||||||
|
|
||||||
const screenW = label.width * scale;
|
|
||||||
const screenH = label.height * scale;
|
|
||||||
const cx = treeContainer.x + label.x * scale;
|
|
||||||
const top = treeContainer.y + label.y * scale;
|
|
||||||
|
|
||||||
const box: Box = {
|
|
||||||
left: cx - screenW / 2 - PAD,
|
|
||||||
top: top - PAD,
|
|
||||||
right: cx + screenW / 2 + PAD,
|
|
||||||
bottom: top + screenH + PAD
|
|
||||||
};
|
|
||||||
|
|
||||||
let blocked = placed.some((other) => overlaps(box, other));
|
|
||||||
if (!blocked) {
|
|
||||||
for (const [id, nodeBox] of nodeBoxes) {
|
|
||||||
if (id === obj.node.id) continue;
|
|
||||||
if (overlaps(box, nodeBox)) {
|
|
||||||
blocked = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
label.visible = !blocked;
|
|
||||||
if (!blocked) placed.push(box);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Offset the background layers behind the tree to create parallax depth. */
|
/** Offset the background layers behind the tree to create parallax depth. */
|
||||||
export function updateParallax(ctrl: SkillTreeController) {
|
export function updateParallax(ctrl: SkillTreeController) {
|
||||||
@@ -127,31 +25,16 @@ export function updateParallax(ctrl: SkillTreeController) {
|
|||||||
|
|
||||||
interface PanZoomOpts {
|
interface PanZoomOpts {
|
||||||
onTooltipMove: (x: number, y: number) => void;
|
onTooltipMove: (x: number, y: number) => void;
|
||||||
/** Tooltip coords are only worth computing while a node is actually hovered. */
|
|
||||||
hasHover: () => boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PanZoomHandle {
|
/** Wire mouse/touch/wheel pan + zoom. Returns a cleanup for window listeners. */
|
||||||
/**
|
export function attachPanZoom(ctrl: SkillTreeController, opts: PanZoomOpts): () => void {
|
||||||
* Push the current pointer position to the tooltip immediately. Needed on
|
|
||||||
* hover-start: the move handler below only writes state while a node is
|
|
||||||
* hovered, so without this the first hover would render at stale coords.
|
|
||||||
*/
|
|
||||||
flushTooltip: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wire mouse/touch/wheel pan + zoom. Every listener is owned by `life`. */
|
|
||||||
export function attachPanZoom(
|
|
||||||
ctrl: SkillTreeController,
|
|
||||||
opts: PanZoomOpts,
|
|
||||||
life: Lifecycle
|
|
||||||
): PanZoomHandle {
|
|
||||||
const { app, treeContainer } = ctrl;
|
const { app, treeContainer } = ctrl;
|
||||||
const minZoom = ZOOM.min;
|
const minZoom = ZOOM.min;
|
||||||
const maxZoom = ZOOM.max;
|
const maxZoom = ZOOM.max;
|
||||||
|
|
||||||
// ===== MOUSE PANNING =====
|
// ===== MOUSE PANNING =====
|
||||||
life.listen(app.canvas, 'mousedown', (e: MouseEvent) => {
|
app.canvas.addEventListener('mousedown', (e: MouseEvent) => {
|
||||||
ctrl.isDragging = true;
|
ctrl.isDragging = true;
|
||||||
ctrl.hasMoved = false;
|
ctrl.hasMoved = false;
|
||||||
ctrl.dragStartX = e.clientX;
|
ctrl.dragStartX = e.clientX;
|
||||||
@@ -172,47 +55,23 @@ export function attachPanZoom(
|
|||||||
treeContainer.y = ctrl.containerStartY + dy;
|
treeContainer.y = ctrl.containerStartY + dy;
|
||||||
updateParallax(ctrl);
|
updateParallax(ctrl);
|
||||||
};
|
};
|
||||||
life.listen(window, 'mousemove', onWindowMouseMove);
|
window.addEventListener('mousemove', onWindowMouseMove);
|
||||||
|
|
||||||
const onWindowMouseUp = () => {
|
const onWindowMouseUp = () => {
|
||||||
ctrl.isDragging = false;
|
ctrl.isDragging = false;
|
||||||
app.canvas.style.cursor = 'grab';
|
app.canvas.style.cursor = 'grab';
|
||||||
};
|
};
|
||||||
life.listen(window, 'mouseup', onWindowMouseUp);
|
window.addEventListener('mouseup', onWindowMouseUp);
|
||||||
|
|
||||||
app.canvas.style.cursor = 'grab';
|
app.canvas.style.cursor = 'grab';
|
||||||
|
|
||||||
// Track mouse position for the tooltip.
|
// Track mouse position for tooltip
|
||||||
//
|
app.canvas.addEventListener('mousemove', (e: MouseEvent) => {
|
||||||
// Three things matter here. It is gated on `hasHover()` so moving the mouse
|
opts.onTooltipMove(e.clientX, e.clientY);
|
||||||
// across empty canvas costs nothing (this previously wrote Svelte state on
|
|
||||||
// every single mousemove, hovered or not). It coalesces to one write per
|
|
||||||
// frame instead of one per pointer event. And it listens on `window`, not the
|
|
||||||
// canvas, so coordinates keep updating when a drag leaves the canvas bounds.
|
|
||||||
let tooltipFrame = -1;
|
|
||||||
let tooltipPointerX = 0;
|
|
||||||
let tooltipPointerY = 0;
|
|
||||||
|
|
||||||
const flushTooltip = () => {
|
|
||||||
tooltipFrame = -1;
|
|
||||||
opts.onTooltipMove(tooltipPointerX, tooltipPointerY);
|
|
||||||
};
|
|
||||||
|
|
||||||
life.listen(window, 'mousemove', (e: MouseEvent) => {
|
|
||||||
// Raw coords are always tracked — two plain number writes, negligible.
|
|
||||||
// What was expensive, and is now gated, is writing Svelte state.
|
|
||||||
tooltipPointerX = e.clientX;
|
|
||||||
tooltipPointerY = e.clientY;
|
|
||||||
if (!opts.hasHover()) return;
|
|
||||||
if (tooltipFrame === -1) tooltipFrame = requestAnimationFrame(flushTooltip);
|
|
||||||
});
|
|
||||||
life.add(() => {
|
|
||||||
if (tooltipFrame !== -1) cancelAnimationFrame(tooltipFrame);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== TOUCH PANNING & PINCH ZOOM =====
|
// ===== TOUCH PANNING & PINCH ZOOM =====
|
||||||
life.listen(
|
app.canvas.addEventListener(
|
||||||
app.canvas,
|
|
||||||
'touchstart',
|
'touchstart',
|
||||||
(e: TouchEvent) => {
|
(e: TouchEvent) => {
|
||||||
if (e.touches.length === 1) {
|
if (e.touches.length === 1) {
|
||||||
@@ -235,8 +94,7 @@ export function attachPanZoom(
|
|||||||
{ passive: true }
|
{ passive: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
life.listen(
|
app.canvas.addEventListener(
|
||||||
app.canvas,
|
|
||||||
'touchmove',
|
'touchmove',
|
||||||
(e: TouchEvent) => {
|
(e: TouchEvent) => {
|
||||||
if (e.touches.length === 1 && ctrl.isDragging && !ctrl.isTouchZooming) {
|
if (e.touches.length === 1 && ctrl.isDragging && !ctrl.isTouchZooming) {
|
||||||
@@ -262,7 +120,6 @@ export function attachPanZoom(
|
|||||||
// Zoom centered on origin node (same as mouse wheel zoom)
|
// Zoom centered on origin node (same as mouse wheel zoom)
|
||||||
treeContainer.scale.set(newScale);
|
treeContainer.scale.set(newScale);
|
||||||
updateParallax(ctrl);
|
updateParallax(ctrl);
|
||||||
updateLabels(ctrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctrl.lastTouchDistance = distance;
|
ctrl.lastTouchDistance = distance;
|
||||||
@@ -272,8 +129,7 @@ export function attachPanZoom(
|
|||||||
{ passive: true }
|
{ passive: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
life.listen(
|
app.canvas.addEventListener(
|
||||||
app.canvas,
|
|
||||||
'touchend',
|
'touchend',
|
||||||
(e: TouchEvent) => {
|
(e: TouchEvent) => {
|
||||||
if (e.touches.length === 0) {
|
if (e.touches.length === 0) {
|
||||||
@@ -294,15 +150,7 @@ export function attachPanZoom(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ===== WHEEL ZOOM =====
|
// ===== WHEEL ZOOM =====
|
||||||
// Explicitly non-passive: this calls preventDefault(). It works by default on
|
app.canvas.addEventListener('wheel', (e: WheelEvent) => {
|
||||||
// a non-root element, but relying on that is an implicit dependency.
|
|
||||||
life.listen(
|
|
||||||
app.canvas,
|
|
||||||
'wheel',
|
|
||||||
(e: WheelEvent) => {
|
|
||||||
// Ctrl+wheel is the browser's page-zoom gesture (and trackpad pinch);
|
|
||||||
// swallowing it broke zooming for anyone who relies on it.
|
|
||||||
if (e.ctrlKey || e.metaKey) return;
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const oldScale = treeContainer.scale.x;
|
const oldScale = treeContainer.scale.x;
|
||||||
const zoomFactor = e.deltaY > 0 ? ZOOM.wheelOut : ZOOM.wheelIn;
|
const zoomFactor = e.deltaY > 0 ? ZOOM.wheelOut : ZOOM.wheelIn;
|
||||||
@@ -312,135 +160,28 @@ export function attachPanZoom(
|
|||||||
// (treeContainer.x, treeContainer.y), so scaling keeps it anchored.
|
// (treeContainer.x, treeContainer.y), so scaling keeps it anchored.
|
||||||
treeContainer.scale.set(newScale);
|
treeContainer.scale.set(newScale);
|
||||||
updateParallax(ctrl);
|
updateParallax(ctrl);
|
||||||
updateLabels(ctrl);
|
});
|
||||||
updateLabels(ctrl);
|
|
||||||
},
|
|
||||||
{ passive: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
return { flushTooltip };
|
return () => {
|
||||||
}
|
window.removeEventListener('mousemove', onWindowMouseMove);
|
||||||
|
window.removeEventListener('mouseup', onWindowMouseUp);
|
||||||
/**
|
|
||||||
* Centre the view on a node in tree-space. Used by the accessible tree
|
|
||||||
* navigator so keyboard focus and the canvas stay in sync.
|
|
||||||
*/
|
|
||||||
export function panToNode(
|
|
||||||
ctrl: SkillTreeController,
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
life: Lifecycle,
|
|
||||||
options: { scale?: number; duration?: number } = {}
|
|
||||||
) {
|
|
||||||
const { app, treeContainer } = ctrl;
|
|
||||||
const targetScale = options.scale ?? treeContainer.scale.x;
|
|
||||||
const targetX = app.screen.width / 2 - x * targetScale;
|
|
||||||
const targetY = app.screen.height / 2 - y * targetScale;
|
|
||||||
|
|
||||||
// Reduced motion jumps instead of flying — the objection is to the camera
|
|
||||||
// movement, not to being taken somewhere.
|
|
||||||
const duration = prefersReducedMotion() ? 0 : (options.duration ?? 0.4);
|
|
||||||
|
|
||||||
life.animate(() => {
|
|
||||||
const sync = () => {
|
|
||||||
updateParallax(ctrl);
|
|
||||||
updateLabels(ctrl);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
gsap.to(treeContainer, {
|
|
||||||
x: targetX,
|
|
||||||
y: targetY,
|
|
||||||
duration,
|
|
||||||
ease: 'power2.inOut',
|
|
||||||
overwrite: 'auto',
|
|
||||||
onUpdate: sync,
|
|
||||||
onComplete: () => {
|
|
||||||
// Re-baseline parallax so later panning stays consistent.
|
|
||||||
ctrl.initialTreeX = treeContainer.x;
|
|
||||||
ctrl.initialTreeY = treeContainer.y;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
gsap.to(treeContainer.scale, {
|
|
||||||
x: targetScale,
|
|
||||||
y: targetScale,
|
|
||||||
duration,
|
|
||||||
ease: 'power2.inOut',
|
|
||||||
overwrite: 'auto',
|
|
||||||
onUpdate: sync
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface KeyboardOpts {
|
interface KeyboardOpts {
|
||||||
onToggleStats: () => void;
|
onToggleStats: () => void;
|
||||||
/** Cmd/Ctrl-K — open the search palette. */
|
|
||||||
onOpenSearch: () => void;
|
|
||||||
isSearchOpen: () => boolean;
|
|
||||||
/** 'J' — start the guided career journey. */
|
|
||||||
onStartJourney: () => void;
|
|
||||||
isJourneyActive: () => boolean;
|
|
||||||
onCloseModal: () => void;
|
onCloseModal: () => void;
|
||||||
isModalVisible: () => boolean;
|
isModalVisible: () => boolean;
|
||||||
isStatsVisible: () => boolean;
|
isStatsVisible: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Narrow an event target to an Element, or null. */
|
/** Wire keyboard navigation (pan/zoom/reset/stats/escape). Returns cleanup. */
|
||||||
function target0(e: Event): Element | null {
|
export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts): () => void {
|
||||||
return e.target instanceof Element ? e.target : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wire keyboard navigation (pan/zoom/reset/stats/escape). Owned by `life`. */
|
|
||||||
export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, life: Lifecycle) {
|
|
||||||
const { app, treeContainer } = ctrl;
|
const { app, treeContainer } = ctrl;
|
||||||
const minZoom = ZOOM.min;
|
const minZoom = ZOOM.min;
|
||||||
const maxZoom = ZOOM.max;
|
const maxZoom = ZOOM.max;
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
// Don't hijack keys the browser or another widget owns.
|
|
||||||
//
|
|
||||||
// - Modifier combos belong to the browser (Ctrl+Plus/Minus zoom, etc).
|
|
||||||
// - Typing in a field must never pan the canvas.
|
|
||||||
// - Arrow keys inside a scrollable overlay (the 380px stats panel, the
|
|
||||||
// modal body) must scroll it, not pan the tree behind it. Previously
|
|
||||||
// every arrow press called preventDefault() unconditionally, so those
|
|
||||||
// regions could not be scrolled by keyboard at all.
|
|
||||||
// Cmd/Ctrl-K opens search. Checked before the modifier bail-out below,
|
|
||||||
// since it is itself a modifier combo.
|
|
||||||
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
|
|
||||||
e.preventDefault();
|
|
||||||
opts.onOpenSearch();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// While the palette or the guided journey is open, it owns the keyboard.
|
|
||||||
if (opts.isSearchOpen() || opts.isJourneyActive()) return;
|
|
||||||
|
|
||||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
|
||||||
|
|
||||||
// `instanceof Element`, not a truthiness check: a keydown dispatched on
|
|
||||||
// `window` has `window` as its target, which has no tagName/closest — and
|
|
||||||
// calling closest() on it throws, killing the whole handler (Escape
|
|
||||||
// included).
|
|
||||||
const target = e.target instanceof Element ? target0(e) : null;
|
|
||||||
if (target) {
|
|
||||||
const tag = target.tagName;
|
|
||||||
if (
|
|
||||||
tag === 'INPUT' ||
|
|
||||||
tag === 'TEXTAREA' ||
|
|
||||||
tag === 'SELECT' ||
|
|
||||||
(target as HTMLElement).isContentEditable ||
|
|
||||||
target.closest('#stats-panel, [role="dialog"]')
|
|
||||||
) {
|
|
||||||
// Escape still needs to work from inside those regions.
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
if (opts.isModalVisible()) opts.onCloseModal();
|
|
||||||
else if (opts.isStatsVisible()) opts.onToggleStats();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle stats panel with 'C' key
|
// Toggle stats panel with 'C' key
|
||||||
if (e.key === 'c' || e.key === 'C') {
|
if (e.key === 'c' || e.key === 'C') {
|
||||||
if (!opts.isModalVisible()) {
|
if (!opts.isModalVisible()) {
|
||||||
@@ -486,8 +227,6 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
|
|||||||
const newScale = Math.min(maxZoom, currentScale + zoomSpeed);
|
const newScale = Math.min(maxZoom, currentScale + zoomSpeed);
|
||||||
treeContainer.scale.set(newScale);
|
treeContainer.scale.set(newScale);
|
||||||
updateParallax(ctrl);
|
updateParallax(ctrl);
|
||||||
updateLabels(ctrl);
|
|
||||||
updateLabels(ctrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// - for zoom out
|
// - for zoom out
|
||||||
@@ -497,39 +236,17 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
|
|||||||
const newScale = Math.max(minZoom, currentScale - zoomSpeed);
|
const newScale = Math.max(minZoom, currentScale - zoomSpeed);
|
||||||
treeContainer.scale.set(newScale);
|
treeContainer.scale.set(newScale);
|
||||||
updateParallax(ctrl);
|
updateParallax(ctrl);
|
||||||
updateLabels(ctrl);
|
|
||||||
updateLabels(ctrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// J starts the guided career journey.
|
|
||||||
if (e.key === 'j' || e.key === 'J') {
|
|
||||||
e.preventDefault();
|
|
||||||
opts.onStartJourney();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// L forces labels on/off regardless of zoom.
|
|
||||||
if (e.key === 'l' || e.key === 'L') {
|
|
||||||
e.preventDefault();
|
|
||||||
ctrl.labelsForced = !ctrl.labelsForced;
|
|
||||||
updateLabels(ctrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// R to reset view to origin
|
// R to reset view to origin
|
||||||
if (e.key === 'r' || e.key === 'R') {
|
if (e.key === 'r' || e.key === 'R') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Re-enter the GSAP context: these are created per keypress.
|
|
||||||
life.animate(() => {
|
|
||||||
gsap.to(treeContainer, {
|
gsap.to(treeContainer, {
|
||||||
x: app.screen.width / 2,
|
x: app.screen.width / 2,
|
||||||
y: app.screen.height / 2,
|
y: app.screen.height / 2,
|
||||||
duration: 0.5,
|
duration: 0.5,
|
||||||
ease: 'power2.out',
|
ease: 'power2.out',
|
||||||
overwrite: 'auto',
|
onUpdate: () => updateParallax(ctrl),
|
||||||
onUpdate: () => {
|
|
||||||
updateParallax(ctrl);
|
|
||||||
updateLabels(ctrl);
|
|
||||||
},
|
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
// Update parallax baseline after reset
|
// Update parallax baseline after reset
|
||||||
ctrl.initialTreeX = treeContainer.x;
|
ctrl.initialTreeX = treeContainer.x;
|
||||||
@@ -541,15 +258,11 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
|
|||||||
y: ctrl.endScale,
|
y: ctrl.endScale,
|
||||||
duration: 0.5,
|
duration: 0.5,
|
||||||
ease: 'power2.out',
|
ease: 'power2.out',
|
||||||
overwrite: 'auto',
|
onUpdate: () => updateParallax(ctrl)
|
||||||
onUpdate: () => {
|
|
||||||
updateParallax(ctrl);
|
|
||||||
updateLabels(ctrl);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
life.listen(window, 'keydown', handleKeydown);
|
window.addEventListener('keydown', handleKeydown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeydown);
|
||||||
}
|
}
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
// Single owner for every resource the tree allocates, so one dispose() call
|
|
||||||
// releases all of it.
|
|
||||||
//
|
|
||||||
// Why this exists: the PixiJS layer creates ~480 infinite GSAP tweens, a ticker
|
|
||||||
// callback, and a dozen DOM listeners. None of them were ever released, and 112
|
|
||||||
// of the tweens are created inside an `onComplete` — so they don't exist yet at
|
|
||||||
// the moment a naive `killTweensOf` sweep would run.
|
|
||||||
//
|
|
||||||
// `gsap.context()` solves exactly that: GSAP records each animation's context on
|
|
||||||
// creation, and restores that context while invoking the animation's callbacks
|
|
||||||
// (see `_callback` in gsap-core). So a tween created inside a tracked tween's
|
|
||||||
// onComplete is tracked by the same context, and ctx.kill() gets all of them.
|
|
||||||
import { gsap } from 'gsap';
|
|
||||||
|
|
||||||
export function createLifecycle() {
|
|
||||||
const disposers: Array<() => void> = [];
|
|
||||||
const gsapCtx = gsap.context(() => {});
|
|
||||||
let isDisposed = false;
|
|
||||||
|
|
||||||
return {
|
|
||||||
/** True once dispose() has run — guards async work that resolves late. */
|
|
||||||
get disposed() {
|
|
||||||
return isDisposed;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run `fn` with GSAP's active context set, so every tween it creates is
|
|
||||||
* tracked for teardown. Returns whatever `fn` returns.
|
|
||||||
*/
|
|
||||||
animate<T>(fn: () => T): T {
|
|
||||||
return gsapCtx.add(fn) as T;
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Register an arbitrary teardown step. */
|
|
||||||
add(fn: () => void) {
|
|
||||||
disposers.push(fn);
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Add a DOM listener and register its removal in one call. */
|
|
||||||
listen<T extends EventTarget>(
|
|
||||||
target: T,
|
|
||||||
type: string,
|
|
||||||
handler: (event: never) => void,
|
|
||||||
options?: AddEventListenerOptions
|
|
||||||
) {
|
|
||||||
target.addEventListener(type, handler as EventListener, options);
|
|
||||||
disposers.push(() => target.removeEventListener(type, handler as EventListener, options));
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Release everything, newest first. `kill()` rather than `revert()`: we do
|
|
||||||
* not want GSAP writing properties back onto Pixi objects that are about
|
|
||||||
* to be destroyed.
|
|
||||||
*/
|
|
||||||
dispose() {
|
|
||||||
if (isDisposed) return;
|
|
||||||
isDisposed = true;
|
|
||||||
|
|
||||||
gsapCtx.kill();
|
|
||||||
|
|
||||||
for (let i = disposers.length - 1; i >= 0; i--) {
|
|
||||||
try {
|
|
||||||
disposers[i]();
|
|
||||||
} catch (err) {
|
|
||||||
// One failing teardown step must not strand the rest.
|
|
||||||
console.warn('Teardown step failed:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
disposers.length = 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Lifecycle = ReturnType<typeof createLifecycle>;
|
|
||||||
+15
-72
@@ -1,20 +1,18 @@
|
|||||||
// Tree node rendering: the origin profile node and regular icon nodes.
|
// Tree node rendering: the origin profile node and regular icon nodes.
|
||||||
// Nodes start non-interactive (eventMode 'none') until the entrance animation
|
// Nodes start non-interactive (eventMode 'none') until the entrance animation
|
||||||
// enables them; see animation.ts.
|
// enables them; see animation.ts.
|
||||||
import { Graphics, Container, Assets, Sprite, Text, TextStyle, type Texture } from 'pixi.js';
|
import { Graphics, Container, Assets, Sprite } from 'pixi.js';
|
||||||
import { gsap } from 'gsap';
|
import { gsap } from 'gsap';
|
||||||
import { treeNodes, type TreeNode } from '$lib/data';
|
import { treeNodes, type TreeNode } from '$lib/data';
|
||||||
import { categoryColors, categoryColorHex } from '$lib/theme';
|
import { categoryColors } from '$lib/theme';
|
||||||
import { getNodeRadius } from '$lib/utils/tree';
|
import { getNodeRadius } from '$lib/utils/tree';
|
||||||
import type { NodeObject } from './types';
|
import type { NodeObject } from './types';
|
||||||
import type { SkillTreeController } from './controller';
|
import type { SkillTreeController } from './controller';
|
||||||
import type { Lifecycle } from './lifecycle';
|
|
||||||
|
|
||||||
interface NodeOpts {
|
interface NodeOpts {
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
isTouchDevice: boolean;
|
isTouchDevice: boolean;
|
||||||
controller: SkillTreeController;
|
controller: SkillTreeController;
|
||||||
life: Lifecycle;
|
|
||||||
onOpen: (node: TreeNode) => void;
|
onOpen: (node: TreeNode) => void;
|
||||||
onHover: (node: TreeNode | null) => void;
|
onHover: (node: TreeNode | null) => void;
|
||||||
}
|
}
|
||||||
@@ -36,76 +34,24 @@ function attachNodeInteraction(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hover tweens are created per-event, long after setup, so each creation has
|
|
||||||
// to re-enter the GSAP context to stay killable. `overwrite: 'auto'` stops
|
|
||||||
// rapid in/out sweeps from stacking conflicting tweens on the same property.
|
|
||||||
target.on('pointerover', () => {
|
target.on('pointerover', () => {
|
||||||
opts.life.animate(() => {
|
gsap.to(target.scale, { x: 1.2, y: 1.2, duration: 0.2 });
|
||||||
gsap.to(target.scale, { x: 1.2, y: 1.2, duration: 0.2, overwrite: 'auto' });
|
gsap.to(glowGraphics, { alpha: 1.5, duration: 0.2 });
|
||||||
gsap.to(glowGraphics, { alpha: 1.5, duration: 0.2, overwrite: 'auto' });
|
|
||||||
});
|
|
||||||
if (!opts.isTouchDevice) opts.onHover(node);
|
if (!opts.isTouchDevice) opts.onHover(node);
|
||||||
});
|
});
|
||||||
|
|
||||||
target.on('pointerout', () => {
|
target.on('pointerout', () => {
|
||||||
opts.life.animate(() => {
|
gsap.to(target.scale, { x: 1, y: 1, duration: 0.2 });
|
||||||
gsap.to(target.scale, { x: 1, y: 1, duration: 0.2, overwrite: 'auto' });
|
gsap.to(glowGraphics, { alpha: 1, duration: 0.2 });
|
||||||
gsap.to(glowGraphics, { alpha: 1, duration: 0.2, overwrite: 'auto' });
|
|
||||||
});
|
|
||||||
if (!opts.isTouchDevice) opts.onHover(null);
|
if (!opts.isTouchDevice) opts.onHover(null);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* One shared TextStyle per category. Pixi rasterises Text to a texture, so
|
|
||||||
* reusing styles keeps that cheap; labels also start invisible and are only
|
|
||||||
* revealed above the zoom threshold.
|
|
||||||
*/
|
|
||||||
function makeLabel(node: TreeNode, radius: number, isMobile: boolean): Text {
|
|
||||||
const style = new TextStyle({
|
|
||||||
fontFamily: 'system-ui, sans-serif',
|
|
||||||
fontSize: isMobile ? 11 : 12,
|
|
||||||
fontWeight: '600',
|
|
||||||
fill: categoryColorHex[node.category],
|
|
||||||
align: 'center',
|
|
||||||
stroke: { color: '#05050a', width: 3, join: 'round' }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Long labels are the main source of collisions ("Data Structures &
|
|
||||||
// Algorithms" is nearly 3x a node's diameter). The full text is always
|
|
||||||
// available in the tooltip and modal.
|
|
||||||
const MAX_LABEL_CHARS = 18;
|
|
||||||
const text =
|
|
||||||
node.label.length > MAX_LABEL_CHARS
|
|
||||||
? `${node.label.slice(0, MAX_LABEL_CHARS - 1)}…`
|
|
||||||
: node.label;
|
|
||||||
|
|
||||||
const label = new Text({ text, style });
|
|
||||||
label.anchor.set(0.5, 0);
|
|
||||||
label.x = node.x;
|
|
||||||
label.y = node.y + radius + 6;
|
|
||||||
label.alpha = 0;
|
|
||||||
label.eventMode = 'none';
|
|
||||||
label.resolution = Math.min(window.devicePixelRatio || 1, 2);
|
|
||||||
return label;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createNodes(treeContainer: Container, opts: NodeOpts): Promise<NodeObject[]> {
|
export async function createNodes(treeContainer: Container, opts: NodeOpts): Promise<NodeObject[]> {
|
||||||
const { isMobile } = opts;
|
const { isMobile } = opts;
|
||||||
const nodeObjects: NodeObject[] = [];
|
const nodeObjects: NodeObject[] = [];
|
||||||
|
|
||||||
// Load every texture in ONE parallel batch. Previously each icon was awaited
|
const profileTexture = await Assets.load('/headshot.jpg');
|
||||||
// inside the loop below, which serialised ~56 round-trips before the tree
|
|
||||||
// could render or accept input.
|
|
||||||
const iconUrls = [...new Set(treeNodes.map((n) => n.icon).filter((s): s is string => !!s))];
|
|
||||||
|
|
||||||
const [profileTexture, iconTextures] = await Promise.all([
|
|
||||||
Assets.load<Texture>('/headshot.jpg'),
|
|
||||||
// Array form resolves to a url -> Texture map and fetches concurrently.
|
|
||||||
iconUrls.length
|
|
||||||
? Assets.load<Texture>(iconUrls)
|
|
||||||
: Promise.resolve({} as Record<string, Texture>)
|
|
||||||
]);
|
|
||||||
|
|
||||||
for (const node of treeNodes) {
|
for (const node of treeNodes) {
|
||||||
const radius = getNodeRadius(node.size, isMobile);
|
const radius = getNodeRadius(node.size, isMobile);
|
||||||
@@ -150,9 +96,7 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro
|
|||||||
attachNodeInteraction(profileContainer, glowGraphics, node, opts);
|
attachNodeInteraction(profileContainer, glowGraphics, node, opts);
|
||||||
|
|
||||||
treeContainer.addChild(profileContainer);
|
treeContainer.addChild(profileContainer);
|
||||||
const originLabel = makeLabel(node, radius, isMobile);
|
nodeObjects.push({ graphics: profileContainer, node, glowGraphics });
|
||||||
treeContainer.addChild(originLabel);
|
|
||||||
nodeObjects.push({ graphics: profileContainer, node, glowGraphics, label: originLabel });
|
|
||||||
} else {
|
} else {
|
||||||
const nodeContainer = new Container();
|
const nodeContainer = new Container();
|
||||||
nodeContainer.x = node.x;
|
nodeContainer.x = node.x;
|
||||||
@@ -163,11 +107,9 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro
|
|||||||
nodeGraphics.circle(0, 0, radius * 0.75).stroke({ width: 1, color, alpha: 0.2 });
|
nodeGraphics.circle(0, 0, radius * 0.75).stroke({ width: 1, color, alpha: 0.2 });
|
||||||
nodeContainer.addChild(nodeGraphics);
|
nodeContainer.addChild(nodeGraphics);
|
||||||
|
|
||||||
const iconTexture = node.icon ? iconTextures[node.icon] : undefined;
|
if (node.icon) {
|
||||||
if (node.icon && !iconTexture) {
|
try {
|
||||||
console.warn(`Could not load icon for ${node.id}:`, node.icon);
|
const iconTexture = await Assets.load(node.icon);
|
||||||
}
|
|
||||||
if (iconTexture) {
|
|
||||||
const iconSprite = new Sprite(iconTexture);
|
const iconSprite = new Sprite(iconTexture);
|
||||||
|
|
||||||
const iconSize = radius * 1.4;
|
const iconSize = radius * 1.4;
|
||||||
@@ -177,6 +119,9 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro
|
|||||||
iconSprite.tint = color;
|
iconSprite.tint = color;
|
||||||
|
|
||||||
nodeContainer.addChild(iconSprite);
|
nodeContainer.addChild(iconSprite);
|
||||||
|
} catch {
|
||||||
|
console.warn(`Could not load icon for ${node.id}:`, node.icon);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nodeContainer.alpha = 0;
|
nodeContainer.alpha = 0;
|
||||||
@@ -189,9 +134,7 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro
|
|||||||
attachNodeInteraction(nodeContainer, glowGraphics, node, opts);
|
attachNodeInteraction(nodeContainer, glowGraphics, node, opts);
|
||||||
|
|
||||||
treeContainer.addChild(nodeContainer);
|
treeContainer.addChild(nodeContainer);
|
||||||
const nodeLabel = makeLabel(node, radius, isMobile);
|
nodeObjects.push({ graphics: nodeContainer, node, glowGraphics });
|
||||||
treeContainer.addChild(nodeLabel);
|
|
||||||
nodeObjects.push({ graphics: nodeContainer, node, glowGraphics, label: nodeLabel });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Shared structural types for the PixiJS skill-tree rendering modules.
|
// Shared structural types for the PixiJS skill-tree rendering modules.
|
||||||
import type { Graphics, Container, Text } from 'pixi.js';
|
import type { Graphics, Container } from 'pixi.js';
|
||||||
import type { TreeNode } from '$lib/data';
|
import type { TreeNode } from '$lib/data';
|
||||||
|
|
||||||
/** A parent→child connection: glow + core line + animated plasma beam. */
|
/** A parent→child connection: glow + core line + animated plasma beam. */
|
||||||
@@ -17,15 +17,11 @@ export interface NodeObject {
|
|||||||
graphics: Graphics | Container;
|
graphics: Graphics | Container;
|
||||||
node: TreeNode;
|
node: TreeNode;
|
||||||
glowGraphics: Graphics;
|
glowGraphics: Graphics;
|
||||||
/** On-canvas label, shown above the zoom threshold. */
|
|
||||||
label?: Text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The parallax background layers plus the vignette overlay. */
|
/** The three parallax background layers (vignette lives on the stage). */
|
||||||
export interface BackgroundLayers {
|
export interface BackgroundLayers {
|
||||||
deepBackgroundContainer: Container;
|
deepBackgroundContainer: Container;
|
||||||
nebulaContainer: Container;
|
nebulaContainer: Container;
|
||||||
starContainer: Container;
|
starContainer: Container;
|
||||||
/** Static overlay on the stage; returned so a resize rebuild can destroy it. */
|
|
||||||
vignette: Graphics;
|
|
||||||
}
|
}
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import {
|
|
||||||
pathToRoot,
|
|
||||||
allocate,
|
|
||||||
deallocate,
|
|
||||||
isEdgeAllocated,
|
|
||||||
encodeBuild,
|
|
||||||
decodeBuild
|
|
||||||
} from './allocation';
|
|
||||||
import { treeNodes, type TreeNode } from '$lib/data';
|
|
||||||
|
|
||||||
const node = (id: string, parentId: string | null): TreeNode =>
|
|
||||||
({
|
|
||||||
id,
|
|
||||||
label: id,
|
|
||||||
description: '',
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
parentId,
|
|
||||||
category: 'skills',
|
|
||||||
size: 'small'
|
|
||||||
}) as TreeNode;
|
|
||||||
|
|
||||||
const chain = [node('root', null), node('mid', 'root'), node('leaf', 'mid')];
|
|
||||||
|
|
||||||
describe('pathToRoot', () => {
|
|
||||||
it('walks a leaf up to the root inclusive', () => {
|
|
||||||
expect(pathToRoot('leaf', chain)).toEqual(['leaf', 'mid', 'root']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns just the root for the root', () => {
|
|
||||||
expect(pathToRoot('root', chain)).toEqual(['root']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns nothing for an unknown id', () => {
|
|
||||||
expect(pathToRoot('nope', chain)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('terminates on a cyclic parent chain', () => {
|
|
||||||
const cyclic = [node('a', 'b'), node('b', 'a')];
|
|
||||||
expect(pathToRoot('a', cyclic).length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reaches the origin for every real node', () => {
|
|
||||||
for (const n of treeNodes) {
|
|
||||||
expect(pathToRoot(n.id).at(-1)).toBe('origin');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('allocate / deallocate', () => {
|
|
||||||
it('allocating a leaf brings its whole path', () => {
|
|
||||||
expect([...allocate([], 'leaf', chain)].sort()).toEqual(['leaf', 'mid', 'root']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is idempotent', () => {
|
|
||||||
const once = allocate([], 'leaf', chain);
|
|
||||||
expect([...allocate(once, 'leaf', chain)].sort()).toEqual([...once].sort());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deallocating removes descendants so nothing is orphaned', () => {
|
|
||||||
const full = allocate([], 'leaf', chain);
|
|
||||||
expect([...deallocate(full, 'mid', chain)]).toEqual(['root']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deallocating a leaf leaves its ancestors alone', () => {
|
|
||||||
const full = allocate([], 'leaf', chain);
|
|
||||||
expect([...deallocate(full, 'leaf', chain)].sort()).toEqual(['mid', 'root']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not mutate the input', () => {
|
|
||||||
const start = new Set(['root']);
|
|
||||||
allocate(start, 'leaf', chain);
|
|
||||||
expect([...start]).toEqual(['root']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isEdgeAllocated', () => {
|
|
||||||
it('is true only when both ends are allocated', () => {
|
|
||||||
const allocated = new Set(['root', 'mid']);
|
|
||||||
expect(isEdgeAllocated(allocated, chain[1])).toBe(true);
|
|
||||||
expect(isEdgeAllocated(allocated, chain[2])).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is false for a root node, which has no edge', () => {
|
|
||||||
expect(isEdgeAllocated(new Set(['root']), chain[0])).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('encodeBuild / decodeBuild', () => {
|
|
||||||
it('round-trips', () => {
|
|
||||||
const build = allocate([], 'lang-typescript');
|
|
||||||
expect(decodeBuild(encodeBuild(build))).toEqual(build);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is order-independent, so the same build is always the same link', () => {
|
|
||||||
expect(encodeBuild(['b', 'a', 'c'])).toBe(encodeBuild(['c', 'b', 'a']));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns an empty build for empty or missing input', () => {
|
|
||||||
expect(decodeBuild(null).size).toBe(0);
|
|
||||||
expect(decodeBuild('').size).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('discards ids that no longer exist', () => {
|
|
||||||
expect(decodeBuild('lang-typescript~was-deleted').has('was-deleted')).toBe(false);
|
|
||||||
expect(decodeBuild('lang-typescript~was-deleted').has('lang-typescript')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('repairs a hand-edited link that skips ancestors', () => {
|
|
||||||
// Only the leaf in the URL — the path must come back with it.
|
|
||||||
const decoded = decodeBuild('lang-typescript');
|
|
||||||
expect(decoded.has('origin')).toBe(true);
|
|
||||||
expect(decoded.has('languages')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
// ==========================================================================
|
|
||||||
// ALLOCATION — Path-of-Exile style node allocation. Pure; no DOM, no PixiJS.
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
import { treeNodes, type TreeNode } from '$lib/data';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every node from `id` up to the root, inclusive.
|
|
||||||
*
|
|
||||||
* Allocation is path-connected like the game it borrows from: you cannot
|
|
||||||
* light a leaf without the branch that reaches it. Cycle-safe — a malformed
|
|
||||||
* parent chain terminates rather than looping forever.
|
|
||||||
*/
|
|
||||||
export function pathToRoot(id: string, nodes: TreeNode[] = treeNodes): string[] {
|
|
||||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
||||||
const path: string[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
|
|
||||||
let current = byId.get(id);
|
|
||||||
while (current && !seen.has(current.id)) {
|
|
||||||
seen.add(current.id);
|
|
||||||
path.push(current.id);
|
|
||||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Allocate a node: adds it and its whole path to the root.
|
|
||||||
* Returns a new set — callers hold reactive state.
|
|
||||||
*/
|
|
||||||
export function allocate(allocated: Iterable<string>, id: string, nodes = treeNodes): Set<string> {
|
|
||||||
const next = new Set(allocated);
|
|
||||||
for (const nodeId of pathToRoot(id, nodes)) next.add(nodeId);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deallocate a node and everything downstream of it, since orphaned children
|
|
||||||
* would violate path-connectedness.
|
|
||||||
*/
|
|
||||||
export function deallocate(
|
|
||||||
allocated: Iterable<string>,
|
|
||||||
id: string,
|
|
||||||
nodes = treeNodes
|
|
||||||
): Set<string> {
|
|
||||||
const next = new Set(allocated);
|
|
||||||
|
|
||||||
// Collect descendants breadth-first.
|
|
||||||
const doomed = [id];
|
|
||||||
while (doomed.length) {
|
|
||||||
const current = doomed.pop() as string;
|
|
||||||
next.delete(current);
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.parentId === current && next.has(node.id)) doomed.push(node.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Is this edge (child → its parent) fully allocated? */
|
|
||||||
export function isEdgeAllocated(allocated: ReadonlySet<string>, node: TreeNode): boolean {
|
|
||||||
return !!node.parentId && allocated.has(node.id) && allocated.has(node.parentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encode for a shareable URL.
|
|
||||||
*
|
|
||||||
* Node ids rather than indices: a bitset would be shorter, but any edit to
|
|
||||||
* data.ts would silently re-point every previously shared link at the wrong
|
|
||||||
* nodes. Ids only break if an id itself is renamed, which is both rarer and
|
|
||||||
* fails loudly (the node is simply skipped).
|
|
||||||
*/
|
|
||||||
export function encodeBuild(allocated: Iterable<string>): string {
|
|
||||||
return [...allocated].sort().join('~');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Decode a ?build= value, discarding anything that no longer exists. */
|
|
||||||
export function decodeBuild(value: string | null, nodes: TreeNode[] = treeNodes): Set<string> {
|
|
||||||
if (!value) return new Set();
|
|
||||||
const valid = new Set(nodes.map((n) => n.id));
|
|
||||||
const result = new Set<string>();
|
|
||||||
|
|
||||||
for (const id of value.split('~')) {
|
|
||||||
if (!valid.has(id)) continue;
|
|
||||||
// Re-derive the path so a hand-edited link can't produce a broken build.
|
|
||||||
for (const nodeId of pathToRoot(id, nodes)) result.add(nodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { buildHierarchy, flattenVisible } from './tree';
|
|
||||||
import { treeNodes, type TreeNode } from '$lib/data';
|
|
||||||
|
|
||||||
const node = (id: string, parentId: string | null): TreeNode =>
|
|
||||||
({
|
|
||||||
id,
|
|
||||||
label: id,
|
|
||||||
description: '',
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
parentId,
|
|
||||||
category: 'skills',
|
|
||||||
size: 'small'
|
|
||||||
}) as TreeNode;
|
|
||||||
|
|
||||||
describe('buildHierarchy', () => {
|
|
||||||
it('nests children under their parent', () => {
|
|
||||||
const roots = buildHierarchy([node('a', null), node('b', 'a'), node('c', 'b')]);
|
|
||||||
expect(roots).toHaveLength(1);
|
|
||||||
expect(roots[0].node.id).toBe('a');
|
|
||||||
expect(roots[0].children[0].node.id).toBe('b');
|
|
||||||
expect(roots[0].children[0].children[0].node.id).toBe('c');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats a node with an unknown parent as a root rather than dropping it', () => {
|
|
||||||
const roots = buildHierarchy([node('a', null), node('orphan', 'missing')]);
|
|
||||||
expect(roots.map((r) => r.node.id).sort()).toEqual(['a', 'orphan']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('covers every real node exactly once', () => {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const walk = (branches: ReturnType<typeof buildHierarchy>) => {
|
|
||||||
for (const b of branches) {
|
|
||||||
expect(seen.has(b.node.id)).toBe(false);
|
|
||||||
seen.add(b.node.id);
|
|
||||||
walk(b.children);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
walk(buildHierarchy());
|
|
||||||
expect(seen.size).toBe(treeNodes.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('every parentId in the real data resolves to a real node', () => {
|
|
||||||
const ids = new Set(treeNodes.map((n) => n.id));
|
|
||||||
const dangling = treeNodes.filter((n) => n.parentId !== null && !ids.has(n.parentId));
|
|
||||||
expect(dangling.map((n) => n.id)).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('flattenVisible', () => {
|
|
||||||
it('only descends into expanded branches, and reports depth', () => {
|
|
||||||
const roots = buildHierarchy([node('a', null), node('b', 'a'), node('c', 'b')]);
|
|
||||||
expect(flattenVisible(roots, () => false).map((r) => r.branch.node.id)).toEqual(['a']);
|
|
||||||
expect(flattenVisible(roots, (id) => id === 'a').map((r) => r.branch.node.id)).toEqual([
|
|
||||||
'a',
|
|
||||||
'b'
|
|
||||||
]);
|
|
||||||
const all = flattenVisible(roots, () => true);
|
|
||||||
expect(all.map((r) => r.branch.node.id)).toEqual(['a', 'b', 'c']);
|
|
||||||
expect(all.map((r) => r.level)).toEqual([1, 2, 3]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { getJourneyStops, findStopIndex, slugify } from './journey';
|
|
||||||
|
|
||||||
const stops = getJourneyStops();
|
|
||||||
|
|
||||||
describe('slugify', () => {
|
|
||||||
it('normalises company names', () => {
|
|
||||||
expect(slugify('Bluewater Federal Solutions')).toBe('bluewater-federal-solutions');
|
|
||||||
expect(slugify('Insight Global')).toBe('insight-global');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('trims leading and trailing separators', () => {
|
|
||||||
expect(slugify(' Peraton! ')).toBe('peraton');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getJourneyStops', () => {
|
|
||||||
it('opens on the origin', () => {
|
|
||||||
expect(stops[0].slug).toBe('start');
|
|
||||||
expect(stops[0].node.category).toBe('origin');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('includes every real job and no container nodes', () => {
|
|
||||||
const jobs = stops.slice(1);
|
|
||||||
expect(jobs.length).toBeGreaterThanOrEqual(5);
|
|
||||||
expect(jobs.every((s) => s.node.category === 'experience')).toBe(true);
|
|
||||||
expect(jobs.every((s) => !!s.title)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('runs oldest-first so it reads as progression', () => {
|
|
||||||
const years = stops.slice(1).map((s) => Number(s.kicker.match(/\d{4}/)?.[0]));
|
|
||||||
const sorted = [...years].sort((a, b) => a - b);
|
|
||||||
expect(years).toEqual(sorted);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ends on the current role', () => {
|
|
||||||
expect(stops[stops.length - 1].kicker).toMatch(/Present$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('caps each stop at two points so the card stays readable', () => {
|
|
||||||
expect(stops.every((s) => s.points.length <= 2)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gives every stop a unique slug', () => {
|
|
||||||
const slugs = stops.map((s) => s.slug);
|
|
||||||
expect(new Set(slugs).size).toBe(slugs.length);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('findStopIndex', () => {
|
|
||||||
it('finds a stop by slug', () => {
|
|
||||||
expect(findStopIndex(stops, 'peraton')).toBeGreaterThan(0);
|
|
||||||
expect(stops[findStopIndex(stops, 'peraton')].title).toBe('Peraton');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is forgiving about case and punctuation', () => {
|
|
||||||
expect(findStopIndex(stops, 'Peraton')).toBe(findStopIndex(stops, 'peraton'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns -1 for an unknown or missing slug', () => {
|
|
||||||
expect(findStopIndex(stops, 'nope')).toBe(-1);
|
|
||||||
expect(findStopIndex(stops, null)).toBe(-1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
// ==========================================================================
|
|
||||||
// JOURNEY — the guided career narrative. Pure; derived from data.ts so the
|
|
||||||
// tour can never drift from the tree.
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
import { treeNodes, type TreeNode, type ExperienceNode, type OriginNode } from '$lib/data';
|
|
||||||
|
|
||||||
export interface JourneyStop {
|
|
||||||
/** Stable slug for deep links (?tour=peraton). */
|
|
||||||
slug: string;
|
|
||||||
node: TreeNode;
|
|
||||||
kicker: string;
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
points: string[];
|
|
||||||
/** Zoom to settle at for this stop. */
|
|
||||||
scale: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Bluewater Federal Solutions" -> "bluewater-federal-solutions" */
|
|
||||||
export function slugify(value: string): string {
|
|
||||||
return value
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The tour: an intro on the origin, then every real job oldest-first, so the
|
|
||||||
* story reads as progression rather than a résumé's reverse-chronology.
|
|
||||||
*/
|
|
||||||
export function getJourneyStops(nodes: TreeNode[] = treeNodes): JourneyStop[] {
|
|
||||||
const stops: JourneyStop[] = [];
|
|
||||||
|
|
||||||
const origin = nodes.find((n): n is OriginNode => n.category === 'origin');
|
|
||||||
if (origin) {
|
|
||||||
stops.push({
|
|
||||||
slug: 'start',
|
|
||||||
node: origin,
|
|
||||||
kicker: 'The short version',
|
|
||||||
title: origin.fullName ?? origin.label,
|
|
||||||
subtitle: origin.jobTitle,
|
|
||||||
points: origin.tagline ? [origin.tagline] : [],
|
|
||||||
scale: 1.1
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const jobs = nodes.filter(
|
|
||||||
(n): n is ExperienceNode => n.category === 'experience' && !!n.company && !!n.startDate
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const job of jobs) {
|
|
||||||
stops.push({
|
|
||||||
slug: slugify(job.company as string),
|
|
||||||
node: job,
|
|
||||||
kicker: `${job.startDate} – ${job.endDate || 'Present'}`,
|
|
||||||
title: job.company as string,
|
|
||||||
subtitle: job.jobTitle,
|
|
||||||
// Two at most: the card has to stay readable at a glance.
|
|
||||||
points: (job.highlights ?? []).slice(0, 2),
|
|
||||||
scale: 1.35
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return stops;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve a ?tour= value to a stop index. -1 when it matches nothing. */
|
|
||||||
export function findStopIndex(stops: JourneyStop[], slug: string | null): number {
|
|
||||||
if (!slug) return -1;
|
|
||||||
const wanted = slugify(slug);
|
|
||||||
return stops.findIndex((s) => s.slug === wanted || s.slug.startsWith(wanted));
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { searchNodes } from './search';
|
|
||||||
|
|
||||||
describe('searchNodes', () => {
|
|
||||||
it('returns nothing for an empty query', () => {
|
|
||||||
expect(searchNodes('')).toEqual([]);
|
|
||||||
expect(searchNodes(' ')).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ranks an exact label match first', () => {
|
|
||||||
expect(searchNodes('React')[0].node.label).toBe('React');
|
|
||||||
expect(searchNodes('Docker')[0].node.label).toBe('Docker');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is case-insensitive', () => {
|
|
||||||
expect(searchNodes('react')[0].node.id).toBe(searchNodes('REACT')[0].node.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('matches a subsequence, not just a substring', () => {
|
|
||||||
const ids = searchNodes('wbrtc').map((r) => r.node.id);
|
|
||||||
expect(ids).toContain('be-webrtc');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prefers the shorter label when both match a prefix', () => {
|
|
||||||
expect(searchNodes('git')[0].node.label).toBe('Git');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('finds projects by their tech stack', () => {
|
|
||||||
const results = searchNodes('Prisma');
|
|
||||||
expect(results.length).toBeGreaterThan(0);
|
|
||||||
expect(results.every((r) => r.node.category === 'projects')).toBe(true);
|
|
||||||
expect(results[0].context).toMatch(/^tech: /);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('finds experience by company name', () => {
|
|
||||||
const results = searchNodes('Peraton');
|
|
||||||
expect(results[0].node.category).toBe('experience');
|
|
||||||
expect(results[0].context).toBe('Peraton');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns nothing for a query that matches nothing', () => {
|
|
||||||
expect(searchNodes('zzzzqqqxyw')).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never returns duplicate nodes', () => {
|
|
||||||
const ids = searchNodes('e').map((r) => r.node.id);
|
|
||||||
expect(new Set(ids).size).toBe(ids.length);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
// ==========================================================================
|
|
||||||
// SEARCH — subsequence matching over the tree data. Pure, no DOM, no PixiJS.
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
import { treeNodes, type TreeNode } from '$lib/data';
|
|
||||||
|
|
||||||
export interface SearchResult {
|
|
||||||
node: TreeNode;
|
|
||||||
score: number;
|
|
||||||
/** Why it matched, for display ("React" vs "tech: React"). */
|
|
||||||
context?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Case-insensitive subsequence match: "wbrtc" matches "WebRTC".
|
|
||||||
*
|
|
||||||
* Returns a score where lower is better, or null for no match. Consecutive
|
|
||||||
* runs and an early first match are rewarded, so "react" ranks the React node
|
|
||||||
* above a project that merely lists React in its stack.
|
|
||||||
*/
|
|
||||||
function subsequenceScore(haystack: string, needle: string): number | null {
|
|
||||||
if (!needle) return 0;
|
|
||||||
const h = haystack.toLowerCase();
|
|
||||||
const n = needle.toLowerCase();
|
|
||||||
|
|
||||||
// Exact and prefix matches short-circuit to the best scores.
|
|
||||||
if (h === n) return -1000;
|
|
||||||
if (h.startsWith(n)) return -500 + h.length;
|
|
||||||
|
|
||||||
let score = 0;
|
|
||||||
let hi = 0;
|
|
||||||
let lastHit = -1;
|
|
||||||
for (let ni = 0; ni < n.length; ni++) {
|
|
||||||
const found = h.indexOf(n[ni], hi);
|
|
||||||
if (found === -1) return null;
|
|
||||||
// Penalise gaps; reward adjacency.
|
|
||||||
score += found === lastHit + 1 ? 0 : found - hi + 1;
|
|
||||||
lastHit = found;
|
|
||||||
hi = found + 1;
|
|
||||||
}
|
|
||||||
// Prefer shorter haystacks — "Git" should beat "GitHub Actions" for "git".
|
|
||||||
return score + h.length * 0.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Search every node by label, then category, then description/tech stack. */
|
|
||||||
export function searchNodes(query: string, nodes: TreeNode[] = treeNodes): SearchResult[] {
|
|
||||||
const q = query.trim();
|
|
||||||
if (!q) return [];
|
|
||||||
|
|
||||||
const results: SearchResult[] = [];
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
|
||||||
const labelScore = subsequenceScore(node.label, q);
|
|
||||||
if (labelScore !== null) {
|
|
||||||
results.push({ node, score: labelScore });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tech stack is a strong signal — searching "prisma" should surface the
|
|
||||||
// projects that use it even though no node is named Prisma.
|
|
||||||
if (node.category === 'projects' && node.techStack) {
|
|
||||||
const tech = node.techStack.find((t) => subsequenceScore(t, q) !== null);
|
|
||||||
if (tech) {
|
|
||||||
results.push({ node, score: 200, context: `tech: ${tech}` });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.category === 'experience' && node.company) {
|
|
||||||
const companyScore = subsequenceScore(node.company, q);
|
|
||||||
if (companyScore !== null) {
|
|
||||||
results.push({ node, score: companyScore + 100, context: node.company });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Descriptions use plain substring matching, not subsequence. Over long
|
|
||||||
// prose a subsequence match is almost always spurious — "Prisma" found a
|
|
||||||
// p...r...i...s...m...a spread across an unrelated sentence.
|
|
||||||
const haystack = node.description.toLowerCase();
|
|
||||||
const needle = q.toLowerCase();
|
|
||||||
const at = haystack.indexOf(needle);
|
|
||||||
if (at !== -1) {
|
|
||||||
results.push({
|
|
||||||
node,
|
|
||||||
score: 400 + at,
|
|
||||||
context: node.description.slice(Math.max(0, at - 20), at + 45).trim()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results.sort((a, b) => a.score - b.score);
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,7 @@ import {
|
|||||||
BRANCH_LABELS,
|
BRANCH_LABELS,
|
||||||
aggregateSkills,
|
aggregateSkills,
|
||||||
countByProficiency,
|
countByProficiency,
|
||||||
getPlayerClass,
|
getPlayerClass
|
||||||
calculatePlayerLevel
|
|
||||||
} from './stats';
|
} from './stats';
|
||||||
import { treeNodes } from '$lib/data';
|
import { treeNodes } from '$lib/data';
|
||||||
|
|
||||||
@@ -56,19 +55,3 @@ describe('getPlayerClass', () => {
|
|||||||
expect(getPlayerClass()).toBe(origin?.jobTitle);
|
expect(getPlayerClass()).toBe(origin?.jobTitle);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('calculatePlayerLevel', () => {
|
|
||||||
it('derives the level from the earliest experience start date', () => {
|
|
||||||
// exp-job1 starts October 2020. Previously this date was hardcoded in
|
|
||||||
// stats.ts as well, so the two could drift apart silently.
|
|
||||||
const earliest = new Date('October 1, 2020').getTime();
|
|
||||||
const expected = Math.floor((Date.now() - earliest) / (1000 * 60 * 60 * 24 * 365.25));
|
|
||||||
expect(calculatePlayerLevel()).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a plausible, non-negative level', () => {
|
|
||||||
const level = calculatePlayerLevel();
|
|
||||||
expect(level).toBeGreaterThanOrEqual(5);
|
|
||||||
expect(level).toBeLessThan(60);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+3
-25
@@ -2,7 +2,7 @@
|
|||||||
// STATS UTILS — pure aggregation for the RPG-style character panel.
|
// STATS UTILS — pure aggregation for the RPG-style character panel.
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
import { treeNodes, type SkillNode, type ExperienceNode } from '$lib/data';
|
import { treeNodes, type SkillNode } from '$lib/data';
|
||||||
|
|
||||||
/** Skill branch ids and their display labels (single source of truth). */
|
/** Skill branch ids and their display labels (single source of truth). */
|
||||||
export const BRANCH_IDS = [
|
export const BRANCH_IDS = [
|
||||||
@@ -29,31 +29,9 @@ export const PROFICIENCY_ORDER: Record<string, number> = {
|
|||||||
beginner: 3
|
beginner: 3
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Character "level" derived from years since the first job started. */
|
||||||
* Parse a "Month YYYY" string (the format used by every startDate in data.ts)
|
|
||||||
* into a timestamp. Built explicitly rather than handed straight to `new Date`:
|
|
||||||
* "October 2020" alone is not a format the spec requires engines to accept.
|
|
||||||
*/
|
|
||||||
function parseMonthYear(value: string): number | null {
|
|
||||||
const [month, year] = value.trim().split(/\s+/);
|
|
||||||
if (!month || !year) return null;
|
|
||||||
const parsed = new Date(`${month} 1, ${year}`).getTime();
|
|
||||||
return Number.isNaN(parsed) ? null : parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Character "level" derived from years since the first job started.
|
|
||||||
*
|
|
||||||
* The start date is read from the experience nodes rather than hardcoded — it
|
|
||||||
* previously duplicated exp-job1.startDate, so the two could silently drift.
|
|
||||||
*/
|
|
||||||
export function calculatePlayerLevel(): number {
|
export function calculatePlayerLevel(): number {
|
||||||
const starts = treeNodes
|
const earliestStart = new Date('2020-10-01'); // First job
|
||||||
.filter((n): n is ExperienceNode => n.category === 'experience' && !!n.startDate)
|
|
||||||
.map((n) => parseMonthYear(n.startDate as string))
|
|
||||||
.filter((t): t is number => t !== null);
|
|
||||||
|
|
||||||
const earliestStart = new Date(starts.length ? Math.min(...starts) : Date.now());
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const millisPerYear = 1000 * 60 * 60 * 24 * 365.25;
|
const millisPerYear = 1000 * 60 * 60 * 24 * 365.25;
|
||||||
const years = (now.getTime() - earliestStart.getTime()) / millisPerYear;
|
const years = (now.getTime() - earliestStart.getTime()) / millisPerYear;
|
||||||
|
|||||||
+1
-50
@@ -26,59 +26,10 @@ export function getDistanceFromOrigin(node: TreeNode): number {
|
|||||||
* years of experience (0.3–1.0); other categories use fixed tiers.
|
* years of experience (0.3–1.0); other categories use fixed tiers.
|
||||||
*/
|
*/
|
||||||
export function getBeamIntensity(node: TreeNode): number {
|
export function getBeamIntensity(node: TreeNode): number {
|
||||||
if (node.category === 'skills' && node.yearsOfExperience !== undefined) {
|
if (node.category === 'skills' && node.yearsOfExperience) {
|
||||||
return Math.min(0.3 + (node.yearsOfExperience / 10) * 0.7, 1.0);
|
return Math.min(0.3 + (node.yearsOfExperience / 10) * 0.7, 1.0);
|
||||||
}
|
}
|
||||||
if (node.category === 'experience') return 0.7;
|
if (node.category === 'experience') return 0.7;
|
||||||
if (node.category === 'origin') return 1.0;
|
if (node.category === 'origin') return 1.0;
|
||||||
return 0.5; // projects and education
|
return 0.5; // projects and education
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A node plus its children, for the accessible `role="tree"` navigator. */
|
|
||||||
export interface TreeBranch {
|
|
||||||
node: TreeNode;
|
|
||||||
children: TreeBranch[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the parent→child hierarchy implied by `parentId`.
|
|
||||||
*
|
|
||||||
* The canvas draws from the flat array, but an accessible tree widget needs
|
|
||||||
* real nesting to expose level/expanded state. Cycle-safe: any node whose
|
|
||||||
* parent chain doesn't terminate at a root is dropped rather than looping.
|
|
||||||
*/
|
|
||||||
export function buildHierarchy(nodes: TreeNode[] = treeNodes): TreeBranch[] {
|
|
||||||
const branches = new Map<string, TreeBranch>();
|
|
||||||
for (const node of nodes) branches.set(node.id, { node, children: [] });
|
|
||||||
|
|
||||||
const roots: TreeBranch[] = [];
|
|
||||||
for (const branch of branches.values()) {
|
|
||||||
const parentId = branch.node.parentId;
|
|
||||||
if (parentId === null) {
|
|
||||||
roots.push(branch);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const parent = branches.get(parentId);
|
|
||||||
// Unknown parent → treat as a root rather than silently dropping content.
|
|
||||||
if (!parent) roots.push(branch);
|
|
||||||
else parent.children.push(branch);
|
|
||||||
}
|
|
||||||
|
|
||||||
return roots;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Flatten a hierarchy into the currently-visible rows of a tree widget. */
|
|
||||||
export function flattenVisible(
|
|
||||||
branches: TreeBranch[],
|
|
||||||
isExpanded: (id: string) => boolean,
|
|
||||||
level = 1
|
|
||||||
): Array<{ branch: TreeBranch; level: number }> {
|
|
||||||
const rows: Array<{ branch: TreeBranch; level: number }> = [];
|
|
||||||
for (const branch of branches) {
|
|
||||||
rows.push({ branch, level });
|
|
||||||
if (branch.children.length && isExpanded(branch.node.id)) {
|
|
||||||
rows.push(...flattenVisible(branch.children, isExpanded, level + 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
+19
-138
@@ -1,8 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// Self-hosted display face. Ships two subsets with unicode-range, so only
|
|
||||||
// the 26KB latin file is fetched for this content; font-display: swap is
|
|
||||||
// already set by the package, so text never blocks on it.
|
|
||||||
import '@fontsource-variable/cinzel';
|
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import favicon from '$lib/assets/favicon.ico';
|
import favicon from '$lib/assets/favicon.ico';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
@@ -46,118 +42,38 @@
|
|||||||
// terminate this block early or trip the Svelte ESLint parser.
|
// terminate this block early or trip the Svelte ESLint parser.
|
||||||
const personLdScript = '<script type="application/ld+json">' + personJsonLd + '</' + 'script>';
|
const personLdScript = '<script type="application/ld+json">' + personJsonLd + '</' + 'script>';
|
||||||
|
|
||||||
// Set when a new worker is installed and waiting; drives the refresh prompt.
|
|
||||||
let updateReady = $state<(() => void) | null>(null);
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!('serviceWorker' in navigator)) return;
|
// Register service worker for PWA support
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
// Whether this page was already under a worker's control when it loaded.
|
|
||||||
// The worker calls clients.claim(), so a FIRST-EVER visit also fires
|
|
||||||
// controllerchange — reloading on that would give every new visitor a
|
|
||||||
// pointless full page reload. Only an actual hand-over should reload.
|
|
||||||
const hadController = !!navigator.serviceWorker.controller;
|
|
||||||
|
|
||||||
let reloading = false;
|
|
||||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
|
||||||
if (!hadController || reloading) return;
|
|
||||||
reloading = true;
|
|
||||||
location.reload();
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Offer the update, once, for whichever worker is waiting. */
|
|
||||||
const offer = (worker: ServiceWorker) => {
|
|
||||||
updateReady = () => {
|
|
||||||
updateReady = null; // don't leave the prompt up while we hand over
|
|
||||||
worker.postMessage({ type: 'SKIP_WAITING' });
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
// NOTE: no `type: 'module'`. SvelteKit/Vite bundles the worker down to a
|
|
||||||
// classic script with no imports, and registering it as a module made the
|
|
||||||
// browser install a fresh worker on EVERY register() call — i.e. an
|
|
||||||
// endless "new version available" prompt that reappeared after each
|
|
||||||
// refresh, forever.
|
|
||||||
.register('/service-worker.js')
|
.register('/service-worker.js')
|
||||||
.then((registration) => {
|
.then((registration) => {
|
||||||
// The old code's comment claimed updates were checked periodically, but
|
if (import.meta.env.DEV) {
|
||||||
// nothing ever called update(). Without this a long-lived tab only
|
console.log('✨ Service Worker registered successfully:', registration.scope);
|
||||||
// re-checks on navigation.
|
}
|
||||||
const hourly = setInterval(
|
|
||||||
() => {
|
|
||||||
registration.update().catch(() => {});
|
|
||||||
},
|
|
||||||
60 * 60 * 1000
|
|
||||||
);
|
|
||||||
window.addEventListener('pagehide', () => clearInterval(hourly));
|
|
||||||
|
|
||||||
// A worker may already be waiting from a previous visit. `updatefound`
|
|
||||||
// does not fire for that one, so without this check the update stays
|
|
||||||
// invisible and the visitor is stuck on the old version.
|
|
||||||
if (registration.waiting && hadController) offer(registration.waiting);
|
|
||||||
|
|
||||||
|
// Check for updates periodically
|
||||||
registration.addEventListener('updatefound', () => {
|
registration.addEventListener('updatefound', () => {
|
||||||
const newWorker = registration.installing;
|
const newWorker = registration.installing;
|
||||||
if (!newWorker) return;
|
if (newWorker) {
|
||||||
|
|
||||||
newWorker.addEventListener('statechange', () => {
|
newWorker.addEventListener('statechange', () => {
|
||||||
if (newWorker.state !== 'installed') return;
|
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
// No controller means this is the first install, not an update.
|
if (import.meta.env.DEV) {
|
||||||
if (!navigator.serviceWorker.controller) return;
|
console.log('🔄 New service worker available. Refresh to update.');
|
||||||
offer(newWorker);
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.warn('Service Worker registration failed:', error);
|
console.warn('Service Worker registration failed:', error);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<!--
|
|
||||||
With JS disabled the canvas never mounts and SeoContent stays clipped to
|
|
||||||
1x1px, so the page renders pure black. This reveals the text version with
|
|
||||||
no JS involved.
|
|
||||||
-->
|
|
||||||
<noscript>
|
|
||||||
<style>
|
|
||||||
.sr-only {
|
|
||||||
position: static !important;
|
|
||||||
width: auto !important;
|
|
||||||
height: auto !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
overflow: visible !important;
|
|
||||||
clip: auto !important;
|
|
||||||
white-space: normal !important;
|
|
||||||
max-width: 60rem;
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
color: #e8e8f0;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
overflow: auto !important;
|
|
||||||
}
|
|
||||||
.sr-only a {
|
|
||||||
color: #ffd700;
|
|
||||||
}
|
|
||||||
.sr-only h1,
|
|
||||||
.sr-only h2,
|
|
||||||
.sr-only h3 {
|
|
||||||
color: #ffd700;
|
|
||||||
margin: 1.25rem 0 0.5rem;
|
|
||||||
}
|
|
||||||
.sr-only ul {
|
|
||||||
padding-left: 1.25rem;
|
|
||||||
}
|
|
||||||
main {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</noscript>
|
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
|
|
||||||
@@ -184,7 +100,7 @@
|
|||||||
<link rel="canonical" href="https://tehriehldeal.com/" />
|
<link rel="canonical" href="https://tehriehldeal.com/" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Kevin Riehl — Lead Software Engineer with 5+ years on Defense Health Agency programs — WebRTC telehealth, real-time accessibility, and air-gapped deployment tooling. Explore his experience, projects, and skills in an interactive skill-tree portfolio."
|
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
|
<meta
|
||||||
name="keywords"
|
name="keywords"
|
||||||
@@ -196,11 +112,11 @@
|
|||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta
|
<meta
|
||||||
property="og:title"
|
property="og:title"
|
||||||
content="Kevin Riehl — Lead Software Engineer (React, Node.js, WebRTC)"
|
content="Kevin Riehl — Software Engineer (React, Node.js, TypeScript)"
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
property="og:description"
|
property="og:description"
|
||||||
content="Lead Software Engineer with 5+ years on Defense Health Agency programs — WebRTC telehealth, real-time accessibility, and air-gapped deployment tooling. Shown as an interactive Path of Exile-inspired skill tree."
|
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:url" content="https://tehriehldeal.com" />
|
||||||
<meta property="og:site_name" content="Atlas of Skills" />
|
<meta property="og:site_name" content="Atlas of Skills" />
|
||||||
@@ -215,11 +131,11 @@
|
|||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta
|
<meta
|
||||||
name="twitter:title"
|
name="twitter:title"
|
||||||
content="Kevin Riehl — Lead Software Engineer (React, Node.js, WebRTC)"
|
content="Kevin Riehl — Software Engineer (React, Node.js, TypeScript)"
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta
|
||||||
name="twitter:description"
|
name="twitter:description"
|
||||||
content="Lead Software Engineer with 5+ years on Defense Health Agency programs — WebRTC telehealth, real-time accessibility, and air-gapped deployment tooling. Shown as an interactive Path of Exile-inspired skill tree."
|
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" content="https://tehriehldeal.com/og-preview.jpg" />
|
||||||
<meta
|
<meta
|
||||||
@@ -234,38 +150,3 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|
||||||
{#if updateReady}
|
|
||||||
<div class="update-toast" role="status">
|
|
||||||
<span>A new version is available.</span>
|
|
||||||
<button onclick={() => updateReady?.()}>Refresh</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.update-toast {
|
|
||||||
position: fixed;
|
|
||||||
right: 1rem;
|
|
||||||
bottom: 1rem;
|
|
||||||
z-index: var(--z-update-toast);
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border: 1px solid var(--color-gold);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
|
|
||||||
color: #e8e8f0;
|
|
||||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.update-toast button {
|
|
||||||
padding: 0.35rem 0.75rem;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--color-gold);
|
|
||||||
color: #12121a;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
+3
-34
@@ -1,23 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SkillTree from '$lib/components/SkillTree.svelte';
|
import SkillTree from '$lib/components/SkillTree.svelte';
|
||||||
import SeoContent from '$lib/components/SeoContent.svelte';
|
import SeoContent from '$lib/components/SeoContent.svelte';
|
||||||
|
|
||||||
// If the canvas can't come up (no WebGL, asset failure), the text version
|
|
||||||
// stops being a hidden mirror and becomes the page.
|
|
||||||
let renderFailed = $state(false);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
|
||||||
First focusable element on the page: jumps straight to the keyboard tree,
|
|
||||||
which otherwise sits behind ~20 links in the text version.
|
|
||||||
-->
|
|
||||||
<a class="skip-link" href="#tree-nav-label">Skip to skill tree navigator</a>
|
|
||||||
|
|
||||||
<!-- Crawlable / screen-reader text version (visually hidden, prerendered). -->
|
<!-- Crawlable / screen-reader text version (visually hidden, prerendered). -->
|
||||||
<SeoContent revealed={renderFailed} />
|
<SeoContent />
|
||||||
|
|
||||||
<main class:hidden={renderFailed}>
|
<main>
|
||||||
<SkillTree onRenderFail={() => (renderFailed = true)} />
|
<SkillTree />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -28,25 +18,4 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
main.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skip-link {
|
|
||||||
position: absolute;
|
|
||||||
top: -100%;
|
|
||||||
left: 0.5rem;
|
|
||||||
z-index: 2000;
|
|
||||||
padding: 0.6rem 1rem;
|
|
||||||
border: 1px solid var(--color-gold);
|
|
||||||
border-radius: 0 0 8px 8px;
|
|
||||||
background: var(--panel-from);
|
|
||||||
color: var(--color-gold);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skip-link:focus-visible {
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
/// <reference types="@sveltejs/kit" />
|
|
||||||
|
|
||||||
// Lives in src/, not static/, so SvelteKit builds it and injects the manifest
|
|
||||||
// below. That matters: `build` and `files` are generated from the actual output,
|
|
||||||
// so the precache list can't drift, and `version` changes every deploy, so cache
|
|
||||||
// names rotate automatically instead of needing a hand-edited version string.
|
|
||||||
import { build, files, version } from '$service-worker';
|
|
||||||
|
|
||||||
const CACHE = `atlas-of-skills-${version}`;
|
|
||||||
|
|
||||||
// Content-hashed build output + everything in static/. Hashed assets are
|
|
||||||
// immutable, so once cached they never need revalidating.
|
|
||||||
const PRECACHE = [...build, ...files];
|
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
(async () => {
|
|
||||||
const cache = await caches.open(CACHE);
|
|
||||||
// Individually, not cache.addAll(): addAll is atomic, so a single 404
|
|
||||||
// failed the whole install and left the site with no offline support at
|
|
||||||
// all — silently.
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
PRECACHE.map((asset) => cache.add(new Request(asset, { cache: 'reload' })))
|
|
||||||
);
|
|
||||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
|
||||||
if (failed) {
|
|
||||||
console.warn(`[sw] ${failed}/${PRECACHE.length} assets failed to precache`);
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
(async () => {
|
|
||||||
// Drop every previous version's cache. Because the name embeds `version`,
|
|
||||||
// this now actually prunes on each deploy instead of accumulating stale
|
|
||||||
// hashed chunks forever.
|
|
||||||
const keys = await caches.keys();
|
|
||||||
await Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)));
|
|
||||||
await self.clients.claim();
|
|
||||||
})()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Assets emitted by the build are content-hashed, so cache-first is safe. */
|
|
||||||
const PRECACHED = new Set(PRECACHE);
|
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
|
||||||
const { request } = event;
|
|
||||||
|
|
||||||
// Never touch non-GET (cache.put throws on them) or cross-origin.
|
|
||||||
if (request.method !== 'GET') return;
|
|
||||||
const url = new URL(request.url);
|
|
||||||
if (url.origin !== self.location.origin) return;
|
|
||||||
|
|
||||||
event.respondWith(handle(event, url));
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handle(event, url) {
|
|
||||||
const { request } = event;
|
|
||||||
const cache = await caches.open(CACHE);
|
|
||||||
|
|
||||||
// Content-hashed build output: cache-first, it can never go stale.
|
|
||||||
if (PRECACHED.has(url.pathname) && build.includes(url.pathname)) {
|
|
||||||
const hit = await cache.match(url.pathname);
|
|
||||||
if (hit) return hit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Everything else — navigations and unhashed static files (icons, headshot,
|
|
||||||
// the résumé PDF) — is network-first. The old worker served these cache-first
|
|
||||||
// with no expiry, which meant replacing the résumé at the same URL never
|
|
||||||
// reached anyone who had already visited.
|
|
||||||
try {
|
|
||||||
const response = await fetch(request);
|
|
||||||
|
|
||||||
// Only cache real successes. A 404/500 body used to get written and then
|
|
||||||
// served as the offline fallback. Opaque/redirected responses throw on
|
|
||||||
// cache.put, so they're excluded too.
|
|
||||||
if (response.status === 200 && response.type === 'basic' && !response.redirected) {
|
|
||||||
// waitUntil so the write survives worker termination.
|
|
||||||
event.waitUntil(cache.put(request, response.clone()));
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
} catch {
|
|
||||||
const hit = await cache.match(request);
|
|
||||||
if (hit) return hit;
|
|
||||||
|
|
||||||
// Navigations fall back to the cached shell.
|
|
||||||
if (request.mode === 'navigate') {
|
|
||||||
const shell = await cache.match('/');
|
|
||||||
if (shell) return shell;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every other failure must still resolve to a Response. Returning
|
|
||||||
// undefined here (the previous behaviour for non-image assets) makes
|
|
||||||
// respondWith throw a TypeError and turns a soft failure into a hard one.
|
|
||||||
if (request.destination === 'image') {
|
|
||||||
return new Response(
|
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120">' +
|
|
||||||
'<rect width="120" height="120" fill="#05050a"/>' +
|
|
||||||
'<text x="60" y="64" fill="#ffd700" font-family="sans-serif" font-size="12" text-anchor="middle">Offline</text>' +
|
|
||||||
'</svg>',
|
|
||||||
{ status: 503, headers: { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-store' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response('Offline', {
|
|
||||||
status: 503,
|
|
||||||
headers: { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The page posts this after the user accepts an update prompt. Deliberately no
|
|
||||||
// skipWaiting() in install: taking over a page whose JS came from the previous
|
|
||||||
// deploy is how you get version skew.
|
|
||||||
self.addEventListener('message', (event) => {
|
|
||||||
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
|
|
||||||
});
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.9 MiB |
@@ -0,0 +1,116 @@
|
|||||||
|
// Service Worker for PWA offline support
|
||||||
|
const CACHE_NAME = 'atlas-of-skills-v2';
|
||||||
|
const RUNTIME_CACHE = 'atlas-runtime-v2';
|
||||||
|
|
||||||
|
// Assets to cache on install
|
||||||
|
const PRECACHE_ASSETS = [
|
||||||
|
'/',
|
||||||
|
'/headshot.jpg',
|
||||||
|
'/kevin-riehl-resume.pdf',
|
||||||
|
'/robots.txt',
|
||||||
|
// Add icon paths when they're created
|
||||||
|
'/icons/pwa.svg'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install event - precache critical assets
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME)
|
||||||
|
.then((cache) => cache.addAll(PRECACHE_ASSETS))
|
||||||
|
.then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate event - clean up old caches
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys()
|
||||||
|
.then((cacheNames) => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames
|
||||||
|
.filter((name) => name !== CACHE_NAME && name !== RUNTIME_CACHE)
|
||||||
|
.map((name) => caches.delete(name))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch event - network first, fallback to cache
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
// Skip cross-origin requests
|
||||||
|
if (!event.request.url.startsWith(self.location.origin)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network first strategy for HTML
|
||||||
|
if (event.request.mode === 'navigate') {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
// Clone response to store in cache
|
||||||
|
const responseClone = response.clone();
|
||||||
|
caches.open(RUNTIME_CACHE)
|
||||||
|
.then((cache) => cache.put(event.request, responseClone));
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Fallback to cache if network fails
|
||||||
|
return caches.match(event.request)
|
||||||
|
.then((cachedResponse) => {
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
// Return offline page if available
|
||||||
|
return caches.match('/');
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache first strategy for assets (CSS, JS, images, icons)
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request)
|
||||||
|
.then((cachedResponse) => {
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not in cache, fetch from network
|
||||||
|
return fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
// Don't cache if not a success response
|
||||||
|
if (!response || response.status !== 200 || response.type === 'error') {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone response to store in cache
|
||||||
|
const responseClone = response.clone();
|
||||||
|
|
||||||
|
caches.open(RUNTIME_CACHE)
|
||||||
|
.then((cache) => {
|
||||||
|
cache.put(event.request, responseClone);
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Return fallback for images if network and cache fail
|
||||||
|
if (event.request.destination === 'image') {
|
||||||
|
return new Response(
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><rect fill="#05050a"/><text x="50%" y="50%" text-anchor="middle" fill="#ffd700">Offline</text></svg>',
|
||||||
|
{ headers: { 'Content-Type': 'image/svg+xml' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Message event - allow clients to skip waiting
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
});
|
||||||
+1
-1
@@ -10,7 +10,7 @@ const config = {
|
|||||||
pages: 'build',
|
pages: 'build',
|
||||||
assets: 'build',
|
assets: 'build',
|
||||||
fallback: null,
|
fallback: null,
|
||||||
precompress: true,
|
precompress: false,
|
||||||
strict: true
|
strict: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user