@glossarist/concept-browser 0.7.41 → 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 +44 -23
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
|
|
|
@@ -30,10 +32,17 @@ const chunkLoading = ref(false);
|
|
|
30
32
|
// Background chunk preloading via requestIdleCallback
|
|
31
33
|
let idlePreloadHandle: ReturnType<typeof requestIdleCallback> | ReturnType<typeof setTimeout> | null = null;
|
|
32
34
|
|
|
33
|
-
watch(adapter, (a) => {
|
|
34
|
-
if (idlePreloadHandle !== null) return;
|
|
35
|
+
watch(adapter, async (a) => {
|
|
35
36
|
if (!a || !a.index) return;
|
|
36
37
|
|
|
38
|
+
// If a section filter is active, load all chunks immediately (not idle)
|
|
39
|
+
if (sectionQuery.value && !allChunksLoaded.value) {
|
|
40
|
+
await ensureAllChunksForFilter(true);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (idlePreloadHandle !== null) return;
|
|
45
|
+
|
|
37
46
|
const schedule: (cb: () => void) => number = typeof requestIdleCallback !== 'undefined'
|
|
38
47
|
? (cb) => requestIdleCallback(cb, { timeout: 2000 })
|
|
39
48
|
: (cb) => window.setTimeout(cb, 0);
|
|
@@ -87,6 +96,7 @@ const allChunksLoaded = ref(false);
|
|
|
87
96
|
const selectedLang = ref<string | null>(null);
|
|
88
97
|
const viewMode = ref<'systematic' | 'alphabetical'>('systematic');
|
|
89
98
|
const sectionQuery = computed(() => (route.query.section as string) || null);
|
|
99
|
+
const page = ref(1);
|
|
90
100
|
|
|
91
101
|
interface LangOption {
|
|
92
102
|
code: string;
|
|
@@ -124,10 +134,14 @@ onUnmounted(() => window.removeEventListener('keydown', onGlobalKeydown));
|
|
|
124
134
|
|
|
125
135
|
async function ensureAllChunksForFilter(needsLoad: boolean) {
|
|
126
136
|
page.value = 1;
|
|
127
|
-
if (needsLoad
|
|
128
|
-
|
|
129
|
-
|
|
137
|
+
if (!needsLoad || allChunksLoaded.value) return;
|
|
138
|
+
const a = adapter.value;
|
|
139
|
+
if (!a?.index) return;
|
|
140
|
+
chunkLoading.value = true;
|
|
141
|
+
try {
|
|
142
|
+
await a.ensureAllChunksLoaded();
|
|
130
143
|
allChunksLoaded.value = true;
|
|
144
|
+
} finally {
|
|
131
145
|
chunkLoading.value = false;
|
|
132
146
|
}
|
|
133
147
|
}
|
|
@@ -138,7 +152,7 @@ watch(filter, async (q) => {
|
|
|
138
152
|
|
|
139
153
|
watch(sectionQuery, async () => {
|
|
140
154
|
await ensureAllChunksForFilter(!!sectionQuery.value);
|
|
141
|
-
});
|
|
155
|
+
}, { immediate: true });
|
|
142
156
|
|
|
143
157
|
watch(selectedLang, async (lang) => {
|
|
144
158
|
await ensureAllChunksForFilter(!!lang);
|
|
@@ -155,20 +169,34 @@ const filtered = computed(() => {
|
|
|
155
169
|
const q = filter.value.trim().toLowerCase();
|
|
156
170
|
const lang = selectedLang.value;
|
|
157
171
|
const sec = sectionQuery.value;
|
|
172
|
+
const closure = sectionClosure.value;
|
|
158
173
|
return loadedConcepts.value.filter(c => {
|
|
159
174
|
if (lang && !(lang in (c.designations ?? {}))) return false;
|
|
160
|
-
if (sec && !conceptMatchesSection(c, sec)) return false;
|
|
175
|
+
if (sec && !conceptMatchesSection(c, sec.replace(/^section-/, ''), closure)) return false;
|
|
161
176
|
if (!q) return true;
|
|
162
177
|
return (c.eng || '').toLowerCase().includes(q) || c.id.toLowerCase().includes(q);
|
|
163
178
|
});
|
|
164
179
|
});
|
|
165
180
|
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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 + '-');
|
|
172
200
|
}
|
|
173
201
|
|
|
174
202
|
function getSections(): SectionNode[] {
|
|
@@ -176,18 +204,12 @@ function getSections(): SectionNode[] {
|
|
|
176
204
|
return adapter.value.getSectionTree();
|
|
177
205
|
}
|
|
178
206
|
|
|
179
|
-
function sectionName(section: SectionNode): string {
|
|
180
|
-
return section.names[locale.value] || section.names.eng || section.id;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
207
|
const sectionDisplayName = computed(() => {
|
|
184
208
|
if (!sectionQuery.value) return '';
|
|
185
209
|
const prefix = sectionQuery.value.replace(/^section-/, '');
|
|
186
|
-
const
|
|
187
|
-
const found = sections.find(s => s.id === prefix);
|
|
210
|
+
const found = findSectionNode(getSections(), prefix);
|
|
188
211
|
if (!found) return prefix;
|
|
189
|
-
|
|
190
|
-
return name !== found.id ? `${found.id} — ${name}` : name;
|
|
212
|
+
return formatSectionLabel(found, locale.value);
|
|
191
213
|
});
|
|
192
214
|
|
|
193
215
|
// Alphabetical grouping
|
|
@@ -202,7 +224,6 @@ const alphabetGroups = computed(() => {
|
|
|
202
224
|
return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
203
225
|
});
|
|
204
226
|
|
|
205
|
-
const page = ref(1);
|
|
206
227
|
const perPage = 50;
|
|
207
228
|
|
|
208
229
|
// Check if the current page range is loaded in the index
|