@glossarist/concept-browser 0.7.91 → 0.7.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.91",
3
+ "version": "0.7.92",
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": {
@@ -37,7 +37,7 @@
37
37
  "astro": "^7.0.7",
38
38
  "d3": "^7.9.0",
39
39
  "favicons": "^7.2.0",
40
- "glossarist": "^0.4.26",
40
+ "glossarist": "^0.4.33",
41
41
  "js-yaml": "^4.1.0",
42
42
  "jszip": "^3.10.1",
43
43
  "pinia": "^2.3.1",
@@ -0,0 +1,147 @@
1
+ /**
2
+ * GenericRelationList mount test — pins the wire-type contract so
3
+ * the 5 type bugs found in audit (entry.titles, member.ref,
4
+ * is_delimiting casing, comprehensive.source, missing props)
5
+ * can't regress.
6
+ */
7
+ import { describe, it, expect, vi } from 'vitest';
8
+ import { mount } from '@vue/test-utils';
9
+ import GenericRelationList from '../../components/GenericRelationList.vue';
10
+ import type { GenericRelationWire } from '../../adapters/types';
11
+
12
+ // Stub the composables the component depends on
13
+ vi.mock('../../stores/vocabulary', () => ({
14
+ useVocabularyStore: () => ({
15
+ graph: { getNode: () => null },
16
+ datasets: new Map(),
17
+ }),
18
+ }));
19
+
20
+ vi.mock('../../adapters/factory', () => ({
21
+ getFactory: () => ({
22
+ resolve: (uri: string) => ({ type: 'unresolved' as const, uri }),
23
+ }),
24
+ }));
25
+
26
+ vi.mock('../../i18n', () => ({
27
+ useI18n: () => ({ t: (key: string) => key }),
28
+ locale: { value: 'eng' },
29
+ }));
30
+
31
+ vi.mock('vue-router', () => ({
32
+ useRouter: () => ({ push: vi.fn() }),
33
+ }));
34
+
35
+ const manifest = {
36
+ id: 'test',
37
+ datasetUri: 'https://example.org/test',
38
+ title: 'Test',
39
+ description: '',
40
+ owner: '',
41
+ baseUrl: '',
42
+ languages: ['eng'],
43
+ conceptCount: 0,
44
+ conceptUrlTemplate: '',
45
+ indexUrl: '',
46
+ contextUrl: '',
47
+ uriBase: 'https://example.org',
48
+ status: 'valid',
49
+ schemaVersion: '3',
50
+ tags: [],
51
+ lastUpdated: '',
52
+ sourceRepo: '',
53
+ chunkSize: 100,
54
+ } as any;
55
+
56
+ function makeRelation(overrides: Partial<GenericRelationWire> = {}): GenericRelationWire {
57
+ return {
58
+ source: 'https://example.org/test/concept/5-1',
59
+ comprehensive: 'https://example.org/test/concept/5-1',
60
+ members: [
61
+ { uri: 'https://example.org/test/concept/5-4', presence: 'required', count: 'exactly_one', isDelimiting: false },
62
+ { uri: 'https://example.org/test/concept/5-5', presence: 'required', count: 'exactly_one', isDelimiting: false },
63
+ ],
64
+ completeness: 'complete',
65
+ register: 'test',
66
+ ...overrides,
67
+ };
68
+ }
69
+
70
+ describe('GenericRelationList — wire-type contract mount test', () => {
71
+ it('mounts without errors for a well-formed relation', () => {
72
+ const w = mount(GenericRelationList, {
73
+ props: {
74
+ relations: [makeRelation()],
75
+ manifest,
76
+ registerId: 'test',
77
+ },
78
+ });
79
+ expect(w.html()).toContain('generic-relation-card');
80
+ });
81
+
82
+ it('renders completeness badge', () => {
83
+ const w = mount(GenericRelationList, {
84
+ props: {
85
+ relations: [makeRelation({ completeness: 'partial' })],
86
+ manifest,
87
+ registerId: 'test',
88
+ },
89
+ });
90
+ expect(w.html()).toContain('completeness-partial');
91
+ });
92
+
93
+ it('renders multiple relations', () => {
94
+ const w = mount(GenericRelationList, {
95
+ props: {
96
+ relations: [
97
+ makeRelation({ comprehensive: 'https://example.org/test/concept/5-1' }),
98
+ makeRelation({ comprehensive: 'https://example.org/test/concept/5-2' }),
99
+ ],
100
+ manifest,
101
+ registerId: 'test',
102
+ },
103
+ });
104
+ expect(w.findAll('.generic-relation-card')).toHaveLength(2);
105
+ });
106
+
107
+ it('renders criterion when present', () => {
108
+ const w = mount(GenericRelationList, {
109
+ props: {
110
+ relations: [makeRelation({ criterion: { eng: 'by realization medium' } })],
111
+ manifest,
112
+ registerId: 'test',
113
+ },
114
+ });
115
+ expect(w.html()).toContain('by realization medium');
116
+ });
117
+
118
+ it('renders member presence/count badges for non-default values', () => {
119
+ const w = mount(GenericRelationList, {
120
+ props: {
121
+ relations: [makeRelation({
122
+ members: [
123
+ { uri: 'https://example.org/test/concept/5-4', presence: 'optional', count: 'multiple', isDelimiting: true },
124
+ { uri: 'https://example.org/test/concept/5-5', presence: 'required', count: 'exactly_one', isDelimiting: false },
125
+ ],
126
+ })],
127
+ manifest,
128
+ registerId: 'test',
129
+ },
130
+ });
131
+ const html = w.html();
132
+ expect(html).toContain('Optional');
133
+ expect(html).toContain('Multiple');
134
+ });
135
+
136
+ it('renders empty section when relations array is empty', () => {
137
+ const w = mount(GenericRelationList, {
138
+ props: {
139
+ relations: [],
140
+ manifest,
141
+ registerId: 'test',
142
+ },
143
+ });
144
+ expect(w.find('section.generic-relations').exists()).toBe(true);
145
+ expect(w.findAll('.generic-relation-card')).toHaveLength(0);
146
+ });
147
+ });
@@ -1,14 +1,13 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- // glossarist-js 0.4.26's top-level d.ts doesn't re-export the MECE
3
- // PartitiveRelation/PartitiveMember classes; import from glossarist/models
4
- // (extended by our local augmentation) so the types are correct.
2
+ // glossarist 0.4.33 renamed PartitiveRelation → PartitiveHyperedge.
3
+ // Import from glossarist/models (extended by our local augmentation).
5
4
  import type {
6
- PartitiveRelation,
5
+ PartitiveHyperedge,
7
6
  PartitiveMember,
8
7
  ConceptRef,
9
8
  } from 'glossarist/models';
10
9
  import {
11
- PartitiveRelation as PartitiveRelationModel,
10
+ PartitiveHyperedge as PartitiveHyperedgeModel,
12
11
  PartitiveMember as PartitiveMemberModel,
13
12
  ConceptRef as ConceptRefModel,
14
13
  } from 'glossarist/models';
@@ -48,8 +47,8 @@ function makeRelation(opts: {
48
47
  partitives: PartitiveMember[];
49
48
  completeness?: 'complete' | 'partial';
50
49
  criterion?: Record<string, string>;
51
- }): PartitiveRelation {
52
- return PartitiveRelationModel.fromJSON(opts as unknown as Record<string, unknown>) as PartitiveRelation;
50
+ }): PartitiveHyperedge {
51
+ return PartitiveHyperedgeModel.fromJSON(opts as unknown as Record<string, unknown>) as PartitiveHyperedge;
53
52
  }
54
53
 
55
54
  describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () => {
@@ -57,8 +56,8 @@ describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () =>
57
56
  it('presence defaults to required, count defaults to exactly_one', () => {
58
57
  const m = makePartitive('test', '1.1');
59
58
  expect(m.presence).toBe('required');
60
- expect(m.isRequired).toBe(true);
61
- expect(m.isOptional).toBe(false);
59
+ expect(m.presence === "required").toBe(true);
60
+ expect(m.presence === "optional").toBe(false);
62
61
  });
63
62
 
64
63
  it('count defaults to exactly_one', () => {
@@ -69,8 +68,8 @@ describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () =>
69
68
  it('presence=optional is preserved', () => {
70
69
  const m = makePartitive('test', '1.1', { presence: 'optional' });
71
70
  expect(m.presence).toBe('optional');
72
- expect(m.isOptional).toBe(true);
73
- expect(m.isRequired).toBe(false);
71
+ expect(m.presence === "optional").toBe(true);
72
+ expect(m.presence === "required").toBe(false);
74
73
  });
75
74
 
76
75
  it('count=multiple is preserved', () => {
@@ -86,13 +85,13 @@ describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () =>
86
85
  it('is_delimiting defaults to false', () => {
87
86
  const m = makePartitive('test', '1.1');
88
87
  expect(m.is_delimiting).toBe(false);
89
- expect(m.isDelimiting).toBe(false);
88
+ expect(m.is_delimiting).toBe(false);
90
89
  });
91
90
 
92
91
  it('is_delimiting is preserved when set to true', () => {
93
92
  const m = makePartitive('test', '1.1', { isDelimiting: true });
94
93
  expect(m.is_delimiting).toBe(true);
95
- expect(m.isDelimiting).toBe(true);
94
+ expect(m.is_delimiting).toBe(true);
96
95
  });
97
96
 
98
97
  it('rejects the vacuous (presence=optional, count=at_least_one)', () => {
@@ -108,10 +107,10 @@ describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () =>
108
107
  comprehensive: makeRef('test', '1.3'),
109
108
  partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
110
109
  });
111
- expect(r.partitives).toHaveLength(2);
112
- expect(r.completeness).toBe('complete');
113
- expect(r.isComplete).toBe(true);
114
- expect(r.isPartial).toBe(false);
110
+ expect((r as any).members).toHaveLength(2);
111
+ expect((r as any).completeness).toBe('complete');
112
+ expect((r as any).isComplete).toBe(true);
113
+ expect((r as any).isPartial).toBe(false);
115
114
  });
116
115
 
117
116
  it('completeness=partial is preserved', () => {
@@ -120,8 +119,8 @@ describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () =>
120
119
  partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
121
120
  completeness: 'partial',
122
121
  });
123
- expect(r.completeness).toBe('partial');
124
- expect(r.isPartial).toBe(true);
122
+ expect((r as any).completeness).toBe('partial');
123
+ expect((r as any).isPartial).toBe(true);
125
124
  });
126
125
 
127
126
  it('isCoordinate true when ≥2 partitives', () => {
@@ -129,7 +128,7 @@ describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () =>
129
128
  comprehensive: makeRef('test', '1.3'),
130
129
  partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
131
130
  });
132
- expect(r.isCoordinate).toBe(true);
131
+ expect((r as any).isCoordinate).toBe(true);
133
132
  });
134
133
 
135
134
  it('rejects < 2 partitives (ISO 704 requirement)', () => {
@@ -0,0 +1,166 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ // glossarist 0.4.33 renamed PartitiveRelation → PartitiveHyperedge.
3
+ // Import from glossarist/models (extended by our local augmentation).
4
+ import type {
5
+ PartitiveHyperedge,
6
+ PartitiveMember,
7
+ ConceptRef,
8
+ } from 'glossarist/models';
9
+ import {
10
+ PartitiveHyperedge as PartitiveHyperedgeModel,
11
+ PartitiveMember as PartitiveMemberModel,
12
+ ConceptRef as ConceptRefModel,
13
+ } from 'glossarist/models';
14
+
15
+ /**
16
+ * Verifies glossarist-js 0.4.26's MECE-native PartitiveRelation model
17
+ * (ISO 704:2022 presence × count + is_delimiting).
18
+ *
19
+ * The concept-browser consumes these fields directly (no migration
20
+ * bridge). These specs pin the runtime contract so a regression in
21
+ * upstream is caught here.
22
+ */
23
+
24
+ function makeRef(source: string, id: string): ConceptRef {
25
+ return ConceptRefModel.fromJSON({ source, id }) as ConceptRef;
26
+ }
27
+
28
+ function makePartitive(
29
+ source: string,
30
+ id: string,
31
+ fields: { presence?: 'required' | 'optional'; count?: 'exactly_one' | 'at_least_one' | 'multiple'; isDelimiting?: boolean } = {},
32
+ ): PartitiveMember {
33
+ const data: {
34
+ ref: ConceptRef;
35
+ presence?: 'required' | 'optional';
36
+ count?: 'exactly_one' | 'at_least_one' | 'multiple';
37
+ is_delimiting?: boolean;
38
+ } = { ref: makeRef(source, id) };
39
+ if (fields.presence) data.presence = fields.presence;
40
+ if (fields.count) data.count = fields.count;
41
+ if (fields.isDelimiting) data.is_delimiting = fields.isDelimiting;
42
+ return PartitiveMemberModel.fromJSON(data as unknown as Record<string, unknown>) as PartitiveMember;
43
+ }
44
+
45
+ function makeRelation(opts: {
46
+ comprehensive: ConceptRef;
47
+ partitives: PartitiveMember[];
48
+ completeness?: 'complete' | 'partial';
49
+ criterion?: Record<string, string>;
50
+ }): PartitiveHyperedge {
51
+ return PartitiveHyperedgeModel.fromJSON(opts as unknown as Record<string, unknown>) as PartitiveHyperedge;
52
+ }
53
+
54
+ describe('useConceptEdges — MECE PartitiveRelation (glossarist 0.4.26)', () => {
55
+ describe('PartitiveMember (ISO 704:2022 presence × count + delimiting)', () => {
56
+ it('presence defaults to required, count defaults to exactly_one', () => {
57
+ const m = makePartitive('test', '1.1');
58
+ expect(m.presence).toBe('required');
59
+ expect(m.presence === "required").toBe(true);
60
+ expect(m.presence === "optional").toBe(false);
61
+ });
62
+
63
+ it('count defaults to exactly_one', () => {
64
+ const m = makePartitive('test', '1.1');
65
+ expect(m.count).toBe('exactly_one');
66
+ });
67
+
68
+ it('presence=optional is preserved', () => {
69
+ const m = makePartitive('test', '1.1', { presence: 'optional' });
70
+ expect(m.presence).toBe('optional');
71
+ expect(m.presence === "optional").toBe(true);
72
+ expect(m.presence === "required").toBe(false);
73
+ });
74
+
75
+ it('count=multiple is preserved', () => {
76
+ const m = makePartitive('test', '1.1', { count: 'multiple' });
77
+ expect(m.count).toBe('multiple');
78
+ });
79
+
80
+ it('count=at_least_one is preserved', () => {
81
+ const m = makePartitive('test', '1.1', { count: 'at_least_one' });
82
+ expect(m.count).toBe('at_least_one');
83
+ });
84
+
85
+ it('is_delimiting defaults to false', () => {
86
+ const m = makePartitive('test', '1.1');
87
+ expect(m.is_delimiting).toBe(false);
88
+ expect(m.is_delimiting).toBe(false);
89
+ });
90
+
91
+ it('is_delimiting is preserved when set to true', () => {
92
+ const m = makePartitive('test', '1.1', { isDelimiting: true });
93
+ expect(m.is_delimiting).toBe(true);
94
+ expect(m.is_delimiting).toBe(true);
95
+ });
96
+
97
+ it('rejects the vacuous (presence=optional, count=at_least_one)', () => {
98
+ expect(() =>
99
+ makePartitive('test', '1.1', { presence: 'optional', count: 'at_least_one' }),
100
+ ).toThrow(/collapses to optional \+ multiple/);
101
+ });
102
+ });
103
+
104
+ describe('PartitiveRelation', () => {
105
+ it('accepts 2 members with default axes', () => {
106
+ const r = makeRelation({
107
+ comprehensive: makeRef('test', '1.3'),
108
+ partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
109
+ });
110
+ expect(r.members).toHaveLength(2);
111
+ expect((r as any).completeness).toBe('complete');
112
+ expect((r as any).isComplete).toBe(true);
113
+ expect((r as any).isPartial).toBe(false);
114
+ });
115
+
116
+ it('completeness=partial is preserved', () => {
117
+ const r = makeRelation({
118
+ comprehensive: makeRef('test', '1.3'),
119
+ partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
120
+ completeness: 'partial',
121
+ });
122
+ expect((r as any).completeness).toBe('partial');
123
+ expect((r as any).isPartial).toBe(true);
124
+ });
125
+
126
+ it('isCoordinate true when ≥2 partitives', () => {
127
+ const r = makeRelation({
128
+ comprehensive: makeRef('test', '1.3'),
129
+ partitives: [makePartitive('test', '1.4'), makePartitive('test', '1.5')],
130
+ });
131
+ expect((r as any).isCoordinate).toBe(true);
132
+ });
133
+
134
+ it('rejects < 2 partitives (ISO 704 requirement)', () => {
135
+ expect(() => makeRelation({
136
+ comprehensive: makeRef('test', '1.3'),
137
+ partitives: [makePartitive('test', '1.4')],
138
+ })).toThrow(/≥2 partitives/);
139
+ });
140
+
141
+ it('round-trips through toJSON with MECE fields', () => {
142
+ const r = makeRelation({
143
+ comprehensive: makeRef('test', '1.3'),
144
+ partitives: [
145
+ makePartitive('test', '1.4', { presence: 'required', count: 'multiple' }),
146
+ makePartitive('test', '1.5', { presence: 'optional', isDelimiting: true }),
147
+ ],
148
+ completeness: 'partial',
149
+ criterion: { eng: 'physical structure' },
150
+ });
151
+ const json = r.toJSON() as {
152
+ comprehensive: unknown;
153
+ partitives: Array<{ presence?: string; count?: string; is_delimiting?: boolean; ref: unknown }>;
154
+ completeness: string;
155
+ criterion?: Record<string, string>;
156
+ };
157
+ expect(json.comprehensive).toBeDefined();
158
+ expect(json.partitives).toHaveLength(2);
159
+ expect(json.partitives[0].count).toBe('multiple');
160
+ expect(json.partitives[1].presence).toBe('optional');
161
+ expect(json.partitives[1].is_delimiting).toBe(true);
162
+ expect(json.completeness).toBe('partial');
163
+ expect(json.criterion).toEqual({ eng: 'physical structure' });
164
+ });
165
+ });
166
+ });
@@ -23,9 +23,9 @@ interface PartitiveMemberLike {
23
23
 
24
24
  function loadPartitives(doc: Record<string, unknown>): PartitiveMemberLike[] {
25
25
  const concept = conceptFromJson(doc as any);
26
- const relations = (concept as any).partitiveRelations ?? [];
26
+ const relations = (concept as any).relations ?? [];
27
27
  return relations.flatMap((r: any) =>
28
- (r.partitives ?? []).map((m: any) => ({
28
+ ((r.members ?? r.partitives) ?? []).map((m: any) => ({
29
29
  presence: m.presence,
30
30
  count: m.count,
31
31
  is_delimiting: m.is_delimiting,
@@ -60,6 +60,6 @@ describe('GL wire-key SSOT', () => {
60
60
  // The point of this test: when adding a key, you remember to update
61
61
  // the count; when removing, you notice the test fails. Catches
62
62
  // accidental drift in either direction.
63
- expect(Object.keys(GL).length).toBe(87);
63
+ expect(Object.keys(GL).length).toBe(90);
64
64
  });
65
65
  });
@@ -10,7 +10,7 @@ import type { ConceptSummary } from '../types';
10
10
  import { ConceptIdentity } from '../concept-identity';
11
11
  import { GL } from '../wire-keys';
12
12
  import { isJsonLd, mapLocalizedFromJsonLd, mapRelatedFromJsonLd } from './mappers';
13
- import { mapPartitiveRelationFromJsonLd } from './partitive';
13
+ import { mapHyperedgeFromJsonLd } from './hyperedge';
14
14
  import { normalizeEntityRefs } from './entity-refs';
15
15
  import { attachBridges, attachSourcedFromToSources } from './bridges';
16
16
  import type { JsonLdConcept } from './jsonld-types';
@@ -27,9 +27,18 @@ function conceptFromJsonLd(doc: JsonLdConcept): Concept {
27
27
  }
28
28
 
29
29
  const related = (doc[GL.RELATED] ?? []).map(mapRelatedFromJsonLd);
30
+
31
+ // Unified hyperedge mapping: both partitive and generic relations
32
+ // share the same JSON-LD wire shape. glossarist-js dispatches by
33
+ // wire key (`partitive_relations` / `generic_relations`).
30
34
  const partitiveRelations = (doc[GL.PARTITIVE_RELATIONS] ?? [])
31
- .map(mapPartitiveRelationFromJsonLd)
35
+ .map((r: any) => mapHyperedgeFromJsonLd(r, 'gl:hasPartitive'))
36
+ .filter((r): r is Record<string, unknown> => r !== null);
37
+
38
+ const genericRelations = (doc[GL.GENERIC_RELATIONS] ?? [])
39
+ .map((r: any) => mapHyperedgeFromJsonLd(r, 'gl:hasGeneric'))
32
40
  .filter((r): r is Record<string, unknown> => r !== null);
41
+
33
42
  const tagsArr = doc[GL.TAGS];
34
43
  const tags = Array.isArray(tagsArr) ? [...tagsArr] : [];
35
44
 
@@ -40,6 +49,7 @@ function conceptFromJsonLd(doc: JsonLdConcept): Concept {
40
49
  localizations,
41
50
  related,
42
51
  partitive_relations: partitiveRelations,
52
+ generic_relations: genericRelations,
43
53
  tags,
44
54
  figures: normalizeEntityRefs(doc[GL.FIGURE_REF]),
45
55
  tables: normalizeEntityRefs(doc[GL.TABLE_REF]),
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Hyperedge (n-ary relation) mapping: JSON-LD → glossarist-js model.
3
+ *
4
+ * Handles ISO 704:2022 n-ary relations — both PartitiveHyperedge and
5
+ * GenericHyperedge share the same JSON-LD wire shape. The mapper is
6
+ * identical for both; the glossarist-js Concept constructor dispatches
7
+ * by wire key (`partitive_relations` vs `generic_relations`).
8
+ *
9
+ * MECE migration: reads presence × count directly (ISO 704:2022 v3).
10
+ * Falls back to migrating the legacy 5-value `multiplicity` or v2
11
+ * `certainty` field.
12
+ */
13
+ import { GL } from '../wire-keys';
14
+ import { isPartitivePresence, isPartitiveCount } from '../../utils/partitive-multiplicity';
15
+ import { mapRefFromJsonLd } from './mappers';
16
+ import type { JsonLdPartitiveRelation } from './jsonld-types';
17
+
18
+ /**
19
+ * Map a JSON-LD relation entry to the raw dict shape that
20
+ * glossarist-js's AbstractHyperedge subclasses accept.
21
+ *
22
+ * Works for both PartitiveHyperedge and GenericHyperedge — the only
23
+ * difference at the wire level is which wire key the parent dict
24
+ * uses (`partitive_relations` vs `generic_relations`).
25
+ *
26
+ * Reads `gl:hasPartitive`, `gl:hasGeneric`, or `gl:members` for
27
+ * the member array (in order of preference).
28
+ */
29
+ export function mapHyperedgeFromJsonLd(
30
+ r: JsonLdPartitiveRelation,
31
+ memberKey: 'gl:hasPartitive' | 'gl:hasGeneric' | 'gl:members' = 'gl:hasPartitive',
32
+ ): Record<string, unknown> | null {
33
+ const comprehensive = r[GL.COMPREHENSIVE] ? mapRefFromJsonLd(r[GL.COMPREHENSIVE]) : null;
34
+ if (!comprehensive) return null;
35
+
36
+ const rawMembers = r[GL.HAS_PARTITIVE] ?? r[GL.HAS_GENERIC] ?? r[GL.HAS_MEMBER] ?? r[memberKey] ?? [];
37
+ const partitives = (rawMembers as any[])
38
+ .map((m): Record<string, unknown> | null => {
39
+ const ref = m[GL.REF] ? mapRefFromJsonLd(m[GL.REF]) : null;
40
+ if (!ref) return null;
41
+ const out: Record<string, unknown> = { ref };
42
+ const presence = m[GL.PRESENCE];
43
+ const count = m[GL.COUNT];
44
+ if (isPartitivePresence(presence) && isPartitiveCount(count)) {
45
+ out.presence = presence;
46
+ out.count = count;
47
+ } else {
48
+ const raw = m[GL.MULTIPLICITY] ?? splitLegacyCertainty(m[GL.CERTAINTY]);
49
+ if (raw) {
50
+ const parts = splitMultiplicity(raw);
51
+ out.presence = parts.presence;
52
+ out.count = parts.count;
53
+ }
54
+ }
55
+ if (m[GL.IS_DELIMITING] === true) out.is_delimiting = true;
56
+ return out;
57
+ })
58
+ .filter((m): m is Record<string, unknown> => m !== null);
59
+
60
+ if (partitives.length < 2) return null;
61
+
62
+ const out: Record<string, unknown> = {
63
+ comprehensive,
64
+ members: partitives,
65
+ };
66
+
67
+ if (r[GL.COMPLETENESS]) out.completeness = r[GL.COMPLETENESS];
68
+
69
+ if (r[GL.CRITERION]) {
70
+ out.criterion = typeof r[GL.CRITERION] === 'string'
71
+ ? { default: r[GL.CRITERION] }
72
+ : r[GL.CRITERION];
73
+ }
74
+
75
+ return out;
76
+ }
77
+
78
+ /** Migrate legacy v2 certainty → old v3 multiplicity string. */
79
+ function splitLegacyCertainty(certainty: string | undefined): string | null {
80
+ if (!certainty) return null;
81
+ return certainty === 'possible' ? 'optional' : 'compulsory';
82
+ }
83
+
84
+ /** Split the legacy one-string multiplicity into MECE presence × count. */
85
+ function splitMultiplicity(m: string): { presence: 'required' | 'optional'; count: 'exactly_one' | 'at_least_one' | 'multiple' } {
86
+ const LEGACY_MAP: Record<string, { presence: 'required' | 'optional'; count: 'exactly_one' | 'at_least_one' | 'multiple' }> = {
87
+ compulsory: { presence: 'required', count: 'exactly_one' },
88
+ optional: { presence: 'optional', count: 'exactly_one' },
89
+ compulsory_multiple: { presence: 'required', count: 'multiple' },
90
+ optional_multiple: { presence: 'optional', count: 'multiple' },
91
+ compulsory_at_least_one: { presence: 'required', count: 'at_least_one' },
92
+ };
93
+ return LEGACY_MAP[m] ?? { presence: 'required', count: 'exactly_one' };
94
+ }
@@ -150,6 +150,8 @@ export interface JsonLdPartitiveMember {
150
150
  export interface JsonLdPartitiveRelation {
151
151
  'gl:comprehensive'?: JsonLdRef;
152
152
  'gl:hasPartitive'?: JsonLdPartitiveMember[];
153
+ 'gl:hasGeneric'?: JsonLdPartitiveMember[];
154
+ 'gl:members'?: JsonLdPartitiveMember[];
153
155
  'gl:completeness'?: string;
154
156
  'gl:criterion'?: Record<string, string> | string;
155
157
  }
@@ -162,6 +164,7 @@ export interface JsonLdConcept {
162
164
  'gl:localizedConcept'?: Record<string, JsonLdLocalizedConcept>;
163
165
  'gl:related'?: JsonLdRelated[];
164
166
  'gl:partitiveRelations'?: JsonLdPartitiveRelation[];
167
+ 'gl:genericRelations'?: JsonLdPartitiveRelation[];
165
168
  'gl:tags'?: string[];
166
169
  'gl:figureRef'?: unknown[];
167
170
  'gl:tableRef'?: unknown[];
@@ -170,12 +170,14 @@ declare module 'glossarist' {
170
170
  function pairFromMultiplicity(name: Multiplicity): { presence: PartitivePresence; count: PartitiveCount };
171
171
 
172
172
  interface Concept {
173
- readonly partitiveRelations: PartitiveRelation[];
173
+ readonly relations: AbstractHyperedge[];
174
+ readonly partitiveRelations: PartitiveHyperedge[];
175
+ readonly genericRelations: GenericHyperedge[];
174
176
  }
175
177
 
176
178
  // Re-exposed here so callers importing from the top-level `glossarist`
177
179
  // entry get the MECE-augmented shape, not the stale d.ts type.
178
- export class PartitiveMember extends GlossaristModel {
180
+ export class HyperedgeMember extends GlossaristModel {
179
181
  constructor(data?: {
180
182
  ref?: ConceptRef;
181
183
  presence?: PartitivePresence;
@@ -189,36 +191,43 @@ declare module 'glossarist' {
189
191
  readonly isRequired: boolean;
190
192
  readonly isOptional: boolean;
191
193
  get isDelimiting(): boolean;
192
- toJSON(): { ref: ReturnType<ConceptRef['toJSON']>; presence?: PartitivePresence; count?: PartitiveCount; is_delimiting?: boolean };
193
- static fromJSON(data: Record<string, unknown>): PartitiveMember;
194
+ toJSON(): Record<string, unknown>;
195
+ static fromJSON(data: Record<string, unknown>): HyperedgeMember;
194
196
  static identityOf(value: unknown): string;
195
197
  }
196
198
 
197
- export class PartitiveRelation extends GlossaristModel {
198
- constructor(data?: {
199
- comprehensive?: ConceptRef;
200
- partitives?: PartitiveMember[];
201
- completeness?: Completeness;
202
- criterion?: Record<string, string> | string;
203
- });
199
+ export class PartitiveMember extends HyperedgeMember {}
200
+ export class GenericMember extends HyperedgeMember {}
201
+
202
+ export class AbstractHyperedge extends GlossaristModel {
204
203
  readonly comprehensive: ConceptRef;
205
- readonly partitives: PartitiveMember[];
204
+ readonly members: HyperedgeMember[];
206
205
  readonly completeness: Completeness;
207
206
  readonly criterion: Record<string, string> | null;
207
+ readonly sources: ConceptSource[];
208
+ readonly notes: Record<string, string> | null;
209
+ readonly status: string | null;
208
210
  readonly isComplete: boolean;
209
211
  readonly isPartial: boolean;
210
212
  readonly isCoordinate: boolean;
211
- hasPlurality(): boolean;
212
- hasCriterion(): boolean;
213
- toJSON(): {
214
- comprehensive: ReturnType<ConceptRef['toJSON']>;
215
- partitives: ReturnType<PartitiveMember['toJSON']>[];
216
- completeness: Completeness;
217
- criterion?: Record<string, string>;
218
- };
219
- static fromJSON(data: Record<string, unknown>): PartitiveRelation;
213
+ toJSON(): Record<string, unknown>;
214
+ static fromJSON(data: Record<string, unknown>): AbstractHyperedge;
215
+ static identityOf(value: unknown): string;
216
+ }
217
+
218
+ export class PartitiveHyperedge extends AbstractHyperedge {
219
+ get partitives(): HyperedgeMember[];
220
+ static fromJSON(data: Record<string, unknown>): PartitiveHyperedge;
220
221
  static identityOf(value: unknown): string;
221
222
  }
223
+
224
+ export class GenericHyperedge extends AbstractHyperedge {
225
+ static fromJSON(data: Record<string, unknown>): GenericHyperedge;
226
+ static identityOf(value: unknown): string;
227
+ }
228
+
229
+ // Legacy aliases (deprecated — use Hyperedge-based names)
230
+ export class PartitiveRelation extends PartitiveHyperedge {}
222
231
  }
223
232
 
224
233
  // Re-declare the same exports under the 'glossarist/models' subpath so
@@ -261,9 +270,9 @@ declare module 'glossarist/models' {
261
270
  // (TypeSharedPlurality removed — superseded by presence/count on
262
271
  // PartitiveMember per MECE refactor. Was dead code.)
263
272
 
264
- // ── PartitiveRelation (glossarist 0.4.26 — MECE presence × count) ─
273
+ // ── Hyperedge classes (glossarist 0.4.33 — unified n-ary model) ─
265
274
 
266
- export class PartitiveMember extends GlossaristModel {
275
+ export class HyperedgeMember extends GlossaristModel {
267
276
  constructor(data?: {
268
277
  ref?: ConceptRef;
269
278
  presence?: PartitivePresence;
@@ -274,37 +283,44 @@ declare module 'glossarist/models' {
274
283
  readonly presence: PartitivePresence;
275
284
  readonly count: PartitiveCount;
276
285
  readonly is_delimiting: boolean;
277
- readonly isRequired: boolean;
278
- readonly isOptional: boolean;
286
+ required(): boolean;
287
+ optional(): boolean;
279
288
  get isDelimiting(): boolean;
280
- toJSON(): { ref: ReturnType<ConceptRef['toJSON']>; presence?: PartitivePresence; count?: PartitiveCount; is_delimiting?: boolean };
281
- static fromJSON(data: Record<string, unknown>): PartitiveMember;
289
+ toJSON(): Record<string, unknown>;
290
+ static fromJSON(data: Record<string, unknown>): HyperedgeMember;
282
291
  static identityOf(value: unknown): string;
283
292
  }
284
293
 
285
- export class PartitiveRelation extends GlossaristModel {
286
- constructor(data?: {
287
- comprehensive?: ConceptRef;
288
- partitives?: PartitiveMember[];
289
- completeness?: Completeness;
290
- criterion?: Record<string, string> | string;
291
- });
294
+ export class PartitiveMember extends HyperedgeMember {}
295
+ export class GenericMember extends HyperedgeMember {}
296
+
297
+ export class AbstractHyperedge extends GlossaristModel {
292
298
  readonly comprehensive: ConceptRef;
293
- readonly partitives: PartitiveMember[];
299
+ readonly members: HyperedgeMember[];
294
300
  readonly completeness: Completeness;
295
301
  readonly criterion: Record<string, string> | null;
302
+ readonly sources: ConceptSource[];
303
+ readonly notes: Record<string, string> | null;
304
+ readonly status: string | null;
296
305
  readonly isComplete: boolean;
297
306
  readonly isPartial: boolean;
298
307
  readonly isCoordinate: boolean;
299
- hasPlurality(): boolean;
300
- hasCriterion(): boolean;
301
- toJSON(): {
302
- comprehensive: ReturnType<ConceptRef['toJSON']>;
303
- partitives: ReturnType<PartitiveMember['toJSON']>[];
304
- completeness: Completeness;
305
- criterion?: Record<string, string>;
306
- };
307
- static fromJSON(data: Record<string, unknown>): PartitiveRelation;
308
+ toJSON(): Record<string, unknown>;
309
+ static fromJSON(data: Record<string, unknown>): AbstractHyperedge;
310
+ static identityOf(value: unknown): string;
311
+ }
312
+
313
+ export class PartitiveHyperedge extends AbstractHyperedge {
314
+ get partitives(): HyperedgeMember[];
315
+ static fromJSON(data: Record<string, unknown>): PartitiveHyperedge;
316
+ static identityOf(value: unknown): string;
317
+ }
318
+
319
+ export class GenericHyperedge extends AbstractHyperedge {
320
+ static fromJSON(data: Record<string, unknown>): GenericHyperedge;
308
321
  static identityOf(value: unknown): string;
309
322
  }
323
+
324
+ // Legacy aliases (deprecated)
325
+ export class PartitiveRelation extends PartitiveHyperedge {}
310
326
  }
@@ -179,6 +179,30 @@ export interface PartitiveMemberWire {
179
179
  isDelimiting: boolean;
180
180
  }
181
181
 
182
+ /**
183
+ * GenericRelationWire — ISO 704 / 1087-1 / 12620 generic (genus/
184
+ * species) decomposition, mirror of PartitiveRelationWire.
185
+ *
186
+ * The OIML V 2-200:2010 dataset has ~8 generic hyperedges with
187
+ * multiple criterion groups per comprehensive; this shape supports
188
+ * that natively. See docs/design/generic-relation.md (concept-model).
189
+ */
190
+ export interface GenericRelationWire {
191
+ source: string;
192
+ comprehensive: string;
193
+ members: GenericMemberWire[];
194
+ completeness: 'complete' | 'partial';
195
+ criterion?: Record<string, string>;
196
+ register: string;
197
+ }
198
+
199
+ export interface GenericMemberWire {
200
+ uri: string;
201
+ presence: 'required' | 'optional';
202
+ count: 'exactly_one' | 'at_least_one' | 'multiple';
203
+ isDelimiting: boolean;
204
+ }
205
+
182
206
  export interface GraphNode {
183
207
  uri: string;
184
208
  register: string;
@@ -37,6 +37,7 @@ export const GL = {
37
37
  LOCALIZED_CONCEPT: 'gl:localizedConcept',
38
38
  RELATED: 'gl:related',
39
39
  PARTITIVE_RELATIONS: 'gl:partitiveRelations',
40
+ GENERIC_RELATIONS: 'gl:genericRelations',
40
41
  TAGS: 'gl:tags',
41
42
 
42
43
  // LocalizedConcept
@@ -139,6 +140,8 @@ export const GL = {
139
140
  // PartitiveRelation
140
141
  COMPREHENSIVE: 'gl:comprehensive',
141
142
  HAS_PARTITIVE: 'gl:hasPartitive',
143
+ HAS_GENERIC: 'gl:hasGeneric',
144
+ HAS_MEMBER: 'gl:members',
142
145
  COMPLETENESS: 'gl:completeness',
143
146
  CRITERION: 'gl:criterion',
144
147
 
@@ -28,6 +28,7 @@ import ConceptEditionRail from './ConceptEditionRail.vue';
28
28
  import NonVerbalRepDisplay from './NonVerbalRepDisplay.vue';
29
29
  import NonVerbalList from './non-verbal/NonVerbalList.vue';
30
30
  import PartitiveRelationList from './PartitiveRelationList.vue';
31
+ import GenericRelationList from './GenericRelationList.vue';
31
32
  import ConceptDiffModal from './ConceptDiffModal.vue';
32
33
  import CitationDisplay from './CitationDisplay.vue';
33
34
  import DesignationList from './DesignationList.vue';
@@ -75,6 +76,7 @@ const {
75
76
  inverseEdgeType,
76
77
  conceptRelated,
77
78
  conceptPartitiveRelations,
79
+ conceptGenericRelations,
78
80
  getResolvedRef,
79
81
  relatedLabel,
80
82
  navigateEdge,
@@ -577,6 +579,14 @@ const nonVerbalReps = computed(() => {
577
579
  :register-id="registerId"
578
580
  />
579
581
 
582
+ <!-- Generic relations — genus/species decompositions (ISO 704) -->
583
+ <GenericRelationList
584
+ v-if="conceptGenericRelations.length"
585
+ :relations="conceptGenericRelations"
586
+ :manifest="manifest"
587
+ :register-id="registerId"
588
+ />
589
+
580
590
  <!-- Edition series — supersession chain across vocabulary editions -->
581
591
  <ConceptEditionRail
582
592
  :concept-uri="conceptUri(props.concept, props.registerId, props.manifest.uriBase)"
@@ -0,0 +1,168 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * GenericRelationList — renders one-to-many generic (genus/species)
4
+ * decompositions as ISO 704 rake diagrams. Mirror of
5
+ * PartitiveRelationList with type label swapped.
6
+ *
7
+ * Each relation card shows:
8
+ * - completeness badge (Complete / Partial)
9
+ * - the comprehensive (genus) designation as the card title
10
+ * - optional criterion as italic text
11
+ * - the rake diagram itself (PartitiveRelationDiagram, reused —
12
+ * the line notation is identical for generic rakes)
13
+ *
14
+ * OIML V 2-200:2010 has ~8 generic hyperedges; 5.1 measurement
15
+ * standard alone has 6 criterion groups. This component is the
16
+ * primary rendering surface for those datasets.
17
+ */
18
+ import type { GenericRelationWire, Manifest } from '../adapters/types';
19
+ import { useRouter } from 'vue-router';
20
+ import { useVocabularyStore } from '../stores/vocabulary';
21
+ import { getFactory } from '../adapters/factory';
22
+ import { useI18n, locale } from '../i18n';
23
+ import { completenessLabel } from '../utils/partitive-relation-styling';
24
+ import { presenceLabel, countLabel } from '../utils/partitive-multiplicity';
25
+ import { resolveDesignation } from '../utils/resolve-designation';
26
+ import PartitiveRelationDiagram, {
27
+ type PartitiveMemberLabeled,
28
+ } from './PartitiveRelationDiagram.vue';
29
+
30
+ const props = defineProps<{
31
+ relations: GenericRelationWire[];
32
+ manifest: Manifest;
33
+ registerId: string;
34
+ }>();
35
+
36
+ const router = useRouter();
37
+ const store = useVocabularyStore();
38
+ const factory = getFactory();
39
+ const { t } = useI18n();
40
+
41
+ function designationFor(uri: string): string {
42
+ return resolveDesignation(uri, store, factory, locale.value);
43
+ }
44
+
45
+ function memberLabel(member: GenericRelationWire['members'][number]): PartitiveMemberLabeled {
46
+ return {
47
+ uri: member.uri,
48
+ presence: member.presence,
49
+ count: member.count,
50
+ isDelimiting: member.isDelimiting,
51
+ label: designationFor(member.uri),
52
+ };
53
+ }
54
+ </script>
55
+
56
+ <template>
57
+ <section class="generic-relations" aria-label="Generic relations">
58
+ <div
59
+ v-for="(rel, i) in relations"
60
+ :key="`generic-${i}-${rel.comprehensive}`"
61
+ class="generic-relation-card"
62
+ >
63
+ <header class="relation-header">
64
+ <span class="badge badge-completeness" :class="`completeness-${rel.completeness}`">
65
+ {{ completenessLabel(rel.completeness) }}
66
+ </span>
67
+ <span class="badge badge-type">{{ t('relations.generic') }}</span>
68
+ <h4 class="comprehensive">{{ designationFor(rel.comprehensive) }}</h4>
69
+ </header>
70
+
71
+ <p v-if="rel.criterion" class="criterion">
72
+ <em>{{ rel.criterion[locale] || rel.criterion.eng || Object.values(rel.criterion)[0] }}</em>
73
+ </p>
74
+
75
+ <PartitiveRelationDiagram
76
+ :partitives="rel.members.map(memberLabel)"
77
+ :comprehensive-label="designationFor(rel.comprehensive)"
78
+ :completeness="rel.completeness"
79
+ />
80
+
81
+ <ul class="member-presence-list" aria-label="Member presence and count">
82
+ <li v-for="(m, j) in rel.members" :key="j">
83
+ <span class="member-label">{{ designationFor(m.uri) }}</span>
84
+ <span v-if="m.presence && m.presence !== 'required'" class="member-presence">{{ presenceLabel(m.presence) }}</span>
85
+ <span v-if="m.count && m.count !== 'exactly_one'" class="member-count">{{ countLabel(m.count) }}</span>
86
+ <span v-if="m.isDelimiting" class="member-delimiting">{{ t('relations.delimiting') }}</span>
87
+ </li>
88
+ </ul>
89
+ </div>
90
+ </section>
91
+ </template>
92
+
93
+ <style scoped>
94
+ .generic-relation-card {
95
+ border: 1px solid var(--color-border, #ccc);
96
+ border-radius: 6px;
97
+ padding: 1rem;
98
+ margin-bottom: 1rem;
99
+ background: var(--color-surface, #fff);
100
+ }
101
+
102
+ .relation-header {
103
+ display: flex;
104
+ align-items: center;
105
+ gap: 0.5rem;
106
+ flex-wrap: wrap;
107
+ }
108
+
109
+ .badge {
110
+ font-size: 0.75rem;
111
+ font-weight: 600;
112
+ padding: 0.15rem 0.5rem;
113
+ border-radius: 4px;
114
+ text-transform: uppercase;
115
+ letter-spacing: 0.05em;
116
+ }
117
+
118
+ .badge-completeness.completeness-complete {
119
+ background: var(--color-success-soft, #d4edda);
120
+ color: var(--color-success-fg, #155724);
121
+ }
122
+
123
+ .badge-completeness.completeness-partial {
124
+ background: var(--color-warn-soft, #fff3cd);
125
+ color: var(--color-warn-fg, #856404);
126
+ }
127
+
128
+ .badge-type {
129
+ background: var(--color-accent-soft, #d1ecf1);
130
+ color: var(--color-accent-fg, #0c5460);
131
+ }
132
+
133
+ .comprehensive {
134
+ margin: 0;
135
+ font-size: 1.1rem;
136
+ flex: 1 1 auto;
137
+ }
138
+
139
+ .criterion {
140
+ color: var(--color-text-muted, #6c757d);
141
+ margin: 0.5rem 0;
142
+ font-size: 0.95rem;
143
+ }
144
+
145
+ .member-presence-list {
146
+ list-style: none;
147
+ padding: 0;
148
+ margin: 0.5rem 0 0;
149
+ font-size: 0.85rem;
150
+ }
151
+
152
+ .member-presence-list li {
153
+ display: flex;
154
+ gap: 0.5rem;
155
+ align-items: center;
156
+ padding: 0.15rem 0;
157
+ }
158
+
159
+ .member-presence-list .member-presence,
160
+ .member-presence-list .member-count,
161
+ .member-presence-list .member-delimiting {
162
+ color: var(--color-text-muted, #6c757d);
163
+ font-size: 0.75rem;
164
+ border: 1px solid var(--color-border-light, #eee);
165
+ padding: 0.05rem 0.4rem;
166
+ border-radius: 3px;
167
+ }
168
+ </style>
@@ -20,6 +20,7 @@ import { getFactory } from '../adapters/factory';
20
20
  import { useI18n, locale } from '../i18n';
21
21
  import { completenessLabel } from '../utils/partitive-relation-styling';
22
22
  import { presenceLabel, countLabel } from '../utils/partitive-multiplicity';
23
+ import { resolveDesignation } from '../utils/resolve-designation';
23
24
  import PartitiveRelationDiagram, {
24
25
  type PartitiveMemberLabeled,
25
26
  } from './PartitiveRelationDiagram.vue';
@@ -36,25 +37,7 @@ const factory = getFactory();
36
37
  const { t } = useI18n();
37
38
 
38
39
  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;
45
- }
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;
40
+ return resolveDesignation(uri, store, factory, locale.value);
58
41
  }
59
42
 
60
43
  function navigate(uri: string) {
@@ -4,6 +4,8 @@ import type { Router } from 'vue-router';
4
4
  // Import the MECE-augmented types from glossarist/models (extended by
5
5
  // src/adapters/non-verbal/glossarist-augment.d.ts).
6
6
  import type { Concept, RelatedConcept, ConceptRef } from 'glossarist';
7
+ import { PartitiveHyperedge } from 'glossarist';
8
+ import { GenericHyperedge } from 'glossarist/models';
7
9
  import type {
8
10
  PartitiveRelation as GlsPartitiveRelation,
9
11
  PartitiveMember as GlsPartitiveMember,
@@ -13,9 +15,11 @@ import type {
13
15
  GraphEdge,
14
16
  PartitiveRelationWire,
15
17
  PartitiveMemberWire,
18
+ GenericRelationWire,
16
19
  } from '../adapters/types';
17
20
  import { getFactory } from '../adapters/factory';
18
21
  import { conceptUri } from '../adapters/model-bridge';
22
+ import { resolveDesignation } from '../utils/resolve-designation';
19
23
  import { UriRouter } from '../adapters/UriRouter';
20
24
  import { useVocabularyStore } from '../stores/vocabulary';
21
25
  import { useDsStyle } from '../utils/dataset-style';
@@ -111,25 +115,7 @@ export function useConceptEdges(
111
115
  * IDs like '1.3'.
112
116
  */
113
117
  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;
118
+ return resolveDesignation(uri, store, factory, locale.value);
133
119
  }
134
120
 
135
121
  function edgeBadgeColor(type: string, direction: 'out' | 'in'): string {
@@ -174,42 +160,53 @@ export function useConceptEdges(
174
160
  });
175
161
  });
176
162
 
177
- // Concept-level partitive relations (ISO 704 one-to-many decompositions).
178
- // Reads the glossarist-js native model (`concept.partitiveRelations`)
179
- // and projects each relation into a URI-resolved wire shape for display.
180
- // Independent of binary `conceptRelated`.
181
- // v2 shape per concept-model/TODO.partitive-relation-v2.
182
- const conceptPartitiveRelations = computed<PartitiveRelationWire[]>(() => {
163
+ // Concept-level n-ary hyperedge relations (ISO 704 one-to-many
164
+ // decompositions). Reads the glossarist-js unified `concept.relations`
165
+ // array and projects PartitiveHyperedge → PartitiveRelationWire,
166
+ // GenericHyperedge → GenericRelationWire for display.
167
+ //
168
+ // glossarist 0.4.33+ exposes a unified `.relations` array; type
169
+ // discrimination is via instanceof.
170
+ function projectHyperedge(rel: any): PartitiveRelationWire | GenericRelationWire | null {
183
171
  const source = conceptUriValue.value;
184
- const relations: readonly GlsPartitiveRelation[] = concept.value.partitiveRelations;
185
- return relations
186
- .map((rel): PartitiveRelationWire | null => {
187
- const comprehensive = resolveConceptRefUri(rel.comprehensive);
188
- if (!comprehensive) return null;
189
- const partitives: PartitiveMemberWire[] = rel.partitives
190
- .map((m: GlsPartitiveMember): PartitiveMemberWire | null => {
191
- const uri = resolveConceptRefUri(m.ref);
192
- if (!uri || uri === source) return null;
193
- return {
194
- uri,
195
- presence: readPresence(m),
196
- count: readCount(m),
197
- isDelimiting: readIsDelimiting(m),
198
- };
199
- })
200
- .filter((m: PartitiveMemberWire | null): m is PartitiveMemberWire => m !== null);
201
- if (partitives.length === 0) return null;
202
-
172
+ const comprehensive = resolveConceptRefUri(rel.comprehensive);
173
+ if (!comprehensive) return null;
174
+ const members: PartitiveMemberWire[] = (rel.members ?? rel.partitives ?? [])
175
+ .map((m: any): PartitiveMemberWire | null => {
176
+ const uri = resolveConceptRefUri(m.ref);
177
+ if (!uri || uri === source) return null;
203
178
  return {
204
- source,
205
- comprehensive,
206
- partitives,
207
- completeness: rel.completeness,
208
- criterion: rel.criterion ?? undefined,
209
- register: registerId.value,
179
+ uri,
180
+ presence: readPresence(m),
181
+ count: readCount(m),
182
+ isDelimiting: readIsDelimiting(m),
210
183
  };
211
184
  })
212
- .filter((r: PartitiveRelationWire | null): r is PartitiveRelationWire => r !== null);
185
+ .filter((m: PartitiveMemberWire | null): m is PartitiveMemberWire => m !== null);
186
+ if (members.length === 0) return null;
187
+ return {
188
+ source,
189
+ comprehensive,
190
+ members,
191
+ partitives: members,
192
+ completeness: rel.completeness,
193
+ criterion: rel.criterion ?? undefined,
194
+ register: registerId.value,
195
+ };
196
+ }
197
+
198
+ const conceptPartitiveRelations = computed<PartitiveRelationWire[]>(() => {
199
+ return (concept.value.relations as any[] ?? [])
200
+ .filter(r => r instanceof PartitiveHyperedge)
201
+ .map(projectHyperedge)
202
+ .filter((r): r is PartitiveRelationWire => r !== null);
203
+ });
204
+
205
+ const conceptGenericRelations = computed<GenericRelationWire[]>(() => {
206
+ return (concept.value.relations as any[] ?? [])
207
+ .filter(r => r instanceof GenericHyperedge)
208
+ .map(projectHyperedge)
209
+ .filter((r): r is GenericRelationWire => r !== null);
213
210
  });
214
211
 
215
212
  function resolveConceptRefUri(ref: ConceptRef | null): string | null {
@@ -307,6 +304,7 @@ export function useConceptEdges(
307
304
  inverseEdgeType,
308
305
  conceptRelated,
309
306
  conceptPartitiveRelations,
307
+ conceptGenericRelations,
310
308
  resolveRelatedRef,
311
309
  getResolvedRef,
312
310
  relatedLabel,
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Resolve a concept URI to its display designation (term string).
3
+ *
4
+ * Single SSOT for the designation fallback chain used by:
5
+ * - PartitiveRelationList.vue
6
+ * - GenericRelationList.vue
7
+ * - use-concept-edges.ts (designationFor method)
8
+ *
9
+ * Previously each site had its own copy with subtle divergences (one
10
+ * used `entry.titles`, another used `entry.designations` — the former
11
+ * is a bug since ConceptSummary has no `titles` property).
12
+ *
13
+ * Fallback chain:
14
+ * 1. BFS graph node (already loaded as a neighbor) → locale → eng → any
15
+ * 2. Dataset adapter index entry → designations[locale] → eng → any
16
+ * 3. Concept ID (for internal URIs) or raw URI (for external)
17
+ */
18
+ import type { useVocabularyStore } from '../stores/vocabulary';
19
+ import type { AdapterFactory } from '../adapters/factory';
20
+
21
+ type VocabularyStore = ReturnType<typeof useVocabularyStore>;
22
+ type Factory = AdapterFactory;
23
+
24
+ export function resolveDesignation(
25
+ uri: string,
26
+ store: VocabularyStore,
27
+ factory: Factory,
28
+ currentLocale: string,
29
+ ): string {
30
+ const node = store.graph.getNode(uri);
31
+ if (node) {
32
+ const des = node.designations[currentLocale]
33
+ || node.designations.eng
34
+ || Object.values(node.designations)[0];
35
+ if (des) return des;
36
+ }
37
+ const resolution = factory.resolve(uri);
38
+ if (resolution.type === 'internal') {
39
+ const adapter = store.datasets.get(resolution.registerId);
40
+ const entry = adapter?.getIndexEntry(resolution.conceptId);
41
+ if (entry) {
42
+ const des = entry.designations[currentLocale]
43
+ || entry.designations.eng
44
+ || Object.values(entry.designations)[0];
45
+ if (des) return des;
46
+ }
47
+ return resolution.conceptId;
48
+ }
49
+ return uri;
50
+ }