feat: audit phase 6 (T2) — on-canvas labels and Cmd/Ctrl-K search
Even with the HUD from T1, the tree itself was 57 wordless icons: no way to
answer "does he know NestJS?" without clicking around.
Search palette (Cmd/Ctrl-K):
- New utils/search.ts — pure, 9 unit tests. Subsequence matching on short
fields ("wbrtc" finds WebRTC), plus tech-stack and company matching so
"Prisma" surfaces the projects using it and "Peraton" finds the role.
- Descriptions deliberately use substring, NOT subsequence matching. Over
long prose a subsequence match is almost always spurious — the first
version matched "Prisma" as p...r...i...s...m...a spread across an
unrelated sentence, which a test caught.
- Selecting a result flies the camera to that node and opens its modal.
- Built to the ARIA combobox pattern: input keeps focus and owns
aria-activedescendant, results are a listbox. Options intentionally have no
keyboard handler — making them focusable would break the pattern — so the
svelte-ignore there is documented rather than incidental.
On-canvas labels:
- A Text per node, hidden below LABEL_ZOOM_THRESHOLD and counter-scaled by
1/zoom so they stay a constant size on screen rather than ballooning.
- Threshold sits just above the settled desktop zoom, so the default view
stays a clean constellation and zooming in reveals labels. 'L' forces them
on at any zoom.
- Long labels truncate at 18 chars; full text is still in the tooltip/modal.
- Collision culling: labels are placed biggest-node-first, and any whose box
would overlap an already-placed label or someone else's node is dropped.
Dense clusters simply show fewer labels until you zoom further in. This is
purely presentational — no node coordinates changed.
Three bugs found during review:
- The entrance zoom tween only called updateParallax, so the label
counter-scale stayed frozen at its pre-zoom value (labels ended up at 1/4
size and effectively vanished). It now updates labels too, as does the
skip/reduced-motion path that sets scale directly.
- updateLabels forced alpha = 1, so labels appeared over nodes that had not
faded in yet — visible during the splash screen. Alpha now belongs to the
entrance and labels arrive with their node.
- animation.ts imported updateParallax only; it needed updateLabels as well.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d8297cf903
commit
ea721ae650
10 files changed
+656
-14
No files matched your search
@@ -5,7 +5,7 @@ import { Container } from 'pixi.js';
|
||||
import { gsap } from 'gsap';
|
||||
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 { Lifecycle } from './lifecycle';
|
||||
|
||||
@@ -36,11 +36,13 @@ export function runEntranceAnimations(
|
||||
/** Put the tree straight into its settled, interactive state. */
|
||||
function showFinishedTree() {
|
||||
treeContainer.scale.set(endScale);
|
||||
updateLabels(ctrl);
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -136,6 +138,14 @@ export function runEntranceAnimations(
|
||||
delay: originDelay,
|
||||
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
|
||||
@@ -147,7 +157,10 @@ export function runEntranceAnimations(
|
||||
duration: zoomDuration,
|
||||
delay: zoomDelay,
|
||||
ease: 'power1.out',
|
||||
onUpdate: () => updateParallax(ctrl)
|
||||
onUpdate: () => {
|
||||
updateParallax(ctrl);
|
||||
updateLabels(ctrl);
|
||||
}
|
||||
});
|
||||
|
||||
// STEP 3: Animate in the rest of the nodes (excluding origin)
|
||||
@@ -182,6 +195,12 @@ export function runEntranceAnimations(
|
||||
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
|
||||
gsap.to(obj.glowGraphics.scale, {
|
||||
x: 1.4,
|
||||
|
||||
@@ -29,6 +29,9 @@ export interface SkillTreeController {
|
||||
initialTreeX: number;
|
||||
initialTreeY: number;
|
||||
endScale: number;
|
||||
|
||||
/** Labels pinned on by the user ('L'), overriding the zoom threshold. */
|
||||
labelsForced: boolean;
|
||||
}
|
||||
|
||||
export function createController(
|
||||
@@ -53,6 +56,7 @@ export function createController(
|
||||
isTouchZooming: false,
|
||||
initialTreeX: treeContainer.x,
|
||||
initialTreeY: treeContainer.y,
|
||||
endScale
|
||||
endScale,
|
||||
labelsForced: false
|
||||
};
|
||||
}
|
||||
+142
-4
@@ -4,10 +4,111 @@
|
||||
// handlers read/write it by reference. attach* functions return a cleanup that
|
||||
// removes the window-level listeners they added.
|
||||
import { gsap } from 'gsap';
|
||||
import { ZOOM, PARALLAX, PAN_SPEED, prefersReducedMotion } from '$lib/config';
|
||||
import { ZOOM, PARALLAX, PAN_SPEED, prefersReducedMotion, LABEL_ZOOM_THRESHOLD } from '$lib/config';
|
||||
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. */
|
||||
export function updateParallax(ctrl: SkillTreeController) {
|
||||
const treeDeltaX = ctrl.treeContainer.x - ctrl.initialTreeX;
|
||||
@@ -161,6 +262,7 @@ export function attachPanZoom(
|
||||
// Zoom centered on origin node (same as mouse wheel zoom)
|
||||
treeContainer.scale.set(newScale);
|
||||
updateParallax(ctrl);
|
||||
updateLabels(ctrl);
|
||||
}
|
||||
|
||||
ctrl.lastTouchDistance = distance;
|
||||
@@ -210,6 +312,8 @@ export function attachPanZoom(
|
||||
// (treeContainer.x, treeContainer.y), so scaling keeps it anchored.
|
||||
treeContainer.scale.set(newScale);
|
||||
updateParallax(ctrl);
|
||||
updateLabels(ctrl);
|
||||
updateLabels(ctrl);
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
@@ -234,13 +338,19 @@ export function panToNode(ctrl: SkillTreeController, x: number, y: number, life:
|
||||
duration: prefersReducedMotion() ? 0 : 0.4,
|
||||
ease: 'power2.out',
|
||||
overwrite: 'auto',
|
||||
onUpdate: () => updateParallax(ctrl)
|
||||
onUpdate: () => {
|
||||
updateParallax(ctrl);
|
||||
updateLabels(ctrl);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface KeyboardOpts {
|
||||
onToggleStats: () => void;
|
||||
/** Cmd/Ctrl-K — open the search palette. */
|
||||
onOpenSearch: () => void;
|
||||
isSearchOpen: () => boolean;
|
||||
onCloseModal: () => void;
|
||||
isModalVisible: () => boolean;
|
||||
isStatsVisible: () => boolean;
|
||||
@@ -266,6 +376,17 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
|
||||
// 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 is open it owns the keyboard entirely.
|
||||
if (opts.isSearchOpen()) return;
|
||||
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
|
||||
// `instanceof Element`, not a truthiness check: a keydown dispatched on
|
||||
@@ -336,6 +457,8 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
|
||||
const newScale = Math.min(maxZoom, currentScale + zoomSpeed);
|
||||
treeContainer.scale.set(newScale);
|
||||
updateParallax(ctrl);
|
||||
updateLabels(ctrl);
|
||||
updateLabels(ctrl);
|
||||
}
|
||||
|
||||
// - for zoom out
|
||||
@@ -345,6 +468,15 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
|
||||
const newScale = Math.max(minZoom, currentScale - zoomSpeed);
|
||||
treeContainer.scale.set(newScale);
|
||||
updateParallax(ctrl);
|
||||
updateLabels(ctrl);
|
||||
updateLabels(ctrl);
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -358,7 +490,10 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
|
||||
duration: 0.5,
|
||||
ease: 'power2.out',
|
||||
overwrite: 'auto',
|
||||
onUpdate: () => updateParallax(ctrl),
|
||||
onUpdate: () => {
|
||||
updateParallax(ctrl);
|
||||
updateLabels(ctrl);
|
||||
},
|
||||
onComplete: () => {
|
||||
// Update parallax baseline after reset
|
||||
ctrl.initialTreeX = treeContainer.x;
|
||||
@@ -371,7 +506,10 @@ export function attachKeyboard(ctrl: SkillTreeController, opts: KeyboardOpts, li
|
||||
duration: 0.5,
|
||||
ease: 'power2.out',
|
||||
overwrite: 'auto',
|
||||
onUpdate: () => updateParallax(ctrl)
|
||||
onUpdate: () => {
|
||||
updateParallax(ctrl);
|
||||
updateLabels(ctrl);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+42
-4
@@ -1,10 +1,10 @@
|
||||
// Tree node rendering: the origin profile node and regular icon nodes.
|
||||
// Nodes start non-interactive (eventMode 'none') until the entrance animation
|
||||
// enables them; see animation.ts.
|
||||
import { Graphics, Container, Assets, Sprite, type Texture } from 'pixi.js';
|
||||
import { Graphics, Container, Assets, Sprite, Text, TextStyle, type Texture } from 'pixi.js';
|
||||
import { gsap } from 'gsap';
|
||||
import { treeNodes, type TreeNode } from '$lib/data';
|
||||
import { categoryColors } from '$lib/theme';
|
||||
import { categoryColors, categoryColorHex } from '$lib/theme';
|
||||
import { getNodeRadius } from '$lib/utils/tree';
|
||||
import type { NodeObject } from './types';
|
||||
import type { SkillTreeController } from './controller';
|
||||
@@ -56,6 +56,40 @@ function attachNodeInteraction(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[]> {
|
||||
const { isMobile } = opts;
|
||||
const nodeObjects: NodeObject[] = [];
|
||||
@@ -116,7 +150,9 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro
|
||||
attachNodeInteraction(profileContainer, glowGraphics, node, opts);
|
||||
|
||||
treeContainer.addChild(profileContainer);
|
||||
nodeObjects.push({ graphics: profileContainer, node, glowGraphics });
|
||||
const originLabel = makeLabel(node, radius, isMobile);
|
||||
treeContainer.addChild(originLabel);
|
||||
nodeObjects.push({ graphics: profileContainer, node, glowGraphics, label: originLabel });
|
||||
} else {
|
||||
const nodeContainer = new Container();
|
||||
nodeContainer.x = node.x;
|
||||
@@ -153,7 +189,9 @@ export async function createNodes(treeContainer: Container, opts: NodeOpts): Pro
|
||||
attachNodeInteraction(nodeContainer, glowGraphics, node, opts);
|
||||
|
||||
treeContainer.addChild(nodeContainer);
|
||||
nodeObjects.push({ graphics: nodeContainer, node, glowGraphics });
|
||||
const nodeLabel = makeLabel(node, radius, isMobile);
|
||||
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.
|
||||
import type { Graphics, Container } from 'pixi.js';
|
||||
import type { Graphics, Container, Text } from 'pixi.js';
|
||||
import type { TreeNode } from '$lib/data';
|
||||
|
||||
/** A parent→child connection: glow + core line + animated plasma beam. */
|
||||
@@ -17,6 +17,8 @@ export interface NodeObject {
|
||||
graphics: Graphics | Container;
|
||||
node: TreeNode;
|
||||
glowGraphics: Graphics;
|
||||
/** On-canvas label, shown above the zoom threshold. */
|
||||
label?: Text;
|
||||
}
|
||||
|
||||
/** The parallax background layers plus the vignette overlay. */
|
||||
|
||||
Reference in new issue
Block a user