@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 +1 -1
- package/src/__tests__/components/PartitiveRelationDiagram.test.ts +248 -0
- package/src/__tests__/model-bridge-partitive.test.ts +146 -0
- package/src/adapters/model-bridge.ts +69 -0
- package/src/components/PartitiveRelationDiagram.vue +292 -0
- package/src/components/PartitiveRelationList.vue +49 -39
- package/src/components/RelationSphere.vue +136 -1
- package/src/composables/use-concept-edges.ts +36 -0
- package/src/views/ConceptView.vue +52 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.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
|
|
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
|
|
7
|
+
* - criterion as italic text under the header
|
|
8
|
+
* - the rake diagram itself (PartitiveRelationDiagram)
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
{{
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 -->
|