@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.41",
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.map(s => this.mapManifestSection(s));
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((c) => this.mapSectionNode(c));
149
+ node.children = dn.children.map(c => this.domainNodeToSection(c));
155
150
  }
156
151
  return node;
157
152
  }
158
153
 
159
- private mapSectionNode(dn: DomainNodeJson): SectionNode {
160
- const node: SectionNode = {
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 || 0,
164
- };
165
- if (dn.children && dn.children.length > 0) {
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.map(s => enrichSectionNode(s));
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
- const name = section.names[locale.value] || section.names.eng || '';
190
- return name || section.id;
183
+ return sectionLocalized(section, locale.value);
191
184
  }
192
185
 
193
186
  function sectionDisplay(section: SectionNode): string {
194
- const name = sectionLabel(section);
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 && !allChunksLoaded.value && adapter.value) {
128
- chunkLoading.value = true;
129
- await adapter.value.ensureAllChunksLoaded();
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
- function conceptMatchesSection(concept: import('../adapters/types').ConceptSummary, sectionPrefix: string): boolean {
167
- const prefix = sectionPrefix.replace(/^section-/, '');
168
- // Check explicit groups (e.g. G18 sections derived from domains)
169
- if (concept.groups?.length && concept.groups.includes(prefix)) return true;
170
- // Check concept ID prefix matching (e.g. VIML/VIM numbered sections)
171
- return concept.id.startsWith(prefix + '.') || concept.id.startsWith(prefix + '-');
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 sections = getSections();
187
- const found = sections.find(s => s.id === prefix);
210
+ const found = findSectionNode(getSections(), prefix);
188
211
  if (!found) return prefix;
189
- const name = sectionName(found);
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