@glossarist/concept-browser 0.7.81 → 0.7.82
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 +169 -0
- package/src/__tests__/model-bridge-partitive.test.ts +146 -0
- package/src/adapters/model-bridge.ts +69 -0
- package/src/components/PartitiveRelationDiagram.vue +230 -0
- package/src/components/PartitiveRelationList.vue +28 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.82",
|
|
4
4
|
"description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { 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
|
+
});
|
|
@@ -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,230 @@
|
|
|
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
|
+
* "more exist but aren't shown")
|
|
24
|
+
*
|
|
25
|
+
* The diagram is pure SVG — no JS layout engine. Coordinates are
|
|
26
|
+
* computed from the partitive count.
|
|
27
|
+
*/
|
|
28
|
+
import { computed } from 'vue';
|
|
29
|
+
import type { TypeSharedPluralityWire } from '../adapters/types';
|
|
30
|
+
|
|
31
|
+
export interface PartitiveMemberLabeled {
|
|
32
|
+
uri: string;
|
|
33
|
+
label: string;
|
|
34
|
+
certainty: 'confirmed' | 'possible';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const props = withDefaults(defineProps<{
|
|
38
|
+
comprehensiveLabel: string;
|
|
39
|
+
partitives: PartitiveMemberLabeled[];
|
|
40
|
+
completeness: 'complete' | 'partial';
|
|
41
|
+
plurality: TypeSharedPluralityWire | null;
|
|
42
|
+
criterion?: string | null;
|
|
43
|
+
}>(), {
|
|
44
|
+
criterion: null,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const emit = defineEmits<{
|
|
48
|
+
(e: 'navigate', uri: string): void;
|
|
49
|
+
}>();
|
|
50
|
+
|
|
51
|
+
// Layout constants — chosen for readability at 100% zoom.
|
|
52
|
+
const NODE_W = 120;
|
|
53
|
+
const NODE_H = 32;
|
|
54
|
+
const STEM_LEN = 28;
|
|
55
|
+
const SPINE_TO_NODE = 32;
|
|
56
|
+
const GAP_X = 16;
|
|
57
|
+
const PADDING_X = 24;
|
|
58
|
+
const PADDING_Y = 16;
|
|
59
|
+
const DOUBLE_GAP = 3; // gap between the two lines of a close-set double
|
|
60
|
+
const PARTIAL_TAIL = 32; // how far the spine extends past the last tooth for partial
|
|
61
|
+
const DASH_PATTERN = '4 3';
|
|
62
|
+
|
|
63
|
+
type LineVariant = 'single' | 'double-solid' | 'solid-plus-dashed';
|
|
64
|
+
|
|
65
|
+
const lineVariant = computed<LineVariant>(() => {
|
|
66
|
+
if (!props.plurality?.isShared) return 'single';
|
|
67
|
+
return props.plurality.isUncertain ? 'solid-plus-dashed' : 'double-solid';
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const layout = computed(() => {
|
|
71
|
+
const n = props.partitives.length;
|
|
72
|
+
const totalWidth = PADDING_X * 2 + n * NODE_W + (n - 1) * GAP_X;
|
|
73
|
+
const totalHeight = PADDING_Y * 2 + NODE_H + STEM_LEN + SPINE_TO_NODE + NODE_H;
|
|
74
|
+
|
|
75
|
+
const centerX = totalWidth / 2;
|
|
76
|
+
const compY = PADDING_Y + NODE_H / 2;
|
|
77
|
+
const spineY = compY + NODE_H / 2 + STEM_LEN;
|
|
78
|
+
const partY = spineY + SPINE_TO_NODE;
|
|
79
|
+
|
|
80
|
+
const partitiveSlots = props.partitives.map((member, i) => {
|
|
81
|
+
const x = PADDING_X + NODE_W / 2 + i * (NODE_W + GAP_X);
|
|
82
|
+
return { member, x };
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const leftX = partitiveSlots[0]?.x ?? centerX;
|
|
86
|
+
const lastX = partitiveSlots[n - 1]?.x ?? centerX;
|
|
87
|
+
const spineEndX = props.completeness === 'partial'
|
|
88
|
+
? lastX + PARTIAL_TAIL
|
|
89
|
+
: lastX;
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
totalWidth,
|
|
93
|
+
totalHeight,
|
|
94
|
+
centerX,
|
|
95
|
+
compY,
|
|
96
|
+
spineY,
|
|
97
|
+
partY,
|
|
98
|
+
partitiveSlots,
|
|
99
|
+
leftX,
|
|
100
|
+
spineEndX,
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
function strokeAttr(variant: LineVariant, opacity = 1) {
|
|
105
|
+
if (variant === 'solid-plus-dashed') {
|
|
106
|
+
return { stroke: 'currentColor', 'stroke-width': 1.4, 'stroke-dasharray': DASH_PATTERN, opacity };
|
|
107
|
+
}
|
|
108
|
+
return { stroke: 'currentColor', 'stroke-width': 1.4, opacity };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function emitNav(uri: string) {
|
|
112
|
+
emit('navigate', uri);
|
|
113
|
+
}
|
|
114
|
+
</script>
|
|
115
|
+
|
|
116
|
+
<template>
|
|
117
|
+
<svg
|
|
118
|
+
:viewBox="`0 0 ${layout.totalWidth} ${layout.totalHeight}`"
|
|
119
|
+
width="100%"
|
|
120
|
+
:style="{ maxWidth: `${layout.totalWidth}px` }"
|
|
121
|
+
preserveAspectRatio="xMidYMid meet"
|
|
122
|
+
class="partitive-rake text-emerald-700 dark:text-emerald-300"
|
|
123
|
+
role="img"
|
|
124
|
+
aria-label="Partitive relation diagram"
|
|
125
|
+
>
|
|
126
|
+
<!-- Comprehensive label -->
|
|
127
|
+
<g>
|
|
128
|
+
<rect
|
|
129
|
+
:x="layout.centerX - NODE_W / 2"
|
|
130
|
+
:y="layout.compY - NODE_H / 2"
|
|
131
|
+
:width="NODE_W"
|
|
132
|
+
:height="NODE_H"
|
|
133
|
+
rx="6"
|
|
134
|
+
class="fill-emerald-50 dark:fill-emerald-900/30 stroke-current"
|
|
135
|
+
stroke-width="1.2"
|
|
136
|
+
/>
|
|
137
|
+
<text
|
|
138
|
+
:x="layout.centerX"
|
|
139
|
+
:y="layout.compY"
|
|
140
|
+
text-anchor="middle"
|
|
141
|
+
dominant-baseline="middle"
|
|
142
|
+
class="text-[11px] fill-emerald-900 dark:fill-emerald-100"
|
|
143
|
+
>{{ comprehensiveLabel }}</text>
|
|
144
|
+
</g>
|
|
145
|
+
|
|
146
|
+
<!-- Criterion italic under the comprehensive -->
|
|
147
|
+
<text
|
|
148
|
+
v-if="criterion"
|
|
149
|
+
:x="layout.centerX"
|
|
150
|
+
:y="layout.compY + NODE_H / 2 + 10"
|
|
151
|
+
text-anchor="middle"
|
|
152
|
+
class="text-[9px] italic fill-emerald-800 dark:fill-emerald-200/80"
|
|
153
|
+
>{{ criterion }}</text>
|
|
154
|
+
|
|
155
|
+
<!-- Stem (vertical from comprehensive to spine) -->
|
|
156
|
+
<line
|
|
157
|
+
v-if="lineVariant === 'single'"
|
|
158
|
+
:x1="layout.centerX" :y1="layout.compY + NODE_H / 2"
|
|
159
|
+
:x2="layout.centerX" :y2="layout.spineY"
|
|
160
|
+
v-bind="strokeAttr('single')"
|
|
161
|
+
/>
|
|
162
|
+
<template v-else>
|
|
163
|
+
<line
|
|
164
|
+
:x1="layout.centerX - DOUBLE_GAP / 2" :y1="layout.compY + NODE_H / 2"
|
|
165
|
+
:x2="layout.centerX - DOUBLE_GAP / 2" :y2="layout.spineY"
|
|
166
|
+
v-bind="strokeAttr('double-solid')"
|
|
167
|
+
/>
|
|
168
|
+
<line
|
|
169
|
+
:x1="layout.centerX + DOUBLE_GAP / 2" :y1="layout.compY + NODE_H / 2"
|
|
170
|
+
:x2="layout.centerX + DOUBLE_GAP / 2" :y2="layout.spineY"
|
|
171
|
+
v-bind="strokeAttr(lineVariant === 'solid-plus-dashed' ? 'solid-plus-dashed' : 'double-solid')"
|
|
172
|
+
/>
|
|
173
|
+
</template>
|
|
174
|
+
|
|
175
|
+
<!-- Spine (horizontal, from leftmost tooth to rightmost tooth or extended) -->
|
|
176
|
+
<line
|
|
177
|
+
v-if="lineVariant === 'single'"
|
|
178
|
+
:x1="layout.leftX" :y1="layout.spineY"
|
|
179
|
+
:x2="layout.spineEndX" :y2="layout.spineY"
|
|
180
|
+
v-bind="strokeAttr('single')"
|
|
181
|
+
/>
|
|
182
|
+
<template v-else>
|
|
183
|
+
<line
|
|
184
|
+
:x1="layout.leftX" :y1="layout.spineY - DOUBLE_GAP / 2"
|
|
185
|
+
:x2="layout.spineEndX" :y2="layout.spineY - DOUBLE_GAP / 2"
|
|
186
|
+
v-bind="strokeAttr('double-solid')"
|
|
187
|
+
/>
|
|
188
|
+
<line
|
|
189
|
+
:x1="layout.leftX" :y1="layout.spineY + DOUBLE_GAP / 2"
|
|
190
|
+
:x2="layout.spineEndX" :y2="layout.spineY + DOUBLE_GAP / 2"
|
|
191
|
+
v-bind="strokeAttr(lineVariant === 'solid-plus-dashed' ? 'solid-plus-dashed' : 'double-solid')"
|
|
192
|
+
/>
|
|
193
|
+
</template>
|
|
194
|
+
|
|
195
|
+
<!-- Drops + partitive nodes -->
|
|
196
|
+
<g v-for="(slot, i) in layout.partitiveSlots" :key="i">
|
|
197
|
+
<!-- drop from spine to node (always single line per ISO 704) -->
|
|
198
|
+
<line
|
|
199
|
+
:x1="slot.x" :y1="layout.spineY"
|
|
200
|
+
:x2="slot.x" :y2="layout.partY - NODE_H / 2"
|
|
201
|
+
v-bind="strokeAttr('single', slot.member.certainty === 'possible' ? 0.5 : 1)"
|
|
202
|
+
:stroke-dasharray="slot.member.certainty === 'possible' ? DASH_PATTERN : undefined"
|
|
203
|
+
/>
|
|
204
|
+
<!-- node -->
|
|
205
|
+
<g class="cursor-pointer" @click="emitNav(slot.member.uri)">
|
|
206
|
+
<rect
|
|
207
|
+
:x="slot.x - NODE_W / 2"
|
|
208
|
+
:y="layout.partY - NODE_H / 2"
|
|
209
|
+
:width="NODE_W"
|
|
210
|
+
:height="NODE_H"
|
|
211
|
+
rx="6"
|
|
212
|
+
:class="slot.member.certainty === 'possible'
|
|
213
|
+
? 'fill-emerald-50/60 dark:fill-emerald-900/20 stroke-current'
|
|
214
|
+
: 'fill-emerald-50 dark:fill-emerald-900/30 stroke-current'"
|
|
215
|
+
:stroke-dasharray="slot.member.certainty === 'possible' ? DASH_PATTERN : undefined"
|
|
216
|
+
stroke-width="1.2"
|
|
217
|
+
/>
|
|
218
|
+
<text
|
|
219
|
+
:x="slot.x"
|
|
220
|
+
:y="layout.partY"
|
|
221
|
+
text-anchor="middle"
|
|
222
|
+
dominant-baseline="middle"
|
|
223
|
+
:class="slot.member.certainty === 'possible'
|
|
224
|
+
? 'text-[11px] fill-emerald-700/70 dark:fill-emerald-200/60 italic'
|
|
225
|
+
: 'text-[11px] fill-emerald-900 dark:fill-emerald-100'"
|
|
226
|
+
>{{ slot.member.label }}</text>
|
|
227
|
+
</g>
|
|
228
|
+
</g>
|
|
229
|
+
</svg>
|
|
230
|
+
</template>
|
|
@@ -1,17 +1,15 @@
|
|
|
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';
|
|
@@ -19,10 +17,12 @@ import { useVocabularyStore } from '../stores/vocabulary';
|
|
|
19
17
|
import { getFactory } from '../adapters/factory';
|
|
20
18
|
import { useI18n } 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,10 +30,6 @@ 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();
|
|
@@ -78,6 +74,14 @@ function pluralityBadge(plurality: NonNullable<PartitiveRelationWire['plurality'
|
|
|
78
74
|
}
|
|
79
75
|
return label;
|
|
80
76
|
}
|
|
77
|
+
|
|
78
|
+
function labeledMembers(rel: PartitiveRelationWire): PartitiveMemberLabeled[] {
|
|
79
|
+
return rel.partitives.map(m => ({
|
|
80
|
+
uri: m.uri,
|
|
81
|
+
label: labelFor(m.uri),
|
|
82
|
+
certainty: m.certainty,
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
81
85
|
</script>
|
|
82
86
|
|
|
83
87
|
<template>
|
|
@@ -115,25 +119,17 @@ function pluralityBadge(plurality: NonNullable<PartitiveRelationWire['plurality'
|
|
|
115
119
|
>
|
|
116
120
|
{{ criterionText(rel.criterion) }}
|
|
117
121
|
</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>
|
|
122
|
+
<!-- Rake diagram (ISO 704 partitive-relation convention) -->
|
|
123
|
+
<div class="px-3 py-3 overflow-x-auto">
|
|
124
|
+
<PartitiveRelationDiagram
|
|
125
|
+
:comprehensive-label="labelFor(rel.comprehensive)"
|
|
126
|
+
:partitives="labeledMembers(rel)"
|
|
127
|
+
:completeness="rel.completeness"
|
|
128
|
+
:plurality="rel.plurality"
|
|
129
|
+
:criterion="null"
|
|
130
|
+
@navigate="navigate"
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
137
133
|
</div>
|
|
138
134
|
</div>
|
|
139
135
|
</template>
|