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.
- package/README.md +167 -77
- package/blueprints/ember-tribe/files/app/components/storylang/arc-diagram.hbs +242 -0
- package/blueprints/ember-tribe/files/app/components/storylang/arc-diagram.js +432 -0
- package/blueprints/ember-tribe/files/app/components/storylang/index.hbs +457 -0
- package/blueprints/ember-tribe/files/app/components/storylang/index.js +177 -0
- package/blueprints/ember-tribe/files/app/components/storylang/node-detail.hbs +265 -0
- package/blueprints/ember-tribe/files/app/components/storylang/node-detail.js +91 -0
- package/blueprints/ember-tribe/files/app/index.html +2 -2
- package/blueprints/ember-tribe/files/app/templates/index.hbs +4 -4
- package/blueprints/ember-tribe/files/public/composer.json +11 -13
- package/blueprints/ember-tribe/files/storylang +492 -75
- package/blueprints/ember-tribe/index.js +0 -1
- package/package.json +2 -2
- package/blueprints/ember-tribe/files/app/components/welcome-flame.hbs +0 -5
- package/blueprints/ember-tribe/files/public/assets/css/custom.css +0 -0
- package/blueprints/ember-tribe/files/public/assets/js/custom.js +0 -0
|
@@ -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
|
+
}
|