@glossarist/concept-browser 0.7.22 → 0.7.24
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/index.html +2 -1
- package/package.json +1 -1
- package/scripts/build-edges.js +50 -5
- package/scripts/generate-data.mjs +33 -6
- package/src/App.vue +10 -12
- package/src/__tests__/concept-view.test.ts +7 -1
- package/src/__tests__/dataset-adapter.test.ts +87 -0
- package/src/__tests__/dataset-view.test.ts +1 -0
- package/src/__tests__/factory-lazy.test.ts +183 -0
- package/src/__tests__/graph-engine-fixes.test.ts +104 -0
- package/src/__tests__/ontology-registry.test.ts +4 -4
- package/src/__tests__/performance-v2.test.ts +77 -0
- package/src/__tests__/performance.test.ts +95 -0
- package/src/__tests__/search-utils.test.ts +59 -0
- package/src/__tests__/test-helpers.ts +4 -0
- package/src/__tests__/utils-barrel.test.ts +15 -0
- package/src/__tests__/vocabulary-layered.test.ts +291 -0
- package/src/adapters/DatasetAdapter.ts +41 -1
- package/src/adapters/factory.ts +35 -4
- package/src/adapters/ontology-registry.ts +1 -1
- package/src/adapters/types.ts +12 -0
- package/src/components/AppSidebar.vue +17 -343
- package/src/components/ConceptDetail.vue +121 -70
- package/src/components/GraphPanel.vue +14 -6
- package/src/components/OntologySidebarSection.vue +338 -0
- package/src/config/use-site-config.ts +20 -9
- package/src/data/taxonomies.json +12 -6
- package/src/directives/v-math.ts +2 -3
- package/src/graph/GraphEngine.ts +22 -5
- package/src/i18n/index.ts +1 -1
- package/src/stores/vocabulary.ts +65 -105
- package/src/utils/index.ts +1 -0
- package/src/utils/relationship-categories.ts +3 -2
- package/src/utils/search.ts +15 -0
- package/src/views/ConceptView.vue +0 -2
- package/src/views/DatasetView.vue +64 -39
- package/src/views/HomeView.vue +0 -1
- package/vite.config.ts +94 -6
package/src/stores/vocabulary.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { defineStore } from 'pinia';
|
|
2
|
-
import { ref,
|
|
2
|
+
import { ref, shallowRef, computed } from 'vue';
|
|
3
3
|
import { getFactory } from '../adapters/factory';
|
|
4
4
|
import type { DatasetAdapter } from '../adapters/DatasetAdapter';
|
|
5
5
|
import type { Manifest, SearchHit, GraphEdge } from '../adapters/types';
|
|
6
6
|
import type { Concept } from 'glossarist';
|
|
7
7
|
import { conceptUri } from '../adapters/model-bridge';
|
|
8
8
|
import { GraphEngine } from '../graph';
|
|
9
|
+
import { deduplicateSearchHits } from '../utils/search';
|
|
9
10
|
|
|
10
11
|
export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
11
12
|
// State
|
|
@@ -16,7 +17,7 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
|
16
17
|
const currentConceptId = ref<string>('');
|
|
17
18
|
const loading = ref(false);
|
|
18
19
|
const error = ref<string | null>(null);
|
|
19
|
-
const graph =
|
|
20
|
+
const graph = shallowRef(new GraphEngine());
|
|
20
21
|
const conceptEdges = ref<GraphEdge[]>([]);
|
|
21
22
|
const initialized = ref(false);
|
|
22
23
|
|
|
@@ -76,75 +77,19 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
|
76
77
|
manifests.value.set(registerId, adapter.manifest);
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
// Load pre-computed edges (lightweight)
|
|
80
|
-
await loadEdges(adapter);
|
|
81
|
-
|
|
82
80
|
touchGraph();
|
|
83
|
-
|
|
84
|
-
// Seed graph nodes lazily — don't block UI for large datasets
|
|
85
|
-
seedGraphNodes(registerId, adapter);
|
|
86
81
|
} catch (e: unknown) {
|
|
87
82
|
error.value = `Failed to load dataset ${registerId}: ${e instanceof Error ? e.message : String(e)}`;
|
|
88
83
|
throw e;
|
|
89
84
|
}
|
|
90
85
|
}
|
|
91
86
|
|
|
92
|
-
function seedGraphNodes(registerId: string, adapter: DatasetAdapter, sync = false) {
|
|
93
|
-
const entries = adapter.getConcepts();
|
|
94
|
-
|
|
95
|
-
if (sync) {
|
|
96
|
-
for (const entry of entries) {
|
|
97
|
-
if (!entry) continue;
|
|
98
|
-
graph.value.addNode({
|
|
99
|
-
uri: factory.router.buildUri(registerId, entry.id),
|
|
100
|
-
register: registerId,
|
|
101
|
-
conceptId: entry.id,
|
|
102
|
-
designations: entry.designations,
|
|
103
|
-
status: entry.status,
|
|
104
|
-
loaded: false,
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
touchGraph();
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const batchSize = 500;
|
|
112
|
-
let offset = 0;
|
|
113
|
-
const schedule = typeof requestIdleCallback !== 'undefined'
|
|
114
|
-
? requestIdleCallback
|
|
115
|
-
: (cb: () => void) => setTimeout(cb, 0);
|
|
116
|
-
|
|
117
|
-
function processBatch() {
|
|
118
|
-
const end = Math.min(offset + batchSize, entries.length);
|
|
119
|
-
for (let i = offset; i < end; i++) {
|
|
120
|
-
const entry = entries[i];
|
|
121
|
-
if (!entry) continue;
|
|
122
|
-
graph.value.addNode({
|
|
123
|
-
uri: factory.router.buildUri(registerId, entry.id),
|
|
124
|
-
register: registerId,
|
|
125
|
-
conceptId: entry.id,
|
|
126
|
-
designations: entry.designations,
|
|
127
|
-
status: entry.status,
|
|
128
|
-
loaded: false,
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
offset = end;
|
|
132
|
-
if (offset < entries.length) {
|
|
133
|
-
schedule(processBatch);
|
|
134
|
-
} else {
|
|
135
|
-
touchGraph();
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
schedule(processBatch);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
87
|
async function loadAllGraphData() {
|
|
143
88
|
if (!initialized.value) {
|
|
144
89
|
await discoverDatasets();
|
|
145
90
|
}
|
|
146
91
|
|
|
147
|
-
const engine =
|
|
92
|
+
const engine = graph.value;
|
|
148
93
|
const adapters = factory.getAdapters();
|
|
149
94
|
|
|
150
95
|
await Promise.allSettled(adapters.map(async (adapter) => {
|
|
@@ -197,11 +142,12 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
|
197
142
|
adapter.loadEdgeIndex(),
|
|
198
143
|
adapter.loadDomainNodes(),
|
|
199
144
|
]);
|
|
145
|
+
const engine = graph.value;
|
|
200
146
|
for (const dn of domainNodes) {
|
|
201
|
-
|
|
147
|
+
engine.addNode(dn);
|
|
202
148
|
}
|
|
203
149
|
for (const edge of edges) {
|
|
204
|
-
|
|
150
|
+
engine.addEdge(edge);
|
|
205
151
|
}
|
|
206
152
|
edgeStatus.value[adapter.registerId] = { loaded: true, count: edges.length };
|
|
207
153
|
} catch {
|
|
@@ -209,19 +155,19 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
|
209
155
|
}
|
|
210
156
|
}
|
|
211
157
|
|
|
212
|
-
async function
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
158
|
+
async function ensureEdgesForDataset(registerId: string) {
|
|
159
|
+
const adapter = datasets.value.get(registerId);
|
|
160
|
+
if (adapter && !edgeStatus.value[registerId]?.loaded) {
|
|
161
|
+
await loadEdges(adapter);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const index = await factory.loadCrossRefIndex();
|
|
165
|
+
const refs = index[registerId] || [];
|
|
166
|
+
for (const refId of refs) {
|
|
167
|
+
if (edgeStatus.value[refId]?.loaded) continue;
|
|
168
|
+
const refAdapter = datasets.value.get(refId);
|
|
169
|
+
if (!refAdapter) continue;
|
|
170
|
+
await loadEdges(refAdapter);
|
|
225
171
|
}
|
|
226
172
|
}
|
|
227
173
|
|
|
@@ -234,16 +180,18 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
|
234
180
|
const adapter = datasets.value.get(registerId);
|
|
235
181
|
if (!adapter) throw new Error(`Dataset ${registerId} not loaded`);
|
|
236
182
|
|
|
237
|
-
|
|
183
|
+
// Fetch concept and cross-dataset edges in parallel
|
|
184
|
+
const [concept] = await Promise.all([
|
|
185
|
+
adapter.fetchConcept(conceptId),
|
|
186
|
+
ensureEdgesForDataset(registerId),
|
|
187
|
+
]);
|
|
238
188
|
currentConcept.value = concept;
|
|
239
189
|
|
|
240
|
-
// Extract and register edges for this specific concept
|
|
241
190
|
const edges = adapter.extractEdges(concept);
|
|
242
191
|
const domainEdges = adapter.extractDomainEdges(concept);
|
|
243
192
|
const uriBase = adapter.manifest?.uriBase || 'https://glossarist.org';
|
|
244
193
|
const uri = conceptUri(concept, registerId, uriBase);
|
|
245
194
|
|
|
246
|
-
// Update graph node with full data
|
|
247
195
|
const designations: Record<string, string> = {};
|
|
248
196
|
const indexEntry = adapter.getIndexEntry(conceptId);
|
|
249
197
|
if (indexEntry) {
|
|
@@ -258,7 +206,8 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
|
258
206
|
}
|
|
259
207
|
}
|
|
260
208
|
|
|
261
|
-
graph.value
|
|
209
|
+
const engine = graph.value;
|
|
210
|
+
engine.addNode({
|
|
262
211
|
uri,
|
|
263
212
|
register: registerId,
|
|
264
213
|
conceptId,
|
|
@@ -268,14 +217,14 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
|
268
217
|
});
|
|
269
218
|
|
|
270
219
|
for (const edge of edges) {
|
|
271
|
-
|
|
220
|
+
engine.addEdge(edge);
|
|
272
221
|
}
|
|
273
222
|
|
|
274
223
|
for (const edge of domainEdges) {
|
|
275
|
-
|
|
276
|
-
const existing =
|
|
224
|
+
engine.addEdge(edge);
|
|
225
|
+
const existing = engine.getNode(edge.target);
|
|
277
226
|
if (!existing || !existing.loaded) {
|
|
278
|
-
|
|
227
|
+
engine.addNode({
|
|
279
228
|
uri: edge.target,
|
|
280
229
|
register: registerId,
|
|
281
230
|
conceptId: '',
|
|
@@ -287,13 +236,10 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
|
287
236
|
}
|
|
288
237
|
}
|
|
289
238
|
|
|
290
|
-
// Ensure edges from all datasets are loaded for cross-dataset supersession
|
|
291
|
-
await ensureAllEdgesLoaded();
|
|
292
|
-
|
|
293
239
|
touchGraph();
|
|
294
240
|
conceptEdges.value = [
|
|
295
|
-
...
|
|
296
|
-
...
|
|
241
|
+
...engine.getEdges(uri),
|
|
242
|
+
...engine.getIncomingEdges(uri),
|
|
297
243
|
];
|
|
298
244
|
} catch (e: unknown) {
|
|
299
245
|
error.value = `Failed to load concept ${conceptId}: ${e instanceof Error ? e.message : String(e)}`;
|
|
@@ -326,32 +272,46 @@ export const useVocabularyStore = defineStore('vocabulary', () => {
|
|
|
326
272
|
await discoverDatasets();
|
|
327
273
|
}
|
|
328
274
|
|
|
329
|
-
const
|
|
275
|
+
const MIN_RESULTS = 20;
|
|
276
|
+
|
|
277
|
+
// Pass 1: search loaded data only
|
|
278
|
+
const loadedHits: SearchHit[] = [];
|
|
279
|
+
const unloadedAdapters: DatasetAdapter[] = [];
|
|
280
|
+
|
|
330
281
|
for (const adapter of datasets.value.values()) {
|
|
331
|
-
if (adapter.manifest)
|
|
332
|
-
|
|
282
|
+
if (!adapter.manifest) continue;
|
|
283
|
+
if (!adapter.index) {
|
|
284
|
+
try {
|
|
333
285
|
await adapter.loadIndex();
|
|
286
|
+
} catch {
|
|
287
|
+
continue;
|
|
334
288
|
}
|
|
335
|
-
|
|
336
|
-
|
|
289
|
+
}
|
|
290
|
+
const hits = adapter.search(query);
|
|
291
|
+
if (hits.length > 0) {
|
|
292
|
+
loadedHits.push(...hits);
|
|
293
|
+
} else {
|
|
294
|
+
unloadedAdapters.push(adapter);
|
|
337
295
|
}
|
|
338
296
|
}
|
|
339
297
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
298
|
+
const pass1 = deduplicateSearchHits(loadedHits);
|
|
299
|
+
if (pass1.length >= MIN_RESULTS) return pass1;
|
|
300
|
+
|
|
301
|
+
// Pass 2: load chunks lazily for datasets that found nothing in index
|
|
302
|
+
let allHits = [...loadedHits];
|
|
303
|
+
for (const adapter of unloadedAdapters) {
|
|
304
|
+
if (deduplicateSearchHits(allHits).length >= MIN_RESULTS) break;
|
|
305
|
+
try {
|
|
306
|
+
await adapter.ensureAllChunksLoaded();
|
|
307
|
+
const hits = adapter.search(query);
|
|
308
|
+
allHits.push(...hits);
|
|
309
|
+
} catch {
|
|
310
|
+
// Skip datasets that fail to load
|
|
352
311
|
}
|
|
353
312
|
}
|
|
354
|
-
|
|
313
|
+
|
|
314
|
+
return deduplicateSearchHits(allHits);
|
|
355
315
|
}
|
|
356
316
|
|
|
357
317
|
async function getRandomConcept(): Promise<{ registerId: string; conceptId: string } | null> {
|
package/src/utils/index.ts
CHANGED
|
@@ -20,7 +20,7 @@ export const RELATIONSHIP_CATEGORIES: RelationshipCategory[] = [
|
|
|
20
20
|
{
|
|
21
21
|
id: 'mapping',
|
|
22
22
|
label: 'Equivalence',
|
|
23
|
-
types: ['equivalent', 'close_match', 'broad_match', 'narrow_match', 'related_match'],
|
|
23
|
+
types: ['equivalent', 'exact_match', 'close_match', 'broad_match', 'narrow_match', 'related_match'],
|
|
24
24
|
color: 'text-emerald-600 bg-emerald-50',
|
|
25
25
|
},
|
|
26
26
|
{
|
|
@@ -53,7 +53,7 @@ export const RELATIONSHIP_CATEGORIES: RelationshipCategory[] = [
|
|
|
53
53
|
{
|
|
54
54
|
id: 'spatiotemporal',
|
|
55
55
|
label: 'Spatiotemporal',
|
|
56
|
-
types: ['
|
|
56
|
+
types: ['sequentially_related', 'spatially_related', 'temporally_related'],
|
|
57
57
|
color: 'text-teal-600 bg-teal-50',
|
|
58
58
|
},
|
|
59
59
|
{
|
|
@@ -119,6 +119,7 @@ export const INVERSE_RELATIONSHIPS: Record<string, string> = {
|
|
|
119
119
|
|
|
120
120
|
// Symmetric (self-inverse)
|
|
121
121
|
equivalent: 'equivalent',
|
|
122
|
+
exact_match: 'exact_match',
|
|
122
123
|
compare: 'compare',
|
|
123
124
|
contrast: 'contrast',
|
|
124
125
|
close_match: 'close_match',
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SearchHit } from '../adapters/types';
|
|
2
|
+
|
|
3
|
+
export function deduplicateSearchHits(hits: SearchHit[]): SearchHit[] {
|
|
4
|
+
const best = new Map<string, SearchHit>();
|
|
5
|
+
for (const hit of hits) {
|
|
6
|
+
const key = `${hit.registerId}:${hit.conceptId}`;
|
|
7
|
+
const existing = best.get(key);
|
|
8
|
+
if (!existing) {
|
|
9
|
+
best.set(key, hit);
|
|
10
|
+
} else if (hit.matchField === 'designation' && existing.matchField === 'id') {
|
|
11
|
+
best.set(key, hit);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return [...best.values()];
|
|
15
|
+
}
|
|
@@ -61,8 +61,6 @@ async function loadAdjacent() {
|
|
|
61
61
|
adjacent.value = adapter.getAdjacentConcepts(props.conceptId);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
watch(() => props.conceptId, () => { loadAdjacent(); });
|
|
65
|
-
|
|
66
64
|
function goAdjacent(id: string) {
|
|
67
65
|
router.push({ name: 'concept', params: { registerId: props.registerId, conceptId: id } });
|
|
68
66
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
|
|
2
|
+
import { computed, ref, watch, onMounted, onUnmounted, onBeforeUnmount } from 'vue';
|
|
3
3
|
import { useRoute, useRouter } from 'vue-router';
|
|
4
4
|
import { useVocabularyStore } from '../stores/vocabulary';
|
|
5
5
|
import { useDsStyle } from '../utils/dataset-style';
|
|
@@ -9,7 +9,7 @@ import { langName, langLabel, sortLanguages } from '../utils/lang';
|
|
|
9
9
|
import ConceptCard from '../components/ConceptCard.vue';
|
|
10
10
|
import { useI18n, locale } from '../i18n';
|
|
11
11
|
import { useSiteConfig } from '../config/use-site-config';
|
|
12
|
-
import type { SectionNode
|
|
12
|
+
import type { SectionNode } from '../adapters/types';
|
|
13
13
|
|
|
14
14
|
const props = defineProps<{ registerId: string }>();
|
|
15
15
|
|
|
@@ -27,6 +27,41 @@ const localizedDescription = computed(() => localizedDatasetField(props.register
|
|
|
27
27
|
const adapter = computed(() => store.datasets.get(props.registerId));
|
|
28
28
|
const chunkLoading = ref(false);
|
|
29
29
|
|
|
30
|
+
// Background chunk preloading via requestIdleCallback
|
|
31
|
+
let idlePreloadHandle: ReturnType<typeof requestIdleCallback> | ReturnType<typeof setTimeout> | null = null;
|
|
32
|
+
|
|
33
|
+
watch(adapter, (a) => {
|
|
34
|
+
if (idlePreloadHandle !== null) return;
|
|
35
|
+
if (!a || !a.index) return;
|
|
36
|
+
|
|
37
|
+
const schedule = typeof requestIdleCallback !== 'undefined'
|
|
38
|
+
? requestIdleCallback
|
|
39
|
+
: (cb: () => void) => setTimeout(cb, 0);
|
|
40
|
+
|
|
41
|
+
idlePreloadHandle = schedule(() => {
|
|
42
|
+
if (allChunksLoaded.value || !a.index) {
|
|
43
|
+
idlePreloadHandle = null;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const count = a.getConceptCount();
|
|
47
|
+
if (count <= 200) {
|
|
48
|
+
a.ensureAllChunksLoaded().then(() => {
|
|
49
|
+
allChunksLoaded.value = true;
|
|
50
|
+
}).catch(() => {});
|
|
51
|
+
} else {
|
|
52
|
+
a.ensureChunksForRange(0, 100).catch(() => {});
|
|
53
|
+
}
|
|
54
|
+
idlePreloadHandle = null;
|
|
55
|
+
}, { timeout: 2000 } as any);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
onBeforeUnmount(() => {
|
|
59
|
+
if (idlePreloadHandle !== null) {
|
|
60
|
+
(typeof cancelIdleCallback !== 'undefined' ? cancelIdleCallback : clearTimeout)(idlePreloadHandle as any);
|
|
61
|
+
idlePreloadHandle = null;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
30
65
|
function formatSize(bytes: number): string {
|
|
31
66
|
if (bytes < 1024) return `${bytes} B`;
|
|
32
67
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
@@ -87,37 +122,26 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
|
|
87
122
|
onMounted(() => window.addEventListener('keydown', onGlobalKeydown));
|
|
88
123
|
onUnmounted(() => window.removeEventListener('keydown', onGlobalKeydown));
|
|
89
124
|
|
|
90
|
-
|
|
91
|
-
watch(filter, async (q) => {
|
|
125
|
+
async function ensureAllChunksForFilter(needsLoad: boolean) {
|
|
92
126
|
page.value = 1;
|
|
93
|
-
if (
|
|
127
|
+
if (needsLoad && !allChunksLoaded.value && adapter.value) {
|
|
94
128
|
chunkLoading.value = true;
|
|
95
129
|
await adapter.value.ensureAllChunksLoaded();
|
|
96
130
|
allChunksLoaded.value = true;
|
|
97
131
|
chunkLoading.value = false;
|
|
98
132
|
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
watch(filter, async (q) => {
|
|
136
|
+
await ensureAllChunksForFilter(q.trim().length >= 2);
|
|
99
137
|
});
|
|
100
138
|
|
|
101
|
-
// When section filter changes, reset page and load all chunks
|
|
102
139
|
watch(sectionQuery, async () => {
|
|
103
|
-
|
|
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
|
-
}
|
|
140
|
+
await ensureAllChunksForFilter(!!sectionQuery.value);
|
|
110
141
|
});
|
|
111
142
|
|
|
112
|
-
// When language filter changes, reset page and load all chunks
|
|
113
143
|
watch(selectedLang, async (lang) => {
|
|
114
|
-
|
|
115
|
-
if (lang && !allChunksLoaded.value && adapter.value) {
|
|
116
|
-
chunkLoading.value = true;
|
|
117
|
-
await adapter.value.ensureAllChunksLoaded();
|
|
118
|
-
allChunksLoaded.value = true;
|
|
119
|
-
chunkLoading.value = false;
|
|
120
|
-
}
|
|
144
|
+
await ensureAllChunksForFilter(!!lang);
|
|
121
145
|
});
|
|
122
146
|
|
|
123
147
|
// Dense array: only loaded (non-undefined) entries
|
|
@@ -133,38 +157,39 @@ const filtered = computed(() => {
|
|
|
133
157
|
const sec = sectionQuery.value;
|
|
134
158
|
return loadedConcepts.value.filter(c => {
|
|
135
159
|
if (lang && !(lang in (c.designations ?? {}))) return false;
|
|
136
|
-
if (sec && !conceptMatchesSection(c
|
|
160
|
+
if (sec && !conceptMatchesSection(c, sec)) return false;
|
|
137
161
|
if (!q) return true;
|
|
138
162
|
return (c.eng || '').toLowerCase().includes(q) || c.id.toLowerCase().includes(q);
|
|
139
163
|
});
|
|
140
164
|
});
|
|
141
165
|
|
|
142
|
-
function conceptMatchesSection(
|
|
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.
|
|
166
|
+
function conceptMatchesSection(concept: import('../adapters/types').ConceptSummary, sectionPrefix: string): boolean {
|
|
146
167
|
const prefix = sectionPrefix.replace(/^section-/, '');
|
|
147
|
-
|
|
168
|
+
// Check explicit groups (e.g. G18 sections derived from domains)
|
|
169
|
+
if (concept.groups?.length && concept.groups.includes(prefix)) return true;
|
|
170
|
+
// Check concept ID prefix matching (e.g. VIML/VIM numbered sections)
|
|
171
|
+
return concept.id.startsWith(prefix + '.') || concept.id.startsWith(prefix + '-');
|
|
148
172
|
}
|
|
149
173
|
|
|
150
174
|
function getSections(): SectionNode[] {
|
|
151
|
-
|
|
152
|
-
|
|
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;
|
|
175
|
+
if (!adapter.value) return [];
|
|
176
|
+
return adapter.value.getSectionTree();
|
|
162
177
|
}
|
|
163
178
|
|
|
164
179
|
function sectionName(section: SectionNode): string {
|
|
165
180
|
return section.names[locale.value] || section.names.eng || section.id;
|
|
166
181
|
}
|
|
167
182
|
|
|
183
|
+
const sectionDisplayName = computed(() => {
|
|
184
|
+
if (!sectionQuery.value) return '';
|
|
185
|
+
const prefix = sectionQuery.value.replace(/^section-/, '');
|
|
186
|
+
const sections = getSections();
|
|
187
|
+
const found = sections.find(s => s.id === prefix);
|
|
188
|
+
if (!found) return prefix;
|
|
189
|
+
const name = sectionName(found);
|
|
190
|
+
return name !== found.id ? `${found.id} — ${name}` : name;
|
|
191
|
+
});
|
|
192
|
+
|
|
168
193
|
// Alphabetical grouping
|
|
169
194
|
const alphabetGroups = computed(() => {
|
|
170
195
|
if (viewMode.value !== 'alphabetical') return [];
|
|
@@ -367,7 +392,7 @@ function clearSection() {
|
|
|
367
392
|
<div v-if="sectionQuery" class="flex items-center gap-2 mb-4">
|
|
368
393
|
<span class="text-sm text-ink-500">{{ t('dataset.sectionFilter') }}:</span>
|
|
369
394
|
<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
|
-
{{
|
|
395
|
+
{{ sectionDisplayName }}
|
|
371
396
|
<button @click="clearSection" class="text-amber-400 hover:text-amber-600 transition-colors">
|
|
372
397
|
<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
398
|
</button>
|
package/src/views/HomeView.vue
CHANGED
|
@@ -24,7 +24,6 @@ async function exploreRandom() {
|
|
|
24
24
|
}
|
|
25
25
|
const result = await store.getRandomConcept();
|
|
26
26
|
if (result) {
|
|
27
|
-
await store.viewConcept(result.registerId, result.conceptId);
|
|
28
27
|
router.push({ name: 'concept', params: { registerId: result.registerId, conceptId: result.conceptId } });
|
|
29
28
|
}
|
|
30
29
|
} finally {
|
package/vite.config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { defineConfig } from 'vite'
|
|
1
|
+
import { defineConfig, type Plugin } from 'vite'
|
|
2
2
|
import vue from '@vitejs/plugin-vue'
|
|
3
|
-
import { resolve, dirname } from 'path'
|
|
3
|
+
import { resolve, dirname, extname, join } from 'path'
|
|
4
|
+
import { readFileSync, existsSync, statSync, createReadStream } from 'fs'
|
|
4
5
|
import { fileURLToPath } from 'url'
|
|
5
6
|
import yaml from 'js-yaml'
|
|
6
7
|
|
|
@@ -41,6 +42,40 @@ function faviconPlugin() {
|
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
function inlineDataPlugin() {
|
|
46
|
+
let publicDir: string | undefined
|
|
47
|
+
const cache: Map<string, string> = new Map()
|
|
48
|
+
return {
|
|
49
|
+
name: 'inline-data',
|
|
50
|
+
configResolved(config: any) {
|
|
51
|
+
publicDir = config.publicDir
|
|
52
|
+
},
|
|
53
|
+
transformIndexHtml(html: string) {
|
|
54
|
+
if (!publicDir) return html
|
|
55
|
+
const tags: any[] = []
|
|
56
|
+
for (const [id, path] of [
|
|
57
|
+
['datasets-json', 'datasets.json'],
|
|
58
|
+
['site-config-json', 'site-config.json'],
|
|
59
|
+
] as const) {
|
|
60
|
+
try {
|
|
61
|
+
let data = cache.get(path)
|
|
62
|
+
if (!data) {
|
|
63
|
+
data = readFileSync(resolve(publicDir!, path), 'utf-8')
|
|
64
|
+
cache.set(path, data)
|
|
65
|
+
}
|
|
66
|
+
tags.push({
|
|
67
|
+
tag: 'script',
|
|
68
|
+
attrs: { type: 'application/json', id },
|
|
69
|
+
children: data,
|
|
70
|
+
injectTo: 'body' as const,
|
|
71
|
+
})
|
|
72
|
+
} catch { /* file may not exist during first build */ }
|
|
73
|
+
}
|
|
74
|
+
return { html, tags }
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
44
79
|
function brandingPlugin() {
|
|
45
80
|
return {
|
|
46
81
|
name: 'branding-inject',
|
|
@@ -53,8 +88,8 @@ function brandingPlugin() {
|
|
|
53
88
|
const fontsUrl = process.env.SITE_FONTS_URL
|
|
54
89
|
if (fontsUrl) {
|
|
55
90
|
result = result.replace(
|
|
56
|
-
/<link[^>]*href="https:\/\/fonts\.googleapis\.com\/css2\?[^"]*"[^>]
|
|
57
|
-
`<link href="${fontsUrl}" rel="stylesheet">`
|
|
91
|
+
/<link[^>]*href="https:\/\/fonts\.googleapis\.com\/css2\?[^"]*"[^>]*>(?:\s*<noscript>[^<]*<\/noscript>)?/,
|
|
92
|
+
`<link rel="preload" as="style" href="${fontsUrl}" onload="this.rel='stylesheet'"><noscript><link href="${fontsUrl}" rel="stylesheet"></noscript>`
|
|
58
93
|
)
|
|
59
94
|
}
|
|
60
95
|
return result
|
|
@@ -62,15 +97,68 @@ function brandingPlugin() {
|
|
|
62
97
|
}
|
|
63
98
|
}
|
|
64
99
|
|
|
100
|
+
const dataDir = resolve(cwd, 'public/data')
|
|
101
|
+
const publicDir = resolve(cwd, 'public')
|
|
102
|
+
|
|
103
|
+
const mimeTypes: Record<string, string> = {
|
|
104
|
+
'.json': 'application/json',
|
|
105
|
+
'.yaml': 'text/yaml',
|
|
106
|
+
'.yml': 'text/yaml',
|
|
107
|
+
'.ttl': 'text/turtle',
|
|
108
|
+
'.jsonld': 'application/ld+json',
|
|
109
|
+
'.tbx': 'application/xml',
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Serves /data/ files via middleware so the dev server doesn't need to watch
|
|
113
|
+
// the 15,000+ files in public/data/ (which causes fsevents to consume ~400% CPU).
|
|
114
|
+
function dataServePlugin(): Plugin {
|
|
115
|
+
return {
|
|
116
|
+
name: 'data-serve',
|
|
117
|
+
configureServer(server) {
|
|
118
|
+
server.middlewares.use((req, res, next) => {
|
|
119
|
+
if (!req.url?.startsWith('/data/')) return next()
|
|
120
|
+
const filePath = join(dataDir, req.url.slice('/data/'.length))
|
|
121
|
+
if (!filePath.startsWith(dataDir + '/') && filePath !== dataDir) return next()
|
|
122
|
+
if (!existsSync(filePath)) return next()
|
|
123
|
+
try {
|
|
124
|
+
const stat = statSync(filePath)
|
|
125
|
+
if (!stat.isFile()) return next()
|
|
126
|
+
const ext = extname(filePath)
|
|
127
|
+
res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream')
|
|
128
|
+
res.setHeader('Content-Length', stat.size)
|
|
129
|
+
createReadStream(filePath).pipe(res)
|
|
130
|
+
} catch { next() }
|
|
131
|
+
})
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
65
136
|
export default defineConfig({
|
|
66
137
|
base: process.env.BASE_PATH || '/',
|
|
67
138
|
root: __dirname,
|
|
68
|
-
publicDir
|
|
139
|
+
publicDir,
|
|
69
140
|
build: {
|
|
70
141
|
outDir: resolve(cwd, 'dist'),
|
|
71
142
|
emptyOutDir: true,
|
|
72
143
|
},
|
|
73
|
-
|
|
144
|
+
server: {
|
|
145
|
+
watch: {
|
|
146
|
+
ignored: [
|
|
147
|
+
// concept-browser's own non-source dirs (121K+ files in dist/public)
|
|
148
|
+
resolve(__dirname, 'dist') + '/**',
|
|
149
|
+
resolve(__dirname, 'public') + '/**',
|
|
150
|
+
resolve(__dirname, '.datasets') + '/**',
|
|
151
|
+
resolve(__dirname, '.gcr') + '/**',
|
|
152
|
+
resolve(__dirname, '.gcr-staging') + '/**',
|
|
153
|
+
// oiml-vocab's data dir (15K+ files)
|
|
154
|
+
resolve(cwd, 'public/data') + '/**',
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
optimizeDeps: {
|
|
159
|
+
exclude: ['@plurimath/plurimath'],
|
|
160
|
+
},
|
|
161
|
+
plugins: [yamlPlugin(), faviconPlugin(), brandingPlugin(), dataServePlugin(), inlineDataPlugin(), vue()],
|
|
74
162
|
resolve: {
|
|
75
163
|
alias: {
|
|
76
164
|
'@': resolve(__dirname, 'src'),
|