@glossarist/concept-browser 0.7.97 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.97",
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
+ });
@@ -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 rake bundles in a distinct hue (amber).
710
- Member shape differs (delimitingCharacteristic instead of isDelimiting),
711
- so normalize to the partitive-shaped input the renderer expects. */
712
- const genericNormalized: PartitiveRelationWire[] = (props.genericRelations ?? []).map(rel => ({
713
- source: rel.source,
714
- comprehensive: rel.comprehensive,
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: genericNormalized,
719
+ relations: props.genericRelations ?? [],
720
+ locale: locale.value,
731
721
  });
732
722
  }
733
723
 
@@ -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
+ }
@@ -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
  *