@poe2-toolkit/tree-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +451 -0
  3. package/dist/geometry/centre.d.ts +17 -0
  4. package/dist/geometry/centre.d.ts.map +1 -0
  5. package/dist/geometry/centre.js +59 -0
  6. package/dist/geometry/centre.js.map +1 -0
  7. package/dist/geometry/framing.d.ts +27 -0
  8. package/dist/geometry/framing.d.ts.map +1 -0
  9. package/dist/geometry/framing.js +101 -0
  10. package/dist/geometry/framing.js.map +1 -0
  11. package/dist/geometry/orbit.d.ts +23 -0
  12. package/dist/geometry/orbit.d.ts.map +1 -0
  13. package/dist/geometry/orbit.js +28 -0
  14. package/dist/geometry/orbit.js.map +1 -0
  15. package/dist/geometry/project.d.ts +20 -0
  16. package/dist/geometry/project.d.ts.map +1 -0
  17. package/dist/geometry/project.js +149 -0
  18. package/dist/geometry/project.js.map +1 -0
  19. package/dist/ggg/normalize.d.ts +109 -0
  20. package/dist/ggg/normalize.d.ts.map +1 -0
  21. package/dist/ggg/normalize.js +279 -0
  22. package/dist/ggg/normalize.js.map +1 -0
  23. package/dist/index.d.ts +25 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +24 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/scene/allocate.d.ts +61 -0
  28. package/dist/scene/allocate.d.ts.map +1 -0
  29. package/dist/scene/allocate.js +239 -0
  30. package/dist/scene/allocate.js.map +1 -0
  31. package/dist/scene/buildScene.d.ts +21 -0
  32. package/dist/scene/buildScene.d.ts.map +1 -0
  33. package/dist/scene/buildScene.js +212 -0
  34. package/dist/scene/buildScene.js.map +1 -0
  35. package/dist/scene/connections.d.ts +13 -0
  36. package/dist/scene/connections.d.ts.map +1 -0
  37. package/dist/scene/connections.js +75 -0
  38. package/dist/scene/connections.js.map +1 -0
  39. package/dist/scene/nodeSize.d.ts +26 -0
  40. package/dist/scene/nodeSize.d.ts.map +1 -0
  41. package/dist/scene/nodeSize.js +72 -0
  42. package/dist/scene/nodeSize.js.map +1 -0
  43. package/dist/types.d.ts +426 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +16 -0
  46. package/dist/types.js.map +1 -0
  47. package/package.json +50 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vladislav Rajtmajer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,451 @@
1
+ # @poe2-toolkit/tree-core
2
+
3
+ [![npm](https://img.shields.io/npm/v/@poe2-toolkit/tree-core.svg)](https://www.npmjs.com/package/@poe2-toolkit/tree-core)
4
+ [![types included](https://img.shields.io/badge/types-included-blue.svg)](#)
5
+ [![zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen.svg)](#)
6
+ [![ESM only](https://img.shields.io/badge/module-ESM-f7df1e.svg)](#)
7
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
8
+
9
+ Headless, framework-agnostic geometry engine for the **Path of Exile 2 passive
10
+ tree**. Feed it the official tree data and a build's allocation; get back a fully
11
+ positioned, correctly sized `Scene` that any renderer can draw.
12
+
13
+ Pure TypeScript, zero runtime dependencies, no DOM, no canvas, no framework.
14
+
15
+ > **Live demo:** this engine drives the passive tree at
16
+ > [poe.rajtik.com/tree](https://poe.rajtik.com/tree) (rendered via
17
+ > [`@poe2-toolkit/tree-react`](../poe2-tree-react)).
18
+
19
+ ```ts
20
+ import { buildScene, project } from '@poe2-toolkit/tree-core';
21
+ import { normalizeGggTree } from '@poe2-toolkit/tree-core/ggg'; // data adapter, opt-in
22
+
23
+ const data = normalizeGggTree(rawDataJson, '0_5');
24
+ const scene = buildScene(data, { allocation });
25
+ const screen = project(scene, viewport, { width: 1280, height: 720 });
26
+ // `screen` is pixel-space: walk the arrays and blit. No math left for the view.
27
+ ```
28
+
29
+ The engine entry point is **source-agnostic** — it works against the `TreeData`
30
+ contract and never imports anything GGG-specific. Turning a particular export
31
+ into `TreeData` is a separate, swappable adapter; the one for GGG's official
32
+ `data.json` lives in the `@poe2-toolkit/tree-core/ggg` subpath.
33
+
34
+ ## Contents
35
+
36
+ - [Why](#why)
37
+ - [Install](#install)
38
+ - [How it works](#how-it-works)
39
+ - [Quick start](#quick-start)
40
+ - [The data model (`TreeData`)](#the-data-model-treedata)
41
+ - [The output (`Scene`)](#the-output-scene)
42
+ - [Coordinate spaces and projection](#coordinate-spaces-and-projection)
43
+ - [Build allocation](#build-allocation)
44
+ - [Interactive editing](#interactive-editing)
45
+ - [Graphics: the sprite manifest](#graphics-the-sprite-manifest)
46
+ - [Geometry rules](#geometry-rules)
47
+ - [What gets filtered out](#what-gets-filtered-out)
48
+ - [API reference](#api-reference)
49
+ - [Design principles](#design-principles)
50
+ - [License and credits](#license-and-credits)
51
+
52
+ ## Why
53
+
54
+ Existing PoE2 tree renderers ship as whole applications, not reusable libraries,
55
+ and they tend to hard-code node sizes and positions as magic constants. The goal
56
+ here is the opposite: **from the input data alone, the engine knows exactly where
57
+ everything is and how big it is.** Sizes, connections, and the central hub
58
+ geometry are derived from the source data, never hand-tuned. "Looks like the
59
+ game" becomes a property of the data, not of each author's eyeballing.
60
+
61
+ The package is deliberately split into two halves so the geometry can be reused
62
+ anywhere:
63
+
64
+ - **`@poe2-toolkit/tree-core`** (this package) does all the math and owns no pixels.
65
+ - A thin view adapter (e.g. `@poe2-toolkit/tree-react`) draws what core computed and owns
66
+ no math. The same `Scene` contract is open to Vue, Svelte, or any other view.
67
+
68
+ ## Install
69
+
70
+ ```sh
71
+ npm install @poe2-toolkit/tree-core
72
+ ```
73
+
74
+ ESM only. Node 18+. Ships its own `.d.ts`.
75
+
76
+ ## How it works
77
+
78
+ The engine is a small pipeline. Each stage has one job and a plain data contract
79
+ between it and the next:
80
+
81
+ ```
82
+ your data ──adapter──▶ TreeData ──buildScene──▶ Scene ──project──▶ ScreenScene
83
+ (source) (world) (pixels)
84
+ ```
85
+
86
+ 1. **Adapter** — turn a source export into the clean
87
+ [`TreeData`](#the-data-model-treedata) contract. The engine ships one adapter,
88
+ `normalizeGggTree(raw, version)` from `@poe2-toolkit/tree-core/ggg`, for GGG's official
89
+ `data.json`. It is the only code that knows GGG's field names and quirks, and
90
+ it is tolerant by design: optional fields come and go across patches, and
91
+ missing data never throws. To support another source, write another adapter
92
+ that returns `TreeData` — nothing downstream changes.
93
+
94
+ 2. **`buildScene(data, { allocation })`** assembles a render-ready
95
+ [`Scene`](#the-output-scene): every node positioned and sized in world space,
96
+ every edge resolved to a line or an arc, every effect pattern placed, the
97
+ central hub laid out, and each node marked allocated or not for the given
98
+ build. Nothing geometric is left for the renderer.
99
+
100
+ 3. **`project(scene, viewport, size)`** maps the world-space `Scene` to a
101
+ `ScreenScene` of pixel coordinates, culled to the viewport. The renderer walks
102
+ the resulting arrays and blits, with no `* scale` or `+ offset` of its own.
103
+
104
+ `nodeAt`, `toggleAllocation`, `allocatedBounds`, and friends sit alongside the
105
+ pipeline to cover what an interactive UI needs (hit-testing, click-to-allocate,
106
+ view framing).
107
+
108
+ ## Quick start
109
+
110
+ ```ts
111
+ import { buildScene, project, nodeAt, toggleAllocation } from '@poe2-toolkit/tree-core';
112
+ import { normalizeGggTree } from '@poe2-toolkit/tree-core/ggg';
113
+
114
+ // 1. Normalize the official export once (cache the result per tree version).
115
+ const data = normalizeGggTree(rawDataJson, '0_5');
116
+
117
+ // 2. Build a scene for the current allocation. Omit `allocation` for an
118
+ // unallocated tree.
119
+ const allocation = { classId: 1, ascendId: 'Lich', allocated: [/* skill ids */] };
120
+ const scene = buildScene(data, { allocation });
121
+
122
+ // 3. Project to the viewport, then draw `screen.nodes` / `.connections` /
123
+ // `.masteryEffects` however you like.
124
+ const viewport = { tx: 640, ty: 360, scale: 0.1 };
125
+ const screen = project(scene, viewport, { width: 1280, height: 720 });
126
+
127
+ // 4. Hit-test a click and toggle that node in a manual build.
128
+ const hit = nodeAt(scene, viewport, mouseX, mouseY);
129
+ if (hit !== null) {
130
+ const next = toggleAllocation(data, data.classes[1].startNode, new Set(allocation.allocated), hit);
131
+ // re-run buildScene with the new `next` array.
132
+ }
133
+ ```
134
+
135
+ ## The data model (`TreeData`)
136
+
137
+ `TreeData` is the normalized source shape. Everything downstream reads from it;
138
+ nothing downstream knows about GGG's raw field names.
139
+
140
+ ```ts
141
+ interface TreeData {
142
+ version: string; // tree/patch version, e.g. "0_5"
143
+ constants: { centreInnerRadius: number };
144
+ groups: Record<number, Group>; // orbit clusters, keyed by group id
145
+ nodes: Record<number, TreeNode>; // every node, keyed by skill id
146
+ classes: ClassDef[]; // central art + ascendancy metadata per class
147
+ jewelSlots: number[]; // skill ids that are jewel sockets
148
+ bounds: WorldRect; // world extent of the whole tree
149
+ }
150
+ ```
151
+
152
+ A `TreeNode` carries baked world coordinates plus the flags and metadata the
153
+ engine needs:
154
+
155
+ ```ts
156
+ interface TreeNode {
157
+ skill: number;
158
+ group: number; // direct index into `groups`
159
+ orbit: number;
160
+ orbitIndex: number;
161
+ x: number; y: number; // world position, baked by GGG's export
162
+ connections: NodeConnection[]; // neighbours (in + out, merged)
163
+ name: string;
164
+ icon: string; // atlas key into the sprite manifest
165
+ stats: string[];
166
+
167
+ // kind flags (optional; absence = plain small node)
168
+ isNotable?: boolean;
169
+ isKeystone?: boolean;
170
+ isJewelSocket?: boolean;
171
+ isMastery?: boolean;
172
+ isAscendancyStart?: boolean;
173
+ isAttribute?: boolean;
174
+ noRadius?: boolean; // hidden special socket (Sinister sockets)
175
+
176
+ // metadata
177
+ activeEffectImage?: string; // background pattern key (notables + masteries)
178
+ ascendancyName?: string; // e.g. "Deadeye"
179
+ flavourText?: string; // keystone lore
180
+ options?: NodeOption[]; // attribute nodes: Str / Dex / Int choices
181
+ conditional?: boolean; // hidden unless unlocked (e.g. by ascendancy)
182
+ unlockAscendancy?: string;
183
+ classesStart?: string[]; // class-start node: which classes start here
184
+ }
185
+ ```
186
+
187
+ The source of all this is GGG's official skill-tree export. Two things matter for
188
+ understanding the shape:
189
+
190
+ - **Positions are baked.** GGG ships each node's world `x`/`y` directly and omits
191
+ the orbit-radius constants needed to recompute them, so the engine reads the
192
+ coordinates as-is. `orbit`/`orbitIndex` are kept for reference but positions do
193
+ not depend on them.
194
+ - **Sizes are derived.** GGG ships no per-node sizes, so the engine derives them
195
+ from a fixed size per node type (see [Geometry rules](#geometry-rules)).
196
+
197
+ ## The output (`Scene`)
198
+
199
+ `buildScene` returns a `Scene`: everything positioned and sized in world space,
200
+ ready to project and draw.
201
+
202
+ ```ts
203
+ interface Scene {
204
+ nodes: PlacedNode[]; // positioned + sized + allocation state
205
+ connections: PlacedConnection[]; // each resolved to a line or an arc
206
+ masteryEffects: PlacedEffect[]; // background patterns (notables + masteries)
207
+ centre: CentreLayout; // hub geometry + per-class ring rotation
208
+ bounds: WorldRect; // whole tree, including far ascendancy discs
209
+ mainBounds: WorldRect; // main tree only (use this to fit the view)
210
+ }
211
+ ```
212
+
213
+ Each `PlacedNode` carries its world centre, icon and frame diameters, a hit-test
214
+ radius, its `kind` (`normal`, `notable`, `keystone`, `mastery`, `jewel`,
215
+ `attribute`, `classStart`, `ascendancyStart`, `ascendancyNormal`,
216
+ `ascendancyNotable`), whether it is allocated, and its owning ascendancy if any.
217
+
218
+ > Use `mainBounds`, not `bounds`, to frame the initial view. Ascendancy discs sit
219
+ > thousands of world units out from the main tree, so fitting `bounds` would
220
+ > leave the main tree a tiny speck in the middle.
221
+
222
+ ## Coordinate spaces and projection
223
+
224
+ There are two spaces, and the boundary between them is `project`:
225
+
226
+ - **World space** — the tree's own coordinate system, as it comes out of
227
+ `buildScene`. Stable, view-independent.
228
+ - **Screen space** — pixels, after applying a `Viewport`
229
+ (`screen = world * scale + (tx, ty)`).
230
+
231
+ ```ts
232
+ const screen = project(scene, viewport, { width, height });
233
+ // screen.nodes / .connections / .masteryEffects are in pixels and culled
234
+ // to the viewport. screen.scale is provided for line widths and LOD.
235
+ ```
236
+
237
+ Going the other way:
238
+
239
+ - `projectPoint(viewport, point)` — world point to pixels.
240
+ - `screenToWorld(viewport, sx, sy)` — pixels to a world point.
241
+ - `nodeAt(scene, viewport, sx, sy)` — the skill id under a pixel, or `null`
242
+ (topmost node whose footprint contains the point). Masteries and ascendancy
243
+ nodes are excluded from hit-testing.
244
+
245
+ `project` culls to the viewport and drops nodes whose projected radius is below
246
+ a fraction of a pixel (a cheap level-of-detail pass), so it stays fast even fully
247
+ zoomed out.
248
+
249
+ ## Build allocation
250
+
251
+ A character's tree state is the only build input the engine needs. Whatever
252
+ produces it (a Path of Building export decoder, the GGG OAuth API, a manual
253
+ editor) is none of the engine's concern. Gems and items do not affect the tree
254
+ and are deliberately absent.
255
+
256
+ ```ts
257
+ interface BuildAllocation {
258
+ classId?: number; // matches ClassDef.id; only the editable planner needs it (class start node for pathing)
259
+ ascendId?: string; // matches AscendancyDef.id, e.g. "Lich"
260
+ allocated: number[]; // allocated skill ids
261
+ attributeChoices?: Record<number, 'str' | 'dex' | 'int'>;
262
+ jewels?: Record<number, JewelInfo>; // display-only, keyed by socket node id
263
+ treeVersion?: string;
264
+ }
265
+ ```
266
+
267
+ `attributeChoices` lets a generic `+attribute` node render its chosen Str/Dex/Int
268
+ icon and stat instead of the generic "any". `jewels` is display-only metadata
269
+ keyed by socket node id; the engine never applies a jewel's radius to nearby
270
+ nodes (PoE2 jewels grant global stats).
271
+
272
+ ## Interactive editing
273
+
274
+ For a build editor, the allocation helpers turn clicks into a new allocated set.
275
+ They are pure graph functions, free of any rendering concern.
276
+
277
+ ```ts
278
+ import { buildTreeGraph, toggleAllocation } from '@poe2-toolkit/tree-core';
279
+
280
+ // Build the walkable adjacency graph once and reuse it across clicks.
281
+ const graph = buildTreeGraph(data);
282
+
283
+ // Click a node: allocate the shortest path to it from the class start, or, if it
284
+ // is already allocated, remove it and everything beyond it.
285
+ const next = toggleAllocation(data, classStartNode, new Set(allocated), clickedSkill, graph);
286
+ ```
287
+
288
+ The model:
289
+
290
+ - **Allocate** — clicking an unallocated node allocates the shortest path to it
291
+ from the class start (plus the current allocation). `pathToNode` is the
292
+ underlying breadth-first search.
293
+ - **Remove** — clicking an allocated node removes everything beyond it (the nodes
294
+ that lose their connection to the start once it is cut) and keeps the clicked
295
+ node, which shortens the path back to that node. If nothing lies beyond it (the
296
+ node is a tip), the node itself is removed. `removalSet` computes exactly which
297
+ nodes a click removes, so a UI can preview the removal before committing it.
298
+
299
+ Ascendancy points are a separate pool. `toggleAscendancyAllocation` paths within
300
+ a single ascendancy's own subgraph (rooted at its start node) and leaves the
301
+ main-tree allocation untouched, using `buildAscendancyGraph` and
302
+ `ascendancyStartNode`.
303
+
304
+ For framing the view, `allocatedBounds(scene)` returns the world bounds of the
305
+ allocated nodes (handy to zoom to a freshly imported build), and
306
+ `classBounds(scene, classId)` returns the bounds of one class's sector of the
307
+ tree.
308
+
309
+ ## Graphics: the sprite manifest
310
+
311
+ Core owns no pixels. It produces atlas **keys** (`node.icon`, the centre art
312
+ keys, effect pattern keys) and leaves the bitmaps to the renderer. The renderer
313
+ supplies a `SpriteManifest` that maps each key to a sub-rect inside an atlas, and
314
+ resolves atlas ids to actual images itself:
315
+
316
+ ```ts
317
+ interface SpriteManifest {
318
+ frames: Record<string, { atlas: string; x: number; y: number; w: number; h: number }>;
319
+ }
320
+ ```
321
+
322
+ This keeps the engine atlas-agnostic and lets the consumer ship whatever
323
+ graphics it has rights to. Art is never bundled with the package.
324
+
325
+ ## Geometry rules
326
+
327
+ Everything the engine computes is derived from the data. The rules worth knowing:
328
+
329
+ - **Positions are read, not computed.** GGG bakes world `x`/`y` into every node;
330
+ `buildScene` reads them. (The convention behind the baked coordinates: angle 0
331
+ points up and increases clockwise, so a node sits at
332
+ `x = group.x + r·sin(angle)`, `y = group.y − r·cos(angle)`.)
333
+ - **Sizes follow a fixed per-type table.** Icon, overlay frame, and effect-pattern
334
+ diameters come from `nodeTargetSize`, whose constants and cascade order match
335
+ Path of Building's `GetNodeTargetSize` (the reference renderer). The first
336
+ matching type wins.
337
+ - **The 2× rule.** The game draws each sprite at twice its native width, centred
338
+ on the node, so the world diameter is `2 × targetWidth`. `buildScene` folds the
339
+ factor in; the renderer just scales by the viewport.
340
+ - **Connections are lines or arcs.** GGG's `edges` table marks which edges are
341
+ arcs and gives the arc centre directly; those become a minor arc around that
342
+ centre. Edges whose endpoints share a group orbit fall back to an arc around
343
+ the group centre. Everything else is a straight line.
344
+ - **The hub rotates per class.** The central gold ring rotates to point at the
345
+ active class. The rotation is derived from the direction of the class's start
346
+ node: `ringRotation = π/2 + atan2(start.y − cy, start.x − cx)`. That same
347
+ direction is where the class sits on the rim.
348
+ - **Ascendancies are relocatable blocks.** GGG bakes each ascendancy's nodes at a
349
+ far-flung cluster. The engine exposes the disc's `worldAnchor` so the renderer
350
+ can translate it into the hub (how the game shows it) or leave it where the
351
+ export put it.
352
+
353
+ ## What gets filtered out
354
+
355
+ `buildScene` drops a few things that exist in the data but are not part of the
356
+ playable, drawn tree, to match what the official tree shows:
357
+
358
+ - **Class-start nodes** — invisible launch points; their edges would dangle.
359
+ - **Hidden special sockets** — the "Sinister Jewel Socket" decorations, which the
360
+ official tree never draws.
361
+ - **Conditional nodes** — entries gated by `unlockConstraint` (such as the
362
+ Oracle-only passives), which surface in-game only when their constraint is met
363
+ and are absent from the default tree.
364
+ - **Mastery edges** — masteries render as a background pattern, not a connectable
365
+ node, so edges to and from them are dropped.
366
+ - **Main-tree ↔ ascendancy edges** — the ascendancy is a separate, relocated
367
+ panel, so the link crossing that boundary is not drawn.
368
+
369
+ These are skipped in the scene; the data still contains them.
370
+
371
+ ## API reference
372
+
373
+ **Pipeline** (from `@poe2-toolkit/tree-core`)
374
+
375
+ | Export | Signature | Purpose |
376
+ | --- | --- | --- |
377
+ | `buildScene` | `(data: TreeData, opts?: SceneOptions) => Scene` | World-space, render-ready scene. |
378
+ | `project` | `(scene: Scene, viewport: Viewport, size: Size) => ScreenScene` | Project + cull to pixels. |
379
+
380
+ **Source adapter** (from `@poe2-toolkit/tree-core/ggg`)
381
+
382
+ | Export | Signature | Purpose |
383
+ | --- | --- | --- |
384
+ | `normalizeGggTree` | `(raw: GggTreeJson, version: string) => TreeData` | Normalize GGG's official export into `TreeData`. |
385
+
386
+ **Projection and hit-testing**
387
+
388
+ | Export | Purpose |
389
+ | --- | --- |
390
+ | `projectPoint` | World point to pixels. |
391
+ | `screenToWorld` | Pixels to a world point. |
392
+ | `nodeAt` | Skill id under a pixel, or `null`. |
393
+ | `nodePosition` | World position of a node by skill id. |
394
+
395
+ **Layout helpers**
396
+
397
+ | Export | Purpose |
398
+ | --- | --- |
399
+ | `computeCentreLayout` | Hub geometry + per-class ring rotation. |
400
+ | `placeConnection` | Resolve one edge to a line or an arc. |
401
+ | `classifyNode` | Node's render `kind`. |
402
+ | `nodeTargetSize` | Derived icon/overlay/effect sizes. |
403
+ | `chosenAttributeOption` | The Str/Dex/Int option a build picked for a node. |
404
+ | `allocatedBounds` | World bounds of the allocated nodes. |
405
+ | `classBounds` | World bounds of a class's tree sector. |
406
+
407
+ **Interactive editing**
408
+
409
+ | Export | Purpose |
410
+ | --- | --- |
411
+ | `buildTreeGraph` | Walkable adjacency graph of the main tree. |
412
+ | `toggleAllocation` | Allocate the path to a node, or remove from it. |
413
+ | `pathToNode` | Shortest path (BFS) between a node set and a target. |
414
+ | `reachable` | Nodes still connected to a set of roots. |
415
+ | `removalSet` | Exactly which nodes a removal click drops. |
416
+ | `buildAscendancyGraph` | Adjacency graph of one ascendancy. |
417
+ | `ascendancyStartNode` | An ascendancy's pathing root. |
418
+ | `toggleAscendancyAllocation` | Edit within one ascendancy's subgraph. |
419
+
420
+ All types are exported from the main entry as well (`TreeData`, `TreeNode`,
421
+ `Scene`, `PlacedNode`, `BuildAllocation`, `Viewport`, `ScreenScene`,
422
+ `SpriteManifest`, and the rest). The GGG raw shape `GggTreeJson` is exported from
423
+ the `@poe2-toolkit/tree-core/ggg` subpath.
424
+
425
+ ## Design principles
426
+
427
+ - **Data in, scene out.** Fidelity is a property of the data. No magic-constant
428
+ positioning or sizing leaks into application code.
429
+ - **Pure and headless.** Zero runtime dependencies. No DOM, no canvas, no
430
+ framework. Every function is a deterministic transform over plain data.
431
+ - **Source-agnostic core.** The engine entry point knows only the `TreeData`
432
+ contract; data-source adapters live in their own subpaths (`@poe2-toolkit/tree-core/ggg`
433
+ today). Swapping or adding a source touches nothing downstream.
434
+ - **One boundary, many producers.** The engine takes an allocation and nothing
435
+ more. Import/export formats, OAuth, and manual editors all just produce that
436
+ allocation.
437
+ - **Tolerant normalization.** An adapter is the only code that knows a raw export
438
+ shape, and it never throws on missing or shifting fields.
439
+
440
+ ## License and credits
441
+
442
+ MIT.
443
+
444
+ Path of Exile 2 and its passive-tree data are © Grinding Gear Games. This package
445
+ ships **no game art or data** — only code. Consumers supply the tree data and
446
+ graphics themselves and are responsible for their own use of GGG's assets. This
447
+ project is not affiliated with or endorsed by Grinding Gear Games.
448
+
449
+ The derived node sizes and the central-hub rendering rules follow the conventions
450
+ established by [Path of Building Community](https://github.com/PathOfBuildingCommunity),
451
+ the reference open-source Path of Exile tool.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Central hub geometry: the size and orientation of the middle circle, and how
3
+ * its gold ring rotates to point at the active class.
4
+ *
5
+ * Three concentric layers sit at the hub (the origin): the static class
6
+ * portrait, a rotating gold ring, and a static ornate frame. The ring's
7
+ * rotation is the only moving part, and it is derived — not eyeballed — from the
8
+ * direction of the class's start node:
9
+ *
10
+ * ringRotation = π/2 + atan2(startNode.y − centre.y, startNode.x − centre.x)
11
+ *
12
+ * That same atan2 direction is where the class sits on the rim (its start
13
+ * "leaf").
14
+ */
15
+ import type { CentreLayout, TreeData } from '../types.js';
16
+ export declare function computeCentreLayout(data: TreeData): CentreLayout;
17
+ //# sourceMappingURL=centre.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"centre.d.ts","sourceRoot":"","sources":["../../src/geometry/centre.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAe,QAAQ,EAAE,MAAM,aAAa,CAAC;AAKvE,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,QAAQ,GAAG,YAAY,CA+ChE"}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Central hub geometry: the size and orientation of the middle circle, and how
3
+ * its gold ring rotates to point at the active class.
4
+ *
5
+ * Three concentric layers sit at the hub (the origin): the static class
6
+ * portrait, a rotating gold ring, and a static ornate frame. The ring's
7
+ * rotation is the only moving part, and it is derived — not eyeballed — from the
8
+ * direction of the class's start node:
9
+ *
10
+ * ringRotation = π/2 + atan2(startNode.y − centre.y, startNode.x − centre.x)
11
+ *
12
+ * That same atan2 direction is where the class sits on the rim (its start
13
+ * "leaf").
14
+ */
15
+ import { nodePosition } from './orbit.js';
16
+ const HALF_PI = Math.PI / 2;
17
+ export function computeCentreLayout(data) {
18
+ const first = data.classes[0];
19
+ // The hub is shared by every class; fall back to the origin if there are no
20
+ // classes (a degenerate tree) so the layout is always well-formed.
21
+ const centre = first ? { x: first.centre.x, y: first.centre.y } : { x: 0, y: 0 };
22
+ // World radius = native layer width (each layer is drawn at 2*width centred).
23
+ const ring = first
24
+ ? {
25
+ artRadius: first.centre.art.width,
26
+ activeRadius: first.centre.active.width,
27
+ frameRadius: first.centre.frame.width,
28
+ }
29
+ : { artRadius: 0, activeRadius: 0, frameRadius: 0 };
30
+ const classes = [];
31
+ for (const cls of data.classes) {
32
+ if (cls.startNode < 0 || !data.nodes[cls.startNode]) {
33
+ continue;
34
+ }
35
+ // Skip PoE1 placeholder classes (Marauder/Duelist/Shadow/Templar): GGG keeps
36
+ // their array slots and pairs each with a real class on a shared start node,
37
+ // but they have no ascendancies and are not playable in PoE2.
38
+ if (cls.ascendancies.length === 0) {
39
+ continue;
40
+ }
41
+ const start = nodePosition(data, cls.startNode);
42
+ const startAngle = Math.atan2(start.y - centre.y, start.x - centre.x);
43
+ classes.push({
44
+ classId: cls.id,
45
+ name: cls.name,
46
+ startNode: cls.startNode,
47
+ startAngle,
48
+ ringRotation: HALF_PI + startAngle,
49
+ });
50
+ }
51
+ return {
52
+ centre,
53
+ innerRadius: data.constants.centreInnerRadius,
54
+ ring,
55
+ classes,
56
+ ascendancies: data.classes.flatMap((cls) => cls.ascendancies),
57
+ };
58
+ }
59
+ //# sourceMappingURL=centre.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"centre.js","sourceRoot":"","sources":["../../src/geometry/centre.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE1C,MAAM,OAAO,GAAG,IAAI,CAAC,EAAE,GAAG,CAAC,CAAC;AAE5B,MAAM,UAAU,mBAAmB,CAAC,IAAc;IAChD,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IAC9B,4EAA4E;IAC5E,mEAAmE;IACnE,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC;IACjF,8EAA8E;IAC9E,MAAM,IAAI,GAAG,KAAK;QAChB,CAAC,CAAC;YACE,SAAS,EAAE,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK;YACjC,YAAY,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK;YACvC,WAAW,EAAE,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK;SACtC;QACH,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,YAAY,EAAE,CAAC,EAAE,WAAW,EAAE,CAAC,EAAE,CAAC;IAEtD,MAAM,OAAO,GAAkB,EAAE,CAAC;IAElC,KAAK,MAAM,GAAG,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;QAC/B,IAAI,GAAG,CAAC,SAAS,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC,EAAE,CAAC;YACpD,SAAS;QACX,CAAC;QAED,6EAA6E;QAC7E,6EAA6E;QAC7E,8DAA8D;QAC9D,IAAI,GAAG,CAAC,YAAY,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAClC,SAAS;QACX,CAAC;QAED,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,EAAE,GAAG,CAAC,SAAS,CAAC,CAAC;QAChD,MAAM,UAAU,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;QAEtE,OAAO,CAAC,IAAI,CAAC;YACX,OAAO,EAAE,GAAG,CAAC,EAAE;YACf,IAAI,EAAE,GAAG,CAAC,IAAI;YACd,SAAS,EAAE,GAAG,CAAC,SAAS;YACxB,UAAU;YACV,YAAY,EAAE,OAAO,GAAG,UAAU;SACnC,CAAC,CAAC;IACL,CAAC;IAED,OAAO;QACL,MAAM;QACN,WAAW,EAAE,IAAI,CAAC,SAAS,CAAC,iBAAiB;QAC7C,IAAI;QACJ,OAAO;QACP,YAAY,EAAE,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,YAAY,CAAC;KAC9D,CAAC;AACJ,CAAC"}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * World-space framing helpers: compute the bounding box the view should focus
3
+ * on, so callers can pan/zoom to a build's allocation or a single class's
4
+ * region instead of the whole tree.
5
+ */
6
+ import type { Scene, WorldRect } from '../types.js';
7
+ /**
8
+ * Bounds of the allocated main-tree nodes (ascendancy nodes excluded — they live
9
+ * in the relocated hub panel). `null` when nothing is allocated.
10
+ */
11
+ export declare function allocatedBounds(scene: Scene): WorldRect | null;
12
+ /**
13
+ * Allocated bounds grown to include the centre hub, so a view framed on a fresh
14
+ * import keeps the class portrait at the tree's centre in shot. `allocatedBounds`
15
+ * alone hugs only the outlying nodes and pushes the portrait off-screen — the
16
+ * very art the import is meant to reveal. `null` when nothing main-tree is
17
+ * allocated (callers fall back to the renderer's default hub-centred view).
18
+ */
19
+ export declare function allocatedBoundsWithCentre(scene: Scene): WorldRect | null;
20
+ /**
21
+ * Bounds of the tree sector belonging to one class. The tree radiates from the
22
+ * hub and each class owns a rim direction (`ClassAnchor.startAngle`); a main
23
+ * node belongs to the class whose direction its bearing from the centre is
24
+ * closest to. `null` for an unknown class or an empty sector.
25
+ */
26
+ export declare function classBounds(scene: Scene, classId: number): WorldRect | null;
27
+ //# sourceMappingURL=framing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"framing.d.ts","sourceRoot":"","sources":["../../src/geometry/framing.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAc,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AA8BhE;;;GAGG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,KAAK,GAAG,SAAS,GAAG,IAAI,CAU9D;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,KAAK,GAAG,SAAS,GAAG,IAAI,CAgBxE;AAiBD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAkC3E"}