@glossarist/concept-browser 0.7.16 → 0.7.20

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.16",
3
+ "version": "0.7.20",
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": {
@@ -29,6 +29,7 @@
29
29
  "js-yaml": "^4.1.0",
30
30
  "pinia": "^2.3.1",
31
31
  "postcss": "^8.5.3",
32
+ "sharp": "^0.34.5",
32
33
  "tailwindcss": "^3.4.17",
33
34
  "vite": "^6.3.5",
34
35
  "vue": "^3.5.13",
@@ -8,6 +8,8 @@
8
8
  import { readFileSync, writeFileSync, readdirSync, existsSync } from 'fs';
9
9
  import { join, dirname } from 'path';
10
10
  import { fileURLToPath } from 'url';
11
+ import yaml from 'js-yaml';
12
+ import { Register } from 'glossarist';
11
13
 
12
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
15
  const ROOT = process.cwd();
@@ -47,9 +49,35 @@ function extractDomains(concept, registerId, uriBase) {
47
49
  const base = uriBase || 'https://glossarist.org';
48
50
  const edges = [];
49
51
  const sourceUri = concept['@id'];
52
+ const seen = new Set();
53
+
54
+ // v3 managed concept-level section/domain references
55
+ const domains = concept['gl:domain'];
56
+ if (Array.isArray(domains)) {
57
+ for (const d of domains) {
58
+ const conceptId = d['gl:conceptId'] || d.concept_id;
59
+ if (conceptId) {
60
+ const refType = d['gl:refType'] || d.ref_type || 'domain';
61
+ const isSection = refType === 'section';
62
+ const edgeType = isSection ? 'section' : 'domain';
63
+ const domainUri = `${base}/${registerId}/domain/${conceptId}`;
64
+ if (!seen.has(domainUri)) {
65
+ seen.add(domainUri);
66
+ edges.push({
67
+ source: sourceUri,
68
+ target: domainUri,
69
+ type: edgeType,
70
+ label: conceptId,
71
+ register: registerId,
72
+ });
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ // Legacy: localized domain strings
50
79
  const lcs = concept['gl:localizedConcept'] || {};
51
80
  const langs = Object.keys(lcs);
52
- const seen = new Set();
53
81
  for (const lang of langs) {
54
82
  const domain = lcs[lang]['gl:domain'];
55
83
  if (domain && !seen.has(domain)) {
@@ -97,7 +125,7 @@ function extractAllEdges(concept, registerId, uriBase, urnMap) {
97
125
 
98
126
  // --- Build ---
99
127
 
100
- function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap) {
128
+ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest) {
101
129
  const conceptsDir = join(datasetDir, 'concepts');
102
130
  if (!existsSync(conceptsDir)) {
103
131
  console.log(` Skipping ${registerId}: no concepts directory`);
@@ -118,7 +146,7 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap) {
118
146
  allEdges.push(...edges);
119
147
 
120
148
  for (const edge of edges) {
121
- if (edge.type === 'domain') {
149
+ if (edge.type === 'domain' || edge.type === 'section') {
122
150
  domainConceptCount.set(edge.target, (domainConceptCount.get(edge.target) || 0) + 1);
123
151
  }
124
152
  }
@@ -152,30 +180,66 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap) {
152
180
  writeFileSync(outputPath, JSON.stringify(output, null, 2));
153
181
  console.log(` Written ${deduped.length} edges to edges.json (${(JSON.stringify(output).length / 1024).toFixed(1)} KB)`);
154
182
 
155
- // Build domain-nodes.json
156
- const domainEdgeMap = new Map();
157
- for (const edge of deduped) {
158
- if (edge.type === 'domain') {
159
- const existing = domainEdgeMap.get(edge.target);
160
- if (existing) {
161
- existing.labels.add(edge.label);
162
- } else {
163
- domainEdgeMap.set(edge.target, { uri: edge.target, labels: new Set([edge.label]), registerId });
183
+ // Build domain-nodes.json from manifest sections (authoritative source)
184
+ const manifestSections = manifest.sections;
185
+ if (manifestSections && manifestSections.length > 0) {
186
+ const uriBase = manifest.uriBase || 'https://glossarist.org';
187
+
188
+ function buildSectionNode(section, idx) {
189
+ const sectionId = `section-${section.id}`;
190
+ const domainUri = `${uriBase}/${registerId}/domain/${sectionId}`;
191
+ const domainLabel = section.names?.eng || section.id;
192
+ const node = {
193
+ uri: domainUri,
194
+ id: sectionId,
195
+ names: section.names || {},
196
+ label: domainLabel,
197
+ registerId,
198
+ conceptCount: domainConceptCount.get(domainUri) || 0,
199
+ order: idx,
200
+ };
201
+ if (section.children && section.children.length > 0) {
202
+ node.children = section.children.map((child, childIdx) =>
203
+ buildSectionNode(child, childIdx)
204
+ );
164
205
  }
206
+ return node;
165
207
  }
166
- }
167
208
 
168
- const domainNodes = [...domainEdgeMap.values()].map(d => ({
169
- uri: d.uri,
170
- label: [...d.labels][0],
171
- registerId: d.registerId,
172
- conceptCount: domainConceptCount.get(d.uri) || 0,
173
- })).sort((a, b) => b.conceptCount - a.conceptCount);
174
-
175
- const domainOutput = { registerId, domainNodes };
176
- const domainPath = join(datasetDir, 'domain-nodes.json');
177
- writeFileSync(domainPath, JSON.stringify(domainOutput, null, 2));
178
- console.log(` Written ${domainNodes.length} domain nodes to domain-nodes.json`);
209
+ const domainNodes = manifestSections.map((section, idx) =>
210
+ buildSectionNode(section, idx)
211
+ );
212
+
213
+ const domainOutput = { registerId, domainNodes };
214
+ const domainPath = join(datasetDir, 'domain-nodes.json');
215
+ writeFileSync(domainPath, JSON.stringify(domainOutput, null, 2));
216
+ console.log(` Written ${domainNodes.length} section-based domain nodes to domain-nodes.json`);
217
+ } else {
218
+ // Fallback: derive domain nodes from concept edges (legacy behavior)
219
+ const domainEdgeMap = new Map();
220
+ for (const edge of deduped) {
221
+ if (edge.type === 'domain') {
222
+ const existing = domainEdgeMap.get(edge.target);
223
+ if (existing) {
224
+ existing.labels.add(edge.label);
225
+ } else {
226
+ domainEdgeMap.set(edge.target, { uri: edge.target, labels: new Set([edge.label]), registerId });
227
+ }
228
+ }
229
+ }
230
+
231
+ const domainNodes = [...domainEdgeMap.values()].map(d => ({
232
+ uri: d.uri,
233
+ label: [...d.labels][0],
234
+ registerId: d.registerId,
235
+ conceptCount: domainConceptCount.get(d.uri) || 0,
236
+ })).sort((a, b) => b.conceptCount - a.conceptCount);
237
+
238
+ const domainOutput = { registerId, domainNodes };
239
+ const domainPath = join(datasetDir, 'domain-nodes.json');
240
+ writeFileSync(domainPath, JSON.stringify(domainOutput, null, 2));
241
+ console.log(` Written ${domainNodes.length} edge-derived domain nodes to domain-nodes.json`);
242
+ }
179
243
  }
180
244
 
181
245
  // Main
@@ -217,7 +281,7 @@ for (const ds of datasets) {
217
281
  try {
218
282
  console.log(`${manifest.title} (${ds}):`);
219
283
  const uriBase = manifest.uriBase || 'https://glossarist.org';
220
- buildEdgesForDataset(join(DATA_DIR, ds), ds, uriBase, urnMap);
284
+ buildEdgesForDataset(join(DATA_DIR, ds), ds, uriBase, urnMap, manifest);
221
285
  } catch (e) {
222
286
  console.error(`Error reading manifest for ${ds}: ${e.message}`);
223
287
  }
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import yaml from 'js-yaml';
4
- import { naturalSort } from 'glossarist';
4
+ import { naturalSort, Register } from 'glossarist';
5
5
  import { loadSiteConfig } from './load-site-config.mjs';
6
6
 
7
7
  const __dirname = path.dirname(new URL(import.meta.url).pathname);
@@ -774,6 +774,9 @@ function processDataset(dir, register, opts) {
774
774
  if (opts.languageOrder) manifest.languageOrder = opts.languageOrder;
775
775
  if (opts.ref) manifest.ref = opts.ref;
776
776
  if (opts.refAliases) manifest.refAliases = opts.refAliases;
777
+ if (opts.status) manifest.editionStatus = opts.status;
778
+ if (opts.ordering) manifest.ordering = opts.ordering;
779
+ if (opts.sections && opts.sections.length > 0) manifest.sections = opts.sections;
777
780
  writeJson(path.join(DATA, register, 'manifest.json'), manifest);
778
781
 
779
782
  // Copy bibliography.yaml → bibliography.json
@@ -811,6 +814,21 @@ const { config } = loadSiteConfig();
811
814
  const refMaps = buildRefMaps(config);
812
815
  const counts = {};
813
816
  const registry = [];
817
+ const registerCache = {};
818
+
819
+ // Pre-load all register.yaml files
820
+ for (const ds of config.datasets) {
821
+ const registerDir = path.join(ROOT, '.datasets', ds.id);
822
+ const registerYamlPath = path.join(registerDir, 'register.yaml');
823
+ if (fs.existsSync(registerYamlPath)) {
824
+ try {
825
+ const raw = yaml.load(fs.readFileSync(registerYamlPath, 'utf8'));
826
+ registerCache[ds.id] = Register.fromJSON(raw);
827
+ } catch (e) {
828
+ console.warn(` Warning: failed to parse register.yaml for ${ds.id}: ${e.message}`);
829
+ }
830
+ }
831
+ }
814
832
 
815
833
  for (let i = 0; i < config.datasets.length; i++) {
816
834
  const ds = config.datasets[i];
@@ -822,28 +840,41 @@ for (let i = 0; i < config.datasets.length; i++) {
822
840
  continue;
823
841
  }
824
842
 
825
- const registerDir = path.join(ROOT, '.datasets', ds.id);
826
- const registerYamlPath = path.join(registerDir, 'register.yaml');
827
- let registerMeta = {};
828
- if (fs.existsSync(registerYamlPath)) {
829
- try { registerMeta = readYaml(registerYamlPath) || {}; } catch {}
830
- }
843
+ // Use cached register
844
+ const reg = registerCache[ds.id] || null;
831
845
 
832
- const dsLanguages = ds.languages || (registerMeta.subregisters ? Object.keys(registerMeta.subregisters) : ['eng']);
846
+ // Resolve languages: register.yaml first, then site-config fallback
847
+ const dsLanguages = (reg?.languages?.length ? reg.languages : null)
848
+ || ds.languages
849
+ || ['eng'];
850
+
851
+ // Resolve description: register.yaml first, then site-config
852
+ const defaultLang = dsLanguages[0] || 'eng';
853
+ const regDesc = reg?.description;
854
+ const dsDesc = ds.description;
855
+ const resolvedDescription = (typeof regDesc === 'object' && Object.keys(regDesc).length > 0)
856
+ ? regDesc[defaultLang] || Object.values(regDesc)[0] || ''
857
+ : dsDesc || '';
858
+
859
+ // Resolve title: site-config override, then ref from register
860
+ const resolvedTitle = ds.title || reg?.ref || ds.id;
833
861
 
834
862
  counts[ds.id] = processDataset(dir, ds.id, {
835
- title: ds.title || registerMeta.name || ds.id,
836
- description: ds.description || registerMeta.description || '',
837
- owner: ds.owner,
863
+ title: resolvedTitle,
864
+ description: resolvedDescription,
865
+ owner: ds.owner || reg?.owner,
838
866
  languages: dsLanguages,
839
- sourceRepo: ds.sourceRepo,
840
- languageOrder: ds.languageOrder,
841
- ref: ds.ref,
842
- refAliases: ds.refAliases,
843
- tags: ds.tags,
867
+ sourceRepo: ds.sourceRepo || reg?.sourceRepo,
868
+ languageOrder: ds.languageOrder || reg?.languageOrder,
869
+ ref: ds.ref || reg?.ref,
870
+ refAliases: ds.refAliases || reg?.refAliases,
871
+ tags: ds.tags || reg?.tags,
844
872
  color: ds.color || DS_PALETTE[i % DS_PALETTE.length],
845
- datasetUri: ds.uri,
846
- uriAliases: ds.uriAliases,
873
+ datasetUri: ds.uri || reg?.urn,
874
+ uriAliases: ds.uriAliases || reg?.urnAliases,
875
+ status: ds.editionStatus || reg?.status,
876
+ ordering: reg?.ordering || null,
877
+ sections: reg?.sections ? reg.sections.map(s => s.toJSON()) : [],
847
878
  hasBibliography: fs.existsSync(path.join(ROOT, '.datasets', ds.id, 'bibliography.yaml')),
848
879
  hasImages: fs.existsSync(path.join(ROOT, '.datasets', ds.id, 'images')),
849
880
  });
@@ -1126,21 +1157,32 @@ function processPages(config) {
1126
1157
 
1127
1158
  const processedPages = processPages(config);
1128
1159
 
1129
- // Auto-generate dataset about pages from {localPath}/about.md
1160
+ // Auto-generate dataset about pages from {localPath}/about-{lang}.md
1130
1161
  const _pagesDir = path.join(PUBLIC, 'pages');
1131
1162
  for (const ds of config.datasets || []) {
1132
1163
  if (!ds.localPath) continue;
1133
- const aboutSrc = path.resolve(ROOT, ds.localPath, 'about.md');
1134
- if (!fs.existsSync(aboutSrc)) continue;
1135
- const raw = fs.readFileSync(aboutSrc, 'utf8');
1136
- const html = renderMarkdown(stripFrontmatter(raw));
1164
+ const dsDir = path.resolve(ROOT, ds.localPath);
1165
+ const defaultLang = (ds.languages || ['eng'])[0];
1137
1166
  const route = `${ds.id}-about`;
1138
- writeJson(path.join(_pagesDir, `${route}.json`), { title: 'About', html });
1139
- console.log(` Auto-generated dataset about page: ${route}`);
1140
- const uiLangs = (config.uiLanguages || []).map(l => l.code).filter(l => l !== 'eng');
1141
1167
  const dsTranslations = ds.translations || {};
1168
+
1169
+ // Default-language about page: try about-{defaultLang}.md, fall back to about.md
1170
+ const defaultSrc = [
1171
+ path.join(dsDir, `about-${defaultLang}.md`),
1172
+ path.join(dsDir, 'about.md'),
1173
+ ].find(p => fs.existsSync(p));
1174
+
1175
+ if (defaultSrc) {
1176
+ const raw = fs.readFileSync(defaultSrc, 'utf8');
1177
+ const html = renderMarkdown(stripFrontmatter(raw));
1178
+ writeJson(path.join(_pagesDir, `${route}.json`), { title: 'About', html });
1179
+ console.log(` Auto-generated dataset about page: ${route} (from ${path.basename(defaultSrc)})`);
1180
+ }
1181
+
1182
+ // Translated about pages for all non-default UI languages
1183
+ const uiLangs = (config.uiLanguages || []).map(l => l.code).filter(l => l !== defaultLang);
1142
1184
  for (const lang of uiLangs) {
1143
- const trAboutSrc = path.resolve(ROOT, ds.localPath, `about-${lang}.md`);
1185
+ const trAboutSrc = path.join(dsDir, `about-${lang}.md`);
1144
1186
  if (!fs.existsSync(trAboutSrc)) continue;
1145
1187
  const trRaw = fs.readFileSync(trAboutSrc, 'utf8');
1146
1188
  const trHtml = renderMarkdown(stripFrontmatter(trRaw));
@@ -1168,10 +1210,32 @@ for (const key of ['logo', 'footerLogo']) {
1168
1210
  }
1169
1211
  }
1170
1212
 
1171
- // Build dataset translations map
1213
+ // Build dataset translations map: merge site-config overrides with register.yaml descriptions
1172
1214
  const datasetTranslations = {};
1173
1215
  for (const d of config.datasets) {
1174
- if (d.translations) datasetTranslations[d.id] = d.translations;
1216
+ const reg = registerCache[d.id];
1217
+ const translations = { ...d.translations };
1218
+
1219
+ // Merge register.yaml descriptions as translations
1220
+ if (reg?.description && typeof reg.description === 'object') {
1221
+ for (const [lang, desc] of Object.entries(reg.description)) {
1222
+ if (!translations[lang]) translations[lang] = {};
1223
+ if (!translations[lang].description) translations[lang].description = desc;
1224
+ }
1225
+ }
1226
+
1227
+ // Merge register.yaml ref as translated title if not already set
1228
+ if (reg?.ref) {
1229
+ const langs = reg.languages || [];
1230
+ for (const lang of langs) {
1231
+ if (!translations[lang]) translations[lang] = {};
1232
+ if (!translations[lang].title) translations[lang].title = reg.ref;
1233
+ }
1234
+ }
1235
+
1236
+ if (Object.keys(translations).length > 0) {
1237
+ datasetTranslations[d.id] = translations;
1238
+ }
1175
1239
  }
1176
1240
 
1177
1241
  writeJson(path.join(PUBLIC, 'site-config.json'), {
@@ -6,6 +6,7 @@ import type {
6
6
  SearchHit,
7
7
  GraphEdge,
8
8
  GraphNode,
9
+ SectionNode,
9
10
  } from './types';
10
11
  import type { Concept, LocalizedConcept, Designation } from 'glossarist';
11
12
  import { conceptFromJson, conceptUri } from './model-bridge';
@@ -370,15 +371,50 @@ export class DatasetAdapter {
370
371
  const resp = await fetch(`${this.baseUrl}/domain-nodes.json`);
371
372
  if (!resp.ok) return [];
372
373
  const data = await resp.json();
373
- return (data.domainNodes || []).map((dn: any) => ({
374
+ return (data.domainNodes || []).map((dn: any) => this.mapDomainNode(dn));
375
+ }
376
+
377
+ private mapDomainNode(dn: any): GraphNode {
378
+ const node: GraphNode = {
374
379
  uri: dn.uri,
375
380
  register: dn.registerId,
376
- conceptId: dn.uri.split('/domain/')[1] || '',
377
- designations: { eng: dn.label },
381
+ conceptId: dn.uri?.split('/domain/')[1] || dn.id || '',
382
+ designations: dn.names || { eng: dn.label },
378
383
  status: 'domain',
379
384
  loaded: true,
380
385
  nodeType: 'domain' as const,
381
- }));
386
+ conceptCount: dn.conceptCount || 0,
387
+ };
388
+ if (dn.children && dn.children.length > 0) {
389
+ node.children = dn.children.map((c: any) => this.mapSectionNode(c));
390
+ }
391
+ return node;
392
+ }
393
+
394
+ private mapSectionNode(dn: any): SectionNode {
395
+ const node: SectionNode = {
396
+ id: dn.id,
397
+ names: dn.names || { eng: dn.label },
398
+ conceptCount: dn.conceptCount || 0,
399
+ };
400
+ if (dn.children && dn.children.length > 0) {
401
+ node.children = dn.children.map((c: any) => this.mapSectionNode(c));
402
+ }
403
+ return node;
404
+ }
405
+
406
+ getSectionTree(): SectionNode[] {
407
+ const nodes = this.manifest?.sections;
408
+ if (!nodes || nodes.length === 0) return [];
409
+ return nodes.map(s => this.mapManifestSection(s));
410
+ }
411
+
412
+ private mapManifestSection(s: { id: string; names: Record<string, string>; children?: any[] }): SectionNode {
413
+ const node: SectionNode = { id: s.id, names: s.names || {}, conceptCount: 0 };
414
+ if (s.children && s.children.length > 0) {
415
+ node.children = s.children.map(c => this.mapManifestSection(c));
416
+ }
417
+ return node;
382
418
  }
383
419
 
384
420
  async loadEdgeIndex(): Promise<GraphEdge[]> {
@@ -34,6 +34,13 @@ export { GRAMMAR_GENDERS, GRAMMAR_NUMBERS, GRAMMAR_PARTS_OF_SPEECH } from 'gloss
34
34
 
35
35
  // ── Dataset metadata ──────────────────────────────────────────────────────
36
36
 
37
+ export interface ManifestSection {
38
+ id: string;
39
+ names: Record<string, string>;
40
+ ordering?: string;
41
+ children?: ManifestSection[];
42
+ }
43
+
37
44
  export interface Manifest {
38
45
  id: string;
39
46
  datasetUri: string;
@@ -59,6 +66,9 @@ export interface Manifest {
59
66
  languageOrder?: string[];
60
67
  ref?: string;
61
68
  refAliases?: string[];
69
+ editionStatus?: string;
70
+ ordering?: string;
71
+ sections?: ManifestSection[];
62
72
  languageStats?: Record<string, { terms: number; definitions: number }>;
63
73
  availableFormats?: string[];
64
74
  bulkFormats?: { file: string; format: string; size: number }[];
@@ -98,6 +108,7 @@ export interface DatasetRegistry {
98
108
  export const EDGE_TYPE = {
99
109
  REFERENCES: 'references',
100
110
  DOMAIN: 'domain',
111
+ SECTION: 'section',
101
112
  } as const;
102
113
 
103
114
  export interface GraphEdge {
@@ -117,6 +128,15 @@ export interface GraphNode {
117
128
  status: string;
118
129
  loaded: boolean;
119
130
  nodeType?: 'concept' | 'domain';
131
+ conceptCount?: number;
132
+ children?: SectionNode[];
133
+ }
134
+
135
+ export interface SectionNode {
136
+ id: string;
137
+ names: Record<string, string>;
138
+ conceptCount: number;
139
+ children?: SectionNode[];
120
140
  }
121
141
 
122
142
  // ── Search ─────────────────────────────────────────────────────────────────
@@ -9,6 +9,7 @@ import type { DatasetGroup } from '../config/types';
9
9
  import { useOntologyNav, compactToSlug } from '../composables/use-ontology-nav';
10
10
  import NavIcon from './NavIcon.vue';
11
11
  import { useI18n, locale } from '../i18n';
12
+ import type { SectionNode } from '../adapters/types';
12
13
 
13
14
  const store = useVocabularyStore();
14
15
  const ui = useUiStore();
@@ -223,6 +224,47 @@ function navTitle(page: { route: string }): string {
223
224
  const translated = t(key);
224
225
  return translated === key ? (page as any).title : translated;
225
226
  }
227
+
228
+ const expandedSectionNodes = ref<Set<string>>(new Set());
229
+
230
+ function toggleSectionNode(id: string) {
231
+ const s = new Set(expandedSectionNodes.value);
232
+ if (s.has(id)) s.delete(id);
233
+ else s.add(id);
234
+ expandedSectionNodes.value = s;
235
+ }
236
+
237
+ function getDatasetSections(dsId: string): SectionNode[] {
238
+ const m = store.manifests.get(dsId);
239
+ if (!m?.sections?.length) return [];
240
+ return m.sections.map(s => enrichSectionNode(s));
241
+ }
242
+
243
+ function enrichSectionNode(s: { id: string; names: Record<string, string>; children?: any[] }): SectionNode {
244
+ const node: SectionNode = { id: s.id, names: s.names || {}, conceptCount: 0 };
245
+ if (s.children && s.children.length > 0) {
246
+ node.children = s.children.map(c => enrichSectionNode(c));
247
+ }
248
+ return node;
249
+ }
250
+
251
+ function sectionLabel(section: SectionNode): string {
252
+ return section.names[locale.value] || section.names.eng || section.id;
253
+ }
254
+
255
+ function goToSection(dsId: string, sectionId: string) {
256
+ router.push({ name: 'dataset', params: { registerId: dsId }, query: { section: sectionId } });
257
+ closeMobile();
258
+ }
259
+
260
+ function clearSectionFilter() {
261
+ router.push({ name: 'dataset', params: { registerId: currentDataset.value } });
262
+ closeMobile();
263
+ }
264
+
265
+ const activeSectionId = computed(() => {
266
+ return (route.query.section as string) || null;
267
+ });
226
268
  </script>
227
269
 
228
270
  <template>
@@ -583,6 +625,46 @@ function navTitle(page: { route: string }): string {
583
625
  </router-link>
584
626
  </nav>
585
627
 
628
+ <!-- Sections tree -->
629
+ <div v-if="getDatasetSections(ds.id).length" class="mt-2 pt-2 border-t border-ink-100/60">
630
+ <button @click="toggleSectionNode(ds.id + '-sections')"
631
+ class="w-full flex items-center gap-1.5 px-2 py-1 rounded-lg text-[10px] uppercase tracking-wide text-ink-400 hover:text-ink-600 hover:bg-ink-50 transition-colors"
632
+ >
633
+ <span class="w-3 text-[10px]">{{ expandedSectionNodes.has(ds.id + '-sections') ? '▾' : '▸' }}</span>
634
+ <span class="flex-1 text-left">{{ t('nav.sections') }}</span>
635
+ <span class="badge text-[9px] bg-amber-50 text-amber-600 px-1 py-0.5">{{ getDatasetSections(ds.id).length }}</span>
636
+ </button>
637
+ <div v-if="expandedSectionNodes.has(ds.id + '-sections')" class="mt-0.5 max-h-64 overflow-y-auto">
638
+ <button
639
+ @click="clearSectionFilter()"
640
+ class="w-full flex items-center gap-1.5 px-2 py-1 rounded-lg text-[11px] transition-colors"
641
+ :class="!activeSectionId ? 'bg-ink-800/8 text-blue-700 font-medium' : 'text-ink-500 hover:bg-ink-50'"
642
+ >
643
+ <span class="w-3 text-ink-200">&#183;</span>
644
+ <span class="flex-1 text-left">{{ t('dataset.all') }}</span>
645
+ </button>
646
+ <template v-for="section in getDatasetSections(ds.id)" :key="section.id">
647
+ <button @click="goToSection(ds.id, 'section-' + section.id)"
648
+ class="w-full flex items-center gap-1.5 px-2 py-1 rounded-lg text-[11px] transition-colors"
649
+ :class="activeSectionId === 'section-' + section.id ? 'bg-ink-800/8 text-blue-700 font-medium' : 'text-ink-500 hover:bg-ink-50'"
650
+ >
651
+ <span v-if="section.children?.length" class="text-[10px] text-ink-300 w-3 cursor-pointer" @click.stop="toggleSectionNode(ds.id + '-s-' + section.id)">{{ expandedSectionNodes.has(ds.id + '-s-' + section.id) ? '▾' : '▸' }}</span>
652
+ <span v-else class="w-3 text-ink-200">&#183;</span>
653
+ <span class="flex-1 text-left">{{ sectionLabel(section) }}</span>
654
+ </button>
655
+ <div v-if="section.children?.length && expandedSectionNodes.has(ds.id + '-s-' + section.id)" class="ml-3">
656
+ <button v-for="child in section.children" :key="child.id"
657
+ @click="goToSection(ds.id, 'section-' + child.id)"
658
+ class="w-full flex items-center gap-1.5 px-2 py-1 rounded-lg text-[11px] transition-colors"
659
+ :class="activeSectionId === 'section-' + child.id ? 'bg-ink-800/8 text-blue-700 font-medium' : 'text-ink-400 hover:bg-ink-50'"
660
+ >
661
+ <span class="w-3 text-ink-200">&#183;</span>
662
+ <span class="flex-1 text-left">{{ sectionLabel(child) }}</span>
663
+ </button>
664
+ </div>
665
+ </template>
666
+ </div>
667
+ </div>
586
668
  <div v-if="provenance.owner" class="mt-3 pt-3 border-t border-ink-100/60">
587
669
  <div class="text-[11px] text-ink-300 space-y-1.5 px-1">
588
670
  <div v-if="provenance.ref" class="text-xs font-semibold text-ink-700">
@@ -645,6 +727,47 @@ function navTitle(page: { route: string }): string {
645
727
  </router-link>
646
728
  </nav>
647
729
 
730
+ <!-- Sections tree -->
731
+ <div v-if="getDatasetSections(ds.id).length" class="mt-2 pt-2 border-t border-ink-100/60">
732
+ <button @click="toggleSectionNode(ds.id + '-sections')"
733
+ class="w-full flex items-center gap-1.5 px-2 py-1 rounded-lg text-[10px] uppercase tracking-wide text-ink-400 hover:text-ink-600 hover:bg-ink-50 transition-colors"
734
+ >
735
+ <span class="w-3 text-[10px]">{{ expandedSectionNodes.has(ds.id + '-sections') ? '▾' : '▸' }}</span>
736
+ <span class="flex-1 text-left">{{ t('nav.sections') }}</span>
737
+ <span class="badge text-[9px] bg-amber-50 text-amber-600 px-1 py-0.5">{{ getDatasetSections(ds.id).length }}</span>
738
+ </button>
739
+ <div v-if="expandedSectionNodes.has(ds.id + '-sections')" class="mt-0.5 max-h-64 overflow-y-auto">
740
+ <button
741
+ @click="clearSectionFilter()"
742
+ class="w-full flex items-center gap-1.5 px-2 py-1 rounded-lg text-[11px] transition-colors"
743
+ :class="!activeSectionId ? 'bg-ink-800/8 text-blue-700 font-medium' : 'text-ink-500 hover:bg-ink-50'"
744
+ >
745
+ <span class="w-3 text-ink-200">&#183;</span>
746
+ <span class="flex-1 text-left">{{ t('dataset.all') }}</span>
747
+ </button>
748
+ <template v-for="section in getDatasetSections(ds.id)" :key="section.id">
749
+ <button @click="goToSection(ds.id, 'section-' + section.id)"
750
+ class="w-full flex items-center gap-1.5 px-2 py-1 rounded-lg text-[11px] transition-colors"
751
+ :class="activeSectionId === 'section-' + section.id ? 'bg-ink-800/8 text-blue-700 font-medium' : 'text-ink-500 hover:bg-ink-50'"
752
+ >
753
+ <span v-if="section.children?.length" class="text-[10px] text-ink-300 w-3 cursor-pointer" @click.stop="toggleSectionNode(ds.id + '-s-' + section.id)">{{ expandedSectionNodes.has(ds.id + '-s-' + section.id) ? '▾' : '▸' }}</span>
754
+ <span v-else class="w-3 text-ink-200">&#183;</span>
755
+ <span class="flex-1 text-left">{{ sectionLabel(section) }}</span>
756
+ </button>
757
+ <div v-if="section.children?.length && expandedSectionNodes.has(ds.id + '-s-' + section.id)" class="ml-3">
758
+ <button v-for="child in section.children" :key="child.id"
759
+ @click="goToSection(ds.id, 'section-' + child.id)"
760
+ class="w-full flex items-center gap-1.5 px-2 py-1 rounded-lg text-[11px] transition-colors"
761
+ :class="activeSectionId === 'section-' + child.id ? 'bg-ink-800/8 text-blue-700 font-medium' : 'text-ink-400 hover:bg-ink-50'"
762
+ >
763
+ <span class="w-3 text-ink-200">&#183;</span>
764
+ <span class="flex-1 text-left">{{ sectionLabel(child) }}</span>
765
+ </button>
766
+ </div>
767
+ </template>
768
+ </div>
769
+ </div>
770
+
648
771
  <!-- Provenance -->
649
772
  <div v-if="provenance.owner" class="mt-3 pt-3 border-t border-ink-100/60">
650
773
  <div class="text-[11px] text-ink-300 space-y-1.5 px-1">
@@ -14,7 +14,7 @@ import { useVocabularyStore } from '../stores/vocabulary';
14
14
  import { useDsStyle } from '../utils/dataset-style';
15
15
  import { getFactory } from '../adapters/factory';
16
16
  import { useRenderOptions } from '../composables/use-render-options';
17
- import { categorizeRelationship, relationshipLabel, relationshipDefinition } from '../utils/relationship-categories';
17
+ import { categorizeRelationship, relationshipLabel, relationshipDefinition, INVERSE_RELATIONSHIPS } from '../utils/relationship-categories';
18
18
  import { useSiteConfig } from '../config/use-site-config';
19
19
  import ConceptTimeline from './ConceptTimeline.vue';
20
20
  import ConceptRdfView from './ConceptRdfView.vue';
@@ -98,11 +98,11 @@ const conceptSources = computed(() => props.concept.sources);
98
98
  const conceptTags = computed(() => props.concept.tags ?? []);
99
99
 
100
100
  // Managed concept related (concept-level cross-references)
101
- // Derives superseded_by from incoming supersedes edges instead of storing it.
101
+ // Derives inverse relationships from incoming edges (e.g. supersedes superseded_by).
102
102
  const conceptRelated = computed(() => {
103
- const direct = props.concept.relatedConcepts?.filter(rc => rc.type !== 'superseded_by') ?? [];
103
+ const direct = props.concept.relatedConcepts?.filter(rc => !INVERSE_RELATIONSHIPS[rc.type]) ?? [];
104
104
  const derived = incomingEdges.value
105
- .filter(e => e.type === 'supersedes')
105
+ .filter(e => INVERSE_RELATIONSHIPS[e.type])
106
106
  .map(e => {
107
107
  const parsed = factory.resolve(e.source, props.registerId);
108
108
  const sourceUrn = parsed.type === 'internal'
@@ -110,7 +110,7 @@ const conceptRelated = computed(() => {
110
110
  : null;
111
111
  const conceptId = e.source.match(/\/concept\/([^/]+)$/)?.[1];
112
112
  return {
113
- type: 'superseded_by' as const,
113
+ type: INVERSE_RELATIONSHIPS[e.type],
114
114
  ref: sourceUrn && conceptId ? { source: sourceUrn, id: conceptId } : null,
115
115
  content: '',
116
116
  };
@@ -174,3 +174,11 @@ shortcuts.search: Search
174
174
  shortcuts.showShortcuts: Show shortcuts
175
175
  shortcuts.closeDialog: Close dialog
176
176
  shortcuts.hint: Shortcuts only work when no input field is focused
177
+
178
+ # Sections
179
+ nav.sections: Sections
180
+ dataset.systematic: Systematic
181
+ dataset.alphabetical: Alphabetical
182
+ dataset.sectionFilter: Section
183
+ dataset.clearSection: Clear section
184
+ dataset.conceptsInSection: concepts in section
@@ -174,3 +174,11 @@ shortcuts.search: Recherche
174
174
  shortcuts.showShortcuts: Afficher les raccourcis
175
175
  shortcuts.closeDialog: Fermer
176
176
  shortcuts.hint: Les raccourcis ne fonctionnent que si aucun champ de saisie n'a le focus
177
+
178
+ # Sections
179
+ nav.sections: Sections
180
+ dataset.systematic: Systématique
181
+ dataset.alphabetical: Alphabétique
182
+ dataset.sectionFilter: Section
183
+ dataset.clearSection: Effacer la section
184
+ dataset.conceptsInSection: concepts dans la section
@@ -209,6 +209,22 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
209
209
  }
210
210
  }
211
211
 
212
+ async function ensureAllEdgesLoaded() {
213
+ for (const [id, adapter] of datasets.value) {
214
+ if (!edgeStatus.value[id]?.loaded) {
215
+ try {
216
+ const edges = await adapter.loadEdgeIndex();
217
+ for (const edge of edges) {
218
+ graph.value.addEdge(edge);
219
+ }
220
+ edgeStatus.value[id] = { loaded: true, count: edges.length };
221
+ } catch {
222
+ edgeStatus.value[id] = { loaded: false, count: 0 };
223
+ }
224
+ }
225
+ }
226
+ }
227
+
212
228
  async function viewConcept(registerId: string, conceptId: string) {
213
229
  error.value = null;
214
230
  currentRegisterId.value = registerId;
@@ -271,8 +287,14 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
271
287
  }
272
288
  }
273
289
 
290
+ // Ensure edges from all datasets are loaded for cross-dataset supersession
291
+ await ensureAllEdgesLoaded();
292
+
274
293
  touchGraph();
275
- conceptEdges.value = graph.value.getEdges(uri);
294
+ conceptEdges.value = [
295
+ ...graph.value.getEdges(uri),
296
+ ...graph.value.getIncomingEdges(uri),
297
+ ];
276
298
  } catch (e: unknown) {
277
299
  error.value = `Failed to load concept ${conceptId}: ${e instanceof Error ? e.message : String(e)}`;
278
300
  currentConcept.value = null;
@@ -12,7 +12,9 @@ export const RELATIONSHIP_CATEGORIES: RelationshipCategory[] = [
12
12
  id: 'hierarchical',
13
13
  label: 'Hierarchy',
14
14
  types: ['broader', 'narrower', 'broader_generic', 'narrower_generic',
15
- 'broader_partitive', 'narrower_partitive', 'broader_instantial', 'narrower_instantial'],
15
+ 'broader_partitive', 'narrower_partitive', 'broader_instantial', 'narrower_instantial',
16
+ 'has_concept', 'is_concept_of', 'instance_of', 'has_instance',
17
+ 'has_part', 'is_part_of', 'inherits', 'inherited_by'],
16
18
  color: 'text-blue-600 bg-blue-50',
17
19
  },
18
20
  {
@@ -30,7 +32,9 @@ export const RELATIONSHIP_CATEGORIES: RelationshipCategory[] = [
30
32
  {
31
33
  id: 'lifecycle',
32
34
  label: 'Lifecycle',
33
- types: ['deprecates', 'supersedes', 'superseded_by', 'replaces'],
35
+ types: ['deprecates', 'deprecated_by', 'supersedes', 'superseded_by',
36
+ 'replaces', 'replaced_by', 'invalidates', 'invalidated_by',
37
+ 'retires', 'retired_by'],
34
38
  color: 'text-red-600 bg-red-50',
35
39
  },
36
40
  {
@@ -39,6 +43,13 @@ export const RELATIONSHIP_CATEGORIES: RelationshipCategory[] = [
39
43
  types: ['compare', 'contrast'],
40
44
  color: 'text-amber-600 bg-amber-50',
41
45
  },
46
+ {
47
+ id: 'definitional',
48
+ label: 'Definitional',
49
+ types: ['has_definition', 'definition_of', 'has_version', 'version_of',
50
+ 'current_version', 'current_version_of'],
51
+ color: 'text-cyan-600 bg-cyan-50',
52
+ },
42
53
  {
43
54
  id: 'spatiotemporal',
44
55
  label: 'Spatiotemporal',
@@ -59,6 +70,53 @@ export const RELATIONSHIP_CATEGORIES: RelationshipCategory[] = [
59
70
  },
60
71
  ];
61
72
 
73
+
74
+
75
+ export const INVERSE_RELATIONSHIPS: Record<string, string> = {
76
+ // Lifecycle
77
+ supersedes: 'superseded_by',
78
+ superseded_by: 'supersedes',
79
+ deprecates: 'deprecated_by',
80
+ deprecated_by: 'deprecates',
81
+ replaces: 'replaced_by',
82
+ replaced_by: 'replaces',
83
+ invalidates: 'invalidated_by',
84
+ retires: 'retired_by',
85
+
86
+ // Hierarchical (generic)
87
+ broader: 'narrower',
88
+ narrower: 'broader',
89
+ broader_generic: 'narrower_generic',
90
+ narrower_generic: 'broader_generic',
91
+
92
+ // Hierarchical (partitive)
93
+ broader_partitive: 'narrower_partitive',
94
+ narrower_partitive: 'broader_partitive',
95
+ has_part: 'is_part_of',
96
+ is_part_of: 'has_part',
97
+
98
+ // Hierarchical (instantial)
99
+ broader_instantial: 'narrower_instantial',
100
+ narrower_instantial: 'broader_instantial',
101
+ instance_of: 'has_instance',
102
+ has_instance: 'instance_of',
103
+
104
+ // ISO 19135 register relations
105
+ has_concept: 'is_concept_of',
106
+ is_concept_of: 'has_concept',
107
+ inherits: 'inherited_by',
108
+ inherited_by: 'inherits',
109
+ has_definition: 'definition_of',
110
+ has_version: 'version_of',
111
+ current_version: 'current_version_of',
112
+
113
+ // Symmetric (self-inverse)
114
+ equivalent: 'equivalent',
115
+ compare: 'compare',
116
+ contrast: 'contrast',
117
+ close_match: 'close_match',
118
+ related_match: 'related_match',
119
+ };
62
120
  const CATEGORY_MAP = new Map<string, RelationshipCategory>();
63
121
  for (const cat of RELATIONSHIP_CATEGORIES) {
64
122
  for (const t of cat.types) {
@@ -1,13 +1,15 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
3
+ import { useRoute, useRouter } from 'vue-router';
3
4
  import { useVocabularyStore } from '../stores/vocabulary';
4
5
  import { useDsStyle } from '../utils/dataset-style';
5
6
  import { useDatasetLoader } from '../composables/use-dataset-loader';
6
7
  import { FORMAT_LABELS } from '../config/types';
7
8
  import { langName, langLabel, sortLanguages } from '../utils/lang';
8
9
  import ConceptCard from '../components/ConceptCard.vue';
9
- import { useI18n } from '../i18n';
10
+ import { useI18n, locale } from '../i18n';
10
11
  import { useSiteConfig } from '../config/use-site-config';
12
+ import type { SectionNode, ManifestSection } from '../adapters/types';
11
13
 
12
14
  const props = defineProps<{ registerId: string }>();
13
15
 
@@ -16,6 +18,8 @@ const { getStyle } = useDsStyle();
16
18
  const { ensureLoaded, loading, localError } = useDatasetLoader(() => props.registerId);
17
19
  const { t } = useI18n();
18
20
  const { localizedDatasetField } = useSiteConfig();
21
+ const route = useRoute();
22
+ const router = useRouter();
19
23
 
20
24
  const manifest = computed(() => store.manifests.get(props.registerId));
21
25
  const localizedTitle = computed(() => localizedDatasetField(props.registerId, 'title', manifest.value?.title));
@@ -46,6 +50,8 @@ const filter = ref('');
46
50
  const filterInput = ref<HTMLInputElement | null>(null);
47
51
  const allChunksLoaded = ref(false);
48
52
  const selectedLang = ref<string | null>(null);
53
+ const viewMode = ref<'systematic' | 'alphabetical'>('systematic');
54
+ const sectionQuery = computed(() => (route.query.section as string) || null);
49
55
 
50
56
  interface LangOption {
51
57
  code: string;
@@ -92,6 +98,17 @@ watch(filter, async (q) => {
92
98
  }
93
99
  });
94
100
 
101
+ // When section filter changes, reset page and load all chunks
102
+ watch(sectionQuery, async () => {
103
+ page.value = 1;
104
+ if (sectionQuery.value && !allChunksLoaded.value && adapter.value) {
105
+ chunkLoading.value = true;
106
+ await adapter.value.ensureAllChunksLoaded();
107
+ allChunksLoaded.value = true;
108
+ chunkLoading.value = false;
109
+ }
110
+ });
111
+
95
112
  // When language filter changes, reset page and load all chunks
96
113
  watch(selectedLang, async (lang) => {
97
114
  page.value = 1;
@@ -113,13 +130,53 @@ const loadedConcepts = computed(() => {
113
130
  const filtered = computed(() => {
114
131
  const q = filter.value.trim().toLowerCase();
115
132
  const lang = selectedLang.value;
133
+ const sec = sectionQuery.value;
116
134
  return loadedConcepts.value.filter(c => {
117
135
  if (lang && !(lang in (c.designations ?? {}))) return false;
136
+ if (sec && !conceptMatchesSection(c.id, sec)) return false;
118
137
  if (!q) return true;
119
138
  return (c.eng || '').toLowerCase().includes(q) || c.id.toLowerCase().includes(q);
120
139
  });
121
140
  });
122
141
 
142
+ function conceptMatchesSection(conceptId: string, sectionPrefix: string): boolean {
143
+ // section-X matches concept IDs starting with X.
144
+ // e.g. section-1 matches 1.1, 1.2, etc.
145
+ // section-103-01 matches 103-01-01, 103-01-02, etc.
146
+ const prefix = sectionPrefix.replace(/^section-/, '');
147
+ return conceptId.startsWith(prefix + '.') || conceptId.startsWith(prefix + '-');
148
+ }
149
+
150
+ function getSections(): SectionNode[] {
151
+ const m = manifest.value;
152
+ if (!m?.sections?.length) return [];
153
+ return m.sections.map(s => enrichSection(s));
154
+ }
155
+
156
+ function enrichSection(s: ManifestSection): SectionNode {
157
+ const node: SectionNode = { id: s.id, names: s.names || {}, conceptCount: 0 };
158
+ if (s.children && s.children.length > 0) {
159
+ node.children = s.children.map(c => enrichSection(c));
160
+ }
161
+ return node;
162
+ }
163
+
164
+ function sectionName(section: SectionNode): string {
165
+ return section.names[locale.value] || section.names.eng || section.id;
166
+ }
167
+
168
+ // Alphabetical grouping
169
+ const alphabetGroups = computed(() => {
170
+ if (viewMode.value !== 'alphabetical') return [];
171
+ const groups = new Map<string, import('../adapters/types').ConceptSummary[]>();
172
+ for (const c of filtered.value) {
173
+ const letter = ((c.eng || c.id)[0] || '?').toUpperCase();
174
+ if (!groups.has(letter)) groups.set(letter, []);
175
+ groups.get(letter)!.push(c);
176
+ }
177
+ return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
178
+ });
179
+
123
180
  const page = ref(1);
124
181
  const perPage = 50;
125
182
 
@@ -130,13 +187,15 @@ const pageLoaded = computed(() => {
130
187
  return adapter.value.isRangeLoaded(start, perPage);
131
188
  });
132
189
 
190
+ const isFiltering = computed(() =>
191
+ filter.value.trim() || selectedLang.value || sectionQuery.value
192
+ );
193
+
133
194
  const paged = computed(() => {
134
- // When filtering (text or language), paginate over filtered dense results (all chunks loaded)
135
- if (filter.value.trim() || selectedLang.value) {
195
+ if (isFiltering.value) {
136
196
  const start = (page.value - 1) * perPage;
137
197
  return filtered.value.slice(start, start + perPage);
138
198
  }
139
- // When not filtering, slice directly from the pre-allocated index (may contain undefined)
140
199
  const start = (page.value - 1) * perPage;
141
200
  const arr = adapter.value?.getConcepts();
142
201
  if (!arr) return [];
@@ -144,7 +203,7 @@ const paged = computed(() => {
144
203
  });
145
204
 
146
205
  const totalPages = computed(() => {
147
- if (filter.value.trim() || selectedLang.value) {
206
+ if (isFiltering.value) {
148
207
  return Math.max(1, Math.ceil(filtered.value.length / perPage));
149
208
  }
150
209
  return Math.max(1, Math.ceil(totalConceptCount.value / perPage));
@@ -152,7 +211,7 @@ const totalPages = computed(() => {
152
211
 
153
212
  // Load chunks needed for current page
154
213
  watch(page, async () => {
155
- if (!adapter.value || filter.value.trim() || selectedLang.value) return;
214
+ if (!adapter.value || isFiltering.value) return;
156
215
  const start = (page.value - 1) * perPage;
157
216
  if (!adapter.value.isRangeLoaded(start, perPage)) {
158
217
  chunkLoading.value = true;
@@ -180,6 +239,14 @@ function goToPage(p: number) {
180
239
  page.value = Math.max(1, Math.min(p, totalPages.value));
181
240
  window.scrollTo({ top: 0, behavior: 'smooth' });
182
241
  }
242
+
243
+ function clearSection() {
244
+ const r = { ...route };
245
+ delete (r as any).query?.section;
246
+ const q = { ...route.query };
247
+ delete q.section;
248
+ router.replace({ query: q });
249
+ }
183
250
  </script>
184
251
 
185
252
  <template>
@@ -268,6 +335,18 @@ function goToPage(p: number) {
268
335
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
269
336
  </svg>
270
337
  </div>
338
+ <div v-if="getSections().length" class="flex items-center gap-1">
339
+ <button
340
+ @click="viewMode = 'systematic'"
341
+ :class="viewMode === 'systematic' ? 'bg-ink-800 text-white' : 'bg-surface-raised text-ink-600 hover:bg-ink-50 border border-ink-100'"
342
+ class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
343
+ >{{ t('dataset.systematic') }}</button>
344
+ <button
345
+ @click="viewMode = 'alphabetical'"
346
+ :class="viewMode === 'alphabetical' ? 'bg-ink-800 text-white' : 'bg-surface-raised text-ink-600 hover:bg-ink-50 border border-ink-100'"
347
+ class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
348
+ >{{ t('dataset.alphabetical') }}</button>
349
+ </div>
271
350
  <span class="text-sm text-ink-400">
272
351
  <template v-if="selectedLang">
273
352
  {{ filtered.length.toLocaleString() }} {{ t('dataset.of') }} {{ totalConceptCount.toLocaleString() }} {{ t('dataset.concepts') }} {{ t('dataset.in') }} {{ langName(selectedLang) }}
@@ -284,6 +363,18 @@ function goToPage(p: number) {
284
363
  </span>
285
364
  </div>
286
365
 
366
+ <!-- Section filter indicator -->
367
+ <div v-if="sectionQuery" class="flex items-center gap-2 mb-4">
368
+ <span class="text-sm text-ink-500">{{ t('dataset.sectionFilter') }}:</span>
369
+ <span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-amber-50 text-amber-700 text-sm font-medium">
370
+ {{ sectionQuery }}
371
+ <button @click="clearSection" class="text-amber-400 hover:text-amber-600 transition-colors">
372
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
373
+ </button>
374
+ </span>
375
+ <span class="text-xs text-ink-400">{{ filtered.length.toLocaleString() }} {{ t('dataset.conceptsInSection') }}</span>
376
+ </div>
377
+
287
378
  <!-- Language filter -->
288
379
  <div v-if="languageOptions.length > 1" class="flex flex-wrap gap-1.5 mb-5">
289
380
  <button
@@ -1,10 +0,0 @@
1
- /**
2
- * Module augmentation for glossarist Concept.tags.
3
- * The JS runtime already supports tags (string[]) but the installed
4
- * type declarations have not been updated yet. This shim bridges the gap.
5
- */
6
- declare module 'glossarist/models' {
7
- interface Concept {
8
- tags: string[];
9
- }
10
- }