@poe2-toolkit/tree-react 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.
- package/LICENSE +21 -0
- package/README.md +165 -0
- package/dist/TreeView.d.ts +101 -0
- package/dist/TreeView.d.ts.map +1 -0
- package/dist/TreeView.js +756 -0
- package/dist/TreeView.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/resources.d.ts +13 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +2 -0
- package/dist/resources.js.map +1 -0
- package/dist/spriteKeys.d.ts +35 -0
- package/dist/spriteKeys.d.ts.map +1 -0
- package/dist/spriteKeys.js +75 -0
- package/dist/spriteKeys.js.map +1 -0
- package/package.json +52 -0
package/dist/TreeView.js
ADDED
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { nodeAt, project, projectPoint, screenToWorld } from '@poe2-toolkit/tree-core';
|
|
3
|
+
import { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
|
4
|
+
import { effectKeyFor, frameKeyFor, iconKeyFor } from './spriteKeys.js';
|
|
5
|
+
const MIN_SCALE = 0.02;
|
|
6
|
+
const MAX_SCALE = 4;
|
|
7
|
+
const ZOOM_STEP = 1.3;
|
|
8
|
+
const ZOOM_SENSITIVITY = 0.0015;
|
|
9
|
+
const FIT_PADDING = 0.92;
|
|
10
|
+
/** Connection rail look (world units). Two parallel rails with a gap between. */
|
|
11
|
+
const RAIL_WIDTH = 3.6;
|
|
12
|
+
const RAIL_GAP = 4.8;
|
|
13
|
+
const RAIL_COLOR = '#7d6836';
|
|
14
|
+
const RAIL_GAP_ACTIVE = '#fcde86';
|
|
15
|
+
const RAIL_GAP_INACTIVE = '#000000';
|
|
16
|
+
/**
|
|
17
|
+
* Stroke the current path as twin parallel rails with a gap. One wide stroke in
|
|
18
|
+
* the rail colour lays down both rails; a narrower stroke on top fills the gap
|
|
19
|
+
* (gold when allocated, background otherwise).
|
|
20
|
+
*/
|
|
21
|
+
function strokeRail(ctx, active, scale) {
|
|
22
|
+
const gap = Math.max(0.6, RAIL_GAP * scale);
|
|
23
|
+
const rail = Math.max(0.6, RAIL_WIDTH * scale);
|
|
24
|
+
// Active: both rails and the gap are gold (solid band). Inactive: dark rails
|
|
25
|
+
// around an empty (background) gap.
|
|
26
|
+
ctx.strokeStyle = active ? RAIL_GAP_ACTIVE : RAIL_COLOR;
|
|
27
|
+
ctx.lineWidth = gap + rail * 2;
|
|
28
|
+
ctx.stroke();
|
|
29
|
+
ctx.strokeStyle = active ? RAIL_GAP_ACTIVE : RAIL_GAP_INACTIVE;
|
|
30
|
+
ctx.lineWidth = gap;
|
|
31
|
+
ctx.stroke();
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Ascendancy edges: inactive ones are a single solid black line the full width
|
|
35
|
+
* of a twin rail (matching the in-game ascendancy tree); active ones keep the
|
|
36
|
+
* gold rail used everywhere else.
|
|
37
|
+
*/
|
|
38
|
+
function strokeAscendancyRail(ctx, active, scale) {
|
|
39
|
+
if (active) {
|
|
40
|
+
strokeRail(ctx, true, scale);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const gap = Math.max(0.6, RAIL_GAP * scale);
|
|
44
|
+
const rail = Math.max(0.6, RAIL_WIDTH * scale);
|
|
45
|
+
ctx.strokeStyle = RAIL_GAP_INACTIVE;
|
|
46
|
+
ctx.lineWidth = gap + rail * 2;
|
|
47
|
+
ctx.stroke();
|
|
48
|
+
}
|
|
49
|
+
/** Vector palette by node kind, used when no atlas art is supplied. */
|
|
50
|
+
const KIND_COLOR = {
|
|
51
|
+
normal: '#3a5b54',
|
|
52
|
+
notable: '#6fe0d0',
|
|
53
|
+
keystone: '#d9b86a',
|
|
54
|
+
mastery: '#8a6fd0',
|
|
55
|
+
jewel: '#d06f9a',
|
|
56
|
+
attribute: '#4a6a62',
|
|
57
|
+
classStart: '#9aa7a3',
|
|
58
|
+
ascendancyStart: '#d9b86a',
|
|
59
|
+
ascendancyNormal: '#5a7a72',
|
|
60
|
+
ascendancyNotable: '#7fd0c0',
|
|
61
|
+
};
|
|
62
|
+
/** Item-rarity colours for the gem drawn inside a socketed jewel. */
|
|
63
|
+
const RARITY_COLOR = {
|
|
64
|
+
NORMAL: '#d6d6d6',
|
|
65
|
+
MAGIC: '#8888ff',
|
|
66
|
+
RARE: '#e8e84a',
|
|
67
|
+
UNIQUE: '#cf7a3a',
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Thin canvas view over a core `Scene`. It owns nothing geometric — pan, zoom,
|
|
71
|
+
* device-pixel sizing, the draw loop, and hover hit-testing only. Positions,
|
|
72
|
+
* sizes, projection and hit-testing all come from `@poe2-toolkit/tree-core`.
|
|
73
|
+
*
|
|
74
|
+
* Without `resources` it renders a vector debug view (nodes as discs, edges as
|
|
75
|
+
* lines/arcs, the hub opening as a ring) — enough to see the geometry before any
|
|
76
|
+
* GGG atlas art exists.
|
|
77
|
+
*/
|
|
78
|
+
export function TreeView({ scene, resources, activeClassId, activeAscendancy, centreSprites, onNodeClick, onNodeDoubleClick, onInteractStart, onNodeHover, preview, controls, wheelZoom, focus, highlight, className, style, }) {
|
|
79
|
+
const canvasRef = useRef(null);
|
|
80
|
+
const viewportRef = useRef(null);
|
|
81
|
+
const hoverRef = useRef(null);
|
|
82
|
+
const dragRef = useRef(null);
|
|
83
|
+
const imagesRef = useRef(new Map());
|
|
84
|
+
const [, forceRedraw] = useState(0);
|
|
85
|
+
const draw = useCallback(() => {
|
|
86
|
+
const canvas = canvasRef.current;
|
|
87
|
+
const ctx = canvas?.getContext('2d');
|
|
88
|
+
if (!canvas || !ctx) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const dpr = window.devicePixelRatio || 1;
|
|
92
|
+
const cssWidth = canvas.clientWidth;
|
|
93
|
+
const cssHeight = canvas.clientHeight;
|
|
94
|
+
// Lazily centre the view on the hub (class portrait) the first time we have
|
|
95
|
+
// a size.
|
|
96
|
+
if (!viewportRef.current && cssWidth > 0 && cssHeight > 0) {
|
|
97
|
+
viewportRef.current = centreViewport(scene, cssWidth, cssHeight);
|
|
98
|
+
}
|
|
99
|
+
const viewport = viewportRef.current;
|
|
100
|
+
if (!viewport) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
clampViewport(viewport, scene, cssWidth, cssHeight);
|
|
104
|
+
const screen = project(scene, viewport, { width: cssWidth, height: cssHeight });
|
|
105
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
106
|
+
ctx.clearRect(0, 0, cssWidth, cssHeight);
|
|
107
|
+
// Solid near-black background.
|
|
108
|
+
ctx.fillStyle = '#000000';
|
|
109
|
+
ctx.fillRect(0, 0, cssWidth, cssHeight);
|
|
110
|
+
drawVector(ctx, screen, scene, viewport, hoverRef.current, activeClassId, centreSprites, imagesRef.current, resources);
|
|
111
|
+
if (highlight && highlight.size > 0) {
|
|
112
|
+
drawHighlight(ctx, screen, highlight);
|
|
113
|
+
}
|
|
114
|
+
if (preview) {
|
|
115
|
+
drawPreview(ctx, screen, preview);
|
|
116
|
+
}
|
|
117
|
+
if (activeAscendancy) {
|
|
118
|
+
drawAscendancy(ctx, scene, viewport, activeAscendancy, resources, hoverRef.current, preview);
|
|
119
|
+
}
|
|
120
|
+
}, [scene, activeClassId, activeAscendancy, centreSprites, resources, preview, highlight]);
|
|
121
|
+
// While a highlight set is active, drive its pulse by redrawing every frame.
|
|
122
|
+
// Idle (no matches) keeps the tree static — no animation cost.
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!highlight || highlight.size === 0) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
let raf = 0;
|
|
128
|
+
const tick = () => {
|
|
129
|
+
draw();
|
|
130
|
+
raf = requestAnimationFrame(tick);
|
|
131
|
+
};
|
|
132
|
+
raf = requestAnimationFrame(tick);
|
|
133
|
+
return () => cancelAnimationFrame(raf);
|
|
134
|
+
}, [highlight, draw]);
|
|
135
|
+
// Load centre sprite images; redraw as each arrives.
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
const urls = [centreSprites?.portrait?.url, centreSprites?.ringStatic?.url, centreSprites?.ringActive?.url].filter((url) => Boolean(url));
|
|
138
|
+
for (const url of urls) {
|
|
139
|
+
if (imagesRef.current.has(url)) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const img = new Image();
|
|
143
|
+
img.onload = () => {
|
|
144
|
+
imagesRef.current.set(url, img);
|
|
145
|
+
draw();
|
|
146
|
+
};
|
|
147
|
+
img.src = url;
|
|
148
|
+
}
|
|
149
|
+
}, [centreSprites, draw]);
|
|
150
|
+
// Load socketed-jewel icons (item art keyed by base type); redraw as each
|
|
151
|
+
// arrives. Same image cache as the centre sprites.
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
const urls = new Set();
|
|
154
|
+
for (const node of scene.nodes) {
|
|
155
|
+
if (node.jewel?.icon) {
|
|
156
|
+
urls.add(node.jewel.icon);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const url of urls) {
|
|
160
|
+
if (imagesRef.current.has(url)) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const img = new Image();
|
|
164
|
+
img.crossOrigin = 'anonymous';
|
|
165
|
+
img.onload = () => {
|
|
166
|
+
imagesRef.current.set(url, img);
|
|
167
|
+
draw();
|
|
168
|
+
};
|
|
169
|
+
img.src = url;
|
|
170
|
+
}
|
|
171
|
+
}, [scene, draw]);
|
|
172
|
+
// Redraw on size change (device-pixel backing store + CSS size).
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
const canvas = canvasRef.current;
|
|
175
|
+
if (!canvas) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const observer = new ResizeObserver(() => {
|
|
179
|
+
const dpr = window.devicePixelRatio || 1;
|
|
180
|
+
canvas.width = Math.round(canvas.clientWidth * dpr);
|
|
181
|
+
canvas.height = Math.round(canvas.clientHeight * dpr);
|
|
182
|
+
draw();
|
|
183
|
+
});
|
|
184
|
+
observer.observe(canvas);
|
|
185
|
+
return () => observer.disconnect();
|
|
186
|
+
}, [draw]);
|
|
187
|
+
// Repaint whenever anything affecting the render changes (scene/allocation,
|
|
188
|
+
// atlases finishing loading, centre art, active class/ascendancy) — WITHOUT
|
|
189
|
+
// touching the viewport. The view is centred lazily on first draw and then
|
|
190
|
+
// preserved, so editing the build or panning never snaps it back to the hub.
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
draw();
|
|
193
|
+
}, [draw]);
|
|
194
|
+
const onPointerDown = useCallback((event) => {
|
|
195
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
196
|
+
dragRef.current = { x: event.clientX, y: event.clientY, moved: false };
|
|
197
|
+
onInteractStart?.();
|
|
198
|
+
}, [onInteractStart]);
|
|
199
|
+
const onPointerMove = useCallback((event) => {
|
|
200
|
+
const viewport = viewportRef.current;
|
|
201
|
+
if (!viewport) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const drag = dragRef.current;
|
|
205
|
+
// Recover from a missed pointerup: if the primary button is no longer held
|
|
206
|
+
// (e.g. the release landed outside the canvas, or fullscreen stole pointer
|
|
207
|
+
// capture), `buttons` is 0 — end the drag so the tree stops following the
|
|
208
|
+
// cursor instead of panning forever.
|
|
209
|
+
if (drag && (event.buttons & 1) === 0) {
|
|
210
|
+
dragRef.current = null;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (drag) {
|
|
214
|
+
const dx = event.clientX - drag.x;
|
|
215
|
+
const dy = event.clientY - drag.y;
|
|
216
|
+
if (Math.abs(dx) + Math.abs(dy) > 2) {
|
|
217
|
+
drag.moved = true;
|
|
218
|
+
}
|
|
219
|
+
drag.x = event.clientX;
|
|
220
|
+
drag.y = event.clientY;
|
|
221
|
+
viewport.tx += dx;
|
|
222
|
+
viewport.ty += dy;
|
|
223
|
+
draw();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
// Hover hit-test.
|
|
227
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
228
|
+
const hit = hitTest(scene, viewport, event.clientX - rect.left, event.clientY - rect.top, activeAscendancy);
|
|
229
|
+
if (hit !== hoverRef.current) {
|
|
230
|
+
hoverRef.current = hit;
|
|
231
|
+
const node = hit !== null ? scene.nodes.find((candidate) => candidate.skill === hit) : undefined;
|
|
232
|
+
onNodeHover?.(hit, node ? projectPoint(viewport, { x: node.x, y: node.y }) : undefined);
|
|
233
|
+
draw();
|
|
234
|
+
}
|
|
235
|
+
}, [scene, draw, onNodeHover, activeAscendancy]);
|
|
236
|
+
const onPointerUp = useCallback((event) => {
|
|
237
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
238
|
+
const drag = dragRef.current;
|
|
239
|
+
dragRef.current = null;
|
|
240
|
+
const viewport = viewportRef.current;
|
|
241
|
+
if (drag && !drag.moved && viewport && onNodeClick) {
|
|
242
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
243
|
+
const hit = hitTest(scene, viewport, event.clientX - rect.left, event.clientY - rect.top, activeAscendancy);
|
|
244
|
+
if (hit !== null) {
|
|
245
|
+
const node = scene.nodes.find((candidate) => candidate.skill === hit);
|
|
246
|
+
onNodeClick(hit, node ? projectPoint(viewport, { x: node.x, y: node.y }) : { x: 0, y: 0 });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}, [scene, onNodeClick, activeAscendancy]);
|
|
250
|
+
const onDoubleClick = useCallback((event) => {
|
|
251
|
+
const viewport = viewportRef.current;
|
|
252
|
+
if (!viewport || !onNodeDoubleClick) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
256
|
+
const hit = hitTest(scene, viewport, event.clientX - rect.left, event.clientY - rect.top, activeAscendancy);
|
|
257
|
+
if (hit !== null) {
|
|
258
|
+
onNodeDoubleClick(hit);
|
|
259
|
+
}
|
|
260
|
+
}, [scene, onNodeDoubleClick, activeAscendancy]);
|
|
261
|
+
// Zoom keeping a screen point fixed. Wheel zoom is intentionally omitted so
|
|
262
|
+
// the page can scroll over the canvas; zooming is driven by external buttons.
|
|
263
|
+
const zoomAt = useCallback((px, py, factor) => {
|
|
264
|
+
const viewport = viewportRef.current;
|
|
265
|
+
if (!viewport) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const scale = clamp(viewport.scale * factor, MIN_SCALE, MAX_SCALE);
|
|
269
|
+
const ratio = scale / viewport.scale;
|
|
270
|
+
viewport.tx = px - (px - viewport.tx) * ratio;
|
|
271
|
+
viewport.ty = py - (py - viewport.ty) * ratio;
|
|
272
|
+
viewport.scale = scale;
|
|
273
|
+
draw();
|
|
274
|
+
forceRedraw((n) => n + 1);
|
|
275
|
+
}, [draw]);
|
|
276
|
+
useImperativeHandle(controls, () => ({
|
|
277
|
+
zoomIn: () => {
|
|
278
|
+
const canvas = canvasRef.current;
|
|
279
|
+
if (canvas) {
|
|
280
|
+
zoomAt(canvas.clientWidth / 2, canvas.clientHeight / 2, ZOOM_STEP);
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
zoomOut: () => {
|
|
284
|
+
const canvas = canvasRef.current;
|
|
285
|
+
if (canvas) {
|
|
286
|
+
zoomAt(canvas.clientWidth / 2, canvas.clientHeight / 2, 1 / ZOOM_STEP);
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
}), [zoomAt]);
|
|
290
|
+
// Wheel zoom (fullscreen only): native non-passive listener so we can
|
|
291
|
+
// preventDefault and zoom toward the cursor instead of scrolling.
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
const canvas = canvasRef.current;
|
|
294
|
+
if (!canvas || !wheelZoom) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const onWheel = (event) => {
|
|
298
|
+
event.preventDefault();
|
|
299
|
+
const rect = canvas.getBoundingClientRect();
|
|
300
|
+
const factor = Math.exp(-event.deltaY * ZOOM_SENSITIVITY);
|
|
301
|
+
zoomAt(event.clientX - rect.left, event.clientY - rect.top, factor);
|
|
302
|
+
};
|
|
303
|
+
canvas.addEventListener('wheel', onWheel, { passive: false });
|
|
304
|
+
return () => canvas.removeEventListener('wheel', onWheel);
|
|
305
|
+
}, [wheelZoom, zoomAt]);
|
|
306
|
+
// Frame a requested world rect (e.g. a freshly imported build's allocation).
|
|
307
|
+
// One-shot: keyed on `focus` only, so it fires when the caller passes a new
|
|
308
|
+
// rect — not on every class/ascendancy redraw.
|
|
309
|
+
useEffect(() => {
|
|
310
|
+
if (!focus) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
const canvas = canvasRef.current;
|
|
314
|
+
if (!canvas) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const width = canvas.clientWidth;
|
|
318
|
+
const height = canvas.clientHeight;
|
|
319
|
+
if (width <= 0 || height <= 0) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const viewport = viewportForRect(focus, width, height);
|
|
323
|
+
clampViewport(viewport, scene, width, height);
|
|
324
|
+
viewportRef.current = viewport;
|
|
325
|
+
draw();
|
|
326
|
+
forceRedraw((n) => n + 1);
|
|
327
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
328
|
+
}, [focus]);
|
|
329
|
+
// Suppress unused-resources warning while the atlas path is not yet wired.
|
|
330
|
+
void resources;
|
|
331
|
+
return (_jsx("canvas", { ref: canvasRef, className: className, style: { touchAction: 'none', cursor: 'grab', width: '100%', height: '100%', background: '#000000', ...style }, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: onPointerUp, onLostPointerCapture: () => {
|
|
332
|
+
dragRef.current = null;
|
|
333
|
+
}, onDoubleClick: onDoubleClick }));
|
|
334
|
+
}
|
|
335
|
+
function clamp(value, min, max) {
|
|
336
|
+
return Math.min(Math.max(value, min), max);
|
|
337
|
+
}
|
|
338
|
+
/** The scale at which the main tree fits the viewport. */
|
|
339
|
+
function fitScale(scene, width, height) {
|
|
340
|
+
const { minX, minY, maxX, maxY } = scene.mainBounds;
|
|
341
|
+
const worldWidth = Math.max(1, maxX - minX);
|
|
342
|
+
const worldHeight = Math.max(1, maxY - minY);
|
|
343
|
+
return Math.min(width / worldWidth, height / worldHeight) * FIT_PADDING;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Default view: centred on the hub (class portrait), zoomed so the portrait and
|
|
347
|
+
* its nearest nodes fill the stage.
|
|
348
|
+
*/
|
|
349
|
+
function centreViewport(scene, width, height) {
|
|
350
|
+
const { centre, ring } = scene.centre;
|
|
351
|
+
// Window radius a bit larger than the portrait so nearby nodes show too.
|
|
352
|
+
const windowRadius = Math.max(ring.artRadius * 1.6, 2000);
|
|
353
|
+
const scale = Math.min(width, height) / (windowRadius * 2);
|
|
354
|
+
return { tx: width / 2 - centre.x * scale, ty: height / 2 - centre.y * scale, scale };
|
|
355
|
+
}
|
|
356
|
+
/** A viewport that fits a world rect into the viewport, centred, with padding. */
|
|
357
|
+
function viewportForRect(rect, width, height) {
|
|
358
|
+
const worldWidth = Math.max(1, rect.maxX - rect.minX);
|
|
359
|
+
const worldHeight = Math.max(1, rect.maxY - rect.minY);
|
|
360
|
+
const scale = Math.min(width / worldWidth, height / worldHeight) * FIT_PADDING;
|
|
361
|
+
const cx = (rect.minX + rect.maxX) / 2;
|
|
362
|
+
const cy = (rect.minY + rect.maxY) / 2;
|
|
363
|
+
return { tx: width / 2 - cx * scale, ty: height / 2 - cy * scale, scale };
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Keep the view sane: don't zoom out past ~the fit scale, and don't pan the main
|
|
367
|
+
* tree fully off-screen (the world bounds include far-out ascendancy anchors, so
|
|
368
|
+
* unclamped panning wanders into huge empty space).
|
|
369
|
+
*/
|
|
370
|
+
function clampViewport(viewport, scene, width, height) {
|
|
371
|
+
if (width <= 0 || height <= 0) {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
viewport.scale = clamp(viewport.scale, fitScale(scene, width, height) * 0.85, MAX_SCALE);
|
|
375
|
+
const { minX, minY, maxX, maxY } = scene.mainBounds;
|
|
376
|
+
const { scale } = viewport;
|
|
377
|
+
// Keep at least this much of the tree's extent inside the viewport.
|
|
378
|
+
const marginX = Math.min(width, (maxX - minX) * scale) * 0.5;
|
|
379
|
+
const marginY = Math.min(height, (maxY - minY) * scale) * 0.5;
|
|
380
|
+
viewport.tx = clamp(viewport.tx, width - marginX - maxX * scale, marginX - minX * scale);
|
|
381
|
+
viewport.ty = clamp(viewport.ty, height - marginY - maxY * scale, marginY - minY * scale);
|
|
382
|
+
}
|
|
383
|
+
/** Vector debug render: hub, then edges, then nodes. */
|
|
384
|
+
function drawVector(ctx, screen, scene, viewport, hover, activeClassId, centreSprites, images, resources) {
|
|
385
|
+
drawCentre(ctx, scene, viewport, activeClassId, centreSprites, images);
|
|
386
|
+
// Effect patterns (mastery/notable backgrounds), behind everything. Faint
|
|
387
|
+
// until the mastery is allocated, then lit to full strength — matching the
|
|
388
|
+
// in-game tree.
|
|
389
|
+
if (resources) {
|
|
390
|
+
for (const effect of screen.masteryEffects) {
|
|
391
|
+
const key = effectKeyFor(effect.patternKey);
|
|
392
|
+
ctx.globalAlpha = effect.active ? 1 : 0.15;
|
|
393
|
+
blitFromAtlas(ctx, resources, key, effect.x, effect.y, effect.size);
|
|
394
|
+
ctx.globalAlpha = 1;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// Connections: vector arcs/lines as twin parallel rails with a gap, matching
|
|
398
|
+
// the in-game tree. Geometrically exact; ornate sprite art would need a
|
|
399
|
+
// kite-quad texture warp (WebGL).
|
|
400
|
+
ctx.lineCap = 'round';
|
|
401
|
+
// Inactive first, then active — active rails always sit on top.
|
|
402
|
+
for (const conn of [...screen.connections].sort((a, b) => Number(a.active) - Number(b.active))) {
|
|
403
|
+
ctx.beginPath();
|
|
404
|
+
if (conn.kind === 'arc' && conn.arc) {
|
|
405
|
+
ctx.arc(conn.arc.cx, conn.arc.cy, conn.arc.radius, conn.arc.startAngle, conn.arc.endAngle, conn.arc.clockwise);
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
ctx.moveTo(conn.a.x, conn.a.y);
|
|
409
|
+
ctx.lineTo(conn.b.x, conn.b.y);
|
|
410
|
+
}
|
|
411
|
+
strokeRail(ctx, conn.active, screen.scale);
|
|
412
|
+
}
|
|
413
|
+
// Nodes: real atlas art when supplied, else a vector disc.
|
|
414
|
+
for (const node of screen.nodes) {
|
|
415
|
+
const drew = resources ? blitNode(ctx, node, resources) : false;
|
|
416
|
+
if (!drew) {
|
|
417
|
+
const r = Math.max(1.2, node.radius);
|
|
418
|
+
ctx.beginPath();
|
|
419
|
+
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
|
|
420
|
+
ctx.fillStyle = node.allocated ? KIND_COLOR[node.kind] : withAlpha(KIND_COLOR[node.kind], 0.4);
|
|
421
|
+
ctx.fill();
|
|
422
|
+
}
|
|
423
|
+
// A socketed jewel: draw its item icon in the socket (rarity gem fallback).
|
|
424
|
+
if (node.kind === 'jewel' && node.jewel) {
|
|
425
|
+
drawJewelGem(ctx, node, images);
|
|
426
|
+
}
|
|
427
|
+
// Hover ring sized to the node's frame/icon, not its (large) footprint
|
|
428
|
+
// radius — masteries have no disc to outline, so skip them.
|
|
429
|
+
if (node.skill === hover && node.kind !== 'mastery') {
|
|
430
|
+
const outline = (node.frameSize > 0 ? node.frameSize : node.iconSize) / 2 + 3;
|
|
431
|
+
ctx.beginPath();
|
|
432
|
+
ctx.arc(node.x, node.y, outline, 0, Math.PI * 2);
|
|
433
|
+
ctx.strokeStyle = '#ffffff';
|
|
434
|
+
ctx.lineWidth = 2;
|
|
435
|
+
ctx.stroke();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Standing emphasis for a set of skills (name-search hits): a soft teal glow
|
|
441
|
+
* under a bright core ring, sized to each node's frame like the hover ring. The
|
|
442
|
+
* ring pulses — opacity and radius breathe on a time phase, so matches stay
|
|
443
|
+
* eye-catching against the static tree. Masteries have no disc to outline, so
|
|
444
|
+
* skip them. Driven by the highlight rAF loop, which redraws every frame.
|
|
445
|
+
*/
|
|
446
|
+
function drawHighlight(ctx, screen, highlight) {
|
|
447
|
+
const pulse = (Math.sin(performance.now() / 320) + 1) / 2; // 0..1, ~2s period
|
|
448
|
+
const glowAlpha = 0.2 + pulse * 0.45;
|
|
449
|
+
const coreAlpha = 0.55 + pulse * 0.45;
|
|
450
|
+
const grow = pulse * 5;
|
|
451
|
+
for (const node of screen.nodes) {
|
|
452
|
+
if (!highlight.has(node.skill) || node.kind === 'mastery') {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
const outline = (node.frameSize > 0 ? node.frameSize : node.iconSize) / 2 + 6 + grow;
|
|
456
|
+
ctx.beginPath();
|
|
457
|
+
ctx.arc(node.x, node.y, outline, 0, Math.PI * 2);
|
|
458
|
+
ctx.strokeStyle = `rgba(63, 174, 159, ${glowAlpha})`;
|
|
459
|
+
ctx.lineWidth = 8;
|
|
460
|
+
ctx.stroke();
|
|
461
|
+
ctx.beginPath();
|
|
462
|
+
ctx.arc(node.x, node.y, outline, 0, Math.PI * 2);
|
|
463
|
+
ctx.strokeStyle = `rgba(125, 249, 224, ${coreAlpha})`;
|
|
464
|
+
ctx.lineWidth = 3;
|
|
465
|
+
ctx.stroke();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/** Edge key matching the page's preview set: `min-max` of the two node ids. */
|
|
469
|
+
function edgeKey(a, b) {
|
|
470
|
+
return a < b ? `${a}-${b}` : `${b}-${a}`;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Draw the hover preview on top of the base render: the path's edges as a glowing
|
|
474
|
+
* line and its nodes ringed. Gold for an allocate preview, red for a removal.
|
|
475
|
+
*/
|
|
476
|
+
function drawPreview(ctx, screen, preview) {
|
|
477
|
+
ctx.lineCap = 'round';
|
|
478
|
+
for (const conn of screen.connections) {
|
|
479
|
+
if (!preview.edges.has(edgeKey(conn.from, conn.to))) {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
ctx.beginPath();
|
|
483
|
+
if (conn.kind === 'arc' && conn.arc) {
|
|
484
|
+
ctx.arc(conn.arc.cx, conn.arc.cy, conn.arc.radius, conn.arc.startAngle, conn.arc.endAngle, conn.arc.clockwise);
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
ctx.moveTo(conn.a.x, conn.a.y);
|
|
488
|
+
ctx.lineTo(conn.b.x, conn.b.y);
|
|
489
|
+
}
|
|
490
|
+
strokePreview(ctx, preview.kind, screen.scale);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/** Stroke the current path as a preview rail: a soft glow under a bright core. */
|
|
494
|
+
function strokePreview(ctx, kind, scale) {
|
|
495
|
+
const color = kind === 'remove' ? 'rgba(235, 96, 96, 0.95)' : 'rgba(255, 226, 150, 0.98)';
|
|
496
|
+
const glow = kind === 'remove' ? 'rgba(235, 96, 96, 0.35)' : 'rgba(255, 226, 150, 0.4)';
|
|
497
|
+
ctx.strokeStyle = glow;
|
|
498
|
+
ctx.lineWidth = Math.max(3, 9 * scale);
|
|
499
|
+
ctx.stroke();
|
|
500
|
+
ctx.strokeStyle = color;
|
|
501
|
+
ctx.lineWidth = Math.max(1.5, 4 * scale);
|
|
502
|
+
ctx.stroke();
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Hit-test that accounts for the relocated active ascendancy disc: try its
|
|
506
|
+
* nodes (translated into the hub) first, then the main tree via core's nodeAt.
|
|
507
|
+
*/
|
|
508
|
+
function hitTest(scene, viewport, sx, sy, activeAscendancy) {
|
|
509
|
+
if (activeAscendancy) {
|
|
510
|
+
const disc = scene.centre.ascendancies.find((a) => a.id === activeAscendancy);
|
|
511
|
+
if (disc) {
|
|
512
|
+
const dx = scene.centre.centre.x - disc.worldAnchor.x;
|
|
513
|
+
const dy = scene.centre.centre.y - disc.worldAnchor.y;
|
|
514
|
+
const world = screenToWorld(viewport, sx, sy);
|
|
515
|
+
let best = null;
|
|
516
|
+
let bestDistSq = Infinity;
|
|
517
|
+
for (const node of scene.nodes) {
|
|
518
|
+
if (node.ascendancy !== activeAscendancy || node.radius <= 0) {
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
const ddx = node.x + dx - world.x;
|
|
522
|
+
const ddy = node.y + dy - world.y;
|
|
523
|
+
const distSq = ddx * ddx + ddy * ddy;
|
|
524
|
+
if (distSq <= node.radius * node.radius && distSq < bestDistSq) {
|
|
525
|
+
best = node.skill;
|
|
526
|
+
bestDistSq = distSq;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (best !== null) {
|
|
530
|
+
return best;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return nodeAt(scene, viewport, sx, sy);
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Draw the active ascendancy disc relocated into the hub: each of its nodes is
|
|
538
|
+
* translated from the disc's world anchor to the centre, then projected and
|
|
539
|
+
* blitted. Drawing them at their raw world anchor is the identity of this
|
|
540
|
+
* transform.
|
|
541
|
+
*/
|
|
542
|
+
function drawAscendancy(ctx, scene, viewport, activeAscendancy, resources, hover, preview) {
|
|
543
|
+
const disc = scene.centre.ascendancies.find((a) => a.id === activeAscendancy);
|
|
544
|
+
if (!disc) {
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const { centre } = scene.centre;
|
|
548
|
+
const dx = centre.x - disc.worldAnchor.x;
|
|
549
|
+
const dy = centre.y - disc.worldAnchor.y;
|
|
550
|
+
const scale = viewport.scale;
|
|
551
|
+
// Edges first (under the nodes), relocated by the same translation.
|
|
552
|
+
ctx.lineCap = 'round';
|
|
553
|
+
// Inactive first, then active — active rails always sit on top.
|
|
554
|
+
const ascConnections = scene.connections
|
|
555
|
+
.filter((conn) => conn.ascendancy === activeAscendancy)
|
|
556
|
+
.sort((a, b) => Number(a.active) - Number(b.active));
|
|
557
|
+
for (const conn of ascConnections) {
|
|
558
|
+
const a = projectPoint(viewport, { x: conn.a.x + dx, y: conn.a.y + dy });
|
|
559
|
+
const b = projectPoint(viewport, { x: conn.b.x + dx, y: conn.b.y + dy });
|
|
560
|
+
ctx.beginPath();
|
|
561
|
+
if (conn.kind === 'arc' && conn.arc) {
|
|
562
|
+
const c = projectPoint(viewport, { x: conn.arc.cx + dx, y: conn.arc.cy + dy });
|
|
563
|
+
ctx.arc(c.x, c.y, conn.arc.radius * scale, conn.arc.startAngle, conn.arc.endAngle, conn.arc.clockwise);
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
ctx.moveTo(a.x, a.y);
|
|
567
|
+
ctx.lineTo(b.x, b.y);
|
|
568
|
+
}
|
|
569
|
+
strokeAscendancyRail(ctx, conn.active, scale);
|
|
570
|
+
}
|
|
571
|
+
for (const node of scene.nodes) {
|
|
572
|
+
if (node.ascendancy !== activeAscendancy) {
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
const screen = projectPoint(viewport, { x: node.x + dx, y: node.y + dy });
|
|
576
|
+
const placed = {
|
|
577
|
+
skill: node.skill,
|
|
578
|
+
x: screen.x,
|
|
579
|
+
y: screen.y,
|
|
580
|
+
kind: node.kind,
|
|
581
|
+
icon: node.icon,
|
|
582
|
+
iconSize: node.iconSize * scale,
|
|
583
|
+
frameSize: node.frameSize * scale,
|
|
584
|
+
radius: node.radius * scale,
|
|
585
|
+
allocated: node.allocated,
|
|
586
|
+
};
|
|
587
|
+
const drew = resources ? blitNode(ctx, placed, resources) : false;
|
|
588
|
+
if (!drew) {
|
|
589
|
+
ctx.beginPath();
|
|
590
|
+
ctx.arc(placed.x, placed.y, Math.max(1.5, placed.radius), 0, Math.PI * 2);
|
|
591
|
+
ctx.fillStyle = placed.allocated ? KIND_COLOR[placed.kind] : withAlpha(KIND_COLOR[placed.kind], 0.5);
|
|
592
|
+
ctx.fill();
|
|
593
|
+
}
|
|
594
|
+
if (node.skill === hover && node.kind !== 'mastery') {
|
|
595
|
+
const outline = (placed.frameSize > 0 ? placed.frameSize : placed.iconSize) / 2 + 3;
|
|
596
|
+
ctx.beginPath();
|
|
597
|
+
ctx.arc(placed.x, placed.y, outline, 0, Math.PI * 2);
|
|
598
|
+
ctx.strokeStyle = '#ffffff';
|
|
599
|
+
ctx.lineWidth = 2;
|
|
600
|
+
ctx.stroke();
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
// Hover preview on top, relocated like the edges: the gold path a click would
|
|
604
|
+
// allocate within the ascendancy, or the red set it would remove.
|
|
605
|
+
if (preview) {
|
|
606
|
+
ctx.lineCap = 'round';
|
|
607
|
+
for (const conn of ascConnections) {
|
|
608
|
+
if (!preview.edges.has(edgeKey(conn.from, conn.to))) {
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
ctx.beginPath();
|
|
612
|
+
if (conn.kind === 'arc' && conn.arc) {
|
|
613
|
+
const c = projectPoint(viewport, { x: conn.arc.cx + dx, y: conn.arc.cy + dy });
|
|
614
|
+
ctx.arc(c.x, c.y, conn.arc.radius * scale, conn.arc.startAngle, conn.arc.endAngle, conn.arc.clockwise);
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
const a = projectPoint(viewport, { x: conn.a.x + dx, y: conn.a.y + dy });
|
|
618
|
+
const b = projectPoint(viewport, { x: conn.b.x + dx, y: conn.b.y + dy });
|
|
619
|
+
ctx.moveTo(a.x, a.y);
|
|
620
|
+
ctx.lineTo(b.x, b.y);
|
|
621
|
+
}
|
|
622
|
+
strokePreview(ctx, preview.kind, scale);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Draw a socketed jewel inside its socket: the jewel's own item icon when its
|
|
628
|
+
* art has loaded, otherwise a rarity-coloured gem disc as a fallback. The art is
|
|
629
|
+
* tinted with a soft rarity glow so the socket reads as filled at a glance.
|
|
630
|
+
*/
|
|
631
|
+
function drawJewelGem(ctx, node, images) {
|
|
632
|
+
const frame = node.frameSize > 0 ? node.frameSize : node.iconSize;
|
|
633
|
+
const color = RARITY_COLOR[node.jewel?.rarity ?? ''] ?? '#d6d6d6';
|
|
634
|
+
const iconUrl = node.jewel?.icon;
|
|
635
|
+
const art = iconUrl ? images.get(iconUrl) : undefined;
|
|
636
|
+
if (art) {
|
|
637
|
+
// Jewel item icons are square; fit them inside the ornate socket opening.
|
|
638
|
+
const size = frame * 0.62;
|
|
639
|
+
ctx.save();
|
|
640
|
+
ctx.shadowColor = withAlpha(color, 0.8);
|
|
641
|
+
ctx.shadowBlur = size * 0.35;
|
|
642
|
+
ctx.drawImage(art, node.x - size / 2, node.y - size / 2, size, size);
|
|
643
|
+
ctx.restore();
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const radius = Math.max(2, frame * 0.2);
|
|
647
|
+
ctx.save();
|
|
648
|
+
ctx.beginPath();
|
|
649
|
+
ctx.arc(node.x, node.y, radius, 0, TWO_PI);
|
|
650
|
+
const gradient = ctx.createRadialGradient(node.x, node.y, radius * 0.1, node.x, node.y, radius);
|
|
651
|
+
gradient.addColorStop(0, '#ffffff');
|
|
652
|
+
gradient.addColorStop(0.45, color);
|
|
653
|
+
gradient.addColorStop(1, withAlpha(color, 0.65));
|
|
654
|
+
ctx.fillStyle = gradient;
|
|
655
|
+
ctx.shadowColor = color;
|
|
656
|
+
ctx.shadowBlur = radius;
|
|
657
|
+
ctx.fill();
|
|
658
|
+
ctx.restore();
|
|
659
|
+
}
|
|
660
|
+
/** Blit a node's skill icon and overlay frame from the atlases. */
|
|
661
|
+
function blitNode(ctx, node, resources) {
|
|
662
|
+
// Masteries are drawn as their effect pattern, not as a node disc/icon.
|
|
663
|
+
if (node.kind === 'mastery') {
|
|
664
|
+
return true;
|
|
665
|
+
}
|
|
666
|
+
const iconKey = iconKeyFor(node.kind, node.icon, node.allocated);
|
|
667
|
+
const drewIcon = iconKey ? blitFromAtlas(ctx, resources, iconKey, node.x, node.y, node.iconSize) : false;
|
|
668
|
+
const frameKey = frameKeyFor(node.kind, node.allocated);
|
|
669
|
+
const drewFrame = frameKey ? blitFromAtlas(ctx, resources, frameKey, node.x, node.y, node.frameSize) : false;
|
|
670
|
+
// Drawing a frame counts as handled (e.g. an empty jewel socket has no icon).
|
|
671
|
+
return drewIcon || drewFrame;
|
|
672
|
+
}
|
|
673
|
+
/** Draw a manifest sprite centred at (cx, cy) at the given screen size. */
|
|
674
|
+
function blitFromAtlas(ctx, resources, key, cx, cy, size) {
|
|
675
|
+
const frame = resources.manifest.frames[key];
|
|
676
|
+
if (!frame) {
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
const img = resources.atlases[frame.atlas];
|
|
680
|
+
if (!img) {
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
ctx.drawImage(img, frame.x, frame.y, frame.w, frame.h, cx - size / 2, cy - size / 2, size, size);
|
|
684
|
+
return true;
|
|
685
|
+
}
|
|
686
|
+
const TWO_PI = Math.PI * 2;
|
|
687
|
+
/**
|
|
688
|
+
* Vector stand-in for the centre art: the ornate frame ring, a marker per class
|
|
689
|
+
* at its rim position (`startAngle`), and a highlighted band pointing at the
|
|
690
|
+
* active class — the visible proof of the core-derived rotation. Real atlas art
|
|
691
|
+
* replaces this later; the geometry it sits on is identical.
|
|
692
|
+
*/
|
|
693
|
+
function drawCentre(ctx, scene, viewport, activeClassId, centreSprites, images) {
|
|
694
|
+
const layout = scene.centre;
|
|
695
|
+
const c = projectPoint(viewport, layout.centre);
|
|
696
|
+
const scale = viewport.scale;
|
|
697
|
+
const active = layout.classes.find((cls) => cls.classId === activeClassId);
|
|
698
|
+
const portrait = centreSprites?.portrait;
|
|
699
|
+
const ringStatic = centreSprites?.ringStatic;
|
|
700
|
+
const ringActive = centreSprites?.ringActive;
|
|
701
|
+
const haveArt = Boolean(portrait || ringStatic || ringActive);
|
|
702
|
+
// Layers back-to-front: portrait, rotating active band, ornate frame.
|
|
703
|
+
if (portrait) {
|
|
704
|
+
blitCentre(ctx, images.get(portrait.url), portrait, c.x, c.y, layout.ring.artRadius * scale, 0);
|
|
705
|
+
}
|
|
706
|
+
if (ringActive && active) {
|
|
707
|
+
blitCentre(ctx, images.get(ringActive.url), ringActive, c.x, c.y, layout.ring.activeRadius * scale, active.ringRotation);
|
|
708
|
+
}
|
|
709
|
+
if (ringStatic) {
|
|
710
|
+
blitCentre(ctx, images.get(ringStatic.url), ringStatic, c.x, c.y, layout.ring.frameRadius * scale, 0);
|
|
711
|
+
}
|
|
712
|
+
if (haveArt) {
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
// Vector stand-in when no art is supplied.
|
|
716
|
+
ctx.beginPath();
|
|
717
|
+
ctx.arc(c.x, c.y, layout.ring.frameRadius * scale, 0, TWO_PI);
|
|
718
|
+
ctx.strokeStyle = 'rgba(217, 184, 106, 0.22)';
|
|
719
|
+
ctx.lineWidth = Math.max(1, 3 * scale);
|
|
720
|
+
ctx.stroke();
|
|
721
|
+
if (active) {
|
|
722
|
+
const span = Math.PI / 5;
|
|
723
|
+
ctx.beginPath();
|
|
724
|
+
ctx.arc(c.x, c.y, layout.ring.activeRadius * scale, active.startAngle - span, active.startAngle + span);
|
|
725
|
+
ctx.strokeStyle = '#d9b86a';
|
|
726
|
+
ctx.lineWidth = Math.max(2, 9 * scale);
|
|
727
|
+
ctx.lineCap = 'round';
|
|
728
|
+
ctx.stroke();
|
|
729
|
+
}
|
|
730
|
+
ctx.beginPath();
|
|
731
|
+
ctx.arc(c.x, c.y, layout.innerRadius * scale, 0, TWO_PI);
|
|
732
|
+
ctx.strokeStyle = 'rgba(217, 184, 106, 0.6)';
|
|
733
|
+
ctx.lineWidth = 1.5;
|
|
734
|
+
ctx.stroke();
|
|
735
|
+
}
|
|
736
|
+
/** Blit a centre sprite centred on the hub, sized to a screen radius, rotated. */
|
|
737
|
+
function blitCentre(ctx, img, rect, cx, cy, radius, rotation) {
|
|
738
|
+
if (!img) {
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
ctx.save();
|
|
742
|
+
ctx.translate(cx, cy);
|
|
743
|
+
if (rotation !== 0) {
|
|
744
|
+
ctx.rotate(rotation);
|
|
745
|
+
}
|
|
746
|
+
ctx.drawImage(img, rect.sx, rect.sy, rect.sw, rect.sh, -radius, -radius, radius * 2, radius * 2);
|
|
747
|
+
ctx.restore();
|
|
748
|
+
}
|
|
749
|
+
function withAlpha(hex, alpha) {
|
|
750
|
+
const value = parseInt(hex.slice(1), 16);
|
|
751
|
+
const r = (value >> 16) & 255;
|
|
752
|
+
const g = (value >> 8) & 255;
|
|
753
|
+
const b = value & 255;
|
|
754
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
755
|
+
}
|
|
756
|
+
//# sourceMappingURL=TreeView.js.map
|