@glossarist/concept-browser 0.7.81 → 0.7.83

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.81",
3
+ "version": "0.7.83",
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,248 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { mount } from '@vue/test-utils';
3
+ import PartitiveRelationDiagram, {
4
+ type PartitiveMemberLabeled,
5
+ } from '../../components/PartitiveRelationDiagram.vue';
6
+
7
+ const members = (n: number, certainty: 'confirmed' | 'possible' = 'confirmed'): PartitiveMemberLabeled[] =>
8
+ Array.from({ length: n }, (_, i) => ({
9
+ uri: `https://example.org/test/concept/1.${i + 1}`,
10
+ label: `1.${i + 1}`,
11
+ certainty,
12
+ }));
13
+
14
+ function svgLinesOf(wrapper: ReturnType<typeof mount>) {
15
+ return wrapper.findAll('line');
16
+ }
17
+
18
+ describe('PartitiveRelationDiagram — ISO 704 rake rendering', () => {
19
+ it('renders comprehensive + N partitive nodes as text', () => {
20
+ const w = mount(PartitiveRelationDiagram, {
21
+ props: {
22
+ comprehensiveLabel: '1.3',
23
+ partitives: members(3),
24
+ completeness: 'complete',
25
+ plurality: null,
26
+ },
27
+ });
28
+ const texts = w.findAll('text').map(t => t.text());
29
+ expect(texts).toContain('1.3');
30
+ expect(texts).toContain('1.1');
31
+ expect(texts).toContain('1.2');
32
+ expect(texts).toContain('1.3');
33
+ });
34
+
35
+ it('renders criterion under the comprehensive', () => {
36
+ const w = mount(PartitiveRelationDiagram, {
37
+ props: {
38
+ comprehensiveLabel: '1.3',
39
+ partitives: members(2),
40
+ completeness: 'complete',
41
+ plurality: null,
42
+ criterion: 'physical structure',
43
+ },
44
+ });
45
+ expect(w.findAll('text').map(t => t.text())).toContain('physical structure');
46
+ });
47
+
48
+ describe('line variant by plurality', () => {
49
+ it('single solid line when no plurality (3 lines: stem + spine + 2 drops = 4 actually)', () => {
50
+ const w = mount(PartitiveRelationDiagram, {
51
+ props: {
52
+ comprehensiveLabel: '1.3',
53
+ partitives: members(2),
54
+ completeness: 'complete',
55
+ plurality: null,
56
+ },
57
+ });
58
+ // stem (1) + spine (1) + drops (2) = 4 single lines
59
+ expect(svgLinesOf(w)).toHaveLength(4);
60
+ // none should be dashed
61
+ const dashed = svgLinesOf(w).filter(l => l.attributes('stroke-dasharray'));
62
+ expect(dashed).toHaveLength(0);
63
+ });
64
+
65
+ it('close-set double solid lines when isShared && !isUncertain', () => {
66
+ const w = mount(PartitiveRelationDiagram, {
67
+ props: {
68
+ comprehensiveLabel: '1.3',
69
+ partitives: members(2),
70
+ completeness: 'complete',
71
+ plurality: { isShared: true, isUncertain: false },
72
+ },
73
+ });
74
+ // stem doubles (2) + spine doubles (2) + drops (2, single) = 6 lines
75
+ expect(svgLinesOf(w)).toHaveLength(6);
76
+ const dashed = svgLinesOf(w).filter(l => l.attributes('stroke-dasharray'));
77
+ expect(dashed).toHaveLength(0);
78
+ });
79
+
80
+ it('one solid + one dashed when isShared && isUncertain', () => {
81
+ const w = mount(PartitiveRelationDiagram, {
82
+ props: {
83
+ comprehensiveLabel: '1.3',
84
+ partitives: members(2),
85
+ completeness: 'complete',
86
+ plurality: { isShared: true, isUncertain: true },
87
+ },
88
+ });
89
+ // stem (2: 1 solid + 1 dashed) + spine (2: 1 solid + 1 dashed) + drops (2, single solid) = 6
90
+ const lines = svgLinesOf(w);
91
+ expect(lines).toHaveLength(6);
92
+ const dashed = lines.filter(l => l.attributes('stroke-dasharray'));
93
+ // exactly 2 dashed lines (the second stem + the second spine)
94
+ expect(dashed).toHaveLength(2);
95
+ });
96
+ });
97
+
98
+ describe('completeness: partial extends spine', () => {
99
+ it('extends spine past last tooth when completeness=partial', () => {
100
+ const w = mount(PartitiveRelationDiagram, {
101
+ props: {
102
+ comprehensiveLabel: '1.3',
103
+ partitives: members(2),
104
+ completeness: 'partial',
105
+ plurality: null,
106
+ },
107
+ });
108
+ const lines = svgLinesOf(w);
109
+ // stem + spine + 2 drops = 4 lines (same count, but spine is longer)
110
+ expect(lines).toHaveLength(4);
111
+ // The spine line's x2 should be > the last partitive's x
112
+ const lastMember = w.props('partitives')[1];
113
+ // visual assertion: the spine line's x2 is greater than the second
114
+ // partitive's slot x. We can't compute slot exactly without internals,
115
+ // but we know PARTIAL_TAIL (32) is added.
116
+ const spineLine = lines.find(l => {
117
+ const y1 = l.attributes('y1');
118
+ const y2 = l.attributes('y2');
119
+ return y1 && y2 && y1 === y2; // horizontal
120
+ });
121
+ expect(spineLine).toBeDefined();
122
+ const x2 = Number(spineLine!.attributes('x2'));
123
+ // last member x ≈ PADDING_X + NODE_W * 1.5 + GAP_X = 24 + 180 + 16 = 220
124
+ // spine end with PARTIAL_TAIL = 220 + 32 = 252
125
+ expect(x2).toBeGreaterThan(220);
126
+ void lastMember;
127
+ });
128
+ });
129
+
130
+ describe('per-member certainty', () => {
131
+ it('renders possible members with dashed border', () => {
132
+ const possibleMembers: PartitiveMemberLabeled[] = [
133
+ { uri: 'u1', label: '1', certainty: 'confirmed' },
134
+ { uri: 'u2', label: '2', certainty: 'possible' },
135
+ ];
136
+ const w = mount(PartitiveRelationDiagram, {
137
+ props: {
138
+ comprehensiveLabel: '0',
139
+ partitives: possibleMembers,
140
+ completeness: 'complete',
141
+ plurality: null,
142
+ },
143
+ });
144
+ // Find the rect for the 'possible' member (the 2nd node)
145
+ const rects = w.findAll('rect');
146
+ // 1 comp + 2 partitive = 3 rects
147
+ expect(rects).toHaveLength(3);
148
+ // The possible member's rect should have a dashed border
149
+ const possibleRect = rects[2];
150
+ expect(possibleRect.attributes('stroke-dasharray')).toBeTruthy();
151
+ });
152
+
153
+ it('emits navigate when a partitive is clicked', async () => {
154
+ const w = mount(PartitiveRelationDiagram, {
155
+ props: {
156
+ comprehensiveLabel: '0',
157
+ partitives: members(2),
158
+ completeness: 'complete',
159
+ plurality: null,
160
+ },
161
+ });
162
+ // Click any of the partitive <g> elements
163
+ const groups = w.findAll('g.cursor-pointer');
164
+ expect(groups.length).toBeGreaterThan(0);
165
+ await groups[0].trigger('click');
166
+ expect(w.emitted('navigate')).toBeTruthy();
167
+ });
168
+ });
169
+
170
+ describe('designation-aware sizing', () => {
171
+ it('uses MIN_NODE_W when labels are short', () => {
172
+ const w = mount(PartitiveRelationDiagram, {
173
+ props: {
174
+ comprehensiveLabel: '1.3',
175
+ partitives: members(2),
176
+ completeness: 'complete',
177
+ plurality: null,
178
+ },
179
+ });
180
+ const svg = w.find('svg');
181
+ // Short labels → MIN_NODE_W=110, total = 2*110 + 16 + 40 = 276
182
+ const viewBox = svg.attributes('viewBox') || '';
183
+ const width = Number(viewBox.split(' ')[2]);
184
+ expect(width).toBeLessThan(300);
185
+ });
186
+
187
+ it('grows nodes for long designations', () => {
188
+ const longMembers: PartitiveMemberLabeled[] = [
189
+ { uri: 'u1', label: 'system of quantities', certainty: 'confirmed' },
190
+ { uri: 'u2', label: 'International System of Units', certainty: 'confirmed' },
191
+ { uri: 'u3', label: 'base quantity dimension', certainty: 'confirmed' },
192
+ ];
193
+ const w = mount(PartitiveRelationDiagram, {
194
+ props: {
195
+ comprehensiveLabel: 'coordinate system of quantities',
196
+ partitives: longMembers,
197
+ completeness: 'complete',
198
+ plurality: null,
199
+ },
200
+ });
201
+ const svg = w.find('svg');
202
+ const viewBox = svg.attributes('viewBox') || '';
203
+ const width = Number(viewBox.split(' ')[2]);
204
+ // Each node wider than MIN_NODE_W → total > 3*110 = 330
205
+ expect(width).toBeGreaterThan(330);
206
+ // Capped: node width ≤ MAX_NODE_W=220 → total ≤ 3*220 + 2*16 + 40 = 732
207
+ expect(width).toBeLessThanOrEqual(732);
208
+ });
209
+
210
+ it('wraps long labels to 2 lines via tspan when needed', () => {
211
+ const longLabel = 'International System of Quantities and Units';
212
+ const w = mount(PartitiveRelationDiagram, {
213
+ props: {
214
+ comprehensiveLabel: longLabel,
215
+ partitives: [
216
+ { uri: 'u1', label: 'short', certainty: 'confirmed' },
217
+ { uri: 'u2', label: 'short', certainty: 'confirmed' },
218
+ ],
219
+ completeness: 'complete',
220
+ plurality: null,
221
+ },
222
+ });
223
+ // The comprehensive text should have ≥ 2 tspans (wrapped)
224
+ const texts = w.findAll('text');
225
+ const compText = texts[0]; // first text is the comprehensive
226
+ const tspans = compText.findAll('tspan');
227
+ expect(tspans.length).toBeGreaterThanOrEqual(2);
228
+ });
229
+
230
+ it('renders the designation (not the conceptId) as the label', () => {
231
+ const w = mount(PartitiveRelationDiagram, {
232
+ props: {
233
+ comprehensiveLabel: 'system of quantities',
234
+ partitives: [
235
+ { uri: 'u1', label: 'base quantity', certainty: 'confirmed' },
236
+ { uri: 'u2', label: 'derived quantity', certainty: 'confirmed' },
237
+ ],
238
+ completeness: 'complete',
239
+ plurality: null,
240
+ },
241
+ });
242
+ const allText = w.findAll('text').map(t => t.text());
243
+ expect(allText).toContain('system of quantities');
244
+ expect(allText).toContain('base quantity');
245
+ expect(allText).toContain('derived quantity');
246
+ });
247
+ });
248
+ });
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { conceptFromJson } from '../adapters/model-bridge';
3
+
4
+ describe('conceptFromJson — gl:partitiveRelations (v2)', () => {
5
+ function makeJson(overrides: Record<string, unknown> = {}) {
6
+ return {
7
+ '@type': 'gl:Concept',
8
+ '@id': 'https://example.org/test/concept/1.3',
9
+ 'gl:identifier': '1.3',
10
+ 'gl:localizedConcept': {},
11
+ ...overrides,
12
+ };
13
+ }
14
+
15
+ it('returns empty array when no partitiveRelations', () => {
16
+ const c = conceptFromJson(makeJson());
17
+ expect(c.partitiveRelations).toEqual([]);
18
+ });
19
+
20
+ it('parses comprehensive + 2 partitives', () => {
21
+ const c = conceptFromJson(makeJson({
22
+ 'gl:partitiveRelations': [{
23
+ 'gl:comprehensive': { 'gl:source': 'urn:test', 'gl:id': '1.3' },
24
+ 'gl:hasPartitive': [
25
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.4' } },
26
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.5' } },
27
+ ],
28
+ }],
29
+ }));
30
+ expect(c.partitiveRelations).toHaveLength(1);
31
+ const r = c.partitiveRelations[0];
32
+ expect(r.comprehensive.source).toBe('urn:test');
33
+ expect(r.comprehensive.id).toBe('1.3');
34
+ expect(r.partitives).toHaveLength(2);
35
+ expect(r.partitives[0].ref.id).toBe('1.4');
36
+ expect(r.partitives[1].ref.id).toBe('1.5');
37
+ });
38
+
39
+ it('parses completeness', () => {
40
+ const c = conceptFromJson(makeJson({
41
+ 'gl:partitiveRelations': [{
42
+ 'gl:comprehensive': { 'gl:source': 'urn:test', 'gl:id': '1.3' },
43
+ 'gl:hasPartitive': [
44
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.4' } },
45
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.5' } },
46
+ ],
47
+ 'gl:completeness': 'partial',
48
+ }],
49
+ }));
50
+ expect(c.partitiveRelations[0].completeness).toBe('partial');
51
+ expect(c.partitiveRelations[0].isPartial).toBe(true);
52
+ });
53
+
54
+ it('parses per-member certainty', () => {
55
+ const c = conceptFromJson(makeJson({
56
+ 'gl:partitiveRelations': [{
57
+ 'gl:comprehensive': { 'gl:source': 'urn:test', 'gl:id': '1.3' },
58
+ 'gl:hasPartitive': [
59
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.4' } },
60
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.5' }, 'gl:certainty': 'possible' },
61
+ ],
62
+ }],
63
+ }));
64
+ const members = c.partitiveRelations[0].partitives;
65
+ expect(members[0].certainty).toBe('confirmed');
66
+ expect(members[1].certainty).toBe('possible');
67
+ expect(members[1].isPossible).toBe(true);
68
+ });
69
+
70
+ it('parses plurality block', () => {
71
+ const c = conceptFromJson(makeJson({
72
+ 'gl:partitiveRelations': [{
73
+ 'gl:comprehensive': { 'gl:source': 'urn:test', 'gl:id': '1.3' },
74
+ 'gl:hasPartitive': [
75
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.4' } },
76
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.5' } },
77
+ ],
78
+ 'gl:hasPlurality': {
79
+ 'gl:isShared': true,
80
+ 'gl:isUncertain': true,
81
+ 'gl:sharedType': { 'gl:source': 'urn:test', 'gl:id': '1.99' },
82
+ },
83
+ }],
84
+ }));
85
+ const p = c.partitiveRelations[0].plurality;
86
+ expect(p).not.toBeNull();
87
+ expect(p?.isShared).toBe(true);
88
+ expect(p?.isUncertain).toBe(true);
89
+ expect(p?.sharedType?.id).toBe('1.99');
90
+ });
91
+
92
+ it('parses criterion as localized hash', () => {
93
+ const c = conceptFromJson(makeJson({
94
+ 'gl:partitiveRelations': [{
95
+ 'gl:comprehensive': { 'gl:source': 'urn:test', 'gl:id': '1.3' },
96
+ 'gl:hasPartitive': [
97
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.4' } },
98
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.5' } },
99
+ ],
100
+ 'gl:criterion': { eng: 'physical structure', fra: 'structure physique' },
101
+ }],
102
+ }));
103
+ expect(c.partitiveRelations[0].criterion?.eng).toBe('physical structure');
104
+ expect(c.partitiveRelations[0].criterion?.fra).toBe('structure physique');
105
+ });
106
+
107
+ it('parses criterion as plain string (normalized to default)', () => {
108
+ const c = conceptFromJson(makeJson({
109
+ 'gl:partitiveRelations': [{
110
+ 'gl:comprehensive': { 'gl:source': 'urn:test', 'gl:id': '1.3' },
111
+ 'gl:hasPartitive': [
112
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.4' } },
113
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.5' } },
114
+ ],
115
+ 'gl:criterion': 'a bare criterion string',
116
+ }],
117
+ }));
118
+ expect(c.partitiveRelations[0].criterion?.default).toBe('a bare criterion string');
119
+ });
120
+
121
+ it('skips relation with no comprehensive', () => {
122
+ const c = conceptFromJson(makeJson({
123
+ 'gl:partitiveRelations': [{
124
+ 'gl:hasPartitive': [
125
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.4' } },
126
+ ],
127
+ }],
128
+ }));
129
+ expect(c.partitiveRelations).toEqual([]);
130
+ });
131
+
132
+ it('skips relation with < 2 valid partitives', () => {
133
+ const c = conceptFromJson(makeJson({
134
+ 'gl:partitiveRelations': [{
135
+ 'gl:comprehensive': { 'gl:source': 'urn:test', 'gl:id': '1.3' },
136
+ 'gl:hasPartitive': [
137
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.4' } },
138
+ // Single member — PartitiveRelation constructor throws
139
+ ],
140
+ }],
141
+ }));
142
+ // The constructor throws before we catch it, so the relation is dropped
143
+ // via the runtime error path. conceptFromJson should not propagate.
144
+ expect(c.partitiveRelations).toEqual([]);
145
+ });
146
+ });
@@ -164,6 +164,25 @@ interface JsonLdLocalizedConcept {
164
164
  'gl:reviewDecisionNotes'?: string;
165
165
  }
166
166
 
167
+ interface JsonLdPartitiveMember {
168
+ 'gl:ref'?: JsonLdRef;
169
+ 'gl:certainty'?: string;
170
+ }
171
+
172
+ interface JsonLdTypeSharedPlurality {
173
+ 'gl:isShared'?: boolean;
174
+ 'gl:isUncertain'?: boolean;
175
+ 'gl:sharedType'?: JsonLdRef;
176
+ }
177
+
178
+ interface JsonLdPartitiveRelation {
179
+ 'gl:comprehensive'?: JsonLdRef;
180
+ 'gl:hasPartitive'?: JsonLdPartitiveMember[];
181
+ 'gl:completeness'?: string;
182
+ 'gl:hasPlurality'?: JsonLdTypeSharedPlurality;
183
+ 'gl:criterion'?: Record<string, string> | string;
184
+ }
185
+
167
186
  interface JsonLdConcept {
168
187
  '@type'?: string;
169
188
  '@id'?: string;
@@ -171,6 +190,7 @@ interface JsonLdConcept {
171
190
  'gl:term'?: string;
172
191
  'gl:localizedConcept'?: Record<string, JsonLdLocalizedConcept>;
173
192
  'gl:related'?: JsonLdRelated[];
193
+ 'gl:partitiveRelations'?: JsonLdPartitiveRelation[];
174
194
  'gl:tags'?: string[];
175
195
  'gl:figureRef'?: unknown[];
176
196
  'gl:tableRef'?: unknown[];
@@ -563,6 +583,51 @@ function mapLocalizedFromJsonLd(lc: JsonLdLocalizedConcept): Record<string, unkn
563
583
  return data;
564
584
  }
565
585
 
586
+ function mapPartitiveRelationFromJsonLd(r: JsonLdPartitiveRelation): Record<string, unknown> | null {
587
+ const comprehensive = r['gl:comprehensive'] ? mapRefFromJsonLd(r['gl:comprehensive']) : null;
588
+ if (!comprehensive) return null;
589
+
590
+ const partitives = (r['gl:hasPartitive'] ?? [])
591
+ .map((m): Record<string, unknown> | null => {
592
+ const ref = m['gl:ref'] ? mapRefFromJsonLd(m['gl:ref']) : null;
593
+ if (!ref) return null;
594
+ const out: Record<string, unknown> = { ref };
595
+ if (m['gl:certainty']) out.certainty = m['gl:certainty'];
596
+ return out;
597
+ })
598
+ .filter((m): m is Record<string, unknown> => m !== null);
599
+
600
+ // ISO 704 requires ≥2 partitives. Skip malformed relations rather
601
+ // than letting the constructor throw and break the whole concept.
602
+ if (partitives.length < 2) return null;
603
+
604
+ const out: Record<string, unknown> = {
605
+ comprehensive,
606
+ partitives,
607
+ };
608
+
609
+ if (r['gl:completeness']) out.completeness = r['gl:completeness'];
610
+
611
+ const plurality = r['gl:hasPlurality'];
612
+ if (plurality && typeof plurality['gl:isShared'] === 'boolean') {
613
+ const p: Record<string, unknown> = { isShared: plurality['gl:isShared'] };
614
+ if (typeof plurality['gl:isUncertain'] === 'boolean') {
615
+ p.isUncertain = plurality['gl:isUncertain'];
616
+ }
617
+ const sharedType = plurality['gl:sharedType'] ? mapRefFromJsonLd(plurality['gl:sharedType']) : null;
618
+ if (sharedType) p.sharedType = sharedType;
619
+ out.plurality = p;
620
+ }
621
+
622
+ if (r['gl:criterion']) {
623
+ out.criterion = typeof r['gl:criterion'] === 'string'
624
+ ? { default: r['gl:criterion'] }
625
+ : r['gl:criterion'];
626
+ }
627
+
628
+ return out;
629
+ }
630
+
566
631
  function conceptFromJsonLd(doc: JsonLdConcept): Concept {
567
632
  const id = String(doc['gl:identifier'] ?? doc['@id']?.split('/').pop() ?? '');
568
633
  const localizations: Record<string, unknown> = {};
@@ -575,6 +640,9 @@ function conceptFromJsonLd(doc: JsonLdConcept): Concept {
575
640
  }
576
641
 
577
642
  const related = (doc['gl:related'] ?? []).map(mapRelatedFromJsonLd);
643
+ const partitiveRelations = (doc['gl:partitiveRelations'] ?? [])
644
+ .map(mapPartitiveRelationFromJsonLd)
645
+ .filter((r): r is Record<string, unknown> => r !== null);
578
646
  const tags = Array.isArray(doc['gl:tags']) ? [...doc['gl:tags']] : [];
579
647
 
580
648
  const concept = Concept.fromJSON({
@@ -583,6 +651,7 @@ function conceptFromJsonLd(doc: JsonLdConcept): Concept {
583
651
  uri: doc['@id'] ?? null,
584
652
  localizations,
585
653
  related,
654
+ partitive_relations: partitiveRelations,
586
655
  tags,
587
656
  figures: normalizeEntityRefs(doc['gl:figureRef']),
588
657
  tables: normalizeEntityRefs(doc['gl:tableRef']),
@@ -0,0 +1,292 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * PartitiveRelationDiagram — renders a single PartitiveRelation as an
4
+ * ISO 704 rake/bracket diagram in SVG.
5
+ *
6
+ * Layout (looking top-down):
7
+ *
8
+ * [comprehensive]
9
+ * │
10
+ * │ ← vertical stem
11
+ * ┌───────────────┴───────────────┐ ← horizontal spine
12
+ * │ │ │
13
+ * │ │ │ ← perpendicular drops
14
+ * [part1] [part2] [part3]
15
+ *
16
+ * Line conventions per ISO 704:
17
+ * - default : single solid line
18
+ * - plurality.isShared && !isUncertain : close-set DOUBLE solid lines
19
+ * on stem + spine (drops stay single)
20
+ * - plurality.isShared && isUncertain : one solid + one DASHED line
21
+ * - completeness === 'partial' : spine extends past the last
22
+ * tooth (continued backline)
23
+ *
24
+ * Node sizing is designation-aware: the longest label drives the node
25
+ * width (clamped to [MIN_NODE_W, MAX_NODE_W]); labels that don't fit
26
+ * wrap to a second line via tspan. This lets the rake show actual
27
+ * designations ('system of quantities') instead of bare IDs ('1.3').
28
+ */
29
+ import { computed } from 'vue';
30
+ import type { TypeSharedPluralityWire } from '../adapters/types';
31
+
32
+ export interface PartitiveMemberLabeled {
33
+ uri: string;
34
+ label: string;
35
+ certainty: 'confirmed' | 'possible';
36
+ }
37
+
38
+ const props = withDefaults(defineProps<{
39
+ comprehensiveLabel: string;
40
+ partitives: PartitiveMemberLabeled[];
41
+ completeness: 'complete' | 'partial';
42
+ plurality: TypeSharedPluralityWire | null;
43
+ criterion?: string | null;
44
+ }>(), {
45
+ criterion: null,
46
+ });
47
+
48
+ const emit = defineEmits<{
49
+ (e: 'navigate', uri: string): void;
50
+ }>();
51
+
52
+ // Layout constants
53
+ const MIN_NODE_W = 110;
54
+ const MAX_NODE_W = 220;
55
+ const SINGLE_LINE_H = 32;
56
+ const DOUBLE_LINE_H = 50;
57
+ const STEM_LEN = 28;
58
+ const SPINE_TO_NODE = 32;
59
+ const GAP_X = 16;
60
+ const PADDING_X = 20;
61
+ const PADDING_Y = 14;
62
+ const DOUBLE_GAP = 3;
63
+ const PARTIAL_TAIL = 32;
64
+ const DASH_PATTERN = '4 3';
65
+ const CHAR_W = 6.2; // approx for 11px sans-serif
66
+ const NODE_TEXT_PAD = 16; // horizontal padding inside node rect
67
+
68
+ type LineVariant = 'single' | 'double-solid' | 'solid-plus-dashed';
69
+
70
+ const lineVariant = computed<LineVariant>(() => {
71
+ if (!props.plurality?.isShared) return 'single';
72
+ return props.plurality.isUncertain ? 'solid-plus-dashed' : 'double-solid';
73
+ });
74
+
75
+ /** Estimated pixel width of a string at 11px sans-serif. */
76
+ function estimateTextWidth(text: string): number {
77
+ return text.length * CHAR_W;
78
+ }
79
+
80
+ /**
81
+ * Wrap a label to at most 2 lines, breaking on word boundaries.
82
+ * If a single word exceeds the limit, it stays on its own line and
83
+ * gets truncated with an ellipsis.
84
+ */
85
+ function wrapLabel(text: string, maxCharsPerLine: number): string[] {
86
+ if (text.length <= maxCharsPerLine) return [text];
87
+ const words = text.split(/\s+/);
88
+ const lines: string[] = [];
89
+ let current = '';
90
+ for (const w of words) {
91
+ if (w.length > maxCharsPerLine) {
92
+ // word itself too long — flush current, push truncated word
93
+ if (current) { lines.push(current); current = ''; }
94
+ lines.push(w.slice(0, Math.max(1, maxCharsPerLine - 1)) + '…');
95
+ continue;
96
+ }
97
+ if (!current) {
98
+ current = w;
99
+ } else if ((current + ' ' + w).length <= maxCharsPerLine) {
100
+ current += ' ' + w;
101
+ } else {
102
+ lines.push(current);
103
+ current = w;
104
+ }
105
+ }
106
+ if (current) lines.push(current);
107
+ if (lines.length > 2) {
108
+ return [lines[0], lines.slice(1).join(' ').slice(0, maxCharsPerLine - 1) + '…'];
109
+ }
110
+ return lines;
111
+ }
112
+
113
+ const layout = computed(() => {
114
+ const allLabels = [props.comprehensiveLabel, ...props.partitives.map(p => p.label)];
115
+ const longestWidth = Math.max(...allLabels.map(estimateTextWidth));
116
+ const nodeW = Math.min(MAX_NODE_W, Math.max(MIN_NODE_W, Math.ceil(longestWidth) + NODE_TEXT_PAD));
117
+ const charsPerLine = Math.max(10, Math.floor((nodeW - NODE_TEXT_PAD) / CHAR_W));
118
+
119
+ const wrapped = allLabels.map(label => wrapLabel(label, charsPerLine));
120
+ const anyWrapped = wrapped.some(lines => lines.length > 1);
121
+ const nodeH = anyWrapped ? DOUBLE_LINE_H : SINGLE_LINE_H;
122
+
123
+ const n = props.partitives.length;
124
+ const totalWidth = PADDING_X * 2 + n * nodeW + (n - 1) * GAP_X;
125
+ const totalHeight = PADDING_Y * 2 + nodeH + STEM_LEN + SPINE_TO_NODE + nodeH;
126
+
127
+ const centerX = totalWidth / 2;
128
+ const compY = PADDING_Y + nodeH / 2;
129
+ const spineY = compY + nodeH / 2 + STEM_LEN;
130
+ const partY = spineY + SPINE_TO_NODE;
131
+
132
+ const partitiveSlots = props.partitives.map((member, i) => {
133
+ const x = PADDING_X + nodeW / 2 + i * (nodeW + GAP_X);
134
+ const labelLines = wrapped[i + 1]; // index 0 is comprehensive
135
+ return { member, x, labelLines };
136
+ });
137
+
138
+ const leftX = partitiveSlots[0]?.x ?? centerX;
139
+ const lastX = partitiveSlots[n - 1]?.x ?? centerX;
140
+ const spineEndX = props.completeness === 'partial'
141
+ ? lastX + PARTIAL_TAIL
142
+ : lastX;
143
+
144
+ return {
145
+ nodeW,
146
+ nodeH,
147
+ totalWidth,
148
+ totalHeight,
149
+ centerX,
150
+ compY,
151
+ spineY,
152
+ partY,
153
+ partitiveSlots,
154
+ comprehensiveLines: wrapped[0],
155
+ leftX,
156
+ spineEndX,
157
+ };
158
+ });
159
+
160
+ function strokeAttr(variant: LineVariant, opacity = 1, override?: 'dash') {
161
+ const useDash = override === 'dash' || variant === 'solid-plus-dashed';
162
+ return {
163
+ stroke: 'currentColor',
164
+ 'stroke-width': 1.4,
165
+ opacity,
166
+ ...(useDash ? { 'stroke-dasharray': DASH_PATTERN } : {}),
167
+ };
168
+ }
169
+
170
+ function emitNav(uri: string) {
171
+ emit('navigate', uri);
172
+ }
173
+ </script>
174
+
175
+ <template>
176
+ <svg
177
+ :viewBox="`0 0 ${layout.totalWidth} ${layout.totalHeight}`"
178
+ width="100%"
179
+ :style="{ maxWidth: `${layout.totalWidth}px` }"
180
+ preserveAspectRatio="xMidYMid meet"
181
+ class="partitive-rake text-emerald-700 dark:text-emerald-300"
182
+ role="img"
183
+ aria-label="Partitive relation diagram"
184
+ >
185
+ <!-- Comprehensive label -->
186
+ <g>
187
+ <rect
188
+ :x="layout.centerX - layout.nodeW / 2"
189
+ :y="layout.compY - layout.nodeH / 2"
190
+ :width="layout.nodeW"
191
+ :height="layout.nodeH"
192
+ rx="6"
193
+ class="fill-emerald-50 dark:fill-emerald-900/30 stroke-current"
194
+ stroke-width="1.2"
195
+ />
196
+ <text :x="layout.centerX" text-anchor="middle" class="text-[11px] fill-emerald-900 dark:fill-emerald-100">
197
+ <tspan
198
+ v-for="(line, i) in layout.comprehensiveLines"
199
+ :key="i"
200
+ :x="layout.centerX"
201
+ :y="layout.compY - (layout.comprehensiveLines.length - 1) * 7 + i * 14"
202
+ >{{ line }}</tspan>
203
+ </text>
204
+ </g>
205
+
206
+ <!-- Criterion italic under the comprehensive -->
207
+ <text
208
+ v-if="criterion"
209
+ :x="layout.centerX"
210
+ :y="layout.compY + layout.nodeH / 2 + 10"
211
+ text-anchor="middle"
212
+ class="text-[9px] italic fill-emerald-800 dark:fill-emerald-200/80"
213
+ >{{ criterion }}</text>
214
+
215
+ <!-- Stem (vertical from comprehensive to spine) -->
216
+ <line
217
+ v-if="lineVariant === 'single'"
218
+ :x1="layout.centerX" :y1="layout.compY + layout.nodeH / 2"
219
+ :x2="layout.centerX" :y2="layout.spineY"
220
+ v-bind="strokeAttr('single')"
221
+ />
222
+ <template v-else>
223
+ <line
224
+ :x1="layout.centerX - DOUBLE_GAP / 2" :y1="layout.compY + layout.nodeH / 2"
225
+ :x2="layout.centerX - DOUBLE_GAP / 2" :y2="layout.spineY"
226
+ v-bind="strokeAttr('double-solid')"
227
+ />
228
+ <line
229
+ :x1="layout.centerX + DOUBLE_GAP / 2" :y1="layout.compY + layout.nodeH / 2"
230
+ :x2="layout.centerX + DOUBLE_GAP / 2" :y2="layout.spineY"
231
+ v-bind="strokeAttr(lineVariant, 1, lineVariant === 'solid-plus-dashed' ? 'dash' : undefined)"
232
+ />
233
+ </template>
234
+
235
+ <!-- Spine (horizontal, from leftmost tooth to rightmost tooth or extended) -->
236
+ <line
237
+ v-if="lineVariant === 'single'"
238
+ :x1="layout.leftX" :y1="layout.spineY"
239
+ :x2="layout.spineEndX" :y2="layout.spineY"
240
+ v-bind="strokeAttr('single')"
241
+ />
242
+ <template v-else>
243
+ <line
244
+ :x1="layout.leftX" :y1="layout.spineY - DOUBLE_GAP / 2"
245
+ :x2="layout.spineEndX" :y2="layout.spineY - DOUBLE_GAP / 2"
246
+ v-bind="strokeAttr('double-solid')"
247
+ />
248
+ <line
249
+ :x1="layout.leftX" :y1="layout.spineY + DOUBLE_GAP / 2"
250
+ :x2="layout.spineEndX" :y2="layout.spineY + DOUBLE_GAP / 2"
251
+ v-bind="strokeAttr(lineVariant, 1, lineVariant === 'solid-plus-dashed' ? 'dash' : undefined)"
252
+ />
253
+ </template>
254
+
255
+ <!-- Drops + partitive nodes -->
256
+ <g v-for="(slot, i) in layout.partitiveSlots" :key="i">
257
+ <line
258
+ :x1="slot.x" :y1="layout.spineY"
259
+ :x2="slot.x" :y2="layout.partY - layout.nodeH / 2"
260
+ v-bind="strokeAttr('single', slot.member.certainty === 'possible' ? 0.5 : 1, slot.member.certainty === 'possible' ? 'dash' : undefined)"
261
+ />
262
+ <g class="cursor-pointer" @click="emitNav(slot.member.uri)">
263
+ <rect
264
+ :x="slot.x - layout.nodeW / 2"
265
+ :y="layout.partY - layout.nodeH / 2"
266
+ :width="layout.nodeW"
267
+ :height="layout.nodeH"
268
+ rx="6"
269
+ :class="slot.member.certainty === 'possible'
270
+ ? 'fill-emerald-50/60 dark:fill-emerald-900/20 stroke-current'
271
+ : 'fill-emerald-50 dark:fill-emerald-900/30 stroke-current'"
272
+ :stroke-dasharray="slot.member.certainty === 'possible' ? DASH_PATTERN : undefined"
273
+ stroke-width="1.2"
274
+ />
275
+ <text
276
+ :x="slot.x"
277
+ text-anchor="middle"
278
+ :class="slot.member.certainty === 'possible'
279
+ ? 'text-[11px] fill-emerald-700/70 dark:fill-emerald-200/60 italic'
280
+ : 'text-[11px] fill-emerald-900 dark:fill-emerald-100'"
281
+ >
282
+ <tspan
283
+ v-for="(line, j) in slot.labelLines"
284
+ :key="j"
285
+ :x="slot.x"
286
+ :y="layout.partY - (slot.labelLines.length - 1) * 7 + j * 14"
287
+ >{{ line }}</tspan>
288
+ </text>
289
+ </g>
290
+ </g>
291
+ </svg>
292
+ </template>
@@ -1,28 +1,28 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
3
  * PartitiveRelationList — renders one-to-many partitive decompositions
4
- * as grouped cards. Each relation shows:
5
- * - the comprehensive (whole) concept as the card header
6
- * - each partitive member as an indented child with a certainty badge
4
+ * as ISO 704 rake diagrams. Each relation shows:
7
5
  * - completeness badge (complete / partial)
8
6
  * - plurality badge (shared / uncertain / sharedType) when present
9
- * - criterion as italic text under the title
7
+ * - criterion as italic text under the header
8
+ * - the rake diagram itself (PartitiveRelationDiagram)
10
9
  *
11
- * Independent of RelationshipList.vue, which renders binary edges.
12
- * Both consume the same stores but render in separate sections.
13
- *
14
- * v2 shape per concept-model/TODO.partitive-relation-v2.
10
+ * The diagram is the primary renderer. Badges in the header communicate
11
+ * the metadata that the diagram's line styles also encode (so users who
12
+ * can't perceive the visual difference still get the info).
15
13
  */
16
14
  import type { PartitiveRelationWire, Manifest } from '../adapters/types';
17
15
  import { useRouter } from 'vue-router';
18
16
  import { useVocabularyStore } from '../stores/vocabulary';
19
17
  import { getFactory } from '../adapters/factory';
20
- import { useI18n } from '../i18n';
18
+ import { useI18n, locale } from '../i18n';
21
19
  import {
22
- partitiveRelationStyle,
23
20
  completenessLabel,
24
21
  certaintyLabel,
25
22
  } from '../utils/partitive-relation-styling';
23
+ import PartitiveRelationDiagram, {
24
+ type PartitiveMemberLabeled,
25
+ } from './PartitiveRelationDiagram.vue';
26
26
 
27
27
  const props = defineProps<{
28
28
  relations: PartitiveRelationWire[];
@@ -30,21 +30,31 @@ const props = defineProps<{
30
30
  registerId: string;
31
31
  }>();
32
32
 
33
- const emit = defineEmits<{
34
- (e: 'navigate', registerId: string, conceptId: string): void;
35
- }>();
36
-
37
33
  const router = useRouter();
38
34
  const store = useVocabularyStore();
39
35
  const factory = getFactory();
40
36
  const { t } = useI18n();
41
37
 
42
- function labelFor(uri: string): string {
43
- const parsed = factory.resolve(uri);
44
- if (parsed.type === 'internal') {
45
- return parsed.conceptId || uri;
38
+ function designationFor(uri: string): string {
39
+ const node = store.graph.getNode(uri);
40
+ if (node) {
41
+ const des = node.designations[locale.value]
42
+ || node.designations.eng
43
+ || Object.values(node.designations)[0];
44
+ if (des) return des;
46
45
  }
47
- return uri;
46
+ const resolution = factory.resolve(uri);
47
+ if (resolution.type === 'internal') {
48
+ const adapter = store.datasets.get(resolution.registerId);
49
+ const entry = adapter?.getIndexEntry(resolution.conceptId);
50
+ if (entry) {
51
+ const des = entry.designations[locale.value]
52
+ || entry.designations.eng
53
+ || Object.values(entry.designations)[0];
54
+ if (des) return des;
55
+ }
56
+ }
57
+ return resolution.type === 'internal' ? resolution.conceptId : uri;
48
58
  }
49
59
 
50
60
  function navigate(uri: string) {
@@ -78,6 +88,14 @@ function pluralityBadge(plurality: NonNullable<PartitiveRelationWire['plurality'
78
88
  }
79
89
  return label;
80
90
  }
91
+
92
+ function labeledMembers(rel: PartitiveRelationWire): PartitiveMemberLabeled[] {
93
+ return rel.partitives.map(m => ({
94
+ uri: m.uri,
95
+ label: designationFor(m.uri),
96
+ certainty: m.certainty,
97
+ }));
98
+ }
81
99
  </script>
82
100
 
83
101
  <template>
@@ -106,7 +124,7 @@ function pluralityBadge(plurality: NonNullable<PartitiveRelationWire['plurality'
106
124
  @click="navigate(rel.comprehensive)"
107
125
  class="ml-auto text-sm font-medium text-emerald-900 dark:text-emerald-100 hover:underline truncate"
108
126
  >
109
- {{ labelFor(rel.comprehensive) }}
127
+ {{ designationFor(rel.comprehensive) }}
110
128
  </button>
111
129
  </div>
112
130
  <div
@@ -115,25 +133,17 @@ function pluralityBadge(plurality: NonNullable<PartitiveRelationWire['plurality'
115
133
  >
116
134
  {{ criterionText(rel.criterion) }}
117
135
  </div>
118
- <ul class="divide-y divide-emerald-100 dark:divide-emerald-900/30">
119
- <li v-for="(member, j) in rel.partitives" :key="j">
120
- <button
121
- @click="navigate(member.uri)"
122
- class="block w-full text-left px-3 py-1.5 text-sm hover:bg-emerald-100 dark:hover:bg-emerald-900/30 text-emerald-900 dark:text-emerald-100"
123
- :class="{ 'opacity-60': member.certainty === 'possible' }"
124
- >
125
- <span class="text-emerald-600 dark:text-emerald-400 mr-2">└</span>
126
- {{ labelFor(member.uri) }}
127
- <span
128
- v-if="member.certainty === 'possible'"
129
- class="ml-2 text-[10px] font-mono px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/50 text-amber-800 dark:text-amber-200 uppercase"
130
- :title="'plurality uncertain (ISO 704 broken line)'"
131
- >
132
- {{ certaintyLabel(member.certainty) }}
133
- </span>
134
- </button>
135
- </li>
136
- </ul>
136
+ <!-- Rake diagram (ISO 704 partitive-relation convention) -->
137
+ <div class="px-3 py-3 overflow-x-auto">
138
+ <PartitiveRelationDiagram
139
+ :comprehensive-label="designationFor(rel.comprehensive)"
140
+ :partitives="labeledMembers(rel)"
141
+ :completeness="rel.completeness"
142
+ :plurality="rel.plurality"
143
+ :criterion="null"
144
+ @navigate="navigate"
145
+ />
146
+ </div>
137
147
  </div>
138
148
  </div>
139
149
  </template>
@@ -25,7 +25,7 @@ import { hashSeed, expandParams, portSide, portPoint, idToUriGet } from '../util
25
25
  import { useI18n } from '../i18n';
26
26
 
27
27
  const { t, locale } = useI18n();
28
- import type { Concept, Manifest, GraphEdge } from '../adapters/types';
28
+ import type { Concept, Manifest, GraphEdge, PartitiveRelationWire } from '../adapters/types';
29
29
  import { conceptUri } from '../adapters/model-bridge';
30
30
 
31
31
  const props = defineProps<{
@@ -33,6 +33,7 @@ const props = defineProps<{
33
33
  manifest: Manifest;
34
34
  registerId: string;
35
35
  edges: GraphEdge[];
36
+ partitiveRelations?: PartitiveRelationWire[];
36
37
  }>();
37
38
 
38
39
  const emit = defineEmits<{
@@ -390,6 +391,38 @@ function buildGraph() {
390
391
 
391
392
  nodes = Array.from(nodeMap.values());
392
393
  links = resultLinks;
394
+
395
+ /* Ensure every PartitiveRelation member is present in the graph
396
+ so the rake bundle can render. The comprehensive is normally the
397
+ focus; partitives may need to be added as depth-1 neighbors. */
398
+ for (const rel of props.partitiveRelations ?? []) {
399
+ const compParsed = UriRouter.parseUri(rel.comprehensive);
400
+ if (!compParsed) continue;
401
+ const compNodeId = `${compParsed.registerId}/${compParsed.conceptId}`;
402
+ if (!nodeMap.has(compNodeId)) continue;
403
+
404
+ for (const member of rel.partitives) {
405
+ const parsed = UriRouter.parseUri(member.uri);
406
+ if (!parsed) continue;
407
+ const nid = `${parsed.registerId}/${parsed.conceptId}`;
408
+ if (nodeMap.has(nid)) continue;
409
+ if (nodeMap.size >= MAX_NODES) break;
410
+ const pos = fibonacciSpherePosition(1, nodeMap.size, MAX_NODES, hashSeed(parsed.conceptId));
411
+ nodeMap.set(nid, {
412
+ id: nid,
413
+ term: parsed.conceptId,
414
+ ref: parsed.registerId,
415
+ register: parsed.registerId,
416
+ conceptId: parsed.conceptId,
417
+ depth: 1,
418
+ x: pos.x, y: pos.y, z: pos.z,
419
+ vx: 0, vy: 0, vz: 0,
420
+ });
421
+ idToUri.set(nid, member.uri);
422
+ nodes = Array.from(nodeMap.values());
423
+ }
424
+ }
425
+
393
426
  graphVersion.value++;
394
427
  }
395
428
 
@@ -712,6 +745,108 @@ function drawEdges(cx: number, cy: number) {
712
745
  label.textContent = labelText;
713
746
  svg.appendChild(label);
714
747
  }
748
+
749
+ drawRakeBundles(svg, pos, cx, cy);
750
+ }
751
+
752
+ /**
753
+ * Render PartitiveRelations as ISO 704 rake bundles in the sphere.
754
+ *
755
+ * In 3D space the literal "right-angle bracket" rake doesn't apply,
756
+ * so we use a bundle of N curves fanning out from the comprehensive,
757
+ * all sharing visual styling. The bundle communicates "these N edges
758
+ * are ONE relation", not N independent pairwise links.
759
+ *
760
+ * ISO 704 line conventions applied:
761
+ * - default : single solid curve per member
762
+ * - plurality.isShared && !isUncertain : two parallel solid curves (close-set)
763
+ * - plurality.isShared && isUncertain : one solid + one dashed curve
764
+ * - per-member certainty: 'possible' : the member's curve is dashed
765
+ * (regardless of plurality)
766
+ *
767
+ * A diamond marker at the comprehensive end signals the hyperedge origin.
768
+ */
769
+ function drawRakeBundles(
770
+ svg: SVGSVGElement,
771
+ pos: Map<string, {x: number; y: number}>,
772
+ _cx: number,
773
+ _cy: number,
774
+ ) {
775
+ for (const rel of props.partitiveRelations ?? []) {
776
+ const compParsed = UriRouter.parseUri(rel.comprehensive);
777
+ if (!compParsed) continue;
778
+ const compNodeId = `${compParsed.registerId}/${compParsed.conceptId}`;
779
+ const compPos = pos.get(compNodeId);
780
+ if (!compPos) continue;
781
+
782
+ const memberPositions = rel.partitives
783
+ .map(member => {
784
+ const parsed = UriRouter.parseUri(member.uri);
785
+ if (!parsed) return null;
786
+ const nid = `${parsed.registerId}/${parsed.conceptId}`;
787
+ const p = pos.get(nid);
788
+ return p ? { member, pos: p } : null;
789
+ })
790
+ .filter((x): x is { member: { uri: string; certainty: 'confirmed' | 'possible' }; pos: {x: number; y: number} } => x !== null);
791
+
792
+ if (memberPositions.length === 0) continue;
793
+
794
+ const color = uiStore.isDark ? '#2dd4bf' : '#0d9488'; // teal — matches sidebar rake
795
+ const DOUBLE_GAP = 4;
796
+ const DASH = '4 3';
797
+ const isShared = !!rel.plurality?.isShared;
798
+ const isUncertain = !!rel.plurality?.isUncertain;
799
+ const drawDouble = isShared;
800
+ const secondDashed = isUncertain;
801
+
802
+ for (const { member, pos: pPos } of memberPositions) {
803
+ const dx = pPos.x - compPos.x;
804
+ const dy = pPos.y - compPos.y;
805
+ const len = Math.hypot(dx, dy) || 1;
806
+ const px = -dy / len; // perpendicular unit
807
+ const py = dx / len;
808
+
809
+ const drawLine = (offset: number, dashed: boolean) => {
810
+ const ax = compPos.x + px * offset;
811
+ const ay = compPos.y + py * offset;
812
+ const bx = pPos.x + px * offset;
813
+ const by = pPos.y + py * offset;
814
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
815
+ // Quadratic bezier toward a control point biased outward from the focus
816
+ // to suggest the "rake handle" shape.
817
+ const mx = (ax + bx) / 2;
818
+ const my = (ay + by) / 2;
819
+ const ctrlLen = len * 0.15;
820
+ path.setAttribute('d', `M ${ax} ${ay} Q ${mx + px * ctrlLen} ${my + py * ctrlLen} ${bx} ${by}`);
821
+ path.setAttribute('stroke', color);
822
+ path.setAttribute('stroke-width', '1.5');
823
+ path.setAttribute('opacity', member.certainty === 'possible' ? '0.45' : '0.8');
824
+ path.setAttribute('fill', 'none');
825
+ path.setAttribute('stroke-linecap', 'round');
826
+ if (dashed || member.certainty === 'possible') {
827
+ path.setAttribute('stroke-dasharray', DASH);
828
+ }
829
+ svg.appendChild(path);
830
+ };
831
+
832
+ drawLine(0, false);
833
+ if (drawDouble) {
834
+ drawLine(DOUBLE_GAP, secondDashed);
835
+ drawLine(-DOUBLE_GAP, secondDashed);
836
+ }
837
+ }
838
+
839
+ /* Diamond marker at comprehensive end — signals "rake origin" */
840
+ const diamond = document.createElementNS('http://www.w3.org/2000/svg', 'path');
841
+ const dsize = 5;
842
+ diamond.setAttribute('d',
843
+ `M ${compPos.x} ${compPos.y - dsize} L ${compPos.x + dsize} ${compPos.y} L ${compPos.x} ${compPos.y + dsize} L ${compPos.x - dsize} ${compPos.y} Z`);
844
+ diamond.setAttribute('fill', color);
845
+ diamond.setAttribute('opacity', '0.85');
846
+ diamond.setAttribute('stroke', uiStore.isDark ? '#0a1f1c' : '#ffffff');
847
+ diamond.setAttribute('stroke-width', '1');
848
+ svg.appendChild(diamond);
849
+ }
715
850
  }
716
851
 
717
852
  function ensureMarkers(svg: SVGSVGElement) {
@@ -97,6 +97,41 @@ export function useConceptEdges(
97
97
  return edgeDisplayCache.value.get(uri) ?? { uri, conceptId: uri, designation: '', tooltip: uri, isLocal: false, badge: null };
98
98
  }
99
99
 
100
+ /**
101
+ * Resolve a concept's designation in the current UI locale.
102
+ * Fallback chain: locale → eng → any available.
103
+ *
104
+ * Sources tried in order:
105
+ * 1. BFS graph node (already loaded as a neighbor)
106
+ * 2. Dataset adapter index entry (every concept in the dataset)
107
+ * 3. conceptId as last-resort fallback
108
+ *
109
+ * Used by PartitiveRelationList (sidebar rake diagram) so the rake
110
+ * shows designations like 'system of quantities' instead of bare
111
+ * IDs like '1.3'.
112
+ */
113
+ function designationFor(uri: string): string {
114
+ const node = store.graph.getNode(uri);
115
+ if (node) {
116
+ const des = node.designations[locale.value]
117
+ || node.designations.eng
118
+ || Object.values(node.designations)[0];
119
+ if (des) return des;
120
+ }
121
+ const resolution = factory.resolve(uri);
122
+ if (resolution.type === 'internal') {
123
+ const adapter = store.datasets.get(resolution.registerId);
124
+ const entry = adapter?.getIndexEntry(resolution.conceptId);
125
+ if (entry) {
126
+ const des = entry.designations[locale.value]
127
+ || entry.designations.eng
128
+ || Object.values(entry.designations)[0];
129
+ if (des) return des;
130
+ }
131
+ }
132
+ return resolution.type === 'internal' ? resolution.conceptId : uri;
133
+ }
134
+
100
135
  function edgeBadgeColor(type: string, direction: 'out' | 'in'): string {
101
136
  if (type === 'supersedes' || type === 'superseded_by') {
102
137
  return direction === 'out' ? 'text-orange-700 bg-orange-50' : 'text-red-700 bg-red-50';
@@ -262,6 +297,7 @@ export function useConceptEdges(
262
297
  incomingEdges,
263
298
  edgeDisplayCache,
264
299
  getEdgeDisplay,
300
+ designationFor,
265
301
  edgeBadgeColor,
266
302
  inverseEdgeType,
267
303
  conceptRelated,
@@ -3,6 +3,8 @@ import { computed, watch, ref, onMounted, onUnmounted } from 'vue';
3
3
  import { useRouter } from 'vue-router';
4
4
  import { useVocabularyStore } from '../stores/vocabulary';
5
5
  import { conceptUri } from '../adapters/model-bridge';
6
+ import { UriRouter } from '../adapters/UriRouter';
7
+ import type { PartitiveRelationWire, PartitiveMemberWire } from '../adapters/types';
6
8
  import ConceptDetail from '../components/ConceptDetail.vue';
7
9
  import RelationSphere from '../components/RelationSphere.vue';
8
10
  import ShortcutsModal from '../components/ShortcutsModal.vue';
@@ -58,6 +60,55 @@ const manifest = computed(() => store.currentManifest);
58
60
  const edges = computed(() => store.conceptEdges);
59
61
  const activeRegisterId = computed(() => sphereFocusPayload.value?.registerId ?? props.registerId);
60
62
  const adjacent = ref({ prev: null as string | null, next: null as string | null });
63
+
64
+ /**
65
+ * Project concept.partitiveRelations (glossarist-js v2 model) into the
66
+ * URI-resolved wire shape the sphere consumes. Mirrors the projection
67
+ * in use-concept-edges.ts but inline here so the sphere can render rake
68
+ * bundles without a full composable setup.
69
+ */
70
+ const partitiveRelationsForSphere = computed<PartitiveRelationWire[]>(() => {
71
+ const c = concept.value;
72
+ const m = manifest.value;
73
+ if (!c || !m) return [];
74
+ const sourceUri = conceptUri(c, activeRegisterId.value, m.uriBase);
75
+ const out: PartitiveRelationWire[] = [];
76
+ for (const rel of c.partitiveRelations ?? []) {
77
+ const compRef = rel.comprehensive;
78
+ const compTarget = store.manifests.get(activeRegisterId.value)
79
+ ? UriRouter.buildConceptUri(m.uriBase, activeRegisterId.value, compRef.id ?? '')
80
+ : null;
81
+ if (!compRef.id || !compTarget) continue;
82
+ const partitives: PartitiveMemberWire[] = rel.partitives
83
+ .map(member => {
84
+ const ref = member.ref;
85
+ if (!ref?.id) return null;
86
+ const targetRegister = store.manifests.has(activeRegisterId.value)
87
+ ? activeRegisterId.value
88
+ : activeRegisterId.value;
89
+ const uri = UriRouter.buildConceptUri(m.uriBase, targetRegister, ref.id);
90
+ if (uri === sourceUri) return null;
91
+ return { uri, certainty: member.certainty };
92
+ })
93
+ .filter((x): x is PartitiveMemberWire => x !== null);
94
+ if (partitives.length === 0) continue;
95
+ out.push({
96
+ source: sourceUri,
97
+ comprehensive: compTarget,
98
+ partitives,
99
+ completeness: rel.completeness,
100
+ plurality: rel.plurality
101
+ ? {
102
+ isShared: rel.plurality.isShared,
103
+ isUncertain: rel.plurality.isUncertain,
104
+ sharedType: null,
105
+ }
106
+ : null,
107
+ register: activeRegisterId.value,
108
+ });
109
+ }
110
+ return out;
111
+ });
61
112
  const viewMode = ref<'detail' | 'sphere'>(
62
113
  router.currentRoute.value.query.view === 'sphere' ? 'sphere' : 'detail'
63
114
  );
@@ -280,6 +331,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown));
280
331
  :manifest="manifest"
281
332
  :register-id="activeRegisterId"
282
333
  :edges="edges"
334
+ :partitive-relations="partitiveRelationsForSphere"
283
335
  @navigate="onSphereNavigate"
284
336
  />
285
337
  <!-- Detail mode -->