@glossarist/concept-browser 0.7.96 → 0.7.98
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/__tests__/components/pipe-bundles.test.ts +154 -0
- package/src/__tests__/composables/live-19-pipeline.test.ts +112 -0
- package/src/__tests__/sphere-math.test.ts +71 -1
- package/src/adapters/model-bridge/jsonld-types.ts +2 -0
- package/src/adapters/model-bridge.ts +57 -0
- package/src/components/RelationSphere.vue +12 -22
- package/src/components/relation-sphere/pipe-bundles.ts +221 -0
- package/src/utils/sphere-math.ts +59 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.98",
|
|
4
4
|
"description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { drawGenericPipes } from '../../components/relation-sphere/pipe-bundles';
|
|
3
|
+
import type { GenericRelationWire } from '../../adapters/types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renderer tests for the generic-relation pipe-and-thread bundle.
|
|
7
|
+
*
|
|
8
|
+
* Pins the visual contract the user specified:
|
|
9
|
+
* 1. NO rake shape — no perpendicular spine, no right-angle teeth.
|
|
10
|
+
* All segments are direct lines from comp→middle and middle→members.
|
|
11
|
+
* 2. Thick/thin hierarchy — the parent pipe is THICKER than the child
|
|
12
|
+
* threads (reversed from the previous "isDelimiting=true" hack
|
|
13
|
+
* that made children thicker than the parent).
|
|
14
|
+
* 3. Per-member delimiting characteristic is labeled along each thread.
|
|
15
|
+
*/
|
|
16
|
+
function makeSvg(): SVGSVGElement {
|
|
17
|
+
return document.createElementNS('http://www.w3.org/2000/svg', 'svg') as unknown as SVGSVGElement;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeRelation(): GenericRelationWire {
|
|
21
|
+
return {
|
|
22
|
+
source: 'https://example.org/vim-2012/concept/1.9',
|
|
23
|
+
comprehensive: 'https://example.org/vim-2012/concept/1.9',
|
|
24
|
+
completeness: 'partial',
|
|
25
|
+
criterion: { eng: 'by magnitude relationship' },
|
|
26
|
+
register: 'vim-2012',
|
|
27
|
+
members: [
|
|
28
|
+
{ uri: 'https://example.org/vim-2012/concept/1.17', presence: 'required', count: 'at_least_one',
|
|
29
|
+
delimitingCharacteristic: { eng: 'multiple of a unit' } },
|
|
30
|
+
{ uri: 'https://example.org/vim-2012/concept/1.18', presence: 'required', count: 'at_least_one',
|
|
31
|
+
delimitingCharacteristic: { eng: 'submultiple of a unit' } },
|
|
32
|
+
{ uri: 'https://example.org/vim-2012/concept/1.12', presence: 'required', count: 'at_least_one',
|
|
33
|
+
delimitingCharacteristic: { eng: 'coherent derived unit' } },
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('drawGenericPipes — pipe-and-thread contract', () => {
|
|
39
|
+
const pos = new Map<string, { x: number; y: number }>([
|
|
40
|
+
['vim-2012/1.9', { x: 0, y: 0 }],
|
|
41
|
+
['vim-2012/1.17', { x: 100, y: -50 }],
|
|
42
|
+
['vim-2012/1.18', { x: 100, y: 50 }],
|
|
43
|
+
['vim-2012/1.12', { x: 100, y: 0 }],
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
it('renders nothing when muted', () => {
|
|
47
|
+
const svg = makeSvg();
|
|
48
|
+
drawGenericPipes(svg, pos, {
|
|
49
|
+
isDark: false,
|
|
50
|
+
color: '#b45309',
|
|
51
|
+
isMuted: true,
|
|
52
|
+
relations: [makeRelation()],
|
|
53
|
+
locale: 'eng',
|
|
54
|
+
});
|
|
55
|
+
expect(svg.children.length).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('renders exactly one thick pipe (comp → middle) and three thin threads', () => {
|
|
59
|
+
const svg = makeSvg();
|
|
60
|
+
drawGenericPipes(svg, pos, {
|
|
61
|
+
isDark: false,
|
|
62
|
+
color: '#b45309',
|
|
63
|
+
isMuted: false,
|
|
64
|
+
relations: [makeRelation()],
|
|
65
|
+
locale: 'eng',
|
|
66
|
+
});
|
|
67
|
+
const segs = svg.querySelectorAll('line.pipe-seg');
|
|
68
|
+
/* 1 pipe + 3 threads = 4 segments total */
|
|
69
|
+
expect(segs.length).toBe(4);
|
|
70
|
+
|
|
71
|
+
/* Identify the pipe: it's the segment originating at the comp position (0,0). */
|
|
72
|
+
const pipe = Array.from(segs).find(s =>
|
|
73
|
+
s.getAttribute('x1') === '0' && s.getAttribute('y1') === '0');
|
|
74
|
+
expect(pipe).toBeDefined();
|
|
75
|
+
const pipeWidth = parseFloat(pipe!.getAttribute('stroke-width')!);
|
|
76
|
+
|
|
77
|
+
/* Threads: every other segment. Their width must be LESS than the pipe. */
|
|
78
|
+
const threads = Array.from(segs).filter(s => s !== pipe);
|
|
79
|
+
expect(threads.length).toBe(3);
|
|
80
|
+
for (const t of threads) {
|
|
81
|
+
const w = parseFloat(t.getAttribute('stroke-width')!);
|
|
82
|
+
expect(w).toBeLessThan(pipeWidth);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('does NOT emit perpendicular spine or right-angle teeth artifacts', () => {
|
|
87
|
+
const svg = makeSvg();
|
|
88
|
+
drawGenericPipes(svg, pos, {
|
|
89
|
+
isDark: false,
|
|
90
|
+
color: '#b45309',
|
|
91
|
+
isMuted: false,
|
|
92
|
+
relations: [makeRelation()],
|
|
93
|
+
locale: 'eng',
|
|
94
|
+
});
|
|
95
|
+
/* The rake emits circle.rake-junction for its right-angle corner.
|
|
96
|
+
The pipe-and-thread emits rect.pipe-junction (square) instead —
|
|
97
|
+
a different shape so the two are visually distinguishable. */
|
|
98
|
+
expect(svg.querySelectorAll('circle.rake-junction').length).toBe(0);
|
|
99
|
+
expect(svg.querySelectorAll('rect.pipe-junction').length).toBe(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('renders a delimiting-characteristic label per thread', () => {
|
|
103
|
+
const svg = makeSvg();
|
|
104
|
+
drawGenericPipes(svg, pos, {
|
|
105
|
+
isDark: false,
|
|
106
|
+
color: '#b45309',
|
|
107
|
+
isMuted: false,
|
|
108
|
+
relations: [makeRelation()],
|
|
109
|
+
locale: 'eng',
|
|
110
|
+
});
|
|
111
|
+
const labels = Array.from(svg.querySelectorAll('text.pipe-label')).map(t => t.textContent);
|
|
112
|
+
/* 3 per-member characteristics + 1 group-level criterion = 4 labels */
|
|
113
|
+
expect(labels).toContain('multiple of a unit');
|
|
114
|
+
expect(labels).toContain('submultiple of a unit');
|
|
115
|
+
expect(labels).toContain('coherent derived unit');
|
|
116
|
+
expect(labels).toContain('by magnitude relationship');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('renders a diamond at the comprehensive end (origin marker)', () => {
|
|
120
|
+
const svg = makeSvg();
|
|
121
|
+
drawGenericPipes(svg, pos, {
|
|
122
|
+
isDark: false,
|
|
123
|
+
color: '#b45309',
|
|
124
|
+
isMuted: false,
|
|
125
|
+
relations: [makeRelation()],
|
|
126
|
+
locale: 'eng',
|
|
127
|
+
});
|
|
128
|
+
/* Diamond is the only <path> element produced by the renderer. */
|
|
129
|
+
const paths = svg.querySelectorAll('path');
|
|
130
|
+
expect(paths.length).toBe(1);
|
|
131
|
+
const d = paths[0].getAttribute('d') ?? '';
|
|
132
|
+
/* Diamond path starts at (cx, cy - half) — centered on comp (0,0). */
|
|
133
|
+
expect(d.startsWith('M 0 -')).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('skips a relation when fewer than 2 members have positions in the map', () => {
|
|
137
|
+
const svg = makeSvg();
|
|
138
|
+
const sparsePos = new Map([
|
|
139
|
+
['vim-2012/1.9', { x: 0, y: 0 }],
|
|
140
|
+
['vim-2012/1.17', { x: 100, y: 0 }],
|
|
141
|
+
/* 1.18 and 1.12 not in the map */
|
|
142
|
+
]);
|
|
143
|
+
drawGenericPipes(svg, sparsePos, {
|
|
144
|
+
isDark: false,
|
|
145
|
+
color: '#b45309',
|
|
146
|
+
isMuted: false,
|
|
147
|
+
relations: [makeRelation()],
|
|
148
|
+
locale: 'eng',
|
|
149
|
+
});
|
|
150
|
+
/* Single-member layout is rejected (computePipeLayout requires ≥2),
|
|
151
|
+
so no pipe or thread segments should be drawn. */
|
|
152
|
+
expect(svg.querySelectorAll('line.pipe-seg').length).toBe(0);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import type { Router } from 'vue-router';
|
|
4
|
+
import { useConceptEdges } from '../../composables/use-concept-edges';
|
|
5
|
+
import { useVocabularyStore } from '../../stores/vocabulary';
|
|
6
|
+
import { conceptFromJson } from '../../adapters/model-bridge';
|
|
7
|
+
import type { Manifest, GraphEdge } from '../../adapters/types';
|
|
8
|
+
import { makeManifest, setupPinia } from '../test-helpers';
|
|
9
|
+
import { getFactory, resetFactory } from '../../adapters/factory';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* End-to-end pipeline test using the actual live JSON-LD shape from
|
|
13
|
+
* the deployed oimlsmart/vocab site (vim-2012 concept 1.9).
|
|
14
|
+
*
|
|
15
|
+
* Reproduces the live bug: 1.9 has gl:genericRelations but the sphere
|
|
16
|
+
* renders nothing because conceptGenericRelations is empty.
|
|
17
|
+
*/
|
|
18
|
+
describe('1.9 live pipeline — generic projection through model-bridge', () => {
|
|
19
|
+
const URN = 'urn:oiml:pub:v:2:2012';
|
|
20
|
+
const manifest: Manifest = makeManifest({
|
|
21
|
+
id: 'vim-2012',
|
|
22
|
+
datasetUri: URN,
|
|
23
|
+
uriBase: 'https://oimlsmart.github.io/vocab',
|
|
24
|
+
});
|
|
25
|
+
const noEdges: GraphEdge[] = [];
|
|
26
|
+
const noopRouter = { push() {} } as unknown as Router;
|
|
27
|
+
|
|
28
|
+
let store: ReturnType<typeof useVocabularyStore>;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
setupPinia();
|
|
32
|
+
resetFactory();
|
|
33
|
+
store = useVocabularyStore();
|
|
34
|
+
store.manifests.set(manifest.id, manifest);
|
|
35
|
+
|
|
36
|
+
const factory = getFactory();
|
|
37
|
+
factory.resolveRelatedRef = vi.fn((ref: { source: string | null; id: string | null } | null) =>
|
|
38
|
+
ref?.source === URN && ref.id
|
|
39
|
+
? { registerId: manifest.id, conceptId: ref.id }
|
|
40
|
+
: null,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('projects gl:genericRelations → conceptGenericRelations through conceptFromJson', () => {
|
|
45
|
+
const liveJson = {
|
|
46
|
+
'@context': 'https://glossarist.org/ns/context.jsonld',
|
|
47
|
+
'@id': 'https://oimlsmart.github.io/vocab/vim-2012/concept/1.9',
|
|
48
|
+
'@type': 'gl:Concept',
|
|
49
|
+
'gl:identifier': '1.9',
|
|
50
|
+
'gl:status': 'valid',
|
|
51
|
+
'gl:localizedConcept': {
|
|
52
|
+
eng: {
|
|
53
|
+
'@type': 'gl:LocalizedConcept',
|
|
54
|
+
'gl:languageCode': 'eng',
|
|
55
|
+
'gl:entryStatus': 'valid',
|
|
56
|
+
'gl:designation': [{ '@type': 'gl:Expression', 'gl:normativeStatus': 'preferred', 'gl:term': 'measurement unit' }],
|
|
57
|
+
'gl:definition': [{ '@type': 'gl:DetailedDefinition', 'gl:content': 'real scalar quantity' }],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
'gl:related': [
|
|
61
|
+
{ '@type': 'gl:RelatedConcept', 'gl:relationshipType': 'broader_generic',
|
|
62
|
+
'gl:ref': { '@type': 'gl:ConceptRef', 'gl:source': URN, 'gl:id': '1.12' } },
|
|
63
|
+
],
|
|
64
|
+
'gl:genericRelations': [
|
|
65
|
+
{
|
|
66
|
+
'@type': 'gl:GenericRelation',
|
|
67
|
+
'gl:comprehensive': { '@type': 'gl:ConceptRef', 'gl:source': URN, 'gl:id': '1.9' },
|
|
68
|
+
'gl:criterion': { eng: 'by magnitude relationship to a reference measurement unit' },
|
|
69
|
+
'gl:hasGeneric': [
|
|
70
|
+
{ '@type': 'gl:GenericMember',
|
|
71
|
+
'gl:ref': { '@type': 'gl:ConceptRef', 'gl:source': URN, 'gl:id': '1.17' },
|
|
72
|
+
'gl:presence': 'required', 'gl:count': 'at_least_one',
|
|
73
|
+
'gl:delimitingCharacteristic': { eng: 'multiple of a unit' } },
|
|
74
|
+
{ '@type': 'gl:GenericMember',
|
|
75
|
+
'gl:ref': { '@type': 'gl:ConceptRef', 'gl:source': URN, 'gl:id': '1.18' },
|
|
76
|
+
'gl:presence': 'required', 'gl:count': 'at_least_one',
|
|
77
|
+
'gl:delimitingCharacteristic': { eng: 'submultiple of a unit' } },
|
|
78
|
+
{ '@type': 'gl:GenericMember',
|
|
79
|
+
'gl:ref': { '@type': 'gl:ConceptRef', 'gl:source': URN, 'gl:id': '1.12' },
|
|
80
|
+
'gl:presence': 'required', 'gl:count': 'at_least_one',
|
|
81
|
+
'gl:delimitingCharacteristic': { eng: 'coherent derived unit' } },
|
|
82
|
+
],
|
|
83
|
+
'gl:completeness': 'partial',
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const concept = conceptFromJson(liveJson as any);
|
|
89
|
+
|
|
90
|
+
/* Sanity: model-bridge produced a Concept with relations. */
|
|
91
|
+
expect(concept).toBeTruthy();
|
|
92
|
+
|
|
93
|
+
/* Project through useConceptEdges, exactly as ConceptView does. */
|
|
94
|
+
const { conceptGenericRelations } = useConceptEdges(
|
|
95
|
+
computed(() => concept),
|
|
96
|
+
computed(() => 'vim-2012'),
|
|
97
|
+
computed(() => manifest),
|
|
98
|
+
computed(() => noEdges),
|
|
99
|
+
noopRouter,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const rels = conceptGenericRelations.value;
|
|
103
|
+
/* This is the live-bug assertion: must NOT be empty. */
|
|
104
|
+
expect(rels).toHaveLength(1);
|
|
105
|
+
expect(rels[0].members).toHaveLength(3);
|
|
106
|
+
expect(rels[0].members.map(m => m.uri)).toEqual([
|
|
107
|
+
'https://oimlsmart.github.io/vocab/vim-2012/concept/1.17',
|
|
108
|
+
'https://oimlsmart.github.io/vocab/vim-2012/concept/1.18',
|
|
109
|
+
'https://oimlsmart.github.io/vocab/vim-2012/concept/1.12',
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { hashSeed, expandParams, portSide, portPoint, idToUriGet, visibleNodeIds } from '../utils/sphere-math';
|
|
2
|
+
import { hashSeed, expandParams, portSide, portPoint, idToUriGet, visibleNodeIds, computePipeLayout } from '../utils/sphere-math';
|
|
3
3
|
import type { SphereHyperedgeLike } from '../utils/sphere-math';
|
|
4
4
|
|
|
5
5
|
describe('sphere-math', () => {
|
|
@@ -185,4 +185,74 @@ describe('sphere-math', () => {
|
|
|
185
185
|
expect([...visible].sort()).toEqual(['a', 'b', 'c', 'd', 'focus']);
|
|
186
186
|
});
|
|
187
187
|
});
|
|
188
|
+
|
|
189
|
+
describe('computePipeLayout', () => {
|
|
190
|
+
it('returns null for fewer than 2 members', () => {
|
|
191
|
+
expect(computePipeLayout({ x: 0, y: 0 }, [])).toBeNull();
|
|
192
|
+
expect(computePipeLayout({ x: 0, y: 0 }, [{ pos: { x: 1, y: 0 } }])).toBeNull();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('places the middle node at the default 50% split between comp and centroid', () => {
|
|
196
|
+
const comp = { x: 0, y: 0 };
|
|
197
|
+
const members = [
|
|
198
|
+
{ pos: { x: 10, y: 0 } },
|
|
199
|
+
{ pos: { x: -10, y: 0 } },
|
|
200
|
+
];
|
|
201
|
+
const layout = computePipeLayout(comp, members)!;
|
|
202
|
+
/* Centroid is (0,0); midpoint between (0,0) and (0,0) is (0,0) */
|
|
203
|
+
expect(layout.middleNode).toEqual({ x: 0, y: 0 });
|
|
204
|
+
expect(layout.pipeStart).toEqual(comp);
|
|
205
|
+
expect(layout.pipeEnd).toEqual(layout.middleNode);
|
|
206
|
+
expect(layout.threads).toHaveLength(2);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('middle node tracks the member centroid', () => {
|
|
210
|
+
const comp = { x: 0, y: 0 };
|
|
211
|
+
const members = [
|
|
212
|
+
{ pos: { x: 10, y: 0 } },
|
|
213
|
+
{ pos: { x: 10, y: 10 } },
|
|
214
|
+
{ pos: { x: 10, y: -10 } },
|
|
215
|
+
];
|
|
216
|
+
const layout = computePipeLayout(comp, members)!;
|
|
217
|
+
/* Centroid is (10, 0); splitFraction=0.5 → middle at (5, 0) */
|
|
218
|
+
expect(layout.middleNode).toEqual({ x: 5, y: 0 });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('respects a custom split fraction', () => {
|
|
222
|
+
const comp = { x: 0, y: 0 };
|
|
223
|
+
const members = [
|
|
224
|
+
{ pos: { x: 10, y: 0 } },
|
|
225
|
+
{ pos: { x: 10, y: 10 } },
|
|
226
|
+
{ pos: { x: 10, y: -10 } },
|
|
227
|
+
];
|
|
228
|
+
/* splitFraction=0.8 → middle near centroid: (8, 0) */
|
|
229
|
+
const layout = computePipeLayout(comp, members, 0.8)!;
|
|
230
|
+
expect(layout.middleNode).toEqual({ x: 8, y: 0 });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('threads connect the middle node to each member', () => {
|
|
234
|
+
const comp = { x: 0, y: 0 };
|
|
235
|
+
const members = [
|
|
236
|
+
{ pos: { x: 10, y: 0 } },
|
|
237
|
+
{ pos: { x: -10, y: 0 } },
|
|
238
|
+
];
|
|
239
|
+
const layout = computePipeLayout(comp, members)!;
|
|
240
|
+
const mid = layout.middleNode;
|
|
241
|
+
expect(layout.threads[0].start).toEqual(mid);
|
|
242
|
+
expect(layout.threads[0].end).toEqual(members[0].pos);
|
|
243
|
+
expect(layout.threads[1].start).toEqual(mid);
|
|
244
|
+
expect(layout.threads[1].end).toEqual(members[1].pos);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('carries delimitingCharacteristic through to threads', () => {
|
|
248
|
+
const dc1 = { eng: 'multiple of a unit' };
|
|
249
|
+
const dc2 = { eng: 'submultiple of a unit' };
|
|
250
|
+
const layout = computePipeLayout({ x: 0, y: 0 }, [
|
|
251
|
+
{ pos: { x: 10, y: 0 }, delimitingCharacteristic: dc1 },
|
|
252
|
+
{ pos: { x: -10, y: 0 }, delimitingCharacteristic: dc2 },
|
|
253
|
+
])!;
|
|
254
|
+
expect(layout.threads[0].member.delimitingCharacteristic).toBe(dc1);
|
|
255
|
+
expect(layout.threads[1].member.delimitingCharacteristic).toBe(dc2);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
188
258
|
});
|
|
@@ -141,6 +141,8 @@ export interface JsonLdPartitiveMember {
|
|
|
141
141
|
'gl:count'?: string;
|
|
142
142
|
/** ISO 704:2022: delimiting part (3× stroke width in diagrams) */
|
|
143
143
|
'gl:isDelimiting'?: boolean;
|
|
144
|
+
/** ISO 704:2022 §5.5.4.2.1: GenericMember delimiting characteristic (required on generic members) */
|
|
145
|
+
'gl:delimitingCharacteristic'?: Record<string, string> | string;
|
|
144
146
|
/** Legacy v3 field — one-string multiplicity, split into presence+count */
|
|
145
147
|
'gl:multiplicity'?: string;
|
|
146
148
|
/** Legacy v2 field — migrated to presence+count */
|
|
@@ -516,6 +516,59 @@ function mapPartitiveRelationFromJsonLd(r: JsonLdPartitiveRelation): Record<stri
|
|
|
516
516
|
return out;
|
|
517
517
|
}
|
|
518
518
|
|
|
519
|
+
/**
|
|
520
|
+
* Map a GenericRelation JSON-LD entry to the dict shape that
|
|
521
|
+
* glossarist-js's GenericHyperedge constructor expects.
|
|
522
|
+
*
|
|
523
|
+
* Mirrors mapPartitiveRelationFromJsonLd but reads `gl:hasGeneric` and
|
|
524
|
+
* carries each member's `gl:delimitingCharacteristic` (ISO 704:2022
|
|
525
|
+
* §5.5.4.2.1 — required on every GenericMember).
|
|
526
|
+
*/
|
|
527
|
+
function mapGenericRelationFromJsonLd(r: JsonLdPartitiveRelation): Record<string, unknown> | null {
|
|
528
|
+
const comprehensive = r[GL.COMPREHENSIVE] ? mapRefFromJsonLd(r[GL.COMPREHENSIVE]) : null;
|
|
529
|
+
if (!comprehensive) return null;
|
|
530
|
+
|
|
531
|
+
const members = (r[GL.HAS_GENERIC] ?? [])
|
|
532
|
+
.map((m): Record<string, unknown> | null => {
|
|
533
|
+
const ref = m[GL.REF] ? mapRefFromJsonLd(m[GL.REF]) : null;
|
|
534
|
+
if (!ref) return null;
|
|
535
|
+
const out: Record<string, unknown> = { ref };
|
|
536
|
+
const presence = m[GL.PRESENCE];
|
|
537
|
+
const count = m[GL.COUNT];
|
|
538
|
+
if (isPartitivePresence(presence) && isPartitiveCount(count)) {
|
|
539
|
+
out.presence = presence;
|
|
540
|
+
out.count = count;
|
|
541
|
+
} else {
|
|
542
|
+
const raw = m[GL.MULTIPLICITY] ?? splitLegacyCertainty(m[GL.CERTAINTY]);
|
|
543
|
+
if (raw) {
|
|
544
|
+
const parts = splitMultiplicity(raw);
|
|
545
|
+
out.presence = parts.presence;
|
|
546
|
+
out.count = parts.count;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const dc = m[GL.DELIMITING_CHARACTERISTIC];
|
|
550
|
+
if (dc) {
|
|
551
|
+
out.delimitingCharacteristic = typeof dc === 'string' ? { default: dc } : dc;
|
|
552
|
+
}
|
|
553
|
+
return out;
|
|
554
|
+
})
|
|
555
|
+
.filter((m): m is Record<string, unknown> => m !== null);
|
|
556
|
+
|
|
557
|
+
if (members.length < 2) return null;
|
|
558
|
+
|
|
559
|
+
const out: Record<string, unknown> = { comprehensive, members };
|
|
560
|
+
|
|
561
|
+
if (r[GL.COMPLETENESS]) out.completeness = r[GL.COMPLETENESS];
|
|
562
|
+
|
|
563
|
+
if (r[GL.CRITERION]) {
|
|
564
|
+
out.criterion = typeof r[GL.CRITERION] === 'string'
|
|
565
|
+
? { default: r[GL.CRITERION] }
|
|
566
|
+
: r[GL.CRITERION];
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return out;
|
|
570
|
+
}
|
|
571
|
+
|
|
519
572
|
/** Migrate legacy v2 certainty → old v3 multiplicity string. */
|
|
520
573
|
function splitLegacyCertainty(certainty: string | undefined): string | null {
|
|
521
574
|
if (!certainty) return null;
|
|
@@ -549,6 +602,9 @@ function conceptFromJsonLd(doc: JsonLdConcept): Concept {
|
|
|
549
602
|
const partitiveRelations = (doc[GL.PARTITIVE_RELATIONS] ?? [])
|
|
550
603
|
.map(mapPartitiveRelationFromJsonLd)
|
|
551
604
|
.filter((r): r is Record<string, unknown> => r !== null);
|
|
605
|
+
const genericRelations = (doc[GL.GENERIC_RELATIONS] ?? [])
|
|
606
|
+
.map(mapGenericRelationFromJsonLd)
|
|
607
|
+
.filter((r): r is Record<string, unknown> => r !== null);
|
|
552
608
|
const tagsArr = doc[GL.TAGS];
|
|
553
609
|
const tags = Array.isArray(tagsArr) ? [...tagsArr] : [];
|
|
554
610
|
|
|
@@ -559,6 +615,7 @@ function conceptFromJsonLd(doc: JsonLdConcept): Concept {
|
|
|
559
615
|
localizations,
|
|
560
616
|
related,
|
|
561
617
|
partitive_relations: partitiveRelations,
|
|
618
|
+
generic_relations: genericRelations,
|
|
562
619
|
tags,
|
|
563
620
|
figures: normalizeEntityRefs(doc[GL.FIGURE_REF]),
|
|
564
621
|
tables: normalizeEntityRefs(doc[GL.TABLE_REF]),
|
|
@@ -28,6 +28,7 @@ const { t, locale } = useI18n();
|
|
|
28
28
|
import type { Concept, Manifest, GraphEdge, PartitiveRelationWire, PartitiveMemberWire, GenericRelationWire } from '../adapters/types';
|
|
29
29
|
import { rakeStrokeStyle } from '../utils/partitive-multiplicity';
|
|
30
30
|
import { drawRakeBundles } from './relation-sphere/rake-bundles';
|
|
31
|
+
import { drawGenericPipes } from './relation-sphere/pipe-bundles';
|
|
31
32
|
import { ensureMarkers, ensureTypeMarker } from './relation-sphere/svg-markers';
|
|
32
33
|
import {
|
|
33
34
|
sphereConstraint,
|
|
@@ -584,7 +585,7 @@ function drawEdges(cx: number, cy: number) {
|
|
|
584
585
|
/* Remove only path + text + rect (edges), keep defs (markers).
|
|
585
586
|
Also remove <line> elements (rake-bundle segments) and <circle>
|
|
586
587
|
(rake junction markers). */
|
|
587
|
-
svg.querySelectorAll('path:not(defs path), line.rake-seg, circle.rake-junction, text.edge-label, rect.edge-label-bg').forEach(el => el.remove());
|
|
588
|
+
svg.querySelectorAll('path:not(defs path), line.rake-seg, line.pipe-seg, circle.rake-junction, rect.pipe-junction, text.edge-label, text.pipe-label, rect.edge-label-bg, rect.pipe-label-bg').forEach(el => el.remove());
|
|
588
589
|
if (!svg.querySelector('defs')) ensureMarkers(svg);
|
|
589
590
|
|
|
590
591
|
/* Compute visible node positions */
|
|
@@ -706,28 +707,17 @@ function drawEdges(cx: number, cy: number) {
|
|
|
706
707
|
relations: props.partitiveRelations ?? [],
|
|
707
708
|
});
|
|
708
709
|
|
|
709
|
-
/* Generic relations render as
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
completeness: rel.completeness,
|
|
716
|
-
register: rel.register,
|
|
717
|
-
partitives: rel.members.map(m => ({
|
|
718
|
-
uri: m.uri,
|
|
719
|
-
presence: m.presence,
|
|
720
|
-
count: m.count,
|
|
721
|
-
/* Every generic member is delimiting by definition (each carries
|
|
722
|
-
its own delimiting characteristic per ISO 704 §5.5.4.2.1). */
|
|
723
|
-
isDelimiting: true,
|
|
724
|
-
})),
|
|
725
|
-
}));
|
|
726
|
-
drawRakeBundles(svg, pos, {
|
|
710
|
+
/* Generic relations render as tree-branch pipe-and-thread bundles
|
|
711
|
+
(NOT rakes): thick parent pipe from comprehensive to a middle
|
|
712
|
+
node, then thin direct threads from there to each member. The
|
|
713
|
+
per-member delimiting characteristic (ISO 704 §5.5.4.2.1) is
|
|
714
|
+
labeled along each thread; the group criterion at the middle. */
|
|
715
|
+
drawGenericPipes(svg, pos, {
|
|
727
716
|
isDark: uiStore.isDark,
|
|
728
717
|
color: uiStore.isDark ? '#fbbf24' : '#b45309',
|
|
729
718
|
isMuted: mutedTypes.value.has('__generic__'),
|
|
730
|
-
relations:
|
|
719
|
+
relations: props.genericRelations ?? [],
|
|
720
|
+
locale: locale.value,
|
|
731
721
|
});
|
|
732
722
|
}
|
|
733
723
|
|
|
@@ -1078,7 +1068,7 @@ const legendItems = computed(() => {
|
|
|
1078
1068
|
if (partitiveCount > 0) {
|
|
1079
1069
|
typeMap.set('__partitive__', {
|
|
1080
1070
|
type: '__partitive__',
|
|
1081
|
-
label: '
|
|
1071
|
+
label: 'Partitive Relation',
|
|
1082
1072
|
color: uiStore.isDark ? '#2dd4bf' : '#0d9488',
|
|
1083
1073
|
count: partitiveCount,
|
|
1084
1074
|
});
|
|
@@ -1088,7 +1078,7 @@ const legendItems = computed(() => {
|
|
|
1088
1078
|
if (genericCount > 0) {
|
|
1089
1079
|
typeMap.set('__generic__', {
|
|
1090
1080
|
type: '__generic__',
|
|
1091
|
-
label: '
|
|
1081
|
+
label: 'General Relation',
|
|
1092
1082
|
color: uiStore.isDark ? '#fbbf24' : '#b45309',
|
|
1093
1083
|
count: genericCount,
|
|
1094
1084
|
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipe-and-thread rendering for GenericRelation hyperedges.
|
|
3
|
+
*
|
|
4
|
+
* Visual contract (ISO 704:2022 §5.5.4 generic relations, "tree branch"):
|
|
5
|
+
*
|
|
6
|
+
* comprehensive ─── thick pipe ───▶ ◼ middle node
|
|
7
|
+
* ├─ thin thread ──▶ member 1
|
|
8
|
+
* ├─ thin thread ──▶ member 2
|
|
9
|
+
* └─ thin thread ──▶ member N
|
|
10
|
+
*
|
|
11
|
+
* Distinct from the partitive rake (drawRakeBundles) in three ways:
|
|
12
|
+
*
|
|
13
|
+
* 1. No right-angle fork. The pipe is a single straight line from
|
|
14
|
+
* comprehensive to a middle node; threads radiate from there to
|
|
15
|
+
* each member. No perpendicular spine, no teeth.
|
|
16
|
+
*
|
|
17
|
+
* 2. Thick → thin hierarchy. The parent pipe uses PIPE_WIDTH; child
|
|
18
|
+
* threads use THREAD_WIDTH. The parent visually dominates, like a
|
|
19
|
+
* tree trunk vs its twigs. (The previous generic rendering reused
|
|
20
|
+
* the rake code with isDelimiting=true on all members, which made
|
|
21
|
+
* the children THICKER than the parent — backwards.)
|
|
22
|
+
*
|
|
23
|
+
* 3. Per-member delimiting characteristic text is rendered along each
|
|
24
|
+
* thread (ISO 704 §5.5.4.2.1 — the intension difference that
|
|
25
|
+
* distinguishes each coordinate concept). The optional group-level
|
|
26
|
+
* `criterion` is shown at the middle node.
|
|
27
|
+
*
|
|
28
|
+
* Pure DOM/geometry — no Vue reactivity, no d3 state. The caller passes
|
|
29
|
+
* the generic relations, position map, and rendering context.
|
|
30
|
+
*/
|
|
31
|
+
import type { GenericRelationWire } from '../../adapters/types';
|
|
32
|
+
import { computePipeLayout, type PipeLayoutMember } from '../../utils/sphere-math';
|
|
33
|
+
import { UriRouter } from '../../adapters/UriRouter';
|
|
34
|
+
|
|
35
|
+
const PIPE_WIDTH = 3.2;
|
|
36
|
+
const THREAD_WIDTH = 1.0;
|
|
37
|
+
const JUNCTION_RADIUS = 4;
|
|
38
|
+
const DIAMOND_HALF = 5;
|
|
39
|
+
|
|
40
|
+
export interface PipeBundleOptions {
|
|
41
|
+
/** Light/dark mode controls the contrast stroke around markers. */
|
|
42
|
+
isDark: boolean;
|
|
43
|
+
/** Relation color (light or dark variant of the generic amber). */
|
|
44
|
+
color: string;
|
|
45
|
+
/** Skip every bundle when the user mutes generic relations. */
|
|
46
|
+
isMuted: boolean;
|
|
47
|
+
/** Relations to draw. */
|
|
48
|
+
relations: readonly GenericRelationWire[];
|
|
49
|
+
/** UI locale, used to pick the delimiting-characteristic text. */
|
|
50
|
+
locale: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface NodePos { x: number; y: number }
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Draw every pipe-and-thread bundle for the current concept's generic
|
|
57
|
+
* relations.
|
|
58
|
+
*
|
|
59
|
+
* Each relation produces: 1 thick pipe (comp → middle), N thin threads
|
|
60
|
+
* (middle → each member), 1 junction square at the middle node, 1
|
|
61
|
+
* diamond at the comp end, optional criterion label at the middle,
|
|
62
|
+
* optional delimiting-characteristic label per thread.
|
|
63
|
+
*/
|
|
64
|
+
export function drawGenericPipes(
|
|
65
|
+
svg: SVGSVGElement,
|
|
66
|
+
pos: Map<string, NodePos>,
|
|
67
|
+
opts: PipeBundleOptions,
|
|
68
|
+
): void {
|
|
69
|
+
if (opts.isMuted) return;
|
|
70
|
+
const contrastStroke = opts.isDark ? '#1a1208' : '#ffffff';
|
|
71
|
+
|
|
72
|
+
for (const rel of opts.relations) {
|
|
73
|
+
const compParsed = UriRouter.parseUri(rel.comprehensive);
|
|
74
|
+
if (!compParsed) continue;
|
|
75
|
+
const compNodeId = `${compParsed.registerId}/${compParsed.conceptId}`;
|
|
76
|
+
const compPos = pos.get(compNodeId);
|
|
77
|
+
if (!compPos) continue;
|
|
78
|
+
|
|
79
|
+
const memberPositions: PipeLayoutMember[] = [];
|
|
80
|
+
for (const m of rel.members) {
|
|
81
|
+
const parsed = UriRouter.parseUri(m.uri);
|
|
82
|
+
if (!parsed) continue;
|
|
83
|
+
const nid = `${parsed.registerId}/${parsed.conceptId}`;
|
|
84
|
+
const p = pos.get(nid);
|
|
85
|
+
if (!p) continue;
|
|
86
|
+
memberPositions.push({ pos: p, delimitingCharacteristic: m.delimitingCharacteristic });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const layout = computePipeLayout(compPos, memberPositions);
|
|
90
|
+
if (!layout) continue;
|
|
91
|
+
|
|
92
|
+
/* 1. Thick parent pipe: comp → middle node */
|
|
93
|
+
drawSegment(svg, opts.color, layout.pipeStart, layout.pipeEnd, PIPE_WIDTH);
|
|
94
|
+
|
|
95
|
+
/* 2. Thin threads: middle → each member, with characteristic label */
|
|
96
|
+
for (const thread of layout.threads) {
|
|
97
|
+
drawSegment(svg, opts.color, thread.start, thread.end, THREAD_WIDTH);
|
|
98
|
+
const dcText = pickLocalized(thread.member.delimitingCharacteristic, opts.locale);
|
|
99
|
+
if (dcText) {
|
|
100
|
+
const midX = (thread.start.x + thread.end.x) / 2;
|
|
101
|
+
const midY = (thread.start.y + thread.end.y) / 2;
|
|
102
|
+
drawLabel(svg, midX, midY, dcText, opts.color, {
|
|
103
|
+
fontSize: 8.5,
|
|
104
|
+
italic: true,
|
|
105
|
+
bg: true,
|
|
106
|
+
isDark: opts.isDark,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* 3. Square junction at the middle node (distinct from the rake's
|
|
112
|
+
circular junction — gives a visual cue: square = generic,
|
|
113
|
+
circle = partitive). */
|
|
114
|
+
drawSquare(svg, layout.middleNode.x, layout.middleNode.y, JUNCTION_RADIUS, opts.color, contrastStroke);
|
|
115
|
+
|
|
116
|
+
/* 4. Group-level criterion label, if present */
|
|
117
|
+
const critText = pickLocalized(rel.criterion, opts.locale);
|
|
118
|
+
if (critText) {
|
|
119
|
+
const midX = (layout.pipeStart.x + layout.pipeEnd.x) / 2;
|
|
120
|
+
const midY = (layout.pipeStart.y + layout.pipeEnd.y) / 2;
|
|
121
|
+
drawLabel(svg, midX, midY, critText, opts.color, {
|
|
122
|
+
fontSize: 9.5,
|
|
123
|
+
weight: '600',
|
|
124
|
+
bg: true,
|
|
125
|
+
isDark: opts.isDark,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* 5. Diamond at the comprehensive end (origin marker shared with
|
|
130
|
+
the rake — both are hyperedges emanating from one concept). */
|
|
131
|
+
drawDiamond(svg, compPos.x, compPos.y, DIAMOND_HALF, opts.color, contrastStroke);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function drawSegment(svg: SVGSVGElement, color: string, from: NodePos, to: NodePos, width: number): void {
|
|
136
|
+
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
137
|
+
line.setAttribute('class', 'pipe-seg');
|
|
138
|
+
line.setAttribute('x1', String(from.x));
|
|
139
|
+
line.setAttribute('y1', String(from.y));
|
|
140
|
+
line.setAttribute('x2', String(to.x));
|
|
141
|
+
line.setAttribute('y2', String(to.y));
|
|
142
|
+
line.setAttribute('stroke', color);
|
|
143
|
+
line.setAttribute('stroke-width', String(width));
|
|
144
|
+
line.setAttribute('stroke-linecap', 'round');
|
|
145
|
+
line.setAttribute('opacity', '0.9');
|
|
146
|
+
svg.appendChild(line);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function drawSquare(svg: SVGSVGElement, cx: number, cy: number, half: number, fill: string, stroke: string): void {
|
|
150
|
+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
151
|
+
rect.setAttribute('class', 'pipe-junction');
|
|
152
|
+
rect.setAttribute('x', String(cx - half));
|
|
153
|
+
rect.setAttribute('y', String(cy - half));
|
|
154
|
+
rect.setAttribute('width', String(half * 2));
|
|
155
|
+
rect.setAttribute('height', String(half * 2));
|
|
156
|
+
rect.setAttribute('fill', fill);
|
|
157
|
+
rect.setAttribute('opacity', '0.9');
|
|
158
|
+
rect.setAttribute('stroke', stroke);
|
|
159
|
+
rect.setAttribute('stroke-width', '1');
|
|
160
|
+
svg.appendChild(rect);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function drawDiamond(svg: SVGSVGElement, cx: number, cy: number, half: number, fill: string, stroke: string): void {
|
|
164
|
+
const d = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
165
|
+
d.setAttribute('d',
|
|
166
|
+
`M ${cx} ${cy - half} L ${cx + half} ${cy} L ${cx} ${cy + half} L ${cx - half} ${cy} Z`);
|
|
167
|
+
d.setAttribute('fill', fill);
|
|
168
|
+
d.setAttribute('opacity', '0.9');
|
|
169
|
+
d.setAttribute('stroke', stroke);
|
|
170
|
+
d.setAttribute('stroke-width', '1');
|
|
171
|
+
svg.appendChild(d);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface LabelOpts {
|
|
175
|
+
fontSize?: number;
|
|
176
|
+
weight?: string;
|
|
177
|
+
italic?: boolean;
|
|
178
|
+
bg?: boolean;
|
|
179
|
+
isDark?: boolean;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function drawLabel(
|
|
183
|
+
svg: SVGSVGElement,
|
|
184
|
+
x: number,
|
|
185
|
+
y: number,
|
|
186
|
+
text: string,
|
|
187
|
+
color: string,
|
|
188
|
+
opts: LabelOpts,
|
|
189
|
+
): void {
|
|
190
|
+
const fontSize = opts.fontSize ?? 9;
|
|
191
|
+
const w = text.length * (fontSize * 0.55) + 8;
|
|
192
|
+
if (opts.bg) {
|
|
193
|
+
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
194
|
+
bg.setAttribute('class', 'pipe-label-bg');
|
|
195
|
+
bg.setAttribute('x', String(x - w / 2));
|
|
196
|
+
bg.setAttribute('y', String(y - fontSize));
|
|
197
|
+
bg.setAttribute('width', String(w));
|
|
198
|
+
bg.setAttribute('height', String(fontSize + 4));
|
|
199
|
+
bg.setAttribute('fill', opts.isDark ? '#1c1e32' : '#ffffff');
|
|
200
|
+
bg.setAttribute('opacity', '0.88');
|
|
201
|
+
bg.setAttribute('rx', '2');
|
|
202
|
+
svg.appendChild(bg);
|
|
203
|
+
}
|
|
204
|
+
const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
205
|
+
label.setAttribute('class', 'pipe-label');
|
|
206
|
+
label.setAttribute('x', String(x));
|
|
207
|
+
label.setAttribute('y', String(y + 1));
|
|
208
|
+
label.setAttribute('text-anchor', 'middle');
|
|
209
|
+
label.setAttribute('fill', color);
|
|
210
|
+
label.setAttribute('font-size', String(fontSize));
|
|
211
|
+
label.setAttribute('font-family', 'JetBrains Mono, monospace');
|
|
212
|
+
if (opts.weight) label.setAttribute('font-weight', opts.weight);
|
|
213
|
+
if (opts.italic) label.setAttribute('font-style', 'italic');
|
|
214
|
+
label.textContent = text;
|
|
215
|
+
svg.appendChild(label);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function pickLocalized(ls: Record<string, string> | undefined, locale: string): string | undefined {
|
|
219
|
+
if (!ls) return undefined;
|
|
220
|
+
return ls[locale] ?? ls.eng ?? ls.default ?? Object.values(ls)[0];
|
|
221
|
+
}
|
package/src/utils/sphere-math.ts
CHANGED
|
@@ -74,6 +74,65 @@ export interface SphereHyperedgeLike {
|
|
|
74
74
|
muteKey?: string;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Layout for the generic-relation "pipe-and-thread" bundle.
|
|
79
|
+
*
|
|
80
|
+
* Visual contract (ISO 704 generic relations):
|
|
81
|
+
* parent ── thick pipe ──▶ middle node
|
|
82
|
+
* ├── thin thread ──▶ child 1
|
|
83
|
+
* ├── thin thread ──▶ child 2
|
|
84
|
+
* └── thin thread ──▶ child N
|
|
85
|
+
*
|
|
86
|
+
* Unlike the partitive rake (right-angle fork notation), this is a
|
|
87
|
+
* direct tree-branch: straight pipe from comprehensive to a middle
|
|
88
|
+
* node, then straight threads radiating to each member. The middle
|
|
89
|
+
* node sits at `splitFraction` along the line from comprehensive to
|
|
90
|
+
* the member centroid.
|
|
91
|
+
*
|
|
92
|
+
* Thick/thin hierarchy: the caller draws `pipeStart → pipeEnd` with a
|
|
93
|
+
* thick stroke and each thread with a thin stroke — the parent is
|
|
94
|
+
* visually heavier than the children, matching the tree-branch
|
|
95
|
+
* metaphor (trunk thicker than twigs).
|
|
96
|
+
*/
|
|
97
|
+
export interface PipeLayoutMember {
|
|
98
|
+
pos: { x: number; y: number };
|
|
99
|
+
/** Per-member delimiting characteristic (carried through so the
|
|
100
|
+
* renderer can label each thread without re-resolving the member). */
|
|
101
|
+
delimitingCharacteristic?: Record<string, string>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface PipeLayout {
|
|
105
|
+
middleNode: { x: number; y: number };
|
|
106
|
+
pipeStart: { x: number; y: number };
|
|
107
|
+
pipeEnd: { x: number; y: number };
|
|
108
|
+
threads: Array<{
|
|
109
|
+
start: { x: number; y: number };
|
|
110
|
+
end: { x: number; y: number };
|
|
111
|
+
member: PipeLayoutMember;
|
|
112
|
+
}>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function computePipeLayout(
|
|
116
|
+
compPos: { x: number; y: number },
|
|
117
|
+
members: readonly PipeLayoutMember[],
|
|
118
|
+
splitFraction = 0.5,
|
|
119
|
+
): PipeLayout | null {
|
|
120
|
+
if (members.length < 2) return null;
|
|
121
|
+
const sumX = members.reduce((s, m) => s + m.pos.x, 0);
|
|
122
|
+
const sumY = members.reduce((s, m) => s + m.pos.y, 0);
|
|
123
|
+
const cx = sumX / members.length;
|
|
124
|
+
const cy = sumY / members.length;
|
|
125
|
+
const midX = compPos.x + (cx - compPos.x) * splitFraction;
|
|
126
|
+
const midY = compPos.y + (cy - compPos.y) * splitFraction;
|
|
127
|
+
const middleNode = { x: midX, y: midY };
|
|
128
|
+
return {
|
|
129
|
+
middleNode,
|
|
130
|
+
pipeStart: compPos,
|
|
131
|
+
pipeEnd: middleNode,
|
|
132
|
+
threads: members.map(m => ({ start: middleNode, end: m.pos, member: m })),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
77
136
|
/**
|
|
78
137
|
* IDs of nodes that should render given the current mute state.
|
|
79
138
|
*
|