@glossarist/concept-browser 0.7.90 → 0.7.91
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/package.json +1 -1
- package/src/components/RelationSphere.vue +35 -313
- package/src/components/RelationSphere.vue.bak +1921 -0
- package/src/components/relation-sphere/forces.ts +156 -0
- package/src/components/relation-sphere/rake-bundles.ts +221 -0
- package/src/components/relation-sphere/svg-markers.ts +52 -0
- package/src/components/relation-sphere/types.ts +51 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* d3-force-compatible force factories for the sphere simulation.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from RelationSphere.vue. Each factory is pure — takes a
|
|
5
|
+
* ForcesContext (read-only getters for the mutable sim state) and
|
|
6
|
+
* returns a Force compatible with d3-force-simulation's `.force()`.
|
|
7
|
+
*
|
|
8
|
+
* Forces in play:
|
|
9
|
+
* - sphereConstraint: renormalizes node positions to the unit sphere
|
|
10
|
+
* each tick (‖p‖ = 1) and projects velocity onto the tangent plane.
|
|
11
|
+
* - velocityClamp(maxV): caps ‖v‖ at maxV to prevent runaway sim.
|
|
12
|
+
* - repulsion: pairwise chord-distance repulsion so cards spread on
|
|
13
|
+
* the sphere surface. Focus (depth=0) is immovable.
|
|
14
|
+
* - linkForce: 3D spring pulling connected nodes toward a target
|
|
15
|
+
* chord distance. Skips muted types / muted registers.
|
|
16
|
+
* - focusPin: pins the focus node at (0, 0, 1).
|
|
17
|
+
*
|
|
18
|
+
* navForce stays inline in RelationSphere.vue — its state
|
|
19
|
+
* (navActive/navStart/navEnd/etc.) has 10+ touch points in the Vue
|
|
20
|
+
* component's navigation logic, so extracting it adds risk without
|
|
21
|
+
* proportional benefit.
|
|
22
|
+
*/
|
|
23
|
+
import type { Force, ForcesContext, SNode } from './types';
|
|
24
|
+
|
|
25
|
+
/** Renormalize ‖p‖ = 1 each tick + project velocity onto tangent plane. */
|
|
26
|
+
export function sphereConstraint(): Force {
|
|
27
|
+
let nL: SNode[] = [];
|
|
28
|
+
const force = () => {
|
|
29
|
+
for (const n of nL) {
|
|
30
|
+
const len = Math.sqrt(n.x * n.x + n.y * n.y + n.z * n.z);
|
|
31
|
+
if (len > 0.001) {
|
|
32
|
+
const nx = n.x / len, ny = n.y / len, nz = n.z / len;
|
|
33
|
+
n.x = nx; n.y = ny; n.z = nz;
|
|
34
|
+
const rad = n.vx * nx + n.vy * ny + n.vz * nz;
|
|
35
|
+
n.vx -= rad * nx; n.vy -= rad * ny; n.vz -= rad * nz;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
(force as Force).initialize = (n: SNode[]) => { nL = n; };
|
|
40
|
+
return force as Force;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Cap ‖v‖ at maxV to prevent runaway simulation. */
|
|
44
|
+
export function velocityClamp(maxV: number): Force {
|
|
45
|
+
let nL: SNode[] = [];
|
|
46
|
+
const force = () => {
|
|
47
|
+
for (const n of nL) {
|
|
48
|
+
const v2 = n.vx * n.vx + n.vy * n.vy + n.vz * n.vz;
|
|
49
|
+
if (v2 > maxV * maxV) {
|
|
50
|
+
const s = maxV / Math.sqrt(v2);
|
|
51
|
+
n.vx *= s; n.vy *= s; n.vz *= s;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
(force as Force).initialize = (n: SNode[]) => { nL = n; };
|
|
56
|
+
return force as Force;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Pairwise repulsion — pushes nodes apart on the sphere surface so
|
|
61
|
+
* cards don't stack. Operates in 3D (chord distance), with the
|
|
62
|
+
* sphereConstraint normalizing positions back to the unit sphere
|
|
63
|
+
* each tick. Focus node (depth=0) is immovable.
|
|
64
|
+
*/
|
|
65
|
+
export function repulsion(ctx: ForcesContext): Force {
|
|
66
|
+
let nL: SNode[] = [];
|
|
67
|
+
const force = () => {
|
|
68
|
+
const minDist = ctx.repulseMinDist();
|
|
69
|
+
const strength = ctx.repulseStrength();
|
|
70
|
+
const min2 = minDist * minDist;
|
|
71
|
+
for (let i = 0; i < nL.length; i++) {
|
|
72
|
+
const a = nL[i];
|
|
73
|
+
if (a.depth === 0) continue; /* focus pinned */
|
|
74
|
+
for (let j = i + 1; j < nL.length; j++) {
|
|
75
|
+
const b = nL[j];
|
|
76
|
+
const dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z;
|
|
77
|
+
const d2 = dx * dx + dy * dy + dz * dz;
|
|
78
|
+
if (d2 >= min2 || d2 < 1e-6) continue;
|
|
79
|
+
const d = Math.sqrt(d2);
|
|
80
|
+
const f = (strength * (minDist - d)) / d;
|
|
81
|
+
const fx = dx * f, fy = dy * f, fz = dz * f;
|
|
82
|
+
a.vx += fx; a.vy += fy; a.vz += fz;
|
|
83
|
+
if (b.depth !== 0) { b.vx -= fx; b.vy -= fy; b.vz -= fz; }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
(force as Force).initialize = (n: SNode[]) => { nL = n; };
|
|
88
|
+
return force as Force;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 3D spring (link) force — pulls connected nodes toward a target chord
|
|
93
|
+
* distance. This is what actually spreads cards apart: longer springs
|
|
94
|
+
* (higher Expand level) push the graph outward along its edges. Stock
|
|
95
|
+
* d3-forceLink only handles x/y; this custom version operates in full
|
|
96
|
+
* 3D so the sphereConstraint can renormalize positions correctly.
|
|
97
|
+
*
|
|
98
|
+
* Skips edges involving muted types or muted registers (except when
|
|
99
|
+
* the focus node is on either end).
|
|
100
|
+
*/
|
|
101
|
+
export function linkForce(ctx: ForcesContext): Force {
|
|
102
|
+
let nL: SNode[] = [];
|
|
103
|
+
let idToIdx = new Map<string, number>();
|
|
104
|
+
const force = () => {
|
|
105
|
+
const links = ctx.links();
|
|
106
|
+
if (links.length === 0) return;
|
|
107
|
+
const target = ctx.linkDistance();
|
|
108
|
+
const strength = ctx.linkStrength();
|
|
109
|
+
/* Read muted sets each tick so toggleType/toggleRegister take effect
|
|
110
|
+
immediately on the next simulation step. */
|
|
111
|
+
const mutedT = ctx.mutedTypes();
|
|
112
|
+
const mutedR = ctx.mutedRegisters();
|
|
113
|
+
for (const link of links) {
|
|
114
|
+
if (mutedT.has(link.type)) continue;
|
|
115
|
+
const ai = idToIdx.get(link.source);
|
|
116
|
+
const bi = idToIdx.get(link.target);
|
|
117
|
+
if (ai === undefined || bi === undefined) continue;
|
|
118
|
+
const a = nL[ai], b = nL[bi];
|
|
119
|
+
/* Skip edges involving a muted register (unless the node is the focus) */
|
|
120
|
+
if (a.depth !== 0 && mutedR.has(a.register)) continue;
|
|
121
|
+
if (b.depth !== 0 && mutedR.has(b.register)) continue;
|
|
122
|
+
const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
|
|
123
|
+
const d2 = dx * dx + dy * dy + dz * dz;
|
|
124
|
+
if (d2 < 1e-6) continue;
|
|
125
|
+
const d = Math.sqrt(d2);
|
|
126
|
+
const diff = ((d - target) / d) * strength;
|
|
127
|
+
const fx = dx * diff, fy = dy * diff, fz = dz * diff;
|
|
128
|
+
if (a.depth !== 0) { a.vx += fx; a.vy += fy; a.vz += fz; }
|
|
129
|
+
if (b.depth !== 0) { b.vx -= fx; b.vy -= fy; b.vz -= fz; }
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
(force as Force).initialize = (n: SNode[]) => {
|
|
133
|
+
nL = n;
|
|
134
|
+
idToIdx = new Map();
|
|
135
|
+
for (let i = 0; i < n.length; i++) idToIdx.set(n[i].id, i);
|
|
136
|
+
};
|
|
137
|
+
return force as Force;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Pin the focus node at (0, 0, 1) so neighbor repulsion doesn't shove
|
|
142
|
+
* it. Without this, the focus drifts and the whole sphere wobbles.
|
|
143
|
+
*/
|
|
144
|
+
export function focusPin(): Force {
|
|
145
|
+
let nL: SNode[] = [];
|
|
146
|
+
const force = () => {
|
|
147
|
+
for (const n of nL) {
|
|
148
|
+
if (n.depth === 0) {
|
|
149
|
+
n.x = 0; n.y = 0; n.z = 1;
|
|
150
|
+
n.vx = 0; n.vy = 0; n.vz = 0;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
(force as Force).initialize = (n: SNode[]) => { nL = n; };
|
|
155
|
+
return force as Force;
|
|
156
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rake bundle rendering for the RelationSphere.
|
|
3
|
+
*
|
|
4
|
+
* Draws ISO 704:2022 partitive-relation rakes (right-angle "fork"
|
|
5
|
+
* notation) on the SVG layer. Each relation becomes:
|
|
6
|
+
*
|
|
7
|
+
* comprehensive
|
|
8
|
+
* │ (stem, frame-width solid)
|
|
9
|
+
* ◆────────────── (spine, frame-width solid)
|
|
10
|
+
* │ │
|
|
11
|
+
* │ │ (drop, per-member multiplicity + delimiting)
|
|
12
|
+
* ▼ ▼
|
|
13
|
+
* partitive partitive
|
|
14
|
+
*
|
|
15
|
+
* Per-member drop style derives from presence × count × is_delimiting
|
|
16
|
+
* via rakeStrokeStyle() — solid/dashed lines, 1 or 2 lines, normal or
|
|
17
|
+
* 3× delimiting width.
|
|
18
|
+
*
|
|
19
|
+
* Extracted from RelationSphere.vue (1914 lines → smaller). Pure
|
|
20
|
+
* DOM/geometry — no Vue reactivity, no d3 state. The caller passes
|
|
21
|
+
* the partitive relations, position map, and rendering context.
|
|
22
|
+
*/
|
|
23
|
+
import type { PartitiveRelationWire, PartitiveMemberWire } from '../../adapters/types';
|
|
24
|
+
import type { RakeStrokeStyle } from '../../utils/partitive-multiplicity';
|
|
25
|
+
import { rakeStrokeStyle } from '../../utils/partitive-multiplicity';
|
|
26
|
+
import { UriRouter } from '../../adapters/UriRouter';
|
|
27
|
+
|
|
28
|
+
const DOUBLE_GAP = 4;
|
|
29
|
+
const DASH = '4 3';
|
|
30
|
+
const FRAME_STROKE_WIDTH = 1.4;
|
|
31
|
+
const SPINE_FRACTION = 0.55;
|
|
32
|
+
const PARTIAL_TAIL = 28;
|
|
33
|
+
|
|
34
|
+
export interface RakeBundleOptions {
|
|
35
|
+
/** Light/dark mode controls the frame contrast color. */
|
|
36
|
+
isDark: boolean;
|
|
37
|
+
/** Relation color (light or dark variant of the partitive teal). */
|
|
38
|
+
color: string;
|
|
39
|
+
/** Skip the entire bundle when the user mutes partitive relations. */
|
|
40
|
+
isMuted: boolean;
|
|
41
|
+
/** Relations to draw. */
|
|
42
|
+
relations: readonly PartitiveRelationWire[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface NodePos { x: number; y: number; }
|
|
46
|
+
|
|
47
|
+
interface Tooth {
|
|
48
|
+
member: PartitiveMemberWire;
|
|
49
|
+
partitivePos: NodePos;
|
|
50
|
+
toothX: number;
|
|
51
|
+
toothY: number;
|
|
52
|
+
along: number;
|
|
53
|
+
style: RakeStrokeStyle;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Draw all rake bundles for the current concept's partitive relations.
|
|
58
|
+
*
|
|
59
|
+
* Each relation produces: 1 stem, 1 spine, 1–2 drops per member,
|
|
60
|
+
* 1 junction circle at the spine center, 1 diamond at the comp end.
|
|
61
|
+
*/
|
|
62
|
+
export function drawRakeBundles(
|
|
63
|
+
svg: SVGSVGElement,
|
|
64
|
+
pos: Map<string, NodePos>,
|
|
65
|
+
opts: RakeBundleOptions,
|
|
66
|
+
): void {
|
|
67
|
+
if (opts.isMuted) return;
|
|
68
|
+
const contrastStroke = opts.isDark ? '#0a1f1c' : '#ffffff';
|
|
69
|
+
|
|
70
|
+
for (const rel of opts.relations) {
|
|
71
|
+
const compParsed = UriRouter.parseUri(rel.comprehensive);
|
|
72
|
+
if (!compParsed) continue;
|
|
73
|
+
const compNodeId = `${compParsed.registerId}/${compParsed.conceptId}`;
|
|
74
|
+
const compPos = pos.get(compNodeId);
|
|
75
|
+
if (!compPos) continue;
|
|
76
|
+
|
|
77
|
+
const memberPositions = rel.partitives
|
|
78
|
+
.map(member => {
|
|
79
|
+
const parsed = UriRouter.parseUri(member.uri);
|
|
80
|
+
if (!parsed) return null;
|
|
81
|
+
const nid = `${parsed.registerId}/${parsed.conceptId}`;
|
|
82
|
+
const p = pos.get(nid);
|
|
83
|
+
return p ? { member, pos: p } : null;
|
|
84
|
+
})
|
|
85
|
+
.filter((x): x is { member: PartitiveMemberWire; pos: NodePos } => x !== null);
|
|
86
|
+
|
|
87
|
+
if (memberPositions.length === 0) continue;
|
|
88
|
+
|
|
89
|
+
// 1. Compute stem-axis (comp → centroid of partitives) and spine-axis (perpendicular)
|
|
90
|
+
const centroidX = memberPositions.reduce((s, m) => s + m.pos.x, 0) / memberPositions.length;
|
|
91
|
+
const centroidY = memberPositions.reduce((s, m) => s + m.pos.y, 0) / memberPositions.length;
|
|
92
|
+
const stemDx = centroidX - compPos.x;
|
|
93
|
+
const stemDy = centroidY - compPos.y;
|
|
94
|
+
const stemLen = Math.hypot(stemDx, stemDy) || 1;
|
|
95
|
+
const stemDirX = stemDx / stemLen;
|
|
96
|
+
const stemDirY = stemDy / stemLen;
|
|
97
|
+
const spineDirX = -stemDirY;
|
|
98
|
+
const spineDirY = stemDirX;
|
|
99
|
+
|
|
100
|
+
// 2. Spine center: along stem-axis at SPINE_FRACTION from comp
|
|
101
|
+
const spineCx = compPos.x + stemDirX * stemLen * SPINE_FRACTION;
|
|
102
|
+
const spineCy = compPos.y + stemDirY * stemLen * SPINE_FRACTION;
|
|
103
|
+
|
|
104
|
+
// 3. Tooth positions = projection of each partitive onto spine line
|
|
105
|
+
const teeth: Tooth[] = memberPositions.map(({ member, pos: pPos }) => {
|
|
106
|
+
const vx = pPos.x - spineCx;
|
|
107
|
+
const vy = pPos.y - spineCy;
|
|
108
|
+
const along = vx * spineDirX + vy * spineDirY;
|
|
109
|
+
const toothX = spineCx + spineDirX * along;
|
|
110
|
+
const toothY = spineCy + spineDirY * along;
|
|
111
|
+
return {
|
|
112
|
+
member,
|
|
113
|
+
partitivePos: pPos,
|
|
114
|
+
toothX,
|
|
115
|
+
toothY,
|
|
116
|
+
along,
|
|
117
|
+
style: rakeStrokeStyle(member.presence, member.count, member.isDelimiting),
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// 4. Spine extents (leftmost to rightmost tooth along spine-axis)
|
|
122
|
+
let minAlong = teeth[0].along;
|
|
123
|
+
let maxAlong = teeth[0].along;
|
|
124
|
+
for (const t of teeth) {
|
|
125
|
+
if (t.along < minAlong) minAlong = t.along;
|
|
126
|
+
if (t.along > maxAlong) maxAlong = t.along;
|
|
127
|
+
}
|
|
128
|
+
// For completeness: 'partial', extend past last tooth (continued backline)
|
|
129
|
+
if (rel.completeness === 'partial') maxAlong += PARTIAL_TAIL;
|
|
130
|
+
const spineStartX = spineCx + spineDirX * minAlong;
|
|
131
|
+
const spineStartY = spineCy + spineDirY * minAlong;
|
|
132
|
+
const spineEndX = spineCx + spineDirX * maxAlong;
|
|
133
|
+
const spineEndY = spineCy + spineDirY * maxAlong;
|
|
134
|
+
|
|
135
|
+
// 5. Stem: comp → spine center (frame — single solid)
|
|
136
|
+
drawSegment(svg, opts.color, compPos.x, compPos.y, spineCx, spineCy, { width: FRAME_STROKE_WIDTH });
|
|
137
|
+
|
|
138
|
+
// 6. Spine: leftmost tooth → rightmost tooth (frame — single solid)
|
|
139
|
+
if (Math.abs(maxAlong - minAlong) > 1) {
|
|
140
|
+
drawSegment(svg, opts.color, spineStartX, spineStartY, spineEndX, spineEndY, { width: FRAME_STROKE_WIDTH });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 7. Drops: tooth → partitive (per-member multiplicity + delimiting).
|
|
144
|
+
// 1 or 2 parallel lines per the style.
|
|
145
|
+
for (const tooth of teeth) {
|
|
146
|
+
drawSegment(svg, opts.color, tooth.toothX, tooth.toothY, tooth.partitivePos.x, tooth.partitivePos.y, {
|
|
147
|
+
width: tooth.style.strokeWidth,
|
|
148
|
+
dashed: tooth.style.primaryDashed,
|
|
149
|
+
});
|
|
150
|
+
if (tooth.style.lineCount === 2) {
|
|
151
|
+
drawSegment(svg, opts.color, tooth.toothX, tooth.toothY, tooth.partitivePos.x, tooth.partitivePos.y, {
|
|
152
|
+
width: tooth.style.strokeWidth,
|
|
153
|
+
dashed: tooth.style.secondaryDashed,
|
|
154
|
+
offset: DOUBLE_GAP,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 8. Junction marker at spine center (the right-angle corner)
|
|
160
|
+
const junction = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
161
|
+
junction.setAttribute('class', 'rake-junction');
|
|
162
|
+
junction.setAttribute('cx', String(spineCx));
|
|
163
|
+
junction.setAttribute('cy', String(spineCy));
|
|
164
|
+
junction.setAttribute('r', '2.5');
|
|
165
|
+
junction.setAttribute('fill', opts.color);
|
|
166
|
+
junction.setAttribute('opacity', '0.9');
|
|
167
|
+
junction.setAttribute('stroke', contrastStroke);
|
|
168
|
+
junction.setAttribute('stroke-width', '1');
|
|
169
|
+
svg.appendChild(junction);
|
|
170
|
+
|
|
171
|
+
// 9. Diamond marker at comprehensive end (hyperedge origin)
|
|
172
|
+
const diamond = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
173
|
+
const dsize = 5;
|
|
174
|
+
diamond.setAttribute('d',
|
|
175
|
+
`M ${compPos.x} ${compPos.y - dsize} L ${compPos.x + dsize} ${compPos.y} L ${compPos.x} ${compPos.y + dsize} L ${compPos.x - dsize} ${compPos.y} Z`);
|
|
176
|
+
diamond.setAttribute('fill', opts.color);
|
|
177
|
+
diamond.setAttribute('opacity', '0.9');
|
|
178
|
+
diamond.setAttribute('stroke', contrastStroke);
|
|
179
|
+
diamond.setAttribute('stroke-width', '1');
|
|
180
|
+
svg.appendChild(diamond);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
interface SegmentOpts {
|
|
185
|
+
width: number;
|
|
186
|
+
dashed?: boolean;
|
|
187
|
+
offset?: number;
|
|
188
|
+
opacity?: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Draw a single straight segment with the given style.
|
|
193
|
+
*
|
|
194
|
+
* Stem + spine use FRAME_STROKE_WIDTH (single solid frame).
|
|
195
|
+
* Drops use the per-member multiplicity + delimiting style.
|
|
196
|
+
*/
|
|
197
|
+
function drawSegment(
|
|
198
|
+
svg: SVGSVGElement,
|
|
199
|
+
color: string,
|
|
200
|
+
ax: number, ay: number, bx: number, by: number,
|
|
201
|
+
opts: SegmentOpts,
|
|
202
|
+
): void {
|
|
203
|
+
const opacity = opts.opacity ?? 0.85;
|
|
204
|
+
const offset = opts.offset ?? 0;
|
|
205
|
+
const dashed = opts.dashed ?? false;
|
|
206
|
+
const dx = bx - ax, dy = by - ay;
|
|
207
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
208
|
+
const px = -dy / len, py = dx / len;
|
|
209
|
+
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
210
|
+
line.setAttribute('class', 'rake-seg');
|
|
211
|
+
line.setAttribute('x1', String(ax + px * offset));
|
|
212
|
+
line.setAttribute('y1', String(ay + py * offset));
|
|
213
|
+
line.setAttribute('x2', String(bx + px * offset));
|
|
214
|
+
line.setAttribute('y2', String(by + py * offset));
|
|
215
|
+
line.setAttribute('stroke', color);
|
|
216
|
+
line.setAttribute('stroke-width', String(opts.width));
|
|
217
|
+
line.setAttribute('opacity', String(opacity));
|
|
218
|
+
line.setAttribute('stroke-linecap', 'square');
|
|
219
|
+
if (dashed) line.setAttribute('stroke-dasharray', DASH);
|
|
220
|
+
svg.appendChild(line);
|
|
221
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG marker definitions for the RelationSphere edge rendering.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from RelationSphere.vue for scannability. Pure DOM
|
|
5
|
+
* helpers — no Vue reactivity, no d3 state.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Ensure the SVG has a `<defs>` element. No-op if one already exists.
|
|
10
|
+
* Call once per render pass before adding markers.
|
|
11
|
+
*/
|
|
12
|
+
export function ensureMarkers(svg: SVGSVGElement): void {
|
|
13
|
+
let defs = svg.querySelector('defs');
|
|
14
|
+
if (!defs) {
|
|
15
|
+
defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
|
16
|
+
svg.appendChild(defs);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Lazy per-type arrow marker. Arrow color matches the edge's per-type
|
|
22
|
+
* color (which may differ from the category color via
|
|
23
|
+
* TYPE_COLOR_OVERRIDE in relation-sphere-styling).
|
|
24
|
+
*
|
|
25
|
+
* Returns the marker id so the caller can reference it via
|
|
26
|
+
* `marker-end="url(#id)"`. Idempotent — repeated calls for the same
|
|
27
|
+
* (typeId, color) pair return the existing marker.
|
|
28
|
+
*/
|
|
29
|
+
export function ensureTypeMarker(svg: SVGSVGElement, typeId: string, color: string): string {
|
|
30
|
+
const safeId = typeId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
31
|
+
const markerId = `rel-arrow-t-${safeId}`;
|
|
32
|
+
if (svg.querySelector(`#${markerId}`)) return markerId;
|
|
33
|
+
let defs = svg.querySelector('defs');
|
|
34
|
+
if (!defs) {
|
|
35
|
+
defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
|
36
|
+
svg.appendChild(defs);
|
|
37
|
+
}
|
|
38
|
+
const m = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
|
|
39
|
+
m.setAttribute('id', markerId);
|
|
40
|
+
m.setAttribute('viewBox', '0 0 8 8');
|
|
41
|
+
m.setAttribute('refX', '6');
|
|
42
|
+
m.setAttribute('refY', '4');
|
|
43
|
+
m.setAttribute('markerWidth', '5');
|
|
44
|
+
m.setAttribute('markerHeight', '5');
|
|
45
|
+
m.setAttribute('orient', 'auto');
|
|
46
|
+
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
47
|
+
p.setAttribute('d', 'M 0 0 L 6 4 L 0 8 z');
|
|
48
|
+
p.setAttribute('fill', color);
|
|
49
|
+
m.appendChild(p);
|
|
50
|
+
defs.appendChild(m);
|
|
51
|
+
return markerId;
|
|
52
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sphere graph types: SNode + SLink.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from RelationSphere.vue so force factories and rendering
|
|
5
|
+
* helpers in `relation-sphere/` can share the same types without
|
|
6
|
+
* reaching into the Vue component.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface SNode {
|
|
10
|
+
id: string;
|
|
11
|
+
term: string;
|
|
12
|
+
definition?: string;
|
|
13
|
+
/** Languages available on this concept. */
|
|
14
|
+
languages?: string[];
|
|
15
|
+
ref: string;
|
|
16
|
+
register: string;
|
|
17
|
+
conceptId: string;
|
|
18
|
+
depth: number;
|
|
19
|
+
x: number; y: number; z: number;
|
|
20
|
+
vx: number; vy: number; vz: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SLink {
|
|
24
|
+
source: string;
|
|
25
|
+
target: string;
|
|
26
|
+
type: string;
|
|
27
|
+
category: string;
|
|
28
|
+
depth: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Simulation state shared with the force factories.
|
|
33
|
+
*
|
|
34
|
+
* The Vue component holds the mutable bindings; the forces read them
|
|
35
|
+
* via getters each tick so user changes (Expand slider, mute toggles,
|
|
36
|
+
* navigation target) take effect on the next simulation step.
|
|
37
|
+
*/
|
|
38
|
+
export interface ForcesContext {
|
|
39
|
+
nodes: () => SNode[];
|
|
40
|
+
links: () => SLink[];
|
|
41
|
+
mutedTypes: () => Set<string>;
|
|
42
|
+
mutedRegisters: () => Set<string>;
|
|
43
|
+
/** Expand-slider-driven parameters (link distance/strength, repulse min/strength). */
|
|
44
|
+
linkDistance: () => number;
|
|
45
|
+
linkStrength: () => number;
|
|
46
|
+
repulseMinDist: () => number;
|
|
47
|
+
repulseStrength: () => number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** d3-force-compatible force: a callable + initialize hook. */
|
|
51
|
+
export type Force = ((alpha?: number) => void) & { initialize?: (nodes: SNode[]) => void };
|