@glossarist/concept-browser 0.7.42 → 0.7.43
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/scripts/__tests__/concept-groups.test.mjs +51 -0
- package/scripts/generate-data.mjs +1 -19
- package/scripts/lib/concept-groups.mjs +16 -0
- package/src/__tests__/dataset-view.test.ts +95 -1
- package/src/__tests__/section-display.test.ts +46 -0
- package/src/__tests__/section-tree.test.ts +128 -0
- package/src/adapters/GraphDataSource.ts +9 -25
- package/src/components/AppSidebar.vue +5 -14
- package/src/utils/section-display.ts +21 -0
- package/src/utils/section-tree.ts +53 -0
- package/src/views/DatasetView.vue +26 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.43",
|
|
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,51 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { getGroups } from '../lib/concept-groups.mjs';
|
|
3
|
+
|
|
4
|
+
describe('getGroups', () => {
|
|
5
|
+
it('returns explicit eng.groups when present', () => {
|
|
6
|
+
expect(getGroups({ eng: { groups: ['g1', 'g2'] }, termid: 1 })).toEqual(['g1', 'g2']);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('derives groups from _domains with ref_type=section', () => {
|
|
10
|
+
expect(getGroups({
|
|
11
|
+
termid: 1,
|
|
12
|
+
_domains: [
|
|
13
|
+
{ ref_type: 'section', concept_id: 'section-102-01' },
|
|
14
|
+
{ ref_type: 'section', concept_id: 'section-102-02' },
|
|
15
|
+
],
|
|
16
|
+
})).toEqual(['102-01', '102-02']);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('ignores _domains entries without ref_type=section', () => {
|
|
20
|
+
expect(getGroups({
|
|
21
|
+
termid: 1,
|
|
22
|
+
_domains: [
|
|
23
|
+
{ ref_type: 'other', concept_id: 'x' },
|
|
24
|
+
{ ref_type: 'section', concept_id: 'section-103' },
|
|
25
|
+
],
|
|
26
|
+
})).toEqual(['103']);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('falls through when _domains has no section entries', () => {
|
|
30
|
+
expect(getGroups({
|
|
31
|
+
termid: '103-01-02',
|
|
32
|
+
_domains: [{ ref_type: 'other', concept_id: 'x' }],
|
|
33
|
+
})).toEqual(['103']);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('derives group from termid with NNN- prefix (e.g. IEV)', () => {
|
|
37
|
+
expect(getGroups({ termid: '103-01-02' })).toEqual(['103']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('derives group from dotted termid (e.g. VIM)', () => {
|
|
41
|
+
expect(getGroups({ termid: '1.2.3.4' })).toEqual(['1.2.3']);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns empty array when no derivation matches', () => {
|
|
45
|
+
expect(getGroups({ termid: 'abc' })).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns empty array when termid is missing', () => {
|
|
49
|
+
expect(getGroups({})).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -3,7 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import yaml from 'js-yaml';
|
|
4
4
|
import { naturalSort, Register, parseMention } from 'glossarist';
|
|
5
5
|
import { loadSiteConfig } from './load-site-config.mjs';
|
|
6
|
-
|
|
6
|
+
import { getGroups } from './lib/concept-groups.mjs';
|
|
7
7
|
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
|
8
8
|
const ROOT = process.cwd();
|
|
9
9
|
const PUBLIC = path.join(ROOT, 'public');
|
|
@@ -574,24 +574,6 @@ function getPrimaryDesignation(conceptYaml) {
|
|
|
574
574
|
return descs;
|
|
575
575
|
}
|
|
576
576
|
|
|
577
|
-
function getGroups(conceptYaml) {
|
|
578
|
-
if (conceptYaml.eng && conceptYaml.eng.groups) return conceptYaml.eng.groups;
|
|
579
|
-
// Derive groups from domains (e.g. section-based grouping in G18)
|
|
580
|
-
if (conceptYaml._domains) {
|
|
581
|
-
const sectionIds = conceptYaml._domains
|
|
582
|
-
.filter(d => d.ref_type === 'section' && d.concept_id)
|
|
583
|
-
.map(d => d.concept_id.replace(/^section-/, ''));
|
|
584
|
-
if (sectionIds.length) return sectionIds;
|
|
585
|
-
}
|
|
586
|
-
const termid = String(conceptYaml.termid);
|
|
587
|
-
if (/^\d{3}-/.test(termid)) return [termid.substring(0, 3)];
|
|
588
|
-
if (/^\d+\.\d+\.\d+/.test(termid)) {
|
|
589
|
-
const parts = termid.split('.');
|
|
590
|
-
return [`${parts[0]}.${parts[1]}.${parts[2]}`];
|
|
591
|
-
}
|
|
592
|
-
return [];
|
|
593
|
-
}
|
|
594
|
-
|
|
595
577
|
function escapeTurtle(s) {
|
|
596
578
|
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
597
579
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function getGroups(conceptYaml) {
|
|
2
|
+
if (conceptYaml.eng && conceptYaml.eng.groups) return conceptYaml.eng.groups;
|
|
3
|
+
if (conceptYaml._domains) {
|
|
4
|
+
const sectionIds = conceptYaml._domains
|
|
5
|
+
.filter(d => d.ref_type === 'section' && d.concept_id)
|
|
6
|
+
.map(d => d.concept_id.replace(/^section-/, ''));
|
|
7
|
+
if (sectionIds.length) return sectionIds;
|
|
8
|
+
}
|
|
9
|
+
const termid = String(conceptYaml.termid);
|
|
10
|
+
if (/^\d{3}-/.test(termid)) return [termid.substring(0, 3)];
|
|
11
|
+
if (/^\d+\.\d+\.\d+/.test(termid)) {
|
|
12
|
+
const parts = termid.split('.');
|
|
13
|
+
return [`${parts[0]}.${parts[1]}.${parts[2]}`];
|
|
14
|
+
}
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
@@ -4,7 +4,7 @@ import { createPinia, setActivePinia } from 'pinia';
|
|
|
4
4
|
import { createRouter, createMemoryHistory } from 'vue-router';
|
|
5
5
|
import DatasetView from '../views/DatasetView.vue';
|
|
6
6
|
import { useVocabularyStore } from '../stores/vocabulary';
|
|
7
|
-
import type { Manifest, ConceptSummary } from '../adapters/types';
|
|
7
|
+
import type { Manifest, ConceptSummary, SectionNode } from '../adapters/types';
|
|
8
8
|
|
|
9
9
|
function makeManifest(overrides: Partial<Manifest> = {}): Manifest {
|
|
10
10
|
return {
|
|
@@ -230,4 +230,98 @@ describe('DatasetView', () => {
|
|
|
230
230
|
expect(prevBtn).toBeDefined();
|
|
231
231
|
expect(prevBtn!.attributes('disabled')).toBeDefined();
|
|
232
232
|
});
|
|
233
|
+
|
|
234
|
+
describe('hierarchical section filter', () => {
|
|
235
|
+
const HIERARCHICAL_TREE: SectionNode[] = [
|
|
236
|
+
{
|
|
237
|
+
id: '102',
|
|
238
|
+
names: { eng: 'Mathematics' },
|
|
239
|
+
conceptCount: 0,
|
|
240
|
+
children: [
|
|
241
|
+
{ id: '102-01', names: { eng: 'Sets' }, conceptCount: 2 },
|
|
242
|
+
{
|
|
243
|
+
id: '102-02',
|
|
244
|
+
names: { eng: 'Numbers' },
|
|
245
|
+
conceptCount: 2,
|
|
246
|
+
children: [
|
|
247
|
+
{ id: '102-02-01', names: { eng: 'Reals' }, conceptCount: 1 },
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
{ id: '103', names: { eng: 'Functions' }, conceptCount: 1 },
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
function makeHierarchicalAdapter(concepts: ConceptSummary[], sections: SectionNode[]) {
|
|
256
|
+
const dense = concepts.filter(Boolean);
|
|
257
|
+
return {
|
|
258
|
+
registerId: 'test',
|
|
259
|
+
index: dense,
|
|
260
|
+
manifest: null,
|
|
261
|
+
getConceptCount: () => dense.length,
|
|
262
|
+
getConcepts: () => dense,
|
|
263
|
+
isRangeLoaded: () => true,
|
|
264
|
+
ensureChunksForRange: async () => {},
|
|
265
|
+
ensureAllChunksLoaded: async () => {},
|
|
266
|
+
getAdjacentConcepts: () => ({ prev: null, next: null }),
|
|
267
|
+
getSectionTree: () => sections,
|
|
268
|
+
} as any;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function mountHierarchical(concepts: ConceptSummary[], sectionId: string) {
|
|
272
|
+
const store = useVocabularyStore();
|
|
273
|
+
store.manifests.set('test', makeManifest());
|
|
274
|
+
store.datasets.set('test', makeHierarchicalAdapter(concepts, HIERARCHICAL_TREE));
|
|
275
|
+
return mount(DatasetView, {
|
|
276
|
+
global: { plugins: [pinia, router] },
|
|
277
|
+
props: { registerId: 'test' },
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
it('matches concepts in a child section when filtering by parent (closure)', async () => {
|
|
282
|
+
const concepts: ConceptSummary[] = [
|
|
283
|
+
{ id: 'a', designations: { eng: 'set A' }, eng: 'set A', status: 'valid', groups: ['102-01'] },
|
|
284
|
+
{ id: 'b', designations: { eng: 'unrelated' }, eng: 'unrelated', status: 'valid', groups: ['999'] },
|
|
285
|
+
];
|
|
286
|
+
const wrapper = mountHierarchical(concepts, 'section-102');
|
|
287
|
+
await router.push({ name: 'dataset', params: { registerId: 'test' }, query: { section: 'section-102' } });
|
|
288
|
+
await flushPromises();
|
|
289
|
+
expect(wrapper.text()).toContain('set A');
|
|
290
|
+
expect(wrapper.text()).not.toContain('unrelated');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('matches concepts at arbitrary depth (grandparent closure)', async () => {
|
|
294
|
+
const concepts: ConceptSummary[] = [
|
|
295
|
+
{ id: 'deep', designations: { eng: 'real number' }, eng: 'real number', status: 'valid', groups: ['102-02-01'] },
|
|
296
|
+
{ id: 'other', designations: { eng: 'other concept' }, eng: 'other concept', status: 'valid', groups: ['103'] },
|
|
297
|
+
];
|
|
298
|
+
const wrapper = mountHierarchical(concepts, 'section-102');
|
|
299
|
+
await router.push({ name: 'dataset', params: { registerId: 'test' }, query: { section: 'section-102' } });
|
|
300
|
+
await flushPromises();
|
|
301
|
+
expect(wrapper.text()).toContain('real number');
|
|
302
|
+
expect(wrapper.text()).not.toContain('other concept');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('still matches by concept ID prefix when no groups are set', async () => {
|
|
306
|
+
const concepts: ConceptSummary[] = [
|
|
307
|
+
{ id: '102.3.4', designations: { eng: 'numbered term' }, eng: 'numbered term', status: 'valid' },
|
|
308
|
+
{ id: '999.1', designations: { eng: 'different section' }, eng: 'different section', status: 'valid' },
|
|
309
|
+
];
|
|
310
|
+
const wrapper = mountHierarchical(concepts, 'section-102');
|
|
311
|
+
await router.push({ name: 'dataset', params: { registerId: 'test' }, query: { section: 'section-102' } });
|
|
312
|
+
await flushPromises();
|
|
313
|
+
expect(wrapper.text()).toContain('numbered term');
|
|
314
|
+
expect(wrapper.text()).not.toContain('different section');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('renders the section display name when filter is active', async () => {
|
|
318
|
+
const concepts: ConceptSummary[] = [
|
|
319
|
+
{ id: 'a', designations: { eng: 'set A' }, eng: 'set A', status: 'valid', groups: ['102-01'] },
|
|
320
|
+
];
|
|
321
|
+
const wrapper = mountHierarchical(concepts, 'section-102');
|
|
322
|
+
await router.push({ name: 'dataset', params: { registerId: 'test' }, query: { section: 'section-102' } });
|
|
323
|
+
await flushPromises();
|
|
324
|
+
expect(wrapper.text()).toContain('102 — Mathematics');
|
|
325
|
+
});
|
|
326
|
+
});
|
|
233
327
|
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { sectionName, formatSectionLabel } from '../utils/section-display';
|
|
3
|
+
|
|
4
|
+
describe('sectionName', () => {
|
|
5
|
+
it('prefers the requested language', () => {
|
|
6
|
+
expect(sectionName({ id: 'x', names: { eng: 'X', fra: 'X (fra)' } }, 'fra')).toBe('X (fra)');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('falls back to English when requested language missing', () => {
|
|
10
|
+
expect(sectionName({ id: 'x', names: { eng: 'X' } }, 'deu')).toBe('X');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('falls back to id when no names at all', () => {
|
|
14
|
+
expect(sectionName({ id: 'x', names: {} }, 'fra')).toBe('x');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('falls back to id when names object missing', () => {
|
|
18
|
+
expect(sectionName({ id: 'x' }, 'fra')).toBe('x');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('formatSectionLabel', () => {
|
|
23
|
+
it('returns just id when name is empty', () => {
|
|
24
|
+
expect(formatSectionLabel({ id: '102', names: {} }, 'eng')).toBe('102');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns just id when name equals id', () => {
|
|
28
|
+
expect(formatSectionLabel({ id: '102', names: { eng: '102' } }, 'eng')).toBe('102');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns just name when name equals id-with-spaces (EXPRESS schema convention)', () => {
|
|
32
|
+
expect(formatSectionLabel({ id: 'action_schema', names: { eng: 'action schema' } }, 'eng')).toBe('action schema');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns "id — name" when name is meaningfully different', () => {
|
|
36
|
+
expect(formatSectionLabel({ id: '102', names: { eng: 'Mathematics' } }, 'eng')).toBe('102 — Mathematics');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('localizes the name lookup', () => {
|
|
40
|
+
expect(formatSectionLabel({ id: '102', names: { eng: 'Math', fra: 'Maths' } }, 'fra')).toBe('102 — Maths');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('falls back through English when requested language has no name', () => {
|
|
44
|
+
expect(formatSectionLabel({ id: '102', names: { eng: 'Math' } }, 'fra')).toBe('102 — Math');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { SectionNode } from '../adapters/types';
|
|
3
|
+
import {
|
|
4
|
+
findSectionNode,
|
|
5
|
+
collectDescendantSectionIds,
|
|
6
|
+
toSectionNode,
|
|
7
|
+
toSectionTree,
|
|
8
|
+
} from '../utils/section-tree';
|
|
9
|
+
|
|
10
|
+
const TREE: SectionNode[] = [
|
|
11
|
+
{
|
|
12
|
+
id: '102',
|
|
13
|
+
names: { eng: 'Mathematics' },
|
|
14
|
+
conceptCount: 0,
|
|
15
|
+
children: [
|
|
16
|
+
{ id: '102-01', names: { eng: 'Sets' }, conceptCount: 5 },
|
|
17
|
+
{
|
|
18
|
+
id: '102-02',
|
|
19
|
+
names: { eng: 'Numbers' },
|
|
20
|
+
conceptCount: 3,
|
|
21
|
+
children: [
|
|
22
|
+
{ id: '102-02-01', names: { eng: 'Reals' }, conceptCount: 1 },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
{ id: '103', names: { eng: 'Functions' }, conceptCount: 0 },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
describe('findSectionNode', () => {
|
|
31
|
+
it('finds a root by id', () => {
|
|
32
|
+
expect(findSectionNode(TREE, '103')?.id).toBe('103');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('finds a descendant by id (recursive)', () => {
|
|
36
|
+
expect(findSectionNode(TREE, '102-02-01')?.id).toBe('102-02-01');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns null for unknown id', () => {
|
|
40
|
+
expect(findSectionNode(TREE, '999')).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns null for empty tree', () => {
|
|
44
|
+
expect(findSectionNode([], '102')).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('collectDescendantSectionIds', () => {
|
|
49
|
+
it('includes root and all descendants at arbitrary depth', () => {
|
|
50
|
+
const ids = collectDescendantSectionIds(TREE, '102');
|
|
51
|
+
expect([...ids].sort()).toEqual(['102', '102-01', '102-02', '102-02-01']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns single-element set for a leaf', () => {
|
|
55
|
+
expect([...collectDescendantSectionIds(TREE, '103')]).toEqual(['103']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns empty set for unknown root', () => {
|
|
59
|
+
expect(collectDescendantSectionIds(TREE, '999').size).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('toSectionNode', () => {
|
|
64
|
+
it('maps minimal input with id only', () => {
|
|
65
|
+
expect(toSectionNode({ id: 'x' })).toEqual({
|
|
66
|
+
id: 'x',
|
|
67
|
+
names: {},
|
|
68
|
+
conceptCount: 0,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('preserves names and conceptCount', () => {
|
|
73
|
+
expect(toSectionNode({ id: 'x', names: { eng: 'X' }, conceptCount: 7 })).toEqual({
|
|
74
|
+
id: 'x',
|
|
75
|
+
names: { eng: 'X' },
|
|
76
|
+
conceptCount: 7,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('defaults missing id to empty string', () => {
|
|
81
|
+
expect(toSectionNode({}).id).toBe('');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('defaults missing conceptCount to 0', () => {
|
|
85
|
+
expect(toSectionNode({ id: 'x' }).conceptCount).toBe(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('defaults missing names to empty object', () => {
|
|
89
|
+
expect(toSectionNode({ id: 'x' }).names).toEqual({});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('recursively maps children', () => {
|
|
93
|
+
const node = toSectionNode({
|
|
94
|
+
id: 'p',
|
|
95
|
+
names: { eng: 'Parent' },
|
|
96
|
+
children: [{ id: 'c', names: { eng: 'Child' } }],
|
|
97
|
+
});
|
|
98
|
+
expect(node.children?.[0]).toEqual({
|
|
99
|
+
id: 'c',
|
|
100
|
+
names: { eng: 'Child' },
|
|
101
|
+
conceptCount: 0,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('omits children array when source has none', () => {
|
|
106
|
+
expect(toSectionNode({ id: 'x' }).children).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('omits children array when source children is empty', () => {
|
|
110
|
+
expect(toSectionNode({ id: 'x', children: [] }).children).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('toSectionTree', () => {
|
|
115
|
+
it('maps a list of roots', () => {
|
|
116
|
+
const tree = toSectionTree([
|
|
117
|
+
{ id: 'a', names: { eng: 'A' } },
|
|
118
|
+
{ id: 'b' },
|
|
119
|
+
]);
|
|
120
|
+
expect(tree).toHaveLength(2);
|
|
121
|
+
expect(tree[0].id).toBe('a');
|
|
122
|
+
expect(tree[1].names).toEqual({});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('returns empty array for empty input', () => {
|
|
126
|
+
expect(toSectionTree([])).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -3,6 +3,7 @@ import type { Concept, RelatedConcept } from 'glossarist';
|
|
|
3
3
|
import type { DatasetAdapter } from './DatasetAdapter';
|
|
4
4
|
import { UriRouter } from './UriRouter';
|
|
5
5
|
import { slugify } from '../utils/slugify';
|
|
6
|
+
import { toSectionNode, toSectionTree } from '../utils/section-tree';
|
|
6
7
|
|
|
7
8
|
interface DomainNodeJson {
|
|
8
9
|
uri?: string;
|
|
@@ -14,12 +15,6 @@ interface DomainNodeJson {
|
|
|
14
15
|
children?: DomainNodeJson[];
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
interface SectionJson {
|
|
18
|
-
id: string;
|
|
19
|
-
names?: Record<string, string>;
|
|
20
|
-
children?: SectionJson[];
|
|
21
|
-
}
|
|
22
|
-
|
|
23
18
|
function resolveRefTarget(rc: RelatedConcept, uriBase: string, registerId: string, urnMap?: ReadonlyMap<string, string>): string {
|
|
24
19
|
if (!rc.ref) return '';
|
|
25
20
|
const ref = rc.ref;
|
|
@@ -136,7 +131,7 @@ export class GraphDataSource {
|
|
|
136
131
|
getSectionTree(): SectionNode[] {
|
|
137
132
|
const nodes = this.adapter.manifest?.sections;
|
|
138
133
|
if (!nodes || nodes.length === 0) return [];
|
|
139
|
-
return nodes
|
|
134
|
+
return toSectionTree(nodes);
|
|
140
135
|
}
|
|
141
136
|
|
|
142
137
|
private mapDomainNode(dn: DomainNodeJson): GraphNode {
|
|
@@ -151,28 +146,17 @@ export class GraphDataSource {
|
|
|
151
146
|
conceptCount: dn.conceptCount || 0,
|
|
152
147
|
};
|
|
153
148
|
if (dn.children && dn.children.length > 0) {
|
|
154
|
-
node.children = dn.children.map(
|
|
149
|
+
node.children = dn.children.map(c => this.domainNodeToSection(c));
|
|
155
150
|
}
|
|
156
151
|
return node;
|
|
157
152
|
}
|
|
158
153
|
|
|
159
|
-
private
|
|
160
|
-
|
|
161
|
-
id: dn.id
|
|
154
|
+
private domainNodeToSection(dn: DomainNodeJson): SectionNode {
|
|
155
|
+
return toSectionNode({
|
|
156
|
+
id: dn.id,
|
|
162
157
|
names: dn.names || (dn.label ? { eng: dn.label } : {}),
|
|
163
|
-
conceptCount: dn.conceptCount
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
node.children = dn.children.map((c) => this.mapSectionNode(c));
|
|
167
|
-
}
|
|
168
|
-
return node;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
private mapManifestSection(s: SectionJson): SectionNode {
|
|
172
|
-
const node: SectionNode = { id: s.id, names: s.names || {}, conceptCount: 0 };
|
|
173
|
-
if (s.children && s.children.length > 0) {
|
|
174
|
-
node.children = s.children.map(c => this.mapManifestSection(c));
|
|
175
|
-
}
|
|
176
|
-
return node;
|
|
158
|
+
conceptCount: dn.conceptCount,
|
|
159
|
+
children: dn.children,
|
|
160
|
+
});
|
|
177
161
|
}
|
|
178
162
|
}
|
|
@@ -8,6 +8,8 @@ import { useSiteConfig } from '../config/use-site-config';
|
|
|
8
8
|
import NavIcon from './NavIcon.vue';
|
|
9
9
|
import { useI18n, locale } from '../i18n';
|
|
10
10
|
import type { SectionNode } from '../adapters/types';
|
|
11
|
+
import { toSectionTree } from '../utils/section-tree';
|
|
12
|
+
import { formatSectionLabel, sectionName as sectionLocalized } from '../utils/section-display';
|
|
11
13
|
|
|
12
14
|
const OntologySidebarSection = defineAsyncComponent(() => import('./OntologySidebarSection.vue'));
|
|
13
15
|
|
|
@@ -174,26 +176,15 @@ function toggleSectionNode(id: string) {
|
|
|
174
176
|
function getDatasetSections(dsId: string): SectionNode[] {
|
|
175
177
|
const m = store.manifests.get(dsId);
|
|
176
178
|
if (!m?.sections?.length) return [];
|
|
177
|
-
return m.sections
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function enrichSectionNode(s: { id: string; names: Record<string, string>; children?: any[] }): SectionNode {
|
|
181
|
-
const node: SectionNode = { id: s.id, names: s.names || {}, conceptCount: 0 };
|
|
182
|
-
if (s.children && s.children.length > 0) {
|
|
183
|
-
node.children = s.children.map(c => enrichSectionNode(c));
|
|
184
|
-
}
|
|
185
|
-
return node;
|
|
179
|
+
return toSectionTree(m.sections);
|
|
186
180
|
}
|
|
187
181
|
|
|
188
182
|
function sectionLabel(section: SectionNode): string {
|
|
189
|
-
|
|
190
|
-
return name || section.id;
|
|
183
|
+
return sectionLocalized(section, locale.value);
|
|
191
184
|
}
|
|
192
185
|
|
|
193
186
|
function sectionDisplay(section: SectionNode): string {
|
|
194
|
-
|
|
195
|
-
if (name && name !== section.id) return `${section.id} — ${name}`;
|
|
196
|
-
return name || section.id;
|
|
187
|
+
return formatSectionLabel(section, locale.value);
|
|
197
188
|
}
|
|
198
189
|
|
|
199
190
|
function goToSection(dsId: string, sectionId: string) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SectionNode } from '../adapters/types';
|
|
2
|
+
|
|
3
|
+
interface SectionLike {
|
|
4
|
+
id: string;
|
|
5
|
+
names?: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function sectionName(section: SectionLike, lang: string): string {
|
|
9
|
+
const names = section.names || {};
|
|
10
|
+
return names[lang] || names.eng || section.id;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatSectionLabel(section: SectionLike, lang: string): string {
|
|
14
|
+
const names = section.names || {};
|
|
15
|
+
const name = names[lang] || names.eng || '';
|
|
16
|
+
const bare = section.id;
|
|
17
|
+
if (!name) return bare;
|
|
18
|
+
if (name === bare) return name;
|
|
19
|
+
if (name === bare.replace(/_/g, ' ')) return name;
|
|
20
|
+
return `${bare} — ${name}`;
|
|
21
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { SectionNode } from '../adapters/types';
|
|
2
|
+
|
|
3
|
+
interface SectionLike {
|
|
4
|
+
id?: string;
|
|
5
|
+
names?: Record<string, string>;
|
|
6
|
+
conceptCount?: number;
|
|
7
|
+
children?: SectionLike[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function findSectionNode(
|
|
11
|
+
tree: readonly SectionNode[],
|
|
12
|
+
id: string,
|
|
13
|
+
): SectionNode | null {
|
|
14
|
+
for (const node of tree) {
|
|
15
|
+
if (node.id === id) return node;
|
|
16
|
+
if (node.children) {
|
|
17
|
+
const found = findSectionNode(node.children, id);
|
|
18
|
+
if (found) return found;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function collectDescendantSectionIds(
|
|
25
|
+
tree: readonly SectionNode[],
|
|
26
|
+
rootId: string,
|
|
27
|
+
): Set<string> {
|
|
28
|
+
const root = findSectionNode(tree, rootId);
|
|
29
|
+
if (!root) return new Set();
|
|
30
|
+
const ids = new Set<string>();
|
|
31
|
+
const walk = (n: SectionNode) => {
|
|
32
|
+
ids.add(n.id);
|
|
33
|
+
n.children?.forEach(walk);
|
|
34
|
+
};
|
|
35
|
+
walk(root);
|
|
36
|
+
return ids;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function toSectionNode(s: SectionLike): SectionNode {
|
|
40
|
+
const node: SectionNode = {
|
|
41
|
+
id: s.id ?? '',
|
|
42
|
+
names: s.names || {},
|
|
43
|
+
conceptCount: s.conceptCount ?? 0,
|
|
44
|
+
};
|
|
45
|
+
if (s.children && s.children.length > 0) {
|
|
46
|
+
node.children = s.children.map(toSectionNode);
|
|
47
|
+
}
|
|
48
|
+
return node;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function toSectionTree(items: readonly SectionLike[]): SectionNode[] {
|
|
52
|
+
return items.map(toSectionNode);
|
|
53
|
+
}
|
|
@@ -9,7 +9,9 @@ import { langName, langLabel, sortLanguages } from '../utils/lang';
|
|
|
9
9
|
import ConceptCard from '../components/ConceptCard.vue';
|
|
10
10
|
import { useI18n, locale } from '../i18n';
|
|
11
11
|
import { useSiteConfig } from '../config/use-site-config';
|
|
12
|
-
import type { SectionNode } from '../adapters/types';
|
|
12
|
+
import type { SectionNode, ConceptSummary } from '../adapters/types';
|
|
13
|
+
import { collectDescendantSectionIds, findSectionNode } from '../utils/section-tree';
|
|
14
|
+
import { formatSectionLabel } from '../utils/section-display';
|
|
13
15
|
|
|
14
16
|
const props = defineProps<{ registerId: string }>();
|
|
15
17
|
|
|
@@ -167,20 +169,34 @@ const filtered = computed(() => {
|
|
|
167
169
|
const q = filter.value.trim().toLowerCase();
|
|
168
170
|
const lang = selectedLang.value;
|
|
169
171
|
const sec = sectionQuery.value;
|
|
172
|
+
const closure = sectionClosure.value;
|
|
170
173
|
return loadedConcepts.value.filter(c => {
|
|
171
174
|
if (lang && !(lang in (c.designations ?? {}))) return false;
|
|
172
|
-
if (sec && !conceptMatchesSection(c, sec)) return false;
|
|
175
|
+
if (sec && !conceptMatchesSection(c, sec.replace(/^section-/, ''), closure)) return false;
|
|
173
176
|
if (!q) return true;
|
|
174
177
|
return (c.eng || '').toLowerCase().includes(q) || c.id.toLowerCase().includes(q);
|
|
175
178
|
});
|
|
176
179
|
});
|
|
177
180
|
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
181
|
+
const sectionClosure = computed<Set<string> | null>(() => {
|
|
182
|
+
const q = sectionQuery.value;
|
|
183
|
+
if (!q) return null;
|
|
184
|
+
const prefix = q.replace(/^section-/, '');
|
|
185
|
+
const tree = getSections();
|
|
186
|
+
const closure = collectDescendantSectionIds(tree, prefix);
|
|
187
|
+
return closure.size > 0 ? closure : null;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
function conceptMatchesSection(concept: ConceptSummary, sectionId: string, closure: Set<string> | null): boolean {
|
|
191
|
+
if (closure) {
|
|
192
|
+
if (concept.groups?.some(g => closure.has(g))) return true;
|
|
193
|
+
if (closure.has(sectionId)) {
|
|
194
|
+
return concept.id.startsWith(sectionId + '.') || concept.id.startsWith(sectionId + '-');
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
if (concept.groups?.length && concept.groups.includes(sectionId)) return true;
|
|
199
|
+
return concept.id.startsWith(sectionId + '.') || concept.id.startsWith(sectionId + '-');
|
|
184
200
|
}
|
|
185
201
|
|
|
186
202
|
function getSections(): SectionNode[] {
|
|
@@ -188,18 +204,12 @@ function getSections(): SectionNode[] {
|
|
|
188
204
|
return adapter.value.getSectionTree();
|
|
189
205
|
}
|
|
190
206
|
|
|
191
|
-
function sectionName(section: SectionNode): string {
|
|
192
|
-
return section.names[locale.value] || section.names.eng || section.id;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
207
|
const sectionDisplayName = computed(() => {
|
|
196
208
|
if (!sectionQuery.value) return '';
|
|
197
209
|
const prefix = sectionQuery.value.replace(/^section-/, '');
|
|
198
|
-
const
|
|
199
|
-
const found = sections.find(s => s.id === prefix);
|
|
210
|
+
const found = findSectionNode(getSections(), prefix);
|
|
200
211
|
if (!found) return prefix;
|
|
201
|
-
|
|
202
|
-
return name !== found.id ? `${found.id} — ${name}` : name;
|
|
212
|
+
return formatSectionLabel(found, locale.value);
|
|
203
213
|
});
|
|
204
214
|
|
|
205
215
|
// Alphabetical grouping
|