ember-tribe 2.6.8 → 2.6.10

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.
@@ -0,0 +1,242 @@
1
+ {{! app/components/storylang/arc-diagram.hbs }}
2
+
3
+ <style>
4
+ /* ── ArcDiagram wrapper ── */
5
+ .sl-arc-wrapper {
6
+ position: relative;
7
+ width: 100%;
8
+ height: 100%;
9
+ }
10
+
11
+ /* ── Zoom controls ── */
12
+ .sl-zoom-controls {
13
+ position: fixed;
14
+ top: 100px;
15
+ left: 30px;
16
+ display: flex;
17
+ flex-direction: column;
18
+ gap: 4px;
19
+ z-index: 10;
20
+ }
21
+
22
+ .sl-zoom-btn {
23
+ width: 24px;
24
+ height: 24px;
25
+ display: flex;
26
+ align-items: center;
27
+ justify-content: center;
28
+ background: #161b22;
29
+ border: 1px solid #30363d;
30
+ border-radius: 6px;
31
+ color: #8b949e;
32
+ font-size: 16px;
33
+ line-height: 1;
34
+ cursor: pointer;
35
+ transition:
36
+ background 0.15s ease,
37
+ color 0.15s ease,
38
+ border-color 0.15s ease;
39
+ user-select: none;
40
+ }
41
+
42
+ .sl-zoom-btn:hover {
43
+ background: #21262d;
44
+ color: #e6edf3;
45
+ border-color: #58a6ff;
46
+ }
47
+
48
+ .sl-zoom-btn:active {
49
+ background: #30363d;
50
+ }
51
+
52
+ .sl-zoom-reset {
53
+ font-size: 9px;
54
+ font-weight: 600;
55
+ letter-spacing: 0.04em;
56
+ font-family: "Berkeley Mono", "Courier New", monospace;
57
+ }
58
+
59
+ /* ── SVG ── */
60
+ .sl-arc-svg {
61
+ display: block;
62
+ width: 100%;
63
+ height: 100%;
64
+ overflow: visible;
65
+ user-select: none;
66
+ cursor: grab;
67
+ touch-action: none;
68
+ }
69
+
70
+ .sl-arc-svg:active {
71
+ cursor: grabbing;
72
+ }
73
+
74
+ /* ── Type-group ring segments ── */
75
+ .sl-type-ring-segment {
76
+ opacity: 0.18;
77
+ }
78
+
79
+ /* ── Type-group labels ── */
80
+ .sl-type-group-label {
81
+ font-family: "Berkeley Mono", "Courier New", monospace;
82
+ font-size: 7px;
83
+ font-weight: 600;
84
+ letter-spacing: 0.1em;
85
+ text-transform: uppercase;
86
+ dominant-baseline: middle;
87
+ opacity: 0.4;
88
+ pointer-events: none;
89
+ }
90
+
91
+ /* ── Arcs ── */
92
+ .sl-arc-path {
93
+ fill: none;
94
+ pointer-events: none;
95
+ }
96
+ .sl-arc-path.default {
97
+ opacity: 0.15;
98
+ }
99
+ .sl-arc-path.highlighted {
100
+ opacity: 0.75;
101
+ }
102
+ .sl-arc-path.dimmed {
103
+ opacity: 0.02;
104
+ }
105
+
106
+ /* ── Node groups ── */
107
+ .sl-node-group {
108
+ cursor: pointer;
109
+ }
110
+
111
+ .sl-node-dot {
112
+ transition: r 0.12s ease;
113
+ }
114
+
115
+ .sl-node-group.active .sl-node-dot {
116
+ filter: drop-shadow(0 0 5px currentColor);
117
+ }
118
+
119
+ .sl-node-label {
120
+ font-family: "DM Sans", "Segoe UI", system-ui, sans-serif;
121
+ font-size: 8px;
122
+ font-weight: 300;
123
+ dominant-baseline: middle;
124
+ pointer-events: all;
125
+ cursor: pointer;
126
+ }
127
+
128
+ .sl-node-group:hover .sl-node-label {
129
+ fill: #e8edf4 !important;
130
+ }
131
+
132
+ .sl-node-group.dimmed .sl-node-dot {
133
+ opacity: 0.15;
134
+ }
135
+ .sl-node-group.dimmed .sl-node-label {
136
+ opacity: 0.15;
137
+ }
138
+ </style>
139
+
140
+ <div class="sl-arc-wrapper">
141
+
142
+ {{! ── Zoom controls ── }}
143
+ <div class="sl-zoom-controls">
144
+ <button
145
+ class="sl-zoom-btn"
146
+ type="button"
147
+ {{on "click" this.zoomIn}}
148
+ title="Zoom in"
149
+ >+</button>
150
+ <button
151
+ class="sl-zoom-btn"
152
+ type="button"
153
+ {{on "click" this.zoomOut}}
154
+ title="Zoom out"
155
+ >−</button>
156
+ <button
157
+ class="sl-zoom-btn sl-zoom-reset"
158
+ type="button"
159
+ {{on "click" this.zoomReset}}
160
+ title="Reset zoom"
161
+ >1:1</button>
162
+ </div>
163
+
164
+ <svg
165
+ class="sl-arc-svg"
166
+ viewBox={{this.viewBox}}
167
+ preserveAspectRatio="xMidYMid meet"
168
+ xmlns="http://www.w3.org/2000/svg"
169
+ {{did-insert this.registerSvg}}
170
+ {{on "pointerdown" this.handlePointerDown}}
171
+ {{on "pointermove" this.handlePointerMove}}
172
+ {{on "pointerup" this.handlePointerUp}}
173
+ {{on "pointercancel" this.handlePointerUp}}
174
+ >
175
+
176
+ {{! ── Zoom group — wraps everything so scale is centred ── }}
177
+ <g class="sl-zoom-group" transform={{this.zoomTransform}}>
178
+
179
+ {{! ── Rotatable ring group ── }}
180
+ <g class="sl-ring" transform={{this.ringTransform}}>
181
+
182
+ {{! ── Type-group ring segments ── }}
183
+ {{#each this.typeGroupArcs as |seg|}}
184
+ <path
185
+ class="sl-type-ring-segment"
186
+ d={{seg.path}}
187
+ fill={{seg.color}}
188
+ />
189
+ {{/each}}
190
+
191
+ {{! ── Chord arcs ── }}
192
+ <g class="sl-arcs">
193
+ {{#each this.arcDisplayList as |arc|}}
194
+ <path
195
+ class={{arc.arcClass}}
196
+ d={{arc.path}}
197
+ stroke={{arc.stroke}}
198
+ stroke-width={{arc.strokeWidth}}
199
+ />
200
+ {{/each}}
201
+ </g>
202
+
203
+ {{! ── Nodes ── }}
204
+ <g class="sl-nodes">
205
+ {{#each this.nodeDisplayList as |item|}}
206
+ <g class={{item.groupClass}} data-node-slug={{item.node.slug}}>
207
+ {{! Invisible hit area so the whole label+dot region is clickable }}
208
+ <circle
209
+ cx={{item.cx}}
210
+ cy={{item.cy}}
211
+ r="12"
212
+ fill="transparent"
213
+ style="pointer-events:all; cursor:pointer;"
214
+ />
215
+ <circle
216
+ class="sl-node-dot"
217
+ cx={{item.cx}}
218
+ cy={{item.cy}}
219
+ r={{item.dotR}}
220
+ fill={{item.color}}
221
+ opacity={{item.dotOpacity}}
222
+ />
223
+ <text
224
+ class="sl-node-label"
225
+ x={{item.labelX}}
226
+ y={{item.labelY}}
227
+ fill={{item.labelFill}}
228
+ font-weight={{item.labelWeight}}
229
+ text-anchor={{item.textAnchor}}
230
+ transform="rotate({{item.labelRotate}}, {{item.labelX}}, {{item.labelY}})"
231
+ >{{item.node.slug}}</text>
232
+ </g>
233
+ {{/each}}
234
+ </g>
235
+
236
+ </g>{{! end .sl-ring }}
237
+
238
+ </g>{{! end .sl-zoom-group }}
239
+
240
+ </svg>
241
+
242
+ </div>
@@ -0,0 +1,432 @@
1
+ import Component from '@glimmer/component';
2
+ import { tracked } from '@glimmer/tracking';
3
+ import { action } from '@ember/object';
4
+
5
+ // ─── Visual constants ────────────────────────────────────────────────────────
6
+ const RADIUS = 220;
7
+ const CENTER_X = 340;
8
+ const CENTER_Y = 340;
9
+ const SVG_SIZE = 680;
10
+ const DOT_R = 3.5;
11
+ const DOT_R_EDGE = 4.5;
12
+ const DOT_R_SEL = 5.5;
13
+ const LABEL_OFFSET = 18;
14
+ const ARC_PULL = 0.45;
15
+
16
+ // Zoom constants
17
+ const ZOOM_MIN = 0.4;
18
+ const ZOOM_MAX = 3.0;
19
+ const ZOOM_STEP = 0.12; // per button press
20
+
21
+ // Inertia / drag constants
22
+ const INERTIA_FRICTION = 0.88;
23
+ const INERTIA_MIN_VEL = 0.03;
24
+ const VELOCITY_SMOOTH = 0.25;
25
+
26
+ const TYPE_ORDER = [
27
+ 'route',
28
+ 'service',
29
+ 'type',
30
+ 'helper',
31
+ 'modifier',
32
+ 'component',
33
+ ];
34
+
35
+ export const TYPE_COLOR = {
36
+ component: '#4fc3f7',
37
+ route: '#a78bfa',
38
+ service: '#34d399',
39
+ helper: '#fbbf24',
40
+ modifier: '#f87171',
41
+ type: '#fb923c',
42
+ };
43
+
44
+ export const EDGE_COLOR = {
45
+ service: 'rgba(52,211,153,0.7)',
46
+ component: 'rgba(79,195,247,0.6)',
47
+ };
48
+
49
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
50
+
51
+ function polarToXY(angleDeg, r, cx = CENTER_X, cy = CENTER_Y) {
52
+ const rad = (angleDeg - 90) * (Math.PI / 180);
53
+ return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
54
+ }
55
+
56
+ function pointerAngle(svgEl, clientX, clientY) {
57
+ const rect = svgEl.getBoundingClientRect();
58
+ const scaleX = SVG_SIZE / rect.width;
59
+ const scaleY = SVG_SIZE / rect.height;
60
+ const dx = (clientX - rect.left) * scaleX - CENTER_X;
61
+ const dy = (clientY - rect.top) * scaleY - CENTER_Y;
62
+ return Math.atan2(dy, dx) * (180 / Math.PI);
63
+ }
64
+
65
+ function clamp(val, min, max) {
66
+ return Math.min(max, Math.max(min, val));
67
+ }
68
+
69
+ // ─── Component ───────────────────────────────────────────────────────────────
70
+ export default class StorylangArcDiagramComponent extends Component {
71
+ // ── Tracked state ─────────────────────────────────────────────────────────
72
+
73
+ @tracked rotationDeg = 0;
74
+ @tracked zoomScale = 1;
75
+
76
+ // ── Private drag / inertia state ──────────────────────────────────────────
77
+
78
+ _isDragging = false;
79
+ _dragStartAngle = 0;
80
+ _dragStartRot = 0;
81
+ _lastAngle = 0;
82
+ _velocity = 0;
83
+ _dragMoved = false; // true once pointer moves enough to count as a drag
84
+ _rafId = null;
85
+ _svgEl = null;
86
+ _pendingNodeClick = null; // node targeted by the current pointerdown
87
+
88
+ // ── Sorted nodes ─────────────────────────────────────────────────────────
89
+
90
+ get nodes() {
91
+ return [...(this.args.nodes ?? [])].sort((a, b) => {
92
+ const ai = TYPE_ORDER.indexOf(a.type);
93
+ const bi = TYPE_ORDER.indexOf(b.type);
94
+ return (
95
+ (ai === -1 ? TYPE_ORDER.length : ai) -
96
+ (bi === -1 ? TYPE_ORDER.length : bi)
97
+ );
98
+ });
99
+ }
100
+
101
+ // ── Filtered edges ────────────────────────────────────────────────────────
102
+
103
+ get edges() {
104
+ const slugSet = new Set(this.nodes.map((n) => n.slug));
105
+ return (this.args.edges ?? []).filter(
106
+ (e) => slugSet.has(e.source) && slugSet.has(e.target),
107
+ );
108
+ }
109
+
110
+ get selectedSlug() {
111
+ return this.args.selectedNode?.slug ?? null;
112
+ }
113
+
114
+ // ── SVG viewBox ───────────────────────────────────────────────────────────
115
+
116
+ get viewBox() {
117
+ return `0 0 ${SVG_SIZE} ${SVG_SIZE}`;
118
+ }
119
+
120
+ // ── Transforms ────────────────────────────────────────────────────────────
121
+
122
+ /** Rotation applied to the ring group */
123
+ get ringTransform() {
124
+ return `rotate(${this.rotationDeg}, ${CENTER_X}, ${CENTER_Y})`;
125
+ }
126
+
127
+ /** Scale applied to the whole SVG content, centred on the SVG centre */
128
+ get zoomTransform() {
129
+ const s = this.zoomScale;
130
+ // translate so scaling happens around the centre point
131
+ const tx = CENTER_X * (1 - s);
132
+ const ty = CENTER_Y * (1 - s);
133
+ return `translate(${tx}, ${ty}) scale(${s})`;
134
+ }
135
+
136
+ // ── Angle per node ────────────────────────────────────────────────────────
137
+
138
+ get angleStep() {
139
+ const n = this.nodes.length;
140
+ return n > 0 ? 360 / n : 0;
141
+ }
142
+
143
+ get nodeAngles() {
144
+ const map = {};
145
+ this.nodes.forEach((nd, i) => {
146
+ map[nd.slug] = i * this.angleStep;
147
+ });
148
+ return map;
149
+ }
150
+
151
+ // ── Type-group arcs ───────────────────────────────────────────────────────
152
+
153
+ get typeGroups() {
154
+ const nodes = this.nodes;
155
+ const groups = [];
156
+ let i = 0;
157
+ while (i < nodes.length) {
158
+ const t = nodes[i].type;
159
+ let j = i;
160
+ while (j < nodes.length && nodes[j].type === t) j++;
161
+ groups.push({ type: t, startIdx: i, endIdx: j - 1 });
162
+ i = j;
163
+ }
164
+ return groups;
165
+ }
166
+
167
+ get typeGroupArcs() {
168
+ const step = this.angleStep;
169
+ const R = RADIUS + 14;
170
+ const r = RADIUS + 6;
171
+
172
+ return this.typeGroups.map((g) => {
173
+ const startAngle = g.startIdx * step - step * 0.45;
174
+ const endAngle = g.endIdx * step + step * 0.45;
175
+ const midAngle = (startAngle + endAngle) / 2;
176
+
177
+ const s1 = polarToXY(startAngle, R);
178
+ const s2 = polarToXY(startAngle, r);
179
+ const e1 = polarToXY(endAngle, R);
180
+ const e2 = polarToXY(endAngle, r);
181
+
182
+ const largeArc = endAngle - startAngle > 180 ? 1 : 0;
183
+
184
+ const path =
185
+ `M ${s2.x} ${s2.y}` +
186
+ ` A ${r} ${r} 0 ${largeArc} 1 ${e2.x} ${e2.y}` +
187
+ ` L ${e1.x} ${e1.y}` +
188
+ ` A ${R} ${R} 0 ${largeArc} 0 ${s1.x} ${s1.y}` +
189
+ ` Z`;
190
+
191
+ const lp = polarToXY(midAngle, R + 12);
192
+ const isLeftSide = midAngle % 360 > 180;
193
+ const anchor = isLeftSide ? 'end' : 'start';
194
+ const anchorFinal =
195
+ Math.abs((midAngle % 360) - 180) < 20 ||
196
+ midAngle % 360 < 20 ||
197
+ midAngle % 360 > 340
198
+ ? 'middle'
199
+ : anchor;
200
+ const segLabelRotate = isLeftSide
201
+ ? (midAngle % 360) + 90
202
+ : (midAngle % 360) - 90;
203
+
204
+ return {
205
+ type: g.type,
206
+ path,
207
+ color: TYPE_COLOR[g.type] ?? '#8895a7',
208
+ labelX: lp.x,
209
+ labelY: lp.y,
210
+ label: g.type.toUpperCase() + 'S',
211
+ anchor: anchorFinal,
212
+ labelRotate: segLabelRotate,
213
+ };
214
+ });
215
+ }
216
+
217
+ // ── Node display list ─────────────────────────────────────────────────────
218
+ //
219
+ // The key fix: label rotation and text-anchor must be derived from the node's
220
+ // *effective screen angle* = (static nodeAngle + current rotationDeg), so that
221
+ // as the ring spins, labels continuously flip to remain readable.
222
+
223
+ get nodeDisplayList() {
224
+ const { nodes, edges, selectedSlug, nodeAngles, rotationDeg } = this;
225
+
226
+ return nodes.map((nd) => {
227
+ const angle = nodeAngles[nd.slug]; // static angle on the ring
228
+ const pos = polarToXY(angle, RADIUS);
229
+ const labelPos = polarToXY(angle, RADIUS + LABEL_OFFSET);
230
+
231
+ const isSelected = nd.slug === selectedSlug;
232
+ const inEdge =
233
+ selectedSlug != null &&
234
+ edges.some(
235
+ (e) =>
236
+ (e.source === selectedSlug && e.target === nd.slug) ||
237
+ (e.target === selectedSlug && e.source === nd.slug),
238
+ );
239
+ const isDimmed = selectedSlug != null && !isSelected && !inEdge;
240
+ const color = TYPE_COLOR[nd.type] ?? '#8895a7';
241
+
242
+ // ── Rotation-aware label orientation ────────────────────────────────
243
+ // effectiveAngle is the clock-angle at which the node currently appears
244
+ // on screen (0 = top, increases clockwise).
245
+ const effectiveAngle = (((angle + rotationDeg) % 360) + 360) % 360;
246
+ const isLeftHalf = effectiveAngle > 180;
247
+
248
+ // The <text> is placed at labelPos and we rotate it around that point.
249
+ // svgAngle converts our clock-angle to SVG's math-angle convention
250
+ // (SVG rotate: 0=right, clockwise). The static node angle (not the
251
+ // effective one) is used for the rotation axis since labelPos is
252
+ // already inside the rotatable <g>, but we must flip based on the
253
+ // effective (rotated) position.
254
+ const svgAngle = angle - 90;
255
+ const labelRotate = String(isLeftHalf ? svgAngle + 180 : svgAngle);
256
+ const textAnchor = isLeftHalf ? 'end' : 'start';
257
+
258
+ return {
259
+ node: nd,
260
+ cx: pos.x,
261
+ cy: pos.y,
262
+ labelX: labelPos.x,
263
+ labelY: labelPos.y,
264
+ labelRotate,
265
+ textAnchor,
266
+ isSelected,
267
+ inEdge,
268
+ isDimmed,
269
+ color,
270
+ dotR: isSelected ? DOT_R_SEL : inEdge ? DOT_R_EDGE : DOT_R,
271
+ dotOpacity: isDimmed ? 0.15 : 1,
272
+ labelFill: isSelected ? '#f0f6fc' : inEdge ? '#c9d1d9' : '#6e7c8a',
273
+ labelWeight: isSelected ? 600 : 400,
274
+ groupClass: `sl-node-group${isSelected ? ' active' : ''}${isDimmed ? ' dimmed' : ''}`,
275
+ };
276
+ });
277
+ }
278
+
279
+ // ── Arc display list ──────────────────────────────────────────────────────
280
+
281
+ get arcDisplayList() {
282
+ const { edges, nodeAngles, selectedSlug } = this;
283
+
284
+ return edges
285
+ .map((e) => {
286
+ const a1 = nodeAngles[e.source];
287
+ const a2 = nodeAngles[e.target];
288
+ if (a1 == null || a2 == null) return null;
289
+
290
+ const p1 = polarToXY(a1, RADIUS);
291
+ const p2 = polarToXY(a2, RADIUS);
292
+
293
+ const mx = (p1.x + p2.x) / 2;
294
+ const my = (p1.y + p2.y) / 2;
295
+ const cpx = CENTER_X + (mx - CENTER_X) * (1 - ARC_PULL);
296
+ const cpy = CENTER_Y + (my - CENTER_Y) * (1 - ARC_PULL);
297
+
298
+ const path = `M ${p1.x} ${p1.y} Q ${cpx} ${cpy} ${p2.x} ${p2.y}`;
299
+
300
+ const isHighlighted =
301
+ selectedSlug != null &&
302
+ (e.source === selectedSlug || e.target === selectedSlug);
303
+ const isDimmed = selectedSlug != null && !isHighlighted;
304
+
305
+ return {
306
+ path,
307
+ stroke: EDGE_COLOR[e.kind] ?? 'rgba(200,200,200,0.4)',
308
+ strokeWidth: isHighlighted ? 1.8 : 1,
309
+ arcClass: `sl-arc-path${isDimmed ? ' dimmed' : isHighlighted ? ' highlighted' : ' default'}`,
310
+ };
311
+ })
312
+ .filter(Boolean);
313
+ }
314
+
315
+ // ── Centre point ──────────────────────────────────────────────────────────
316
+
317
+ get cx() {
318
+ return CENTER_X;
319
+ }
320
+ get cy() {
321
+ return CENTER_Y;
322
+ }
323
+
324
+ // ── Zoom helpers ──────────────────────────────────────────────────────────
325
+
326
+ _applyZoom(newScale) {
327
+ this.zoomScale = clamp(newScale, ZOOM_MIN, ZOOM_MAX);
328
+ }
329
+
330
+ @action
331
+ zoomIn() {
332
+ this._applyZoom(this.zoomScale * (1 + ZOOM_STEP));
333
+ }
334
+
335
+ @action
336
+ zoomOut() {
337
+ this._applyZoom(this.zoomScale * (1 - ZOOM_STEP));
338
+ }
339
+
340
+ @action
341
+ zoomReset() {
342
+ this.zoomScale = 1;
343
+ }
344
+
345
+ // ── Inertia helpers ───────────────────────────────────────────────────────
346
+
347
+ _cancelInertia() {
348
+ if (this._rafId != null) {
349
+ cancelAnimationFrame(this._rafId);
350
+ this._rafId = null;
351
+ }
352
+ }
353
+
354
+ _startInertia() {
355
+ this._cancelInertia();
356
+ const tick = () => {
357
+ this._velocity *= INERTIA_FRICTION;
358
+ if (Math.abs(this._velocity) < INERTIA_MIN_VEL) {
359
+ this._velocity = 0;
360
+ this._rafId = null;
361
+ return;
362
+ }
363
+ this.rotationDeg = this.rotationDeg + this._velocity;
364
+ this._rafId = requestAnimationFrame(tick);
365
+ };
366
+ this._rafId = requestAnimationFrame(tick);
367
+ }
368
+
369
+ // ── Pointer events ────────────────────────────────────────────────────────
370
+
371
+ @action
372
+ registerSvg(el) {
373
+ this._svgEl = el;
374
+ }
375
+
376
+ @action
377
+ handlePointerDown(event) {
378
+ if (event.button !== undefined && event.button !== 0) return;
379
+
380
+ this._cancelInertia();
381
+ this._isDragging = true;
382
+ this._dragMoved = false;
383
+ this._velocity = 0;
384
+ // Walk up from the event target to see if it's inside a node group.
385
+ // We must record this now because setPointerCapture redirects all
386
+ // subsequent pointer events to the SVG, so the click event on the
387
+ // <g> never fires.
388
+ this._pendingNodeClick =
389
+ event.target?.closest?.('[data-node-slug]')?.dataset?.nodeSlug ?? null;
390
+
391
+ const angle = pointerAngle(this._svgEl, event.clientX, event.clientY);
392
+ this._dragStartAngle = angle;
393
+ this._dragStartRot = this.rotationDeg;
394
+ this._lastAngle = angle;
395
+
396
+ this._svgEl.setPointerCapture(event.pointerId);
397
+ }
398
+
399
+ @action
400
+ handlePointerMove(event) {
401
+ if (!this._isDragging) return;
402
+
403
+ const angle = pointerAngle(this._svgEl, event.clientX, event.clientY);
404
+
405
+ let delta = angle - this._dragStartAngle;
406
+ if (delta > 180) delta -= 360;
407
+ if (delta < -180) delta += 360;
408
+
409
+ this.rotationDeg = this._dragStartRot + delta;
410
+
411
+ // Mark as a real drag once angular movement exceeds a small threshold
412
+ if (Math.abs(delta) > 3) this._dragMoved = true;
413
+ }
414
+
415
+ @action
416
+ handlePointerUp(event) {
417
+ if (!this._isDragging) return;
418
+ this._isDragging = false;
419
+ this._svgEl.releasePointerCapture(event.pointerId);
420
+
421
+ // If the pointer didn't move enough to count as a drag and it started
422
+ // on a node, treat it as a node click.
423
+ if (!this._dragMoved && this._pendingNodeClick) {
424
+ const nd = this.nodes.find((n) => n.slug === this._pendingNodeClick);
425
+ if (nd) this.args.onSelectNode?.(nd);
426
+ }
427
+ this._pendingNodeClick = null;
428
+ }
429
+
430
+ // Node clicks are now handled in handlePointerUp via _pendingNodeClick,
431
+ // so this action is no longer needed.
432
+ }