@xenolithengine/graph-render-pixi 0.7.0-beta.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 +49 -0
- package/dist/bezier.d.ts +30 -0
- package/dist/bezier.d.ts.map +1 -0
- package/dist/bezier.js +70 -0
- package/dist/bezier.js.map +1 -0
- package/dist/comment-renderer.d.ts +35 -0
- package/dist/comment-renderer.d.ts.map +1 -0
- package/dist/comment-renderer.js +194 -0
- package/dist/comment-renderer.js.map +1 -0
- package/dist/edge-paths.d.ts +24 -0
- package/dist/edge-paths.d.ts.map +1 -0
- package/dist/edge-paths.js +123 -0
- package/dist/edge-paths.js.map +1 -0
- package/dist/edge-renderer.d.ts +29 -0
- package/dist/edge-renderer.d.ts.map +1 -0
- package/dist/edge-renderer.js +99 -0
- package/dist/edge-renderer.js.map +1 -0
- package/dist/geom.d.ts +46 -0
- package/dist/geom.d.ts.map +1 -0
- package/dist/geom.js +54 -0
- package/dist/geom.js.map +1 -0
- package/dist/grid.d.ts +12 -0
- package/dist/grid.d.ts.map +1 -0
- package/dist/grid.js +35 -0
- package/dist/grid.js.map +1 -0
- package/dist/icons.d.ts +14 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/icons.js +32 -0
- package/dist/icons.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/interaction.d.ts +69 -0
- package/dist/interaction.d.ts.map +1 -0
- package/dist/interaction.js +229 -0
- package/dist/interaction.js.map +1 -0
- package/dist/layout.d.ts +126 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +360 -0
- package/dist/layout.js.map +1 -0
- package/dist/macro-frame.d.ts +29 -0
- package/dist/macro-frame.d.ts.map +1 -0
- package/dist/macro-frame.js +116 -0
- package/dist/macro-frame.js.map +1 -0
- package/dist/node-renderer.d.ts +102 -0
- package/dist/node-renderer.d.ts.map +1 -0
- package/dist/node-renderer.js +776 -0
- package/dist/node-renderer.js.map +1 -0
- package/dist/reroute-renderer.d.ts +25 -0
- package/dist/reroute-renderer.d.ts.map +1 -0
- package/dist/reroute-renderer.js +163 -0
- package/dist/reroute-renderer.js.map +1 -0
- package/dist/style.d.ts +56 -0
- package/dist/style.d.ts.map +1 -0
- package/dist/style.js +102 -0
- package/dist/style.js.map +1 -0
- package/dist/text-measure.d.ts +6 -0
- package/dist/text-measure.d.ts.map +1 -0
- package/dist/text-measure.js +21 -0
- package/dist/text-measure.js.map +1 -0
- package/dist/theme.d.ts +86 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +2 -0
- package/dist/theme.js.map +1 -0
- package/dist/viewport-math.d.ts +68 -0
- package/dist/viewport-math.d.ts.map +1 -0
- package/dist/viewport-math.js +106 -0
- package/dist/viewport-math.js.map +1 -0
- package/dist/viewport.d.ts +28 -0
- package/dist/viewport.d.ts.map +1 -0
- package/dist/viewport.js +47 -0
- package/dist/viewport.js.map +1 -0
- package/dist/virtualize.d.ts +55 -0
- package/dist/virtualize.d.ts.map +1 -0
- package/dist/virtualize.js +96 -0
- package/dist/virtualize.js.map +1 -0
- package/dist/widget-renderer.d.ts +150 -0
- package/dist/widget-renderer.d.ts.map +1 -0
- package/dist/widget-renderer.js +399 -0
- package/dist/widget-renderer.js.map +1 -0
- package/dist/xen-theme.d.ts +8 -0
- package/dist/xen-theme.d.ts.map +1 -0
- package/dist/xen-theme.js +23 -0
- package/dist/xen-theme.js.map +1 -0
- package/package.json +63 -0
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
import { BlurFilter, CanvasTextMetrics, Circle, Container, FillGradient, Graphics, Rectangle, RenderTexture, Sprite, Text, BitmapText, TextStyle, Ticker, } from 'pixi.js';
|
|
2
|
+
import { widgetBindKey, widgetVisibility, widgetIsVisible } from '@xenolithengine/graph-core';
|
|
3
|
+
import { pinRowIndexFor } from './layout.js';
|
|
4
|
+
import { computeNodeLayout } from './layout.js';
|
|
5
|
+
import { renderWidgets } from './widget-renderer.js';
|
|
6
|
+
import { execPinColors, hexToRgba, resolveCategoryAccent, resolveCategoryGradient, resolvePinFill, resolvePinShape, resolvePinStroke } from './style.js';
|
|
7
|
+
/** Hit-radius multiplier applied to pin Graphics. > 1 makes pins easier to grab with a mouse
|
|
8
|
+
* and gives finger-sized targets for tablets without changing the visual diameter. Bumps to
|
|
9
|
+
* ~5.5 when the primary input is a finger so a tap lands in a 44px-diameter zone (WCAG 2.5.5).
|
|
10
|
+
* Detected once per module load — SSR-safe via the typeof window guard. */
|
|
11
|
+
const PIN_HIT_RADIUS_FACTOR = (() => {
|
|
12
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
|
13
|
+
return 2.4;
|
|
14
|
+
return window.matchMedia('(pointer: coarse)').matches ? 5.5 : 2.4;
|
|
15
|
+
})();
|
|
16
|
+
/** Read the PinHandle from a PIXI event target (or any DisplayObject). Returns null when the
|
|
17
|
+
* target is not a pin Graphics. Editor code uses this on stage `pointerdown` / `pointerover`. */
|
|
18
|
+
export function readPinHandle(target) {
|
|
19
|
+
if (!target || typeof target !== 'object')
|
|
20
|
+
return null;
|
|
21
|
+
const handle = target.__xenPin;
|
|
22
|
+
return handle ?? null;
|
|
23
|
+
}
|
|
24
|
+
export function markPinInteractive(g, pin, nodeId, cx, cy, visualRadius) {
|
|
25
|
+
g.eventMode = 'static';
|
|
26
|
+
g.cursor = 'crosshair';
|
|
27
|
+
g.hitArea = new Circle(cx, cy, visualRadius * PIN_HIT_RADIUS_FACTOR);
|
|
28
|
+
g.__xenPin = {
|
|
29
|
+
nodeId,
|
|
30
|
+
pinId: String(pin.id),
|
|
31
|
+
direction: pin.direction,
|
|
32
|
+
kind: pin.kind,
|
|
33
|
+
type: String(pin.type),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const COLLAPSE_DURATION_MS = 220;
|
|
37
|
+
function buildTopRoundedRect(g, x, y, w, h, r) {
|
|
38
|
+
const radius = Math.max(0, Math.min(r, w / 2, h));
|
|
39
|
+
return g
|
|
40
|
+
.moveTo(x + radius, y)
|
|
41
|
+
.lineTo(x + w - radius, y)
|
|
42
|
+
.arcTo(x + w, y, x + w, y + radius, radius)
|
|
43
|
+
.lineTo(x + w, y + h)
|
|
44
|
+
.lineTo(x, y + h)
|
|
45
|
+
.lineTo(x, y + radius)
|
|
46
|
+
.arcTo(x, y, x + radius, y, radius)
|
|
47
|
+
.closePath();
|
|
48
|
+
}
|
|
49
|
+
function buildTopRimPath(g, x, y, w, r) {
|
|
50
|
+
const radius = Math.max(0, Math.min(r, w / 2));
|
|
51
|
+
return g
|
|
52
|
+
.moveTo(x, y + radius)
|
|
53
|
+
.arcTo(x, y, x + radius, y, radius)
|
|
54
|
+
.lineTo(x + w - radius, y)
|
|
55
|
+
.arcTo(x + w, y, x + w, y + radius, radius);
|
|
56
|
+
}
|
|
57
|
+
function buildChevron(g, cx, cy, size) {
|
|
58
|
+
const halfW = size * 0.22;
|
|
59
|
+
const halfH = size * 0.11;
|
|
60
|
+
return g
|
|
61
|
+
.moveTo(cx - halfW, cy - halfH)
|
|
62
|
+
.lineTo(cx, cy + halfH)
|
|
63
|
+
.lineTo(cx + halfW, cy - halfH);
|
|
64
|
+
}
|
|
65
|
+
/** Append a pin glyph path to `g`: a right-pointing arrow (exec/control flow), a diamond (e.g. struct
|
|
66
|
+
* types), or a circle (data). Shared by both renderers + the expanded/collapsed pin paths. */
|
|
67
|
+
export function buildPinShape(g, shape, x, y, r) {
|
|
68
|
+
if (shape === 'arrow')
|
|
69
|
+
return g.moveTo(x - r, y - r * 1.15).lineTo(x + r * 1.2, y).lineTo(x - r, y + r * 1.15).closePath();
|
|
70
|
+
if (shape === 'diamond')
|
|
71
|
+
return g.moveTo(x, y - r * 1.25).lineTo(x + r * 1.25, y).lineTo(x, y + r * 1.25).lineTo(x - r * 1.25, y).closePath();
|
|
72
|
+
return g.circle(x, y, r);
|
|
73
|
+
}
|
|
74
|
+
/** A filled+stroked pin Graphics of the given shape. `fillAlpha < 1` lets the exec-pin outline
|
|
75
|
+
* style (transparent triangle, white stroke) reuse the same drawing path as a normal data pin. */
|
|
76
|
+
function drawPin(shape, x, y, r, fill, stroke, strokeW, fillAlpha = 1) {
|
|
77
|
+
return buildPinShape(new Graphics(), shape, x, y, r).fill({ color: fill, alpha: fillAlpha }).stroke({ color: stroke, width: strokeW });
|
|
78
|
+
}
|
|
79
|
+
/** Pin paint for a single pin: exec pins use the connected/unconnected scheme; everything else
|
|
80
|
+
* follows the type-driven theme tokens. Centralised so expanded + collapsed paths agree. */
|
|
81
|
+
function pinPaint(pin, tokens, opts) {
|
|
82
|
+
if (pin.kind === 'exec') {
|
|
83
|
+
const connected = opts.connectedPinIds?.has(String(pin.id)) ?? false;
|
|
84
|
+
return execPinColors(connected);
|
|
85
|
+
}
|
|
86
|
+
return { fill: resolvePinFill(String(pin.type), tokens, opts.types), stroke: resolvePinStroke(String(pin.type), tokens), fillAlpha: 1 };
|
|
87
|
+
}
|
|
88
|
+
/** A header glyph scaled to a `size`×`size` box. `svgInner` is the inner markup of a 24×24 viewBox
|
|
89
|
+
* (resolve a name via {@link IconRegistry}); the art is centred within the box, so place its
|
|
90
|
+
* top-left at `(x, centerY - size/2)` to centre it on a line. Shared by Xen + Liquid Glass. */
|
|
91
|
+
export function makeHeaderIcon(svgInner, color, size) {
|
|
92
|
+
const g = new Graphics();
|
|
93
|
+
g.svg(`<svg viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${svgInner}</svg>`);
|
|
94
|
+
g.scale.set(size / 24);
|
|
95
|
+
return g;
|
|
96
|
+
}
|
|
97
|
+
/** Module-level cache of pre-blurred glow textures keyed by signature. The dominant cost in
|
|
98
|
+
* selection rendering is the BlurFilter running per-node-per-frame — caching means each unique
|
|
99
|
+
* (size × radius × style) combination is blurred ONCE and shared across every node that uses
|
|
100
|
+
* it. For a 500-node graph with ~10 unique node sizes that's ~30 textures total (3 states),
|
|
101
|
+
* down from 500×3 = 1500 per-frame filter passes. Collapsed pill is identical size for every
|
|
102
|
+
* node → exactly 3 textures cover every pill in the graph. */
|
|
103
|
+
const glowTextureCache = new Map();
|
|
104
|
+
/** Padding around the rounded rect inside the baked texture — the blur kernel extends past the
|
|
105
|
+
* stroke and would clip if we baked at exactly (w × h). */
|
|
106
|
+
function glowPadding(strength) {
|
|
107
|
+
return Math.max(strength * 4, 32);
|
|
108
|
+
}
|
|
109
|
+
function glowSignature(style, w, h, radius) {
|
|
110
|
+
return [
|
|
111
|
+
w, h, radius,
|
|
112
|
+
String(style.glow ?? ''),
|
|
113
|
+
style.glowBlur ?? 10,
|
|
114
|
+
style.glowWidth ?? 3,
|
|
115
|
+
].join('|');
|
|
116
|
+
}
|
|
117
|
+
function bakeGlowTexture(renderer, style, w, h, radius) {
|
|
118
|
+
const strength = style.glowBlur ?? 10;
|
|
119
|
+
const pad = glowPadding(strength);
|
|
120
|
+
const glow = new Graphics()
|
|
121
|
+
.roundRect(pad, pad, w, h, radius)
|
|
122
|
+
.stroke({ color: style.glow, width: style.glowWidth ?? 3, alignment: 0.5 });
|
|
123
|
+
// BlurFilter with `antialias: 'on'` races the filter pipeline when rendered into a fresh
|
|
124
|
+
// RenderTexture mid-frame (PIXI v8 — BindGroup.setResource null at index 2 on insertNode).
|
|
125
|
+
// The blur kernel already eliminates aliasing on the stroke, so 'on' is pure cost + risk.
|
|
126
|
+
const blur = new BlurFilter({ strength, quality: 4 });
|
|
127
|
+
blur.padding = pad;
|
|
128
|
+
glow.filters = [blur];
|
|
129
|
+
const rt = RenderTexture.create({
|
|
130
|
+
width: w + pad * 2,
|
|
131
|
+
height: h + pad * 2,
|
|
132
|
+
resolution: renderer.resolution,
|
|
133
|
+
});
|
|
134
|
+
renderer.render({ container: glow, target: rt });
|
|
135
|
+
glow.destroy();
|
|
136
|
+
return rt;
|
|
137
|
+
}
|
|
138
|
+
export function makeGlowLayer(style, w, h, radius, renderer) {
|
|
139
|
+
const layer = new Container();
|
|
140
|
+
layer.visible = false;
|
|
141
|
+
if (style.glow === undefined)
|
|
142
|
+
return layer;
|
|
143
|
+
// Defensive: a zero-size glow would build a 0×0 RenderTexture and PIXI's filter pipeline
|
|
144
|
+
// crashes on the resolve step. Just skip the cache lookup and return the empty layer —
|
|
145
|
+
// selection visuals will reappear once the node has a real size.
|
|
146
|
+
if (w <= 0 || h <= 0)
|
|
147
|
+
return layer;
|
|
148
|
+
// Glow source is a STROKE, not a fill. A blurred filled rectangle leaks brighter halos along
|
|
149
|
+
// the long edges than at the corners. A blurred perimeter stroke traces the contour exactly;
|
|
150
|
+
// the body drawn on top hides the inward half, leaving a uniform outward halo regardless of
|
|
151
|
+
// edge length.
|
|
152
|
+
if (renderer) {
|
|
153
|
+
const sig = glowSignature(style, w, h, radius);
|
|
154
|
+
const tex = glowTextureCache.get(sig);
|
|
155
|
+
if (tex) {
|
|
156
|
+
const strength = style.glowBlur ?? 10;
|
|
157
|
+
const pad = glowPadding(strength);
|
|
158
|
+
const sprite = new Sprite(tex);
|
|
159
|
+
sprite.position.set(-pad, -pad);
|
|
160
|
+
layer.addChild(sprite);
|
|
161
|
+
return layer;
|
|
162
|
+
}
|
|
163
|
+
// CACHE MISS: do NOT bake mid-frame. `bakeGlowTexture` calls `renderer.render()` into a fresh
|
|
164
|
+
// RenderTexture; if we're currently INSIDE the editor's render frame (and we typically are —
|
|
165
|
+
// makeGlowLayer is called from view materialisation which runs on first paint of new nodes,
|
|
166
|
+
// e.g. template-dive surfacing ~30 nodes at once), PIXI v8's FilterSystem races itself and
|
|
167
|
+
// throws "Cannot read properties of null (reading '2')" on BindGroup.setResource. Worse,
|
|
168
|
+
// the throw aborts the current render pass so edges never paint that frame.
|
|
169
|
+
//
|
|
170
|
+
// Fix: paint a LIVE-BlurFilter fallback this frame (filter chains on stage-mounted graphics
|
|
171
|
+
// go through the OUTER render pipeline, no nesting), AND schedule the bake at microtask
|
|
172
|
+
// boundary so the cache fills before the next frame. Future nodes of the same signature
|
|
173
|
+
// get the cheap sprite path. The CURRENT node keeps its live filter for its lifetime —
|
|
174
|
+
// worth the extra per-frame blur on a handful of nodes to avoid a crash.
|
|
175
|
+
queueMicrotask(() => {
|
|
176
|
+
if (glowTextureCache.has(sig))
|
|
177
|
+
return; // someone else baked the same signature first
|
|
178
|
+
try {
|
|
179
|
+
const baked = bakeGlowTexture(renderer, style, w, h, radius);
|
|
180
|
+
glowTextureCache.set(sig, baked);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Leave the cache empty; the next miss will retry. Don't let a bake failure crash render.
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
// Fall through to live-filter fallback below.
|
|
187
|
+
}
|
|
188
|
+
// Live BlurFilter — used for unit tests (no renderer) AND as the safe cache-miss fallback above.
|
|
189
|
+
// `antialias: 'on'` stripped on purpose: the blur kernel already hides aliasing, and the option
|
|
190
|
+
// routes through a code path that's caused trouble historically (see #117).
|
|
191
|
+
const strength = style.glowBlur ?? 10;
|
|
192
|
+
const glow = new Graphics()
|
|
193
|
+
.roundRect(0, 0, w, h, radius)
|
|
194
|
+
.stroke({ color: style.glow, width: style.glowWidth ?? 3, alignment: 0.5 });
|
|
195
|
+
const blur = new BlurFilter({ strength, quality: 4 });
|
|
196
|
+
blur.padding = glowPadding(strength);
|
|
197
|
+
glow.filters = [blur];
|
|
198
|
+
layer.addChild(glow);
|
|
199
|
+
return layer;
|
|
200
|
+
}
|
|
201
|
+
/** Drop the module-level cache — call when switching themes or on editor disposal to free the
|
|
202
|
+
* GPU memory the baked glow textures hold. */
|
|
203
|
+
export function clearGlowTextureCache() {
|
|
204
|
+
for (const tex of glowTextureCache.values())
|
|
205
|
+
tex.destroy(true);
|
|
206
|
+
glowTextureCache.clear();
|
|
207
|
+
}
|
|
208
|
+
/** A FillGradient owns a GPU texture that Graphics.destroy() does NOT release (per PIXI docs), so a
|
|
209
|
+
* fresh `new FillGradient` per node leaks one texture per node on every create/destroy — which, under
|
|
210
|
+
* virtualization's pan/zoom churn, exhausts GPU memory and crashes the context. Gradients here use
|
|
211
|
+
* `textureSpace: 'local'` (0–1 coords), so one instance is valid for nodes of ANY size: cache by the
|
|
212
|
+
* options signature and share. Shared instances are never destroyed on node teardown (like glow). */
|
|
213
|
+
const gradientCache = new Map();
|
|
214
|
+
function sharedGradient(options) {
|
|
215
|
+
const key = JSON.stringify(options);
|
|
216
|
+
let g = gradientCache.get(key);
|
|
217
|
+
if (!g) {
|
|
218
|
+
g = new FillGradient(options);
|
|
219
|
+
gradientCache.set(key, g);
|
|
220
|
+
}
|
|
221
|
+
return g;
|
|
222
|
+
}
|
|
223
|
+
/** Drop the shared gradient cache (theme switch / disposal) — releases the gradient textures. */
|
|
224
|
+
export function clearGradientCache() {
|
|
225
|
+
for (const g of gradientCache.values())
|
|
226
|
+
g.destroy();
|
|
227
|
+
gradientCache.clear();
|
|
228
|
+
}
|
|
229
|
+
function makeBorderLayer(style, w, h, radius) {
|
|
230
|
+
const layer = new Container();
|
|
231
|
+
layer.visible = false;
|
|
232
|
+
if (style.border !== undefined) {
|
|
233
|
+
const border = new Graphics()
|
|
234
|
+
.roundRect(0, 0, w, h, radius)
|
|
235
|
+
.stroke({
|
|
236
|
+
color: style.border,
|
|
237
|
+
width: style.borderWidth ?? 1,
|
|
238
|
+
alignment: 0.5,
|
|
239
|
+
});
|
|
240
|
+
layer.addChild(border);
|
|
241
|
+
}
|
|
242
|
+
return layer;
|
|
243
|
+
}
|
|
244
|
+
/** Compute pin positions on the pill (collapsed) form — placed along the curved end caps so each
|
|
245
|
+
* pin sits tangent to the rounded edge, not floating off to one side. */
|
|
246
|
+
function collapsedPinPositions(pins, pillW, pillH, pillR) {
|
|
247
|
+
const inputs = pins.filter((p) => p.direction === 'in');
|
|
248
|
+
const outputs = pins.filter((p) => p.direction === 'out');
|
|
249
|
+
const cy = pillH / 2;
|
|
250
|
+
// Angular layout: ~50° between pins, clamped to 170° total spread.
|
|
251
|
+
const idealStep = Math.PI / 3.6;
|
|
252
|
+
const maxTotal = Math.PI * 0.94;
|
|
253
|
+
const arc = (count, idx) => {
|
|
254
|
+
if (count <= 1)
|
|
255
|
+
return 0;
|
|
256
|
+
const total = Math.min(maxTotal, (count - 1) * idealStep);
|
|
257
|
+
const step = total / (count - 1);
|
|
258
|
+
return -total / 2 + idx * step;
|
|
259
|
+
};
|
|
260
|
+
const out = [];
|
|
261
|
+
// Inputs on the left semicircle, centre at (pillR, cy).
|
|
262
|
+
inputs.forEach((p, i) => {
|
|
263
|
+
const angle = arc(inputs.length, i);
|
|
264
|
+
out.push({
|
|
265
|
+
pinId: p.id,
|
|
266
|
+
x: pillR - pillR * Math.cos(angle),
|
|
267
|
+
y: cy + pillR * Math.sin(angle),
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
// Outputs on the right semicircle, centre at (pillW - pillR, cy).
|
|
271
|
+
outputs.forEach((p, i) => {
|
|
272
|
+
const angle = arc(outputs.length, i);
|
|
273
|
+
out.push({
|
|
274
|
+
pinId: p.id,
|
|
275
|
+
x: pillW - pillR + pillR * Math.cos(angle),
|
|
276
|
+
y: cy + pillR * Math.sin(angle),
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
export function renderNode(node, tokens, opts = {}) {
|
|
282
|
+
const geo = tokens.geometry;
|
|
283
|
+
// Pin-bound widget hides when the bound pin is connected — share the same predicate the widget
|
|
284
|
+
// renderer uses so node geometry (row heights, pin Y) and widget rects always agree.
|
|
285
|
+
// Widget bind keys are pin LABELS/keys (auto-mint pin ids are unknown to schema authors), but
|
|
286
|
+
// `connectedPinIds` is keyed by the real pin id — resolve the bind to a pin first, then check.
|
|
287
|
+
const isPinConnected = (k) => {
|
|
288
|
+
const set = opts.connectedPinIds;
|
|
289
|
+
if (!set)
|
|
290
|
+
return false;
|
|
291
|
+
const pin = node.pins.find((p) => p.label === k || String(p.id) === k);
|
|
292
|
+
return pin ? set.has(String(pin.id)) : false;
|
|
293
|
+
};
|
|
294
|
+
const expandedLayout = computeNodeLayout(node, {
|
|
295
|
+
node: geo.node,
|
|
296
|
+
pin: { diameter: geo.pin.diameter, rowSpacing: geo.pin.rowSpacing, rowHeight: geo.pin.rowHeight },
|
|
297
|
+
header: { toPinsGap: geo.header.toPinsGap },
|
|
298
|
+
widget: { rowHeight: geo.widget.rowHeight, gap: geo.widget.gap, controlMinWidth: geo.widget.controlMinWidth },
|
|
299
|
+
}, isPinConnected);
|
|
300
|
+
const container = new Container({ label: `node:${node.id}` });
|
|
301
|
+
container.position.set(node.position.x, node.position.y);
|
|
302
|
+
// ============================================================================================
|
|
303
|
+
// EXPANDED FORM
|
|
304
|
+
// ============================================================================================
|
|
305
|
+
const expanded = new Container({ label: 'expanded' });
|
|
306
|
+
container.addChild(expanded);
|
|
307
|
+
const w = expandedLayout.body.width;
|
|
308
|
+
const h = expandedLayout.body.height;
|
|
309
|
+
const glowLayers = {
|
|
310
|
+
hover: makeGlowLayer(tokens.state.hover, w, h, geo.node.radius, opts.renderer ?? null),
|
|
311
|
+
selected: makeGlowLayer(tokens.state.selected, w, h, geo.node.radius, opts.renderer ?? null),
|
|
312
|
+
active: makeGlowLayer(tokens.state.active, w, h, geo.node.radius, opts.renderer ?? null),
|
|
313
|
+
};
|
|
314
|
+
expanded.addChild(glowLayers.hover, glowLayers.selected, glowLayers.active);
|
|
315
|
+
const body = new Graphics()
|
|
316
|
+
.roundRect(0, 0, w, h, geo.node.radius)
|
|
317
|
+
.fill({ color: tokens.color.surface.node });
|
|
318
|
+
expanded.addChild(body);
|
|
319
|
+
const padding = geo.node.headerPadding;
|
|
320
|
+
const headerInnerWidth = w - padding * 2;
|
|
321
|
+
const headerInnerHeight = geo.node.headerHeight - padding;
|
|
322
|
+
const headerRadius = Math.max(geo.node.radius - 2, 0);
|
|
323
|
+
const gradient = resolveCategoryGradient(opts.category, tokens, opts.categoryPalette, opts.color);
|
|
324
|
+
const headerFill = sharedGradient({
|
|
325
|
+
type: 'linear',
|
|
326
|
+
start: { x: 0, y: 0 },
|
|
327
|
+
end: { x: 1, y: 0 },
|
|
328
|
+
colorStops: [
|
|
329
|
+
{ offset: 0, color: gradient.start },
|
|
330
|
+
{ offset: 1, color: gradient.end },
|
|
331
|
+
],
|
|
332
|
+
textureSpace: 'local',
|
|
333
|
+
});
|
|
334
|
+
const headerInner = new Graphics();
|
|
335
|
+
buildTopRoundedRect(headerInner, padding, padding, headerInnerWidth, headerInnerHeight, headerRadius).fill(headerFill);
|
|
336
|
+
expanded.addChild(headerInner);
|
|
337
|
+
const fadeStop = Math.min(1, (tokens.effect.headerInnerShadow.blur + tokens.effect.headerInnerShadow.offsetY) / headerInnerHeight);
|
|
338
|
+
const highlightFill = sharedGradient({
|
|
339
|
+
type: 'linear',
|
|
340
|
+
start: { x: 0, y: 0 },
|
|
341
|
+
end: { x: 0, y: 1 },
|
|
342
|
+
colorStops: [
|
|
343
|
+
{ offset: 0, color: 'rgba(255, 255, 255, 0.25)' },
|
|
344
|
+
{ offset: fadeStop, color: 'rgba(255, 255, 255, 0)' },
|
|
345
|
+
],
|
|
346
|
+
textureSpace: 'local',
|
|
347
|
+
});
|
|
348
|
+
const headerHighlight = new Graphics();
|
|
349
|
+
buildTopRoundedRect(headerHighlight, padding, padding, headerInnerWidth, headerInnerHeight, headerRadius).fill(highlightFill);
|
|
350
|
+
expanded.addChild(headerHighlight);
|
|
351
|
+
const rimGradient = sharedGradient({
|
|
352
|
+
type: 'linear',
|
|
353
|
+
start: { x: 0, y: 0 },
|
|
354
|
+
end: { x: 0, y: 1 },
|
|
355
|
+
colorStops: [
|
|
356
|
+
{ offset: 0, color: 'rgba(255, 255, 255, 0.8)' },
|
|
357
|
+
{ offset: 1, color: 'rgba(255, 255, 255, 0)' },
|
|
358
|
+
],
|
|
359
|
+
textureSpace: 'local',
|
|
360
|
+
});
|
|
361
|
+
const headerRim = new Graphics();
|
|
362
|
+
buildTopRimPath(headerRim, padding, padding, headerInnerWidth, headerRadius).stroke({
|
|
363
|
+
width: 0.75,
|
|
364
|
+
fill: rimGradient,
|
|
365
|
+
alignment: 0,
|
|
366
|
+
});
|
|
367
|
+
expanded.addChild(headerRim);
|
|
368
|
+
const chevronY = padding + geo.node.headerHeight / 2 - 0.5;
|
|
369
|
+
const chevronCenterX = padding + 8 + geo.header.chevronSize / 2 - 4;
|
|
370
|
+
const chevron = new Graphics();
|
|
371
|
+
// Draw at origin (0,0); position the Graphics so rotation pivots around the chevron centre.
|
|
372
|
+
buildChevron(chevron, 0, 0, geo.header.chevronSize).stroke({
|
|
373
|
+
color: tokens.color.text.primary,
|
|
374
|
+
width: 1,
|
|
375
|
+
cap: 'round',
|
|
376
|
+
join: 'round',
|
|
377
|
+
});
|
|
378
|
+
chevron.position.set(chevronCenterX, chevronY);
|
|
379
|
+
chevron.eventMode = 'static';
|
|
380
|
+
expanded.addChild(chevron);
|
|
381
|
+
// Header glyph — a small icon marking the node, drawn left or right of the title. Reserves its
|
|
382
|
+
// width so the title shifts in; the glyph itself is positioned after the title is laid out so it
|
|
383
|
+
// can centre on the title's optical middle.
|
|
384
|
+
const glyph = opts.glyph;
|
|
385
|
+
const iconSize = glyph ? Math.round(tokens.typography.heading.size + 1) : 0;
|
|
386
|
+
const leftGlyph = glyph?.side === 'left';
|
|
387
|
+
const rightGlyph = glyph?.side === 'right';
|
|
388
|
+
const baseTitleX = chevronCenterX + geo.header.chevronSize / 2 + geo.header.titleGap;
|
|
389
|
+
const titleStartX = leftGlyph ? baseTitleX + iconSize + geo.header.titleGap : baseTitleX;
|
|
390
|
+
const title = new BitmapText({
|
|
391
|
+
text: opts.title ?? node.type,
|
|
392
|
+
style: {
|
|
393
|
+
fontFamily: tokens.typography.fontFamily,
|
|
394
|
+
fontSize: tokens.typography.heading.size,
|
|
395
|
+
fontWeight: '700',
|
|
396
|
+
fill: tokens.typography.heading.color,
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
// Ellipsise the title to the header's available width (node may be sized by its pin labels, not the
|
|
400
|
+
// full title — e.g. after a long rename) so it never bleeds past the node's right edge. A right-side
|
|
401
|
+
// glyph also reserves room at the right edge.
|
|
402
|
+
const maxTitleW = w - titleStartX - geo.node.headerPadding - 6 - (rightGlyph ? iconSize + geo.header.titleGap : 0);
|
|
403
|
+
if (!opts.fullTitle && maxTitleW > 0) {
|
|
404
|
+
const full = opts.title ?? node.type;
|
|
405
|
+
if (title.width > maxTitleW) {
|
|
406
|
+
let cut = full;
|
|
407
|
+
while (cut.length > 1 && title.width > maxTitleW) {
|
|
408
|
+
cut = cut.slice(0, -1);
|
|
409
|
+
title.text = `${cut}…`;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
title.position.set(titleStartX, 3);
|
|
414
|
+
expanded.addChild(title);
|
|
415
|
+
if (glyph) {
|
|
416
|
+
// Centre the glyph's box on the title's optical middle so the glyph + text read on one line.
|
|
417
|
+
const icon = makeHeaderIcon(glyph.svg, tokens.typography.heading.color, iconSize);
|
|
418
|
+
const iconX = leftGlyph ? baseTitleX : w - geo.node.headerPadding - iconSize;
|
|
419
|
+
icon.position.set(iconX, title.position.y + title.height / 2 - iconSize / 2);
|
|
420
|
+
expanded.addChild(icon);
|
|
421
|
+
}
|
|
422
|
+
const borderLayers = {
|
|
423
|
+
hover: makeBorderLayer(tokens.state.hover, w, h, geo.node.radius),
|
|
424
|
+
selected: makeBorderLayer(tokens.state.selected, w, h, geo.node.radius),
|
|
425
|
+
active: makeBorderLayer(tokens.state.active, w, h, geo.node.radius),
|
|
426
|
+
};
|
|
427
|
+
expanded.addChild(borderLayers.hover, borderLayers.selected, borderLayers.active);
|
|
428
|
+
// Pin Graphics & labels for expanded form. We also track pin position lookups for edge wiring.
|
|
429
|
+
const expandedPinLocations = new Map();
|
|
430
|
+
const pinLabels = [];
|
|
431
|
+
// Rows that host a visible bound widget — the widget rect sits in the RIGHT HALF of the row.
|
|
432
|
+
// We only suppress the OPPOSITE-side (right) pin's label on those rows: that's the one whose
|
|
433
|
+
// text anchors to the right edge and lands inside the widget area. The LEFT pin's label (next
|
|
434
|
+
// to its pin dot) is far from the widget and stays visible — that's the row's actual name,
|
|
435
|
+
// hiding it just leaves a nameless row of dots.
|
|
436
|
+
const rowsWithWidget = new Set();
|
|
437
|
+
for (const w of node.widgets ?? []) {
|
|
438
|
+
if (w.type === 'button')
|
|
439
|
+
continue;
|
|
440
|
+
const bind = widgetBindKey(w);
|
|
441
|
+
if (bind === undefined)
|
|
442
|
+
continue;
|
|
443
|
+
const pin = node.pins.find((p) => p.label === bind || String(p.id) === bind);
|
|
444
|
+
if (!pin)
|
|
445
|
+
continue;
|
|
446
|
+
const visible = (widgetVisibility(w) === 'always' || !(isPinConnected(bind))) && widgetIsVisible(w, node);
|
|
447
|
+
if (!visible)
|
|
448
|
+
continue;
|
|
449
|
+
const rowIdx = pinRowIndexFor(node, bind);
|
|
450
|
+
if (rowIdx !== undefined)
|
|
451
|
+
rowsWithWidget.add(rowIdx);
|
|
452
|
+
}
|
|
453
|
+
const pinLabelHidden = (pin, side) => {
|
|
454
|
+
if (rowsWithWidget.size === 0 || side === 'left')
|
|
455
|
+
return false;
|
|
456
|
+
const idx = pinRowIndexFor(node, String(pin.id));
|
|
457
|
+
return idx !== undefined && rowsWithWidget.has(idx);
|
|
458
|
+
};
|
|
459
|
+
for (const pin of node.pins) {
|
|
460
|
+
const layoutPin = expandedLayout.pins.find((p) => p.id === pin.id);
|
|
461
|
+
if (!layoutPin)
|
|
462
|
+
continue;
|
|
463
|
+
const localX = layoutPin.x - node.position.x;
|
|
464
|
+
const localY = layoutPin.y - node.position.y;
|
|
465
|
+
expandedPinLocations.set(pin.id, { x: localX, y: localY });
|
|
466
|
+
const radius = geo.pin.diameter / 2;
|
|
467
|
+
const paint = pinPaint(pin, tokens, opts);
|
|
468
|
+
const pinGfx = drawPin(resolvePinShape(String(pin.type), pin.kind, opts.types), localX, localY, radius, paint.fill, paint.stroke, geo.pin.stroke, paint.fillAlpha);
|
|
469
|
+
markPinInteractive(pinGfx, pin, String(node.id), localX, localY, radius);
|
|
470
|
+
expanded.addChild(pinGfx);
|
|
471
|
+
if (pin.label && !pinLabelHidden(pin, layoutPin.side)) {
|
|
472
|
+
const label = new BitmapText({
|
|
473
|
+
text: pin.label,
|
|
474
|
+
style: {
|
|
475
|
+
fontFamily: tokens.typography.fontFamily,
|
|
476
|
+
fontSize: tokens.typography.label.size,
|
|
477
|
+
fontWeight: String(tokens.typography.label.weight),
|
|
478
|
+
fill: tokens.typography.label.color,
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
if (layoutPin.side === 'left') {
|
|
482
|
+
label.anchor.set(0, 0.5);
|
|
483
|
+
label.position.set(localX + radius + geo.pin.labelGap, localY);
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
label.anchor.set(1, 0.5);
|
|
487
|
+
label.position.set(localX - radius - geo.pin.labelGap, localY);
|
|
488
|
+
}
|
|
489
|
+
pinLabels.push(label);
|
|
490
|
+
expanded.addChild(label);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
// ============================================================================================
|
|
494
|
+
// COLLAPSED FORM (pill)
|
|
495
|
+
// ============================================================================================
|
|
496
|
+
const collapsed = new Container({ label: 'collapsed' });
|
|
497
|
+
collapsed.visible = false;
|
|
498
|
+
collapsed.alpha = 0;
|
|
499
|
+
container.addChild(collapsed);
|
|
500
|
+
const pillH = geo.node.pillHeight;
|
|
501
|
+
const pillR = geo.node.pillRadius;
|
|
502
|
+
// Pill width fits the collapsed title (chevron at x≈16, title just past it) plus a trailing cap,
|
|
503
|
+
// not a fixed minimum — long titles otherwise overflow the capsule.
|
|
504
|
+
const pillIconSize = glyph ? Math.round(tokens.typography.heading.size + 1) : 0;
|
|
505
|
+
const basePillTitleX = 16 + geo.header.chevronSize / 2 + geo.header.titleGap;
|
|
506
|
+
const pillTitleStartX = leftGlyph ? basePillTitleX + pillIconSize + geo.header.titleGap : basePillTitleX;
|
|
507
|
+
const pillTitleWidth = CanvasTextMetrics.measureText(opts.title ?? node.type, new TextStyle({ fontFamily: tokens.typography.fontFamily, fontSize: tokens.typography.heading.size, fontWeight: '700' })).width;
|
|
508
|
+
const pillTitleEndX = pillTitleStartX + pillTitleWidth + (rightGlyph ? geo.header.titleGap + pillIconSize : 0);
|
|
509
|
+
const pillW = Math.max(geo.node.pillMinWidth, pillTitleEndX + pillR);
|
|
510
|
+
// Centre the pill vertically within the (taller) expanded body so the node's anchor doesn't shift.
|
|
511
|
+
// Canon: a node collapses UP to its header (pill sits at the top), not to the vertical centre —
|
|
512
|
+
// important now that widget nodes are tall.
|
|
513
|
+
const pillOffsetY = 0;
|
|
514
|
+
// Glow layers for pill form. Drawn behind the pill body so the halo bleeds outside the capsule.
|
|
515
|
+
const pillGlowLayers = {
|
|
516
|
+
hover: makeGlowLayer(tokens.state.hover, pillW, pillH, pillR, opts.renderer ?? null),
|
|
517
|
+
selected: makeGlowLayer(tokens.state.selected, pillW, pillH, pillR, opts.renderer ?? null),
|
|
518
|
+
active: makeGlowLayer(tokens.state.active, pillW, pillH, pillR, opts.renderer ?? null),
|
|
519
|
+
};
|
|
520
|
+
for (const l of Object.values(pillGlowLayers))
|
|
521
|
+
l.position.set(0, pillOffsetY);
|
|
522
|
+
collapsed.addChild(pillGlowLayers.hover, pillGlowLayers.selected, pillGlowLayers.active);
|
|
523
|
+
const accent = opts.color ?? resolveCategoryAccent(opts.category, tokens, opts.categoryPalette);
|
|
524
|
+
// Two layers: opaque body, then accent gradient on top — matches Figma's
|
|
525
|
+
// `background: linear-gradient(...), #0F110E` (gradient layered over a solid fill).
|
|
526
|
+
const pillBg = new Graphics()
|
|
527
|
+
.roundRect(0, pillOffsetY, pillW, pillH, pillR)
|
|
528
|
+
.fill({ color: tokens.color.surface.node });
|
|
529
|
+
collapsed.addChild(pillBg);
|
|
530
|
+
// Figma stops are at 12.63% / 51.57% / 90.91% — accent confined to centre, fading at edges.
|
|
531
|
+
const pillFill = sharedGradient({
|
|
532
|
+
type: 'linear',
|
|
533
|
+
start: { x: 0, y: 0 },
|
|
534
|
+
end: { x: 1, y: 0 },
|
|
535
|
+
colorStops: [
|
|
536
|
+
{ offset: 0, color: hexToRgba(tokens.color.surface.node, 0.5) },
|
|
537
|
+
{ offset: 0.1263, color: hexToRgba(tokens.color.surface.node, 0.5) },
|
|
538
|
+
{ offset: 0.5157, color: hexToRgba(accent, 0.3) },
|
|
539
|
+
{ offset: 0.9091, color: hexToRgba(tokens.color.surface.node, 0.5) },
|
|
540
|
+
{ offset: 1, color: hexToRgba(tokens.color.surface.node, 0.5) },
|
|
541
|
+
],
|
|
542
|
+
textureSpace: 'local',
|
|
543
|
+
});
|
|
544
|
+
const pillAccent = new Graphics()
|
|
545
|
+
.roundRect(0, pillOffsetY, pillW, pillH, pillR)
|
|
546
|
+
.fill(pillFill);
|
|
547
|
+
collapsed.addChild(pillAccent);
|
|
548
|
+
// Inset top highlight — consistent with the expanded header rim. Vertical white→transparent
|
|
549
|
+
// gradient, contained to the top portion of the pill.
|
|
550
|
+
const pillHighlightFadeStop = Math.min(1, (tokens.effect.pillInnerShadow.blur + tokens.effect.pillInnerShadow.offsetY) / pillH);
|
|
551
|
+
const pillHighlightFill = sharedGradient({
|
|
552
|
+
type: 'linear',
|
|
553
|
+
start: { x: 0, y: 0 },
|
|
554
|
+
end: { x: 0, y: 1 },
|
|
555
|
+
colorStops: [
|
|
556
|
+
{ offset: 0, color: 'rgba(255, 255, 255, 0.18)' },
|
|
557
|
+
{ offset: pillHighlightFadeStop, color: 'rgba(255, 255, 255, 0)' },
|
|
558
|
+
],
|
|
559
|
+
textureSpace: 'local',
|
|
560
|
+
});
|
|
561
|
+
const pillHighlight = new Graphics()
|
|
562
|
+
.roundRect(0, pillOffsetY, pillW, pillH, pillR)
|
|
563
|
+
.fill(pillHighlightFill);
|
|
564
|
+
collapsed.addChild(pillHighlight);
|
|
565
|
+
// Thin bright stroke along the very top edge of the capsule — mirrors expanded's `headerRim`.
|
|
566
|
+
const pillRimFill = sharedGradient({
|
|
567
|
+
type: 'linear',
|
|
568
|
+
start: { x: 0, y: 0 },
|
|
569
|
+
end: { x: 0, y: 1 },
|
|
570
|
+
colorStops: [
|
|
571
|
+
{ offset: 0, color: 'rgba(255, 255, 255, 0.55)' },
|
|
572
|
+
{ offset: 1, color: 'rgba(255, 255, 255, 0)' },
|
|
573
|
+
],
|
|
574
|
+
textureSpace: 'local',
|
|
575
|
+
});
|
|
576
|
+
const pillRim = new Graphics()
|
|
577
|
+
.roundRect(0, pillOffsetY, pillW, pillH, pillR)
|
|
578
|
+
.stroke({ width: 0.75, fill: pillRimFill, alignment: 0 });
|
|
579
|
+
// Mask so only the top half of the stroke shows.
|
|
580
|
+
const pillRimMask = new Graphics()
|
|
581
|
+
.rect(-4, pillOffsetY - 4, pillW + 8, pillH / 2 + 4)
|
|
582
|
+
.fill({ color: 0xffffff });
|
|
583
|
+
collapsed.addChild(pillRimMask);
|
|
584
|
+
pillRim.mask = pillRimMask;
|
|
585
|
+
collapsed.addChild(pillRim);
|
|
586
|
+
// Pill chevron — rotated 90° clockwise (points right when collapsed).
|
|
587
|
+
const pillChevron = new Graphics();
|
|
588
|
+
const pillChevronCX = 16;
|
|
589
|
+
const pillChevronCY = pillOffsetY + pillH / 2;
|
|
590
|
+
buildChevron(pillChevron, 0, 0, geo.header.chevronSize).stroke({
|
|
591
|
+
color: tokens.color.text.primary,
|
|
592
|
+
width: 1.2,
|
|
593
|
+
cap: 'round',
|
|
594
|
+
join: 'round',
|
|
595
|
+
});
|
|
596
|
+
pillChevron.position.set(pillChevronCX, pillChevronCY);
|
|
597
|
+
pillChevron.rotation = -Math.PI / 2;
|
|
598
|
+
collapsed.addChild(pillChevron);
|
|
599
|
+
const pillTitle = new BitmapText({
|
|
600
|
+
text: opts.title ?? node.type,
|
|
601
|
+
style: {
|
|
602
|
+
fontFamily: tokens.typography.fontFamily,
|
|
603
|
+
fontSize: tokens.typography.heading.size,
|
|
604
|
+
fontWeight: '700',
|
|
605
|
+
fill: tokens.typography.heading.color,
|
|
606
|
+
},
|
|
607
|
+
});
|
|
608
|
+
pillTitle.position.set(pillTitleStartX, pillOffsetY + (pillH - tokens.typography.heading.lineHeight) / 2);
|
|
609
|
+
collapsed.addChild(pillTitle);
|
|
610
|
+
if (glyph) {
|
|
611
|
+
// Same glyph as the expanded header, centred on the pill title's optical middle.
|
|
612
|
+
const pillIcon = makeHeaderIcon(glyph.svg, tokens.typography.heading.color, pillIconSize);
|
|
613
|
+
const pillIconX = leftGlyph ? basePillTitleX : pillTitleStartX + pillTitleWidth + geo.header.titleGap;
|
|
614
|
+
pillIcon.position.set(pillIconX, pillTitle.position.y + pillTitle.height / 2 - pillIconSize / 2);
|
|
615
|
+
collapsed.addChild(pillIcon);
|
|
616
|
+
}
|
|
617
|
+
// Border layers — on top of body+title but UNDER pins (so pins overlap the rim).
|
|
618
|
+
const pillBorderLayers = {
|
|
619
|
+
hover: makeBorderLayer(tokens.state.hover, pillW, pillH, pillR),
|
|
620
|
+
selected: makeBorderLayer(tokens.state.selected, pillW, pillH, pillR),
|
|
621
|
+
active: makeBorderLayer(tokens.state.active, pillW, pillH, pillR),
|
|
622
|
+
};
|
|
623
|
+
for (const l of Object.values(pillBorderLayers))
|
|
624
|
+
l.position.set(0, pillOffsetY);
|
|
625
|
+
collapsed.addChild(pillBorderLayers.hover, pillBorderLayers.selected, pillBorderLayers.active);
|
|
626
|
+
const pillPinLocations = new Map();
|
|
627
|
+
const pillPinPositions = collapsedPinPositions(node.pins, pillW, pillH, pillR);
|
|
628
|
+
for (const pp of pillPinPositions) {
|
|
629
|
+
const pin = node.pins.find((p) => p.id === pp.pinId);
|
|
630
|
+
if (!pin)
|
|
631
|
+
continue;
|
|
632
|
+
const localX = pp.x;
|
|
633
|
+
const localY = pillOffsetY + pp.y;
|
|
634
|
+
pillPinLocations.set(pin.id, { x: localX, y: localY });
|
|
635
|
+
const radius = geo.pin.diameter / 2;
|
|
636
|
+
const paint = pinPaint(pin, tokens, opts);
|
|
637
|
+
const pinGfx = drawPin(resolvePinShape(String(pin.type), pin.kind, opts.types), localX, localY, radius, paint.fill, paint.stroke, geo.pin.stroke, paint.fillAlpha);
|
|
638
|
+
markPinInteractive(pinGfx, pin, String(node.id), localX, localY, radius);
|
|
639
|
+
collapsed.addChild(pinGfx);
|
|
640
|
+
}
|
|
641
|
+
// ============================================================================================
|
|
642
|
+
// STATE & ANIMATION
|
|
643
|
+
// ============================================================================================
|
|
644
|
+
let isCollapsedState = opts.collapsed ?? false;
|
|
645
|
+
let collapseFraction = isCollapsedState ? 1 : 0;
|
|
646
|
+
let animationTicker = null;
|
|
647
|
+
function applyFraction(f) {
|
|
648
|
+
const expandedAlpha = 1 - f;
|
|
649
|
+
const collapsedAlpha = f;
|
|
650
|
+
expanded.alpha = expandedAlpha;
|
|
651
|
+
expanded.visible = expandedAlpha > 0.001;
|
|
652
|
+
collapsed.alpha = collapsedAlpha;
|
|
653
|
+
collapsed.visible = collapsedAlpha > 0.001;
|
|
654
|
+
// Rotate the expanded chevron from 0 → -90° as we collapse.
|
|
655
|
+
chevron.rotation = -f * (Math.PI / 2);
|
|
656
|
+
// Bound node hit-testing to the visible body (or pill when collapsed). Without an explicit
|
|
657
|
+
// hitArea, PIXI unions the children's bounds — which include the blur-padded glow layers — so a
|
|
658
|
+
// hovered/selected node would have a hit/drag radius far larger than the node itself.
|
|
659
|
+
container.hitArea = f >= 0.5
|
|
660
|
+
? new Rectangle(0, pillOffsetY, pillW, pillH)
|
|
661
|
+
: new Rectangle(0, 0, w, h);
|
|
662
|
+
}
|
|
663
|
+
applyFraction(collapseFraction);
|
|
664
|
+
function setCollapsed(c, animated = true) {
|
|
665
|
+
if (c === isCollapsedState && (collapseFraction === 0 || collapseFraction === 1))
|
|
666
|
+
return;
|
|
667
|
+
isCollapsedState = c;
|
|
668
|
+
const targetFraction = c ? 1 : 0;
|
|
669
|
+
if (!animated) {
|
|
670
|
+
collapseFraction = targetFraction;
|
|
671
|
+
applyFraction(targetFraction);
|
|
672
|
+
opts.requestRender?.();
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (animationTicker) {
|
|
676
|
+
Ticker.shared.remove(animationTicker);
|
|
677
|
+
animationTicker = null;
|
|
678
|
+
}
|
|
679
|
+
const start = collapseFraction;
|
|
680
|
+
const delta = targetFraction - start;
|
|
681
|
+
let elapsed = 0;
|
|
682
|
+
animationTicker = (tick) => {
|
|
683
|
+
elapsed += tick.deltaMS;
|
|
684
|
+
const t = Math.min(1, elapsed / COLLAPSE_DURATION_MS);
|
|
685
|
+
// ease-out cubic for snappy feel
|
|
686
|
+
const eased = 1 - Math.pow(1 - t, 3);
|
|
687
|
+
collapseFraction = start + delta * eased;
|
|
688
|
+
applyFraction(collapseFraction);
|
|
689
|
+
opts.requestRender?.();
|
|
690
|
+
if (t >= 1 && animationTicker) {
|
|
691
|
+
Ticker.shared.remove(animationTicker);
|
|
692
|
+
animationTicker = null;
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
Ticker.shared.add(animationTicker);
|
|
696
|
+
}
|
|
697
|
+
function setVisualState(state) {
|
|
698
|
+
glowLayers.hover.visible = state === 'hover';
|
|
699
|
+
glowLayers.selected.visible = state === 'selected';
|
|
700
|
+
glowLayers.active.visible = state === 'active';
|
|
701
|
+
borderLayers.hover.visible = state === 'hover';
|
|
702
|
+
borderLayers.selected.visible = state === 'selected';
|
|
703
|
+
borderLayers.active.visible = state === 'active';
|
|
704
|
+
pillGlowLayers.hover.visible = state === 'hover';
|
|
705
|
+
pillGlowLayers.selected.visible = state === 'selected';
|
|
706
|
+
pillGlowLayers.active.visible = state === 'active';
|
|
707
|
+
pillBorderLayers.hover.visible = state === 'hover';
|
|
708
|
+
pillBorderLayers.selected.visible = state === 'selected';
|
|
709
|
+
pillBorderLayers.active.visible = state === 'active';
|
|
710
|
+
}
|
|
711
|
+
setVisualState(opts.state ?? 'default');
|
|
712
|
+
function pinLocalPosition(pinId) {
|
|
713
|
+
const expandedPos = expandedPinLocations.get(pinId);
|
|
714
|
+
const pillPos = pillPinLocations.get(pinId);
|
|
715
|
+
if (!expandedPos && !pillPos)
|
|
716
|
+
return null;
|
|
717
|
+
if (!expandedPos)
|
|
718
|
+
return pillPos;
|
|
719
|
+
if (!pillPos)
|
|
720
|
+
return expandedPos;
|
|
721
|
+
// Interpolate based on current animation fraction.
|
|
722
|
+
return {
|
|
723
|
+
x: expandedPos.x + (pillPos.x - expandedPos.x) * collapseFraction,
|
|
724
|
+
y: expandedPos.y + (pillPos.y - expandedPos.y) * collapseFraction,
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
// Toggle on chevron click — both forms' chevrons act as a toggle button.
|
|
728
|
+
function onChevronClick() {
|
|
729
|
+
setCollapsed(!isCollapsedState, true);
|
|
730
|
+
}
|
|
731
|
+
chevron.cursor = 'pointer';
|
|
732
|
+
chevron.eventMode = 'static';
|
|
733
|
+
// Hit area is in LOCAL coordinates. Chevron Graphics is drawn at (0,0) and positioned via
|
|
734
|
+
// `chevron.position.set(chevronCenterX, chevronY)`, so the centre is (0,0) in local space.
|
|
735
|
+
chevron.hitArea = { contains: (px, py) => Math.abs(px) <= geo.header.chevronSize / 2 &&
|
|
736
|
+
Math.abs(py) <= geo.header.chevronSize / 2,
|
|
737
|
+
};
|
|
738
|
+
chevron.on('pointerdown', (e) => {
|
|
739
|
+
onChevronClick();
|
|
740
|
+
e.stopPropagation();
|
|
741
|
+
});
|
|
742
|
+
pillChevron.cursor = 'pointer';
|
|
743
|
+
pillChevron.eventMode = 'static';
|
|
744
|
+
pillChevron.hitArea = { contains: (px, py) => Math.hypot(px, py) <= geo.header.chevronSize,
|
|
745
|
+
};
|
|
746
|
+
pillChevron.on('pointerdown', (e) => {
|
|
747
|
+
onChevronClick();
|
|
748
|
+
e.stopPropagation();
|
|
749
|
+
});
|
|
750
|
+
// Widget rows live in the expanded form (below the pins); the pill form has none.
|
|
751
|
+
const widgetLayoutTokens = {
|
|
752
|
+
node: { headerHeight: geo.node.headerHeight },
|
|
753
|
+
pin: { rowSpacing: geo.pin.rowSpacing, rowHeight: geo.pin.rowHeight, diameter: geo.pin.diameter, labelGap: geo.pin.labelGap },
|
|
754
|
+
header: { toPinsGap: geo.header.toPinsGap },
|
|
755
|
+
widget: { rowHeight: geo.widget.rowHeight, gap: geo.widget.gap, paddingX: geo.widget.paddingX },
|
|
756
|
+
};
|
|
757
|
+
const widgetsView = node.widgets && node.widgets.length > 0
|
|
758
|
+
? renderWidgets(node, w, tokens, widgetLayoutTokens, opts.customWidgets, { isPinConnected, ...(opts.pinLiveValue ? { pinLiveValue: opts.pinLiveValue } : {}) })
|
|
759
|
+
: null;
|
|
760
|
+
if (widgetsView)
|
|
761
|
+
expanded.addChild(widgetsView.container);
|
|
762
|
+
const view = {
|
|
763
|
+
container,
|
|
764
|
+
setVisualState,
|
|
765
|
+
setCollapsed,
|
|
766
|
+
isCollapsed: () => isCollapsedState,
|
|
767
|
+
pinLocalPosition,
|
|
768
|
+
collapsedRect: { x: 0, y: pillOffsetY, w: pillW, h: pillH, r: pillR },
|
|
769
|
+
};
|
|
770
|
+
if (widgetsView) {
|
|
771
|
+
view.widgetHit = (x, y) => (isCollapsedState ? null : widgetsView.widgetHit(x, y));
|
|
772
|
+
view.updateWidget = (id, value) => widgetsView.update(id, value);
|
|
773
|
+
}
|
|
774
|
+
return view;
|
|
775
|
+
}
|
|
776
|
+
//# sourceMappingURL=node-renderer.js.map
|