{#if node.icon}
@@ -275,6 +293,29 @@
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;
}
diff --git a/src/lib/components/SkillTree.svelte b/src/lib/components/SkillTree.svelte
index a0d6d59..9ec3a7c 100644
--- a/src/lib/components/SkillTree.svelte
+++ b/src/lib/components/SkillTree.svelte
@@ -14,6 +14,9 @@
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 {
@@ -55,6 +58,34 @@
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();
+
+ 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 {
+ 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;
@@ -362,18 +393,43 @@
// mount instead would flag someone who bounced after a second as
// having seen it, and they'd never get it.
markVisited();
-
- // ?tour=peraton deep-links straight into that stop of the journey.
- const tourParam = new URLSearchParams(window.location.search).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);
- }
}, 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.
@@ -391,6 +447,13 @@
return () => life.dispose();
});
+ // Repaint allocation styling whenever the build changes.
+ $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).
@@ -445,6 +508,8 @@
startJourney(0)} />
+
+
{/if}
diff --git a/src/lib/pixi/allocation.ts b/src/lib/pixi/allocation.ts
new file mode 100644
index 0000000..7ee8b34
--- /dev/null
+++ b/src/lib/pixi/allocation.ts
@@ -0,0 +1,40 @@
+// 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) {
+ 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;
+ }
+}
diff --git a/src/lib/utils/allocation.test.ts b/src/lib/utils/allocation.test.ts
new file mode 100644
index 0000000..6f917ab
--- /dev/null
+++ b/src/lib/utils/allocation.test.ts
@@ -0,0 +1,116 @@
+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);
+ });
+});
diff --git a/src/lib/utils/allocation.ts b/src/lib/utils/allocation.ts
new file mode 100644
index 0000000..e9011f5
--- /dev/null
+++ b/src/lib/utils/allocation.ts
@@ -0,0 +1,93 @@
+// ==========================================================================
+// 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();
+
+ 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, id: string, nodes = treeNodes): Set {
+ 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,
+ id: string,
+ nodes = treeNodes
+): Set {
+ 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, 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 {
+ return [...allocated].sort().join('~');
+}
+
+/** Decode a ?build= value, discarding anything that no longer exists. */
+export function decodeBuild(value: string | null, nodes: TreeNode[] = treeNodes): Set {
+ if (!value) return new Set();
+ const valid = new Set(nodes.map((n) => n.id));
+ const result = new Set();
+
+ 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;
+}