@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 +2 -1
- package/scripts/build-edges.js +89 -25
- package/scripts/generate-data.mjs +93 -29
- package/src/adapters/DatasetAdapter.ts +40 -4
- package/src/adapters/types.ts +20 -0
- package/src/components/AppSidebar.vue +123 -0
- package/src/components/ConceptDetail.vue +5 -5
- package/src/i18n/locales/eng.yml +8 -0
- package/src/i18n/locales/fra.yml +8 -0
- package/src/stores/vocabulary.ts +23 -1
- package/src/utils/relationship-categories.ts +60 -2
- package/src/views/DatasetView.vue +97 -6
- package/src/shims/glossarist-tags.ts +0 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
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",
|
package/scripts/build-edges.js
CHANGED
|
@@ -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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
826
|
-
const
|
|
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
|
-
|
|
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:
|
|
836
|
-
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
|
|
1134
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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[]> {
|
package/src/adapters/types.ts
CHANGED
|
@@ -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">·</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">·</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">·</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">·</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">·</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">·</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
|
|
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
|
|
103
|
+
const direct = props.concept.relatedConcepts?.filter(rc => !INVERSE_RELATIONSHIPS[rc.type]) ?? [];
|
|
104
104
|
const derived = incomingEdges.value
|
|
105
|
-
.filter(e => e.type
|
|
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:
|
|
113
|
+
type: INVERSE_RELATIONSHIPS[e.type],
|
|
114
114
|
ref: sourceUrn && conceptId ? { source: sourceUrn, id: conceptId } : null,
|
|
115
115
|
content: '',
|
|
116
116
|
};
|
package/src/i18n/locales/eng.yml
CHANGED
|
@@ -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
|
package/src/i18n/locales/fra.yml
CHANGED
|
@@ -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
|
package/src/stores/vocabulary.ts
CHANGED
|
@@ -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 =
|
|
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', '
|
|
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
|
-
|
|
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 (
|
|
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 ||
|
|
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
|
-
}
|