@glossarist/concept-browser 0.7.97 → 0.7.99
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.99",
|
|
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,169 @@
|
|
|
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 ONE characteristic label (the criterion) — no per-thread labels', () => {
|
|
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
|
+
/* Exactly one label — the hyperedge-level criterion. Per-member
|
|
113
|
+
delimitingCharacteristic values are NOT rendered. */
|
|
114
|
+
expect(labels).toEqual(['by magnitude relationship']);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('renders NO label when the criterion is absent', () => {
|
|
118
|
+
const svg = makeSvg();
|
|
119
|
+
const rel = makeRelation();
|
|
120
|
+
delete rel.criterion;
|
|
121
|
+
drawGenericPipes(svg, pos, {
|
|
122
|
+
isDark: false,
|
|
123
|
+
color: '#b45309',
|
|
124
|
+
isMuted: false,
|
|
125
|
+
relations: [rel],
|
|
126
|
+
locale: 'eng',
|
|
127
|
+
});
|
|
128
|
+
/* Pipe + threads + junction + diamond still render, but zero labels. */
|
|
129
|
+
expect(svg.querySelectorAll('text.pipe-label').length).toBe(0);
|
|
130
|
+
expect(svg.querySelectorAll('line.pipe-seg').length).toBe(4);
|
|
131
|
+
expect(svg.querySelectorAll('rect.pipe-junction').length).toBe(1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('renders a diamond at the comprehensive end (origin marker)', () => {
|
|
135
|
+
const svg = makeSvg();
|
|
136
|
+
drawGenericPipes(svg, pos, {
|
|
137
|
+
isDark: false,
|
|
138
|
+
color: '#b45309',
|
|
139
|
+
isMuted: false,
|
|
140
|
+
relations: [makeRelation()],
|
|
141
|
+
locale: 'eng',
|
|
142
|
+
});
|
|
143
|
+
/* Diamond is the only <path> element produced by the renderer. */
|
|
144
|
+
const paths = svg.querySelectorAll('path');
|
|
145
|
+
expect(paths.length).toBe(1);
|
|
146
|
+
const d = paths[0].getAttribute('d') ?? '';
|
|
147
|
+
/* Diamond path starts at (cx, cy - half) — centered on comp (0,0). */
|
|
148
|
+
expect(d.startsWith('M 0 -')).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('skips a relation when fewer than 2 members have positions in the map', () => {
|
|
152
|
+
const svg = makeSvg();
|
|
153
|
+
const sparsePos = new Map([
|
|
154
|
+
['vim-2012/1.9', { x: 0, y: 0 }],
|
|
155
|
+
['vim-2012/1.17', { x: 100, y: 0 }],
|
|
156
|
+
/* 1.18 and 1.12 not in the map */
|
|
157
|
+
]);
|
|
158
|
+
drawGenericPipes(svg, sparsePos, {
|
|
159
|
+
isDark: false,
|
|
160
|
+
color: '#b45309',
|
|
161
|
+
isMuted: false,
|
|
162
|
+
relations: [makeRelation()],
|
|
163
|
+
locale: 'eng',
|
|
164
|
+
});
|
|
165
|
+
/* Single-member layout is rejected (computePipeLayout requires ≥2),
|
|
166
|
+
so no pipe or thread segments should be drawn. */
|
|
167
|
+
expect(svg.querySelectorAll('line.pipe-seg').length).toBe(0);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -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
|
});
|
|
@@ -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
|
|
|
@@ -0,0 +1,218 @@
|
|
|
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. Characteristic label — the criterion is a single hyperedge-level
|
|
24
|
+
* field shared by all members (the dimension along which they
|
|
25
|
+
* differ), rendered once on the pipe body. Optional: when the
|
|
26
|
+
* criterion is absent, no label is shown at all. Per-member
|
|
27
|
+
* delimitingCharacteristic is NOT labeled here — those values
|
|
28
|
+
* distinguish each member but are not "the characteristic of
|
|
29
|
+
* the hyperedge".
|
|
30
|
+
*
|
|
31
|
+
* Pure DOM/geometry — no Vue reactivity, no d3 state. The caller passes
|
|
32
|
+
* the generic relations, position map, and rendering context.
|
|
33
|
+
*/
|
|
34
|
+
import type { GenericRelationWire } from '../../adapters/types';
|
|
35
|
+
import { computePipeLayout, type PipeLayoutMember } from '../../utils/sphere-math';
|
|
36
|
+
import { UriRouter } from '../../adapters/UriRouter';
|
|
37
|
+
|
|
38
|
+
const PIPE_WIDTH = 3.2;
|
|
39
|
+
const THREAD_WIDTH = 1.0;
|
|
40
|
+
const JUNCTION_RADIUS = 4;
|
|
41
|
+
const DIAMOND_HALF = 5;
|
|
42
|
+
|
|
43
|
+
export interface PipeBundleOptions {
|
|
44
|
+
/** Light/dark mode controls the contrast stroke around markers. */
|
|
45
|
+
isDark: boolean;
|
|
46
|
+
/** Relation color (light or dark variant of the generic amber). */
|
|
47
|
+
color: string;
|
|
48
|
+
/** Skip every bundle when the user mutes generic relations. */
|
|
49
|
+
isMuted: boolean;
|
|
50
|
+
/** Relations to draw. */
|
|
51
|
+
relations: readonly GenericRelationWire[];
|
|
52
|
+
/** UI locale, used to pick the delimiting-characteristic text. */
|
|
53
|
+
locale: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface NodePos { x: number; y: number }
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Draw every pipe-and-thread bundle for the current concept's generic
|
|
60
|
+
* relations.
|
|
61
|
+
*
|
|
62
|
+
* Each relation produces: 1 thick pipe (comp → middle), N thin threads
|
|
63
|
+
* (middle → each member), 1 junction square at the middle node, 1
|
|
64
|
+
* diamond at the comp end, and — only when the hyperedge has a
|
|
65
|
+
* criterion — 1 characteristic label on the pipe body.
|
|
66
|
+
*/
|
|
67
|
+
export function drawGenericPipes(
|
|
68
|
+
svg: SVGSVGElement,
|
|
69
|
+
pos: Map<string, NodePos>,
|
|
70
|
+
opts: PipeBundleOptions,
|
|
71
|
+
): void {
|
|
72
|
+
if (opts.isMuted) return;
|
|
73
|
+
const contrastStroke = opts.isDark ? '#1a1208' : '#ffffff';
|
|
74
|
+
|
|
75
|
+
for (const rel of opts.relations) {
|
|
76
|
+
const compParsed = UriRouter.parseUri(rel.comprehensive);
|
|
77
|
+
if (!compParsed) continue;
|
|
78
|
+
const compNodeId = `${compParsed.registerId}/${compParsed.conceptId}`;
|
|
79
|
+
const compPos = pos.get(compNodeId);
|
|
80
|
+
if (!compPos) continue;
|
|
81
|
+
|
|
82
|
+
const memberPositions: PipeLayoutMember[] = [];
|
|
83
|
+
for (const m of rel.members) {
|
|
84
|
+
const parsed = UriRouter.parseUri(m.uri);
|
|
85
|
+
if (!parsed) continue;
|
|
86
|
+
const nid = `${parsed.registerId}/${parsed.conceptId}`;
|
|
87
|
+
const p = pos.get(nid);
|
|
88
|
+
if (!p) continue;
|
|
89
|
+
memberPositions.push({ pos: p, delimitingCharacteristic: m.delimitingCharacteristic });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const layout = computePipeLayout(compPos, memberPositions);
|
|
93
|
+
if (!layout) continue;
|
|
94
|
+
|
|
95
|
+
/* 1. Thick parent pipe: comp → middle node. The characteristic
|
|
96
|
+
label (criterion) sits on the pipe body — see step 4. */
|
|
97
|
+
drawSegment(svg, opts.color, layout.pipeStart, layout.pipeEnd, PIPE_WIDTH);
|
|
98
|
+
|
|
99
|
+
/* 2. Thin threads: middle → each member. No per-thread labels —
|
|
100
|
+
the characteristic is a single hyperedge-level field shared
|
|
101
|
+
by all members, rendered once on the pipe body. */
|
|
102
|
+
for (const thread of layout.threads) {
|
|
103
|
+
drawSegment(svg, opts.color, thread.start, thread.end, THREAD_WIDTH);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* 3. Square junction at the middle node (distinct from the rake's
|
|
107
|
+
circular junction — gives a visual cue: square = generic,
|
|
108
|
+
circle = partitive). */
|
|
109
|
+
drawSquare(svg, layout.middleNode.x, layout.middleNode.y, JUNCTION_RADIUS, opts.color, contrastStroke);
|
|
110
|
+
|
|
111
|
+
/* 4. Characteristic label — single, hyperedge-level, optional.
|
|
112
|
+
Drawn on the pipe body (midpoint between comp and middle node).
|
|
113
|
+
When criterion is absent, no label is rendered at all. */
|
|
114
|
+
const critText = pickLocalized(rel.criterion, opts.locale);
|
|
115
|
+
if (critText) {
|
|
116
|
+
const midX = (layout.pipeStart.x + layout.pipeEnd.x) / 2;
|
|
117
|
+
const midY = (layout.pipeStart.y + layout.pipeEnd.y) / 2;
|
|
118
|
+
drawLabel(svg, midX, midY, critText, opts.color, {
|
|
119
|
+
fontSize: 9.5,
|
|
120
|
+
weight: '600',
|
|
121
|
+
bg: true,
|
|
122
|
+
isDark: opts.isDark,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/* 5. Diamond at the comprehensive end (origin marker shared with
|
|
127
|
+
the rake — both are hyperedges emanating from one concept). */
|
|
128
|
+
drawDiamond(svg, compPos.x, compPos.y, DIAMOND_HALF, opts.color, contrastStroke);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function drawSegment(svg: SVGSVGElement, color: string, from: NodePos, to: NodePos, width: number): void {
|
|
133
|
+
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
134
|
+
line.setAttribute('class', 'pipe-seg');
|
|
135
|
+
line.setAttribute('x1', String(from.x));
|
|
136
|
+
line.setAttribute('y1', String(from.y));
|
|
137
|
+
line.setAttribute('x2', String(to.x));
|
|
138
|
+
line.setAttribute('y2', String(to.y));
|
|
139
|
+
line.setAttribute('stroke', color);
|
|
140
|
+
line.setAttribute('stroke-width', String(width));
|
|
141
|
+
line.setAttribute('stroke-linecap', 'round');
|
|
142
|
+
line.setAttribute('opacity', '0.9');
|
|
143
|
+
svg.appendChild(line);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function drawSquare(svg: SVGSVGElement, cx: number, cy: number, half: number, fill: string, stroke: string): void {
|
|
147
|
+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
148
|
+
rect.setAttribute('class', 'pipe-junction');
|
|
149
|
+
rect.setAttribute('x', String(cx - half));
|
|
150
|
+
rect.setAttribute('y', String(cy - half));
|
|
151
|
+
rect.setAttribute('width', String(half * 2));
|
|
152
|
+
rect.setAttribute('height', String(half * 2));
|
|
153
|
+
rect.setAttribute('fill', fill);
|
|
154
|
+
rect.setAttribute('opacity', '0.9');
|
|
155
|
+
rect.setAttribute('stroke', stroke);
|
|
156
|
+
rect.setAttribute('stroke-width', '1');
|
|
157
|
+
svg.appendChild(rect);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function drawDiamond(svg: SVGSVGElement, cx: number, cy: number, half: number, fill: string, stroke: string): void {
|
|
161
|
+
const d = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
162
|
+
d.setAttribute('d',
|
|
163
|
+
`M ${cx} ${cy - half} L ${cx + half} ${cy} L ${cx} ${cy + half} L ${cx - half} ${cy} Z`);
|
|
164
|
+
d.setAttribute('fill', fill);
|
|
165
|
+
d.setAttribute('opacity', '0.9');
|
|
166
|
+
d.setAttribute('stroke', stroke);
|
|
167
|
+
d.setAttribute('stroke-width', '1');
|
|
168
|
+
svg.appendChild(d);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
interface LabelOpts {
|
|
172
|
+
fontSize?: number;
|
|
173
|
+
weight?: string;
|
|
174
|
+
italic?: boolean;
|
|
175
|
+
bg?: boolean;
|
|
176
|
+
isDark?: boolean;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function drawLabel(
|
|
180
|
+
svg: SVGSVGElement,
|
|
181
|
+
x: number,
|
|
182
|
+
y: number,
|
|
183
|
+
text: string,
|
|
184
|
+
color: string,
|
|
185
|
+
opts: LabelOpts,
|
|
186
|
+
): void {
|
|
187
|
+
const fontSize = opts.fontSize ?? 9;
|
|
188
|
+
const w = text.length * (fontSize * 0.55) + 8;
|
|
189
|
+
if (opts.bg) {
|
|
190
|
+
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
191
|
+
bg.setAttribute('class', 'pipe-label-bg');
|
|
192
|
+
bg.setAttribute('x', String(x - w / 2));
|
|
193
|
+
bg.setAttribute('y', String(y - fontSize));
|
|
194
|
+
bg.setAttribute('width', String(w));
|
|
195
|
+
bg.setAttribute('height', String(fontSize + 4));
|
|
196
|
+
bg.setAttribute('fill', opts.isDark ? '#1c1e32' : '#ffffff');
|
|
197
|
+
bg.setAttribute('opacity', '0.88');
|
|
198
|
+
bg.setAttribute('rx', '2');
|
|
199
|
+
svg.appendChild(bg);
|
|
200
|
+
}
|
|
201
|
+
const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
202
|
+
label.setAttribute('class', 'pipe-label');
|
|
203
|
+
label.setAttribute('x', String(x));
|
|
204
|
+
label.setAttribute('y', String(y + 1));
|
|
205
|
+
label.setAttribute('text-anchor', 'middle');
|
|
206
|
+
label.setAttribute('fill', color);
|
|
207
|
+
label.setAttribute('font-size', String(fontSize));
|
|
208
|
+
label.setAttribute('font-family', 'JetBrains Mono, monospace');
|
|
209
|
+
if (opts.weight) label.setAttribute('font-weight', opts.weight);
|
|
210
|
+
if (opts.italic) label.setAttribute('font-style', 'italic');
|
|
211
|
+
label.textContent = text;
|
|
212
|
+
svg.appendChild(label);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function pickLocalized(ls: Record<string, string> | undefined, locale: string): string | undefined {
|
|
216
|
+
if (!ls) return undefined;
|
|
217
|
+
return ls[locale] ?? ls.eng ?? ls.default ?? Object.values(ls)[0];
|
|
218
|
+
}
|
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
|
*
|