@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.96",
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 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
 
@@ -1078,7 +1068,7 @@ const legendItems = computed(() => {
1078
1068
  if (partitiveCount > 0) {
1079
1069
  typeMap.set('__partitive__', {
1080
1070
  type: '__partitive__',
1081
- label: 'partitive relation',
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: 'generic relation',
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
+ }
@@ -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
  *