@glossarist/concept-browser 0.7.77 → 0.7.80

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.
Files changed (58) hide show
  1. package/astro.config.mjs +1 -0
  2. package/package.json +2 -2
  3. package/scripts/bridge-to-astro.mjs +6 -1
  4. package/scripts/build-edges.js +154 -1
  5. package/scripts/generate-data.mjs +138 -54
  6. package/src/__tests__/build-edges-redundancy.test.ts +101 -0
  7. package/src/__tests__/composables/use-concept-diff.test.ts +240 -0
  8. package/src/__tests__/composables/use-concept-edges-v2.test.ts +132 -0
  9. package/src/__tests__/composables/use-dataset-stats.test.ts +96 -0
  10. package/src/__tests__/config/page-types.test.ts +82 -0
  11. package/src/__tests__/model-bridge-bridges.test.ts +3 -2
  12. package/src/__tests__/model-bridge-citation.test.ts +1 -1
  13. package/src/__tests__/ontology-registry.test.ts +2 -2
  14. package/src/__tests__/partitive-relation-styling.test.ts +82 -0
  15. package/src/__tests__/partitive-relation.test.ts +285 -0
  16. package/src/__tests__/sphere-math.test.ts +56 -0
  17. package/src/adapters/GraphDataSource.ts +22 -5
  18. package/src/adapters/UriRouter.ts +5 -0
  19. package/src/adapters/dataset-stats.ts +13 -0
  20. package/src/adapters/model-bridge.ts +1 -1
  21. package/src/adapters/non-verbal/glossarist-augment.d.ts +155 -4
  22. package/src/adapters/types.ts +36 -0
  23. package/src/components/AppSidebar.vue +14 -57
  24. package/src/components/ConceptDetail.vue +29 -0
  25. package/src/components/ConceptDiffModal.vue +120 -0
  26. package/src/components/ConceptDiffView.vue +192 -0
  27. package/src/components/ConceptEditionRail.vue +61 -36
  28. package/src/components/PartitiveRelationList.vue +139 -0
  29. package/src/components/RelationSphere.vue +2 -36
  30. package/src/components/groups/DatasetGroupRenderer.vue +14 -4
  31. package/src/components/groups/DefaultGroupSidebar.vue +28 -11
  32. package/src/components/groups/LineageGroupSidebar.vue +17 -29
  33. package/src/composables/use-concept-diff.ts +231 -0
  34. package/src/composables/use-concept-edges.ts +98 -5
  35. package/src/composables/use-dataset-stats.ts +52 -0
  36. package/src/config/page-types.ts +67 -0
  37. package/src/config/types.ts +6 -1
  38. package/src/config/use-site-config.ts +13 -29
  39. package/src/data/taxonomies.json +24 -1
  40. package/src/i18n/locales/eng.yml +6 -0
  41. package/src/utils/partitive-relation-styling.ts +62 -0
  42. package/src/utils/sphere-math.ts +54 -0
  43. package/src/views/DatasetView.vue +5 -3
  44. package/src/views/StatsView.vue +9 -22
  45. package/src/islands/BibliographyIsland.vue +0 -26
  46. package/src/islands/ConceptDetailIsland.vue +0 -27
  47. package/src/islands/ConceptDiffIsland.vue +0 -214
  48. package/src/islands/ConceptIsland.vue +0 -102
  49. package/src/islands/GraphIsland.vue +0 -15
  50. package/src/islands/GroupRendererIsland.vue +0 -15
  51. package/src/islands/HeaderIsland.vue +0 -10
  52. package/src/islands/NonVerbalIsland.vue +0 -75
  53. package/src/islands/RelationSphereIsland.vue +0 -20
  54. package/src/islands/SearchIsland.vue +0 -23
  55. package/src/islands/SeriesCardIsland.vue +0 -12
  56. package/src/islands/ShortcutsIsland.vue +0 -13
  57. package/src/islands/SidebarIsland.vue +0 -15
  58. package/src/islands/SupersessionDiffIsland.vue +0 -267
@@ -3,12 +3,12 @@ import { ontology } from '../adapters/ontology-registry';
3
3
 
4
4
  describe('OntologyRegistry', () => {
5
5
  it('loads all 10 taxonomies', () => {
6
- expect(ontology.getAll('conceptStatus').length).toBe(7);
6
+ expect(ontology.getAll('conceptStatus').length).toBe(8);
7
7
  expect(ontology.getAll('entryStatus').length).toBe(4);
8
8
  expect(ontology.getAll('normativeStatus').length).toBe(4);
9
9
  expect(ontology.getAll('sourceType').length).toBe(2);
10
10
  expect(ontology.getAll('sourceStatus').length).toBe(10);
11
- expect(ontology.getAll('relationshipType').length).toBe(52);
11
+ expect(ontology.getAll('relationshipType').length).toBe(54);
12
12
  expect(ontology.getAll('designationType').length).toBe(5);
13
13
  expect(ontology.getAll('termType').length).toBe(24);
14
14
  expect(ontology.getAll('grammarGender').length).toBe(4);
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ partitiveRelationStyle,
4
+ pluralityColor,
5
+ completenessLabel,
6
+ certaintyLabel,
7
+ } from '../utils/partitive-relation-styling';
8
+ import type { TypeSharedPluralityWire } from '../adapters/types';
9
+
10
+ describe('partitive-relation-styling', () => {
11
+ describe('partitiveRelationStyle', () => {
12
+ it('returns full opacity for complete', () => {
13
+ expect(partitiveRelationStyle('complete', null, false).opacity).toBe(1.0);
14
+ });
15
+
16
+ it('returns reduced opacity for partial', () => {
17
+ expect(partitiveRelationStyle('partial', null, false).opacity).toBe(0.6);
18
+ });
19
+
20
+ it('uses teal badge class for complete with no plurality', () => {
21
+ expect(partitiveRelationStyle('complete', null, false).badgeClass).toBe('badge-teal');
22
+ });
23
+
24
+ it('uses blue badge class for isShared plurality', () => {
25
+ const p: TypeSharedPluralityWire = { isShared: true, isUncertain: false };
26
+ expect(partitiveRelationStyle('complete', p, false).badgeClass).toBe('badge-blue');
27
+ });
28
+
29
+ it('uses yellow badge class when isShared + isUncertain', () => {
30
+ const p: TypeSharedPluralityWire = { isShared: true, isUncertain: true };
31
+ expect(partitiveRelationStyle('complete', p, false).badgeClass).toBe('badge-yellow');
32
+ });
33
+
34
+ it('returns different colors in dark mode', () => {
35
+ const light = partitiveRelationStyle('complete', null, false);
36
+ const dark = partitiveRelationStyle('complete', null, true);
37
+ expect(light.color).not.toBe(dark.color);
38
+ });
39
+
40
+ it('ignores plurality when isShared is false', () => {
41
+ const p: TypeSharedPluralityWire = { isShared: false, isUncertain: true };
42
+ expect(partitiveRelationStyle('complete', p, false).badgeClass).toBe('badge-teal');
43
+ });
44
+ });
45
+
46
+ describe('pluralityColor', () => {
47
+ it('returns blue for isShared', () => {
48
+ const p: TypeSharedPluralityWire = { isShared: true, isUncertain: false };
49
+ expect(pluralityColor(p, false)).toBe('#3b82f6');
50
+ });
51
+
52
+ it('returns amber when isUncertain', () => {
53
+ const p: TypeSharedPluralityWire = { isShared: true, isUncertain: true };
54
+ expect(pluralityColor(p, false)).toBe('#f59e0b');
55
+ });
56
+
57
+ it('returns dark-mode variants', () => {
58
+ expect(pluralityColor({ isShared: true, isUncertain: false }, true)).toBe('#60a5fa');
59
+ expect(pluralityColor({ isShared: true, isUncertain: true }, true)).toBe('#fbbf24');
60
+ });
61
+ });
62
+
63
+ describe('completenessLabel', () => {
64
+ it('labels complete', () => {
65
+ expect(completenessLabel('complete')).toBe('Complete');
66
+ });
67
+
68
+ it('labels partial', () => {
69
+ expect(completenessLabel('partial')).toBe('Partial');
70
+ });
71
+ });
72
+
73
+ describe('certaintyLabel', () => {
74
+ it('labels confirmed', () => {
75
+ expect(certaintyLabel('confirmed')).toBe('Confirmed');
76
+ });
77
+
78
+ it('labels possible', () => {
79
+ expect(certaintyLabel('possible')).toBe('Possible');
80
+ });
81
+ });
82
+ });
@@ -0,0 +1,285 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { PartitiveRelationWire } from '../adapters/types';
3
+
4
+ // Re-implements the extractor under test so we can test it in isolation
5
+ // without spinning up the full build-edges.js pipeline. The production
6
+ // extractor in scripts/build-edges.js uses the same logic.
7
+ function extractPartitiveRelations(
8
+ concept: { '@id': string; 'gl:partitiveRelations'?: any[] },
9
+ registerId: string,
10
+ uriBase: string,
11
+ urnMap: Map<string, string>,
12
+ ): PartitiveRelationWire[] {
13
+ const relations: PartitiveRelationWire[] = [];
14
+ const sourceUri = concept['@id'];
15
+
16
+ const resolveConceptUri = (ref: any): string | null => {
17
+ const source = ref?.['gl:source'] || ref?.source;
18
+ const id = ref?.['gl:id'] || ref?.id;
19
+ if (!source || !id) return null;
20
+ const reg = urnMap.get(source) || source;
21
+ return `${uriBase}/${reg}/concept/${id}`;
22
+ };
23
+
24
+ const resolvePlurality = (plurality: any) => {
25
+ if (!plurality) return null;
26
+ const isShared = plurality['gl:isShared'] ?? plurality.is_shared;
27
+ if (typeof isShared !== 'boolean') return null;
28
+ const isUncertain = plurality['gl:isUncertain'] ?? plurality.is_uncertain ?? false;
29
+ const sharedTypeRef = plurality['gl:sharedType'] ?? plurality.shared_type;
30
+ let sharedType = null;
31
+ if (sharedTypeRef) {
32
+ const s = sharedTypeRef['gl:source'] ?? sharedTypeRef.source;
33
+ const i = sharedTypeRef['gl:id'] ?? sharedTypeRef.id;
34
+ if (s && i) sharedType = `${s}:${i}`;
35
+ }
36
+ return { isShared, isUncertain, sharedType };
37
+ };
38
+
39
+ for (const rel of concept['gl:partitiveRelations'] || []) {
40
+ const comprehensive = resolveConceptUri(rel['gl:comprehensive']);
41
+ if (!comprehensive) continue;
42
+ const partitives = (rel['gl:hasPartitive'] || [])
43
+ .map((member: any) => {
44
+ const ref = member['gl:ref'] || member;
45
+ const uri = resolveConceptUri(ref);
46
+ if (!uri || uri === sourceUri) return null;
47
+ const certainty = member['gl:certainty'] || 'confirmed';
48
+ if (certainty !== 'confirmed' && certainty !== 'possible') {
49
+ throw new Error(
50
+ `Invalid partitive member certainty: "${certainty}". Allowed: confirmed, possible`,
51
+ );
52
+ }
53
+ return { uri, certainty };
54
+ })
55
+ .filter((p: any): p is { uri: string; certainty: 'confirmed' | 'possible' } => p !== null);
56
+ if (partitives.length === 0) continue;
57
+ const completeness = rel['gl:completeness'] || 'complete';
58
+ if (completeness !== 'complete' && completeness !== 'partial') {
59
+ throw new Error(
60
+ `Invalid partitive relation completeness: "${completeness}". Allowed: complete, partial`,
61
+ );
62
+ }
63
+ relations.push({
64
+ source: sourceUri,
65
+ comprehensive,
66
+ partitives,
67
+ completeness,
68
+ plurality: resolvePlurality(rel['gl:hasPlurality']) as PartitiveRelationWire['plurality'],
69
+ criterion: rel['gl:criterion'],
70
+ register: registerId,
71
+ });
72
+ }
73
+ return relations;
74
+ }
75
+
76
+ describe('extractPartitiveRelations', () => {
77
+ const uriBase = 'https://example.org';
78
+ const urnMap = new Map([['urn:vim:pub:v:2:2012', 'vim-2012']]);
79
+ const registerId = 'vim-2012';
80
+ const sourceUri = `${uriBase}/${registerId}/concept/2.9`;
81
+
82
+ it('extracts a complete relation with two confirmed members', () => {
83
+ const concept = {
84
+ '@id': sourceUri,
85
+ 'gl:partitiveRelations': [
86
+ {
87
+ 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.9' },
88
+ 'gl:hasPartitive': [
89
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.10' } },
90
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.26' } },
91
+ ],
92
+ 'gl:completeness': 'complete',
93
+ 'gl:criterion': { eng: 'measurement result composition' },
94
+ },
95
+ ],
96
+ };
97
+
98
+ const result = extractPartitiveRelations(concept, registerId, uriBase, urnMap);
99
+ expect(result).toHaveLength(1);
100
+ expect(result[0].comprehensive).toBe(`${uriBase}/${registerId}/concept/2.9`);
101
+ expect(result[0].partitives).toEqual([
102
+ { uri: `${uriBase}/${registerId}/concept/2.10`, certainty: 'confirmed' },
103
+ { uri: `${uriBase}/${registerId}/concept/2.26`, certainty: 'confirmed' },
104
+ ]);
105
+ expect(result[0].completeness).toBe('complete');
106
+ expect(result[0].criterion).toEqual({ eng: 'measurement result composition' });
107
+ });
108
+
109
+ it('defaults completeness to complete when omitted', () => {
110
+ const concept = {
111
+ '@id': sourceUri,
112
+ 'gl:partitiveRelations': [
113
+ {
114
+ 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.9' },
115
+ 'gl:hasPartitive': [
116
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.10' } },
117
+ ],
118
+ },
119
+ ],
120
+ };
121
+ const [rel] = extractPartitiveRelations(concept, registerId, uriBase, urnMap);
122
+ expect(rel.completeness).toBe('complete');
123
+ });
124
+
125
+ it('preserves partial completeness', () => {
126
+ const concept = {
127
+ '@id': sourceUri,
128
+ 'gl:partitiveRelations': [
129
+ {
130
+ 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
131
+ 'gl:hasPartitive': [
132
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' } },
133
+ ],
134
+ 'gl:completeness': 'partial',
135
+ },
136
+ ],
137
+ };
138
+ const [rel] = extractPartitiveRelations(concept, registerId, uriBase, urnMap);
139
+ expect(rel.completeness).toBe('partial');
140
+ });
141
+
142
+ it('resolves plurality block (isShared + sharedType)', () => {
143
+ const concept = {
144
+ '@id': sourceUri,
145
+ 'gl:partitiveRelations': [
146
+ {
147
+ 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
148
+ 'gl:hasPartitive': [
149
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' } },
150
+ ],
151
+ 'gl:hasPlurality': {
152
+ 'gl:isShared': true,
153
+ 'gl:isUncertain': false,
154
+ 'gl:sharedType': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.99' },
155
+ },
156
+ },
157
+ ],
158
+ };
159
+ const [rel] = extractPartitiveRelations(concept, registerId, uriBase, urnMap);
160
+ expect(rel.plurality).toEqual({
161
+ isShared: true,
162
+ isUncertain: false,
163
+ sharedType: 'urn:vim:pub:v:2:2012:1.99',
164
+ });
165
+ });
166
+
167
+ it('returns null plurality when isShared missing', () => {
168
+ const concept = {
169
+ '@id': sourceUri,
170
+ 'gl:partitiveRelations': [
171
+ {
172
+ 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
173
+ 'gl:hasPartitive': [
174
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' } },
175
+ ],
176
+ 'gl:hasPlurality': { 'gl:isUncertain': true },
177
+ },
178
+ ],
179
+ };
180
+ const [rel] = extractPartitiveRelations(concept, registerId, uriBase, urnMap);
181
+ expect(rel.plurality).toBeNull();
182
+ });
183
+
184
+ it('preserves per-member certainty', () => {
185
+ const concept = {
186
+ '@id': sourceUri,
187
+ 'gl:partitiveRelations': [
188
+ {
189
+ 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
190
+ 'gl:hasPartitive': [
191
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' } },
192
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.5' }, 'gl:certainty': 'possible' },
193
+ ],
194
+ },
195
+ ],
196
+ };
197
+ const [rel] = extractPartitiveRelations(concept, registerId, uriBase, urnMap);
198
+ expect(rel.partitives).toEqual([
199
+ { uri: `${uriBase}/${registerId}/concept/1.4`, certainty: 'confirmed' },
200
+ { uri: `${uriBase}/${registerId}/concept/1.5`, certainty: 'possible' },
201
+ ]);
202
+ });
203
+
204
+ it('throws on invalid completeness', () => {
205
+ const concept = {
206
+ '@id': sourceUri,
207
+ 'gl:partitiveRelations': [
208
+ {
209
+ 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
210
+ 'gl:hasPartitive': [
211
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' } },
212
+ ],
213
+ 'gl:completeness': 'closed',
214
+ },
215
+ ],
216
+ };
217
+ expect(() => extractPartitiveRelations(concept, registerId, uriBase, urnMap)).toThrow(/Invalid.*completeness.*closed/);
218
+ });
219
+
220
+ it('throws on invalid member certainty', () => {
221
+ const concept = {
222
+ '@id': sourceUri,
223
+ 'gl:partitiveRelations': [
224
+ {
225
+ 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
226
+ 'gl:hasPartitive': [
227
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' }, 'gl:certainty': 'maybe' },
228
+ ],
229
+ },
230
+ ],
231
+ };
232
+ expect(() => extractPartitiveRelations(concept, registerId, uriBase, urnMap)).toThrow(/Invalid.*certainty.*maybe/);
233
+ });
234
+
235
+ it('skips a relation whose comprehensive cannot be resolved', () => {
236
+ const concept = {
237
+ '@id': sourceUri,
238
+ 'gl:partitiveRelations': [
239
+ {
240
+ 'gl:comprehensive': { 'gl:id': '1.3' },
241
+ 'gl:hasPartitive': [
242
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' } },
243
+ ],
244
+ },
245
+ ],
246
+ };
247
+ expect(extractPartitiveRelations(concept, registerId, uriBase, urnMap)).toHaveLength(0);
248
+ });
249
+
250
+ it('skips a relation with no resolvable partitives', () => {
251
+ const concept = {
252
+ '@id': sourceUri,
253
+ 'gl:partitiveRelations': [
254
+ {
255
+ 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.9' },
256
+ 'gl:hasPartitive': [{ 'gl:ref': { 'gl:id': '1.4' } }],
257
+ },
258
+ ],
259
+ };
260
+ expect(extractPartitiveRelations(concept, registerId, uriBase, urnMap)).toHaveLength(0);
261
+ });
262
+
263
+ it('excludes the source concept from partitives (self-loop guard)', () => {
264
+ const concept = {
265
+ '@id': sourceUri,
266
+ 'gl:partitiveRelations': [
267
+ {
268
+ 'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.9' },
269
+ 'gl:hasPartitive': [
270
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.10' } },
271
+ { 'gl:ref': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.9' } },
272
+ ],
273
+ },
274
+ ],
275
+ };
276
+ const [rel] = extractPartitiveRelations(concept, registerId, uriBase, urnMap);
277
+ expect(rel.partitives).toHaveLength(1);
278
+ expect(rel.partitives[0].uri).toBe(`${uriBase}/${registerId}/concept/2.10`);
279
+ });
280
+
281
+ it('returns empty array when concept has no partitiveRelations', () => {
282
+ const concept = { '@id': sourceUri };
283
+ expect(extractPartitiveRelations(concept, registerId, uriBase, urnMap)).toEqual([]);
284
+ });
285
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { hashSeed, expandParams, portSide, portPoint, idToUriGet } from '../utils/sphere-math';
3
+
4
+ describe('sphere-math', () => {
5
+ describe('hashSeed', () => {
6
+ it('produces deterministic positive integers', () => {
7
+ expect(hashSeed('abc')).toBe(hashSeed('abc'));
8
+ expect(hashSeed('abc')).not.toBe(hashSeed('abd'));
9
+ expect(hashSeed('')).toBeGreaterThanOrEqual(0);
10
+ expect(hashSeed('test')).toBeGreaterThan(0);
11
+ });
12
+ });
13
+
14
+ describe('expandParams', () => {
15
+ it('clamps to [0, 10] and maps to physical constants', () => {
16
+ const min = expandParams(0);
17
+ const max = expandParams(10);
18
+ expect(min.linkDist).toBeLessThan(max.linkDist);
19
+ expect(min.repMin).toBeLessThan(max.repMin);
20
+ });
21
+
22
+ it('clamps out-of-range values', () => {
23
+ expect(expandParams(-5)).toEqual(expandParams(0));
24
+ expect(expandParams(20)).toEqual(expandParams(10));
25
+ });
26
+ });
27
+
28
+ describe('portSide', () => {
29
+ it('determines the dominant axis direction', () => {
30
+ expect(portSide({ x: 0, y: 0 }, { x: 10, y: 0 })).toBe('right');
31
+ expect(portSide({ x: 0, y: 0 }, { x: -10, y: 0 })).toBe('left');
32
+ expect(portSide({ x: 0, y: 0 }, { x: 0, y: 10 })).toBe('bottom');
33
+ expect(portSide({ x: 0, y: 0 }, { x: 0, y: -10 })).toBe('top');
34
+ });
35
+ });
36
+
37
+ describe('portPoint', () => {
38
+ it('computes the connection point on each side', () => {
39
+ const c = { x: 100, y: 100 };
40
+ const w = 220, h = 60;
41
+ const right = portPoint(c, 'right', w, h, 0);
42
+ expect(right.x).toBe(210);
43
+ expect(right.y).toBe(100);
44
+ const top = portPoint(c, 'top', w, h, 0);
45
+ expect(top.y).toBe(70);
46
+ });
47
+ });
48
+
49
+ describe('idToUriGet', () => {
50
+ it('reverse-looks-up a URI in an ID→URI map', () => {
51
+ const map = new Map([['a', '/uri/a'], ['b', '/uri/b']]);
52
+ expect(idToUriGet(map, '/uri/b')).toBe('b');
53
+ expect(idToUriGet(map, '/uri/unknown')).toBeUndefined();
54
+ });
55
+ });
56
+ });
@@ -1,4 +1,4 @@
1
- import type { GraphEdge, GraphNode, SectionNode } from './types';
1
+ import type { GraphEdge, GraphNode, PartitiveRelationWire, SectionNode } from './types';
2
2
  import type { Concept, RelatedConcept } from 'glossarist';
3
3
  import type { DatasetAdapter } from './DatasetAdapter';
4
4
  import { UriRouter } from './UriRouter';
@@ -30,6 +30,16 @@ function resolveRefTarget(rc: RelatedConcept, uriBase: string, registerId: strin
30
30
  return ref.source || '';
31
31
  }
32
32
 
33
+ function contentLabel(content: unknown): string | undefined {
34
+ if (content == null) return undefined;
35
+ if (typeof content === 'string') return content;
36
+ if (typeof content === 'object' && content !== null) {
37
+ const obj = content as Record<string, string>;
38
+ return obj.default ?? obj.eng ?? Object.values(obj)[0];
39
+ }
40
+ return undefined;
41
+ }
42
+
33
43
  export class GraphDataSource {
34
44
  constructor(private adapter: DatasetAdapter) {}
35
45
 
@@ -58,6 +68,13 @@ export class GraphDataSource {
58
68
  return data.edges ?? [];
59
69
  }
60
70
 
71
+ async loadPartitiveRelations(): Promise<PartitiveRelationWire[]> {
72
+ const resp = await fetch(`${this.baseUrl}/partitive_relations.json`);
73
+ if (!resp.ok) return [];
74
+ const data = await resp.json();
75
+ return data.relations ?? data.hyperedges ?? [];
76
+ }
77
+
61
78
  async loadGraphNodes(): Promise<{ uriPrefix: string; nodes: [string, Record<string, string>, string][] }> {
62
79
  const resp = await fetch(`${this.baseUrl}/graph-nodes.json`);
63
80
  if (!resp.ok) return { uriPrefix: '', nodes: [] };
@@ -73,7 +90,7 @@ export class GraphDataSource {
73
90
 
74
91
  extractEdges(concept: Concept): GraphEdge[] {
75
92
  const edges: GraphEdge[] = [];
76
- const sourceUri = concept.uri || `${this.uriBase}/${this.registerId}/concept/${concept.id}`;
93
+ const sourceUri = concept.uri || UriRouter.buildConceptUri(this.uriBase, this.registerId, concept.id);
77
94
 
78
95
  for (const rc of concept.relatedConcepts) {
79
96
  const target = resolveRefTarget(rc, this.uriBase, this.registerId, this.urnMap);
@@ -83,7 +100,7 @@ export class GraphDataSource {
83
100
  source: sourceUri,
84
101
  target,
85
102
  type: rc.type || 'references',
86
- label: rc.content || undefined,
103
+ label: contentLabel(rc.content),
87
104
  register: parsed?.registerId ?? this.registerId,
88
105
  });
89
106
  }
@@ -100,7 +117,7 @@ export class GraphDataSource {
100
117
  source: sourceUri,
101
118
  target,
102
119
  type: rc.type || 'references',
103
- label: rc.content || undefined,
120
+ label: contentLabel(rc.content),
104
121
  register: parsed?.registerId ?? this.registerId,
105
122
  lang,
106
123
  });
@@ -113,7 +130,7 @@ export class GraphDataSource {
113
130
 
114
131
  extractDomainEdges(concept: Concept): GraphEdge[] {
115
132
  const edges: GraphEdge[] = [];
116
- const sourceUri = concept.uri || `${this.uriBase}/${this.registerId}/concept/${concept.id}`;
133
+ const sourceUri = concept.uri || UriRouter.buildConceptUri(this.uriBase, this.registerId, concept.id);
117
134
 
118
135
  for (const lang of concept.languages) {
119
136
  const lc = concept.localization(lang);
@@ -101,6 +101,11 @@ export class UriRouter {
101
101
  return m ? { registerId: m[1], conceptId: m[2] } : null;
102
102
  }
103
103
 
104
+ /** Construct a canonical concept URI from components. */
105
+ static buildConceptUri(uriBase: string, registerId: string, conceptId: string): string {
106
+ return `${uriBase}/${registerId}/concept/${conceptId}`;
107
+ }
108
+
104
109
  /** Construct a canonical URI for a concept. */
105
110
  buildUri(registerId: string, conceptId: string): string {
106
111
  const info = this.registerMap.get(registerId);
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Dataset-level stats shape. The data pipeline emits this as
3
+ * `public/data/{registerId}/stats.json`. Consumers fetch via HTTP.
4
+ *
5
+ * Kept in adapters/types.ts (the SSOT for wire shapes) so all views
6
+ * that need stats share the same definition.
7
+ */
8
+ export interface DatasetStats {
9
+ sourceCount: number;
10
+ sources: Array<{ ref: string; types: string[]; conceptCount: number }>;
11
+ relationshipCount: number;
12
+ relationshipTypes: Record<string, number>;
13
+ }
@@ -287,7 +287,7 @@ function attachBridges(concept: Concept, localizations: Record<string, unknown>)
287
287
  * instances, since RelatedConcept.fromJSON only reads type/content/ref.
288
288
  */
289
289
  function attachRelatedBridges(
290
- modelRelated: Array<{ type?: string | null; content?: string | null; ref?: any; related?: any[] }>,
290
+ modelRelated: Array<{ type?: string | null; content?: Record<string, string> | string | null; ref?: any; related?: any[] }>,
291
291
  rawRelated: unknown[],
292
292
  ): void {
293
293
  for (const rawRel of rawRelated) {