@glossarist/concept-browser 0.7.21 → 0.7.23

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.
Files changed (39) hide show
  1. package/index.html +2 -1
  2. package/package.json +2 -2
  3. package/scripts/build-edges.js +50 -5
  4. package/scripts/generate-data.mjs +33 -6
  5. package/src/App.vue +10 -12
  6. package/src/__tests__/concept-view.test.ts +7 -1
  7. package/src/__tests__/dataset-adapter.test.ts +87 -0
  8. package/src/__tests__/dataset-view.test.ts +1 -0
  9. package/src/__tests__/factory-lazy.test.ts +183 -0
  10. package/src/__tests__/graph-engine-fixes.test.ts +104 -0
  11. package/src/__tests__/ontology-registry.test.ts +1 -1
  12. package/src/__tests__/performance-v2.test.ts +77 -0
  13. package/src/__tests__/performance.test.ts +95 -0
  14. package/src/__tests__/relationship-categories.test.ts +3 -3
  15. package/src/__tests__/search-utils.test.ts +59 -0
  16. package/src/__tests__/test-helpers.ts +4 -0
  17. package/src/__tests__/utils-barrel.test.ts +15 -0
  18. package/src/__tests__/vocabulary-layered.test.ts +291 -0
  19. package/src/adapters/DatasetAdapter.ts +41 -1
  20. package/src/adapters/factory.ts +35 -4
  21. package/src/adapters/ontology-registry.ts +1 -1
  22. package/src/adapters/types.ts +12 -0
  23. package/src/components/AppSidebar.vue +17 -343
  24. package/src/components/ConceptDetail.vue +124 -55
  25. package/src/components/GraphPanel.vue +14 -6
  26. package/src/components/OntologySidebarSection.vue +338 -0
  27. package/src/config/use-site-config.ts +20 -9
  28. package/src/data/taxonomies.json +246 -18
  29. package/src/directives/v-math.ts +2 -3
  30. package/src/graph/GraphEngine.ts +22 -5
  31. package/src/i18n/index.ts +1 -1
  32. package/src/stores/vocabulary.ts +65 -105
  33. package/src/utils/index.ts +1 -0
  34. package/src/utils/relationship-categories.ts +15 -6
  35. package/src/utils/search.ts +15 -0
  36. package/src/views/ConceptView.vue +0 -2
  37. package/src/views/DatasetView.vue +64 -39
  38. package/src/views/HomeView.vue +0 -1
  39. package/vite.config.ts +94 -6
@@ -243,24 +243,6 @@
243
243
  "prefLabel": "related concept (narrower)",
244
244
  "definition": "Associative relationship to a concept with narrower scope (ISO 25964 / TBX)."
245
245
  },
246
- "sequentially_related": {
247
- "id": "sequentially_related",
248
- "iri": "gloss:rel/sequentially_related",
249
- "prefLabel": "sequentially related",
250
- "definition": "Sequential spatiotemporal relationship (ISO 25964 / TBX)."
251
- },
252
- "spatially_related": {
253
- "id": "spatially_related",
254
- "iri": "gloss:rel/spatially_related",
255
- "prefLabel": "spatially related",
256
- "definition": "Spatial relationship (ISO 25964 / TBX)."
257
- },
258
- "temporally_related": {
259
- "id": "temporally_related",
260
- "iri": "gloss:rel/temporally_related",
261
- "prefLabel": "temporally related",
262
- "definition": "Temporal relationship (ISO 25964 / TBX)."
263
- },
264
246
  "homograph": {
265
247
  "id": "homograph",
266
248
  "iri": "gloss:rel/homograph",
@@ -284,6 +266,252 @@
284
266
  "iri": "gloss:rel/short_form_for",
285
267
  "prefLabel": "short form for",
286
268
  "definition": "This designation is a short form of another designation."
269
+ },
270
+ "deprecated_by": {
271
+ "id": "deprecated_by",
272
+ "iri": "https://glossarist.org/ontologies/relationship-type#deprecated_by",
273
+ "prefLabel": "deprecated by",
274
+ "definition": "This concept has been deprecated by another concept (ISO 10241-1)."
275
+ },
276
+ "replaces": {
277
+ "id": "replaces",
278
+ "iri": "https://glossarist.org/ontologies/relationship-type#replaces",
279
+ "prefLabel": "replaces",
280
+ "definition": "This concept replaces another concept (ISO 10241-1)."
281
+ },
282
+ "replaced_by": {
283
+ "id": "replaced_by",
284
+ "iri": "https://glossarist.org/ontologies/relationship-type#replaced_by",
285
+ "prefLabel": "replaced by",
286
+ "definition": "This concept has been replaced by another concept (ISO 10241-1)."
287
+ },
288
+ "invalidates": {
289
+ "id": "invalidates",
290
+ "iri": "https://glossarist.org/ontologies/relationship-type#invalidates",
291
+ "prefLabel": "invalidates",
292
+ "definition": "This concept invalidates another concept, indicating it contains a substantial error and should not be used (ISO 19135)."
293
+ },
294
+ "invalidated_by": {
295
+ "id": "invalidated_by",
296
+ "iri": "https://glossarist.org/ontologies/relationship-type#invalidated_by",
297
+ "prefLabel": "invalidated by",
298
+ "definition": "This concept has been invalidated by another concept due to a substantial error (ISO 19135)."
299
+ },
300
+ "retires": {
301
+ "id": "retires",
302
+ "iri": "https://glossarist.org/ontologies/relationship-type#retires",
303
+ "prefLabel": "retires",
304
+ "definition": "This concept retires another concept, indicating it is no longer recommended for use (ISO 19135)."
305
+ },
306
+ "retired_by": {
307
+ "id": "retired_by",
308
+ "iri": "https://glossarist.org/ontologies/relationship-type#retired_by",
309
+ "prefLabel": "retired by",
310
+ "definition": "This concept has been retired by another concept (ISO 19135)."
311
+ },
312
+ "has_concept": {
313
+ "id": "has_concept",
314
+ "iri": "https://glossarist.org/ontologies/relationship-type#has_concept",
315
+ "prefLabel": "has concept",
316
+ "definition": "This concept has the nominated concept as a specialisation of its definition (ISO 19135)."
317
+ },
318
+ "is_concept_of": {
319
+ "id": "is_concept_of",
320
+ "iri": "https://glossarist.org/ontologies/relationship-type#is_concept_of",
321
+ "prefLabel": "is concept of",
322
+ "definition": "This concept is a specialisation of the source concept (ISO 19135)."
323
+ },
324
+ "instance_of": {
325
+ "id": "instance_of",
326
+ "iri": "https://glossarist.org/ontologies/relationship-type#instance_of",
327
+ "prefLabel": "instance of",
328
+ "definition": "This concept is an instance of the nominated concept (ISO 19135)."
329
+ },
330
+ "has_instance": {
331
+ "id": "has_instance",
332
+ "iri": "https://glossarist.org/ontologies/relationship-type#has_instance",
333
+ "prefLabel": "has instance",
334
+ "definition": "This concept has the nominated concept as an instance (ISO 19135)."
335
+ },
336
+ "has_definition": {
337
+ "id": "has_definition",
338
+ "iri": "https://glossarist.org/ontologies/relationship-type#has_definition",
339
+ "prefLabel": "has definition",
340
+ "definition": "This concept has the nominated concept as its definition (ISO 19135)."
341
+ },
342
+ "definition_of": {
343
+ "id": "definition_of",
344
+ "iri": "https://glossarist.org/ontologies/relationship-type#definition_of",
345
+ "prefLabel": "definition of",
346
+ "definition": "This concept is the definition of the source concept (ISO 19135)."
347
+ },
348
+ "has_part": {
349
+ "id": "has_part",
350
+ "iri": "https://glossarist.org/ontologies/relationship-type#has_part",
351
+ "prefLabel": "has part",
352
+ "definition": "This concept has the nominated concept as a part of its composition (ISO 19135)."
353
+ },
354
+ "is_part_of": {
355
+ "id": "is_part_of",
356
+ "iri": "https://glossarist.org/ontologies/relationship-type#is_part_of",
357
+ "prefLabel": "is part of",
358
+ "definition": "This concept is a part of the source concept (ISO 19135)."
359
+ },
360
+ "inherits": {
361
+ "id": "inherits",
362
+ "iri": "https://glossarist.org/ontologies/relationship-type#inherits",
363
+ "prefLabel": "inherits",
364
+ "definition": "This concept inherits and extends properties from the nominated concept (ISO 19135)."
365
+ },
366
+ "inherited_by": {
367
+ "id": "inherited_by",
368
+ "iri": "https://glossarist.org/ontologies/relationship-type#inherited_by",
369
+ "prefLabel": "inherited by",
370
+ "definition": "This concept is inherited by the source concept (ISO 19135)."
371
+ },
372
+ "has_version": {
373
+ "id": "has_version",
374
+ "iri": "https://glossarist.org/ontologies/relationship-type#has_version",
375
+ "prefLabel": "has version",
376
+ "definition": "This concept has the nominated concept as a version (ISO 19135)."
377
+ },
378
+ "version_of": {
379
+ "id": "version_of",
380
+ "iri": "https://glossarist.org/ontologies/relationship-type#version_of",
381
+ "prefLabel": "version of",
382
+ "definition": "This concept is a version of the source concept (ISO 19135)."
383
+ },
384
+ "current_version": {
385
+ "id": "current_version",
386
+ "iri": "https://glossarist.org/ontologies/relationship-type#current_version",
387
+ "prefLabel": "current version",
388
+ "definition": "This concept has the nominated concept as its current version (ISO 19135)."
389
+ },
390
+ "current_version_of": {
391
+ "id": "current_version_of",
392
+ "iri": "https://glossarist.org/ontologies/relationship-type#current_version_of",
393
+ "prefLabel": "current version of",
394
+ "definition": "This concept is the current version of the source concept (ISO 19135)."
395
+ },
396
+ "broader": {
397
+ "id": "broader",
398
+ "iri": "https://glossarist.org/ontologies/relationship-type#broader",
399
+ "prefLabel": "broader",
400
+ "definition": "This concept has a broader (more general) scope (SKOS)."
401
+ },
402
+ "narrower": {
403
+ "id": "narrower",
404
+ "iri": "https://glossarist.org/ontologies/relationship-type#narrower",
405
+ "prefLabel": "narrower",
406
+ "definition": "This concept has a narrower (more specific) scope (SKOS)."
407
+ },
408
+ "broader_generic": {
409
+ "id": "broader_generic",
410
+ "iri": "https://glossarist.org/ontologies/relationship-type#broader_generic",
411
+ "prefLabel": "broader (generic)",
412
+ "definition": "Generic (is-a) broader relationship (ISO 25964)."
413
+ },
414
+ "narrower_generic": {
415
+ "id": "narrower_generic",
416
+ "iri": "https://glossarist.org/ontologies/relationship-type#narrower_generic",
417
+ "prefLabel": "narrower (generic)",
418
+ "definition": "Generic (is-a) narrower relationship (ISO 25964)."
419
+ },
420
+ "broader_partitive": {
421
+ "id": "broader_partitive",
422
+ "iri": "https://glossarist.org/ontologies/relationship-type#broader_partitive",
423
+ "prefLabel": "broader (partitive)",
424
+ "definition": "Partitive (whole-of) broader relationship (ISO 25964)."
425
+ },
426
+ "narrower_partitive": {
427
+ "id": "narrower_partitive",
428
+ "iri": "https://glossarist.org/ontologies/relationship-type#narrower_partitive",
429
+ "prefLabel": "narrower (partitive)",
430
+ "definition": "Partitive (part-of) narrower relationship (ISO 25964)."
431
+ },
432
+ "broader_instantial": {
433
+ "id": "broader_instantial",
434
+ "iri": "https://glossarist.org/ontologies/relationship-type#broader_instantial",
435
+ "prefLabel": "broader (instantial)",
436
+ "definition": "Instantial broader relationship (ISO 25964)."
437
+ },
438
+ "narrower_instantial": {
439
+ "id": "narrower_instantial",
440
+ "iri": "https://glossarist.org/ontologies/relationship-type#narrower_instantial",
441
+ "prefLabel": "narrower (instantial)",
442
+ "definition": "Instantial narrower relationship (ISO 25964)."
443
+ },
444
+ "equivalent": {
445
+ "id": "equivalent",
446
+ "iri": "https://glossarist.org/ontologies/relationship-type#equivalent",
447
+ "prefLabel": "equivalent",
448
+ "definition": "This concept is equivalent to another concept (SKOS)."
449
+ },
450
+ "close_match": {
451
+ "id": "close_match",
452
+ "iri": "https://glossarist.org/ontologies/relationship-type#close_match",
453
+ "prefLabel": "close match",
454
+ "definition": "This concept is a close match to another concept (SKOS)."
455
+ },
456
+ "broad_match": {
457
+ "id": "broad_match",
458
+ "iri": "https://glossarist.org/ontologies/relationship-type#broad_match",
459
+ "prefLabel": "broad match",
460
+ "definition": "This concept has a broader match to another concept (SKOS)."
461
+ },
462
+ "narrow_match": {
463
+ "id": "narrow_match",
464
+ "iri": "https://glossarist.org/ontologies/relationship-type#narrow_match",
465
+ "prefLabel": "narrow match",
466
+ "definition": "This concept has a narrower match to another concept (SKOS)."
467
+ },
468
+ "related_match": {
469
+ "id": "related_match",
470
+ "iri": "https://glossarist.org/ontologies/relationship-type#related_match",
471
+ "prefLabel": "related match",
472
+ "definition": "This concept has a related match to another concept (SKOS)."
473
+ },
474
+ "see": {
475
+ "id": "see",
476
+ "iri": "https://glossarist.org/ontologies/relationship-type#see",
477
+ "prefLabel": "see",
478
+ "definition": "Reference to another concept (ISO 10241-1)."
479
+ },
480
+ "related_concept": {
481
+ "id": "related_concept",
482
+ "iri": "https://glossarist.org/ontologies/relationship-type#related_concept",
483
+ "prefLabel": "related concept",
484
+ "definition": "Associative relationship to another concept (ISO 25964)."
485
+ },
486
+ "references": {
487
+ "id": "references",
488
+ "iri": "https://glossarist.org/ontologies/relationship-type#references",
489
+ "prefLabel": "references",
490
+ "definition": "This concept references another concept."
491
+ },
492
+ "sequentially_related": {
493
+ "id": "sequentially_related",
494
+ "iri": "gloss:rel/sequentially_related",
495
+ "prefLabel": "sequentially related",
496
+ "definition": "Sequential spatiotemporal relationship (ISO 25964 / TBX)."
497
+ },
498
+ "spatially_related": {
499
+ "id": "spatially_related",
500
+ "iri": "gloss:rel/spatially_related",
501
+ "prefLabel": "spatially related",
502
+ "definition": "Spatial relationship (ISO 25964 / TBX)."
503
+ },
504
+ "temporally_related": {
505
+ "id": "temporally_related",
506
+ "iri": "gloss:rel/temporally_related",
507
+ "prefLabel": "temporally related",
508
+ "definition": "Temporal relationship (ISO 25964 / TBX)."
509
+ },
510
+ "exact_match": {
511
+ "id": "exact_match",
512
+ "iri": "gloss:rel/exact_match",
513
+ "prefLabel": "exact match",
514
+ "definition": "Cross-vocabulary exact equivalence (SKOS skos:exactMatch)."
287
515
  }
288
516
  }
289
517
  },
@@ -4,8 +4,7 @@ import { loadPlurimath, mathToHtml } from '../utils/plurimath';
4
4
  let loaded = false;
5
5
 
6
6
  function upgrade(el: HTMLElement) {
7
- const pending = el.querySelectorAll('.math-pending');
8
- if (!pending.length) return;
7
+ if (!el.querySelector('.math-pending')) return;
9
8
 
10
9
  if (!loaded) {
11
10
  loadPlurimath().then(() => {
@@ -15,7 +14,7 @@ function upgrade(el: HTMLElement) {
15
14
  return;
16
15
  }
17
16
 
18
- pending.forEach((span) => {
17
+ el.querySelectorAll('.math-pending').forEach((span) => {
19
18
  const expr = (span as HTMLElement).dataset.expr;
20
19
  const format = (span as HTMLElement).dataset.format || 'asciimath';
21
20
  const bold = span.classList.contains('math-bold');
@@ -72,15 +72,23 @@ export class GraphEngine {
72
72
  if (from) {
73
73
  const adj = this.adjacency.get(from);
74
74
  if (!adj) return [];
75
- return [...adj.values()].flat();
75
+ const result: GraphEdge[] = [];
76
+ for (const list of adj.values()) {
77
+ for (const e of list) result.push(e);
78
+ }
79
+ return result;
76
80
  }
77
- return this.edges;
81
+ return [...this.edges];
78
82
  }
79
83
 
80
84
  getIncomingEdges(uri: string): GraphEdge[] {
81
85
  const adj = this.reverseAdjacency.get(uri);
82
86
  if (!adj) return [];
83
- return [...adj.values()].flat();
87
+ const result: GraphEdge[] = [];
88
+ for (const list of adj.values()) {
89
+ for (const e of list) result.push(e);
90
+ }
91
+ return result;
84
92
  }
85
93
 
86
94
  getNeighbors(uri: string): { outgoing: string[]; incoming: string[] } {
@@ -100,9 +108,10 @@ export class GraphEngine {
100
108
  const collectedNodes: GraphNode[] = [];
101
109
  const collectedEdges: GraphEdge[] = [];
102
110
  const queue: { uri: string; d: number }[] = [{ uri: rootUri, d: 0 }];
111
+ let head = 0;
103
112
 
104
- while (queue.length > 0) {
105
- const { uri, d } = queue.shift()!;
113
+ while (head < queue.length) {
114
+ const { uri, d } = queue[head++];
106
115
  if (visited.has(uri) || d > depth) continue;
107
116
  visited.add(uri);
108
117
 
@@ -137,4 +146,12 @@ export class GraphEngine {
137
146
  get edgeCount(): number {
138
147
  return this.edges.length;
139
148
  }
149
+
150
+ clear(): void {
151
+ this.nodes.clear();
152
+ this.edges.length = 0;
153
+ this.edgeKeys.clear();
154
+ this.adjacency.clear();
155
+ this.reverseAdjacency.clear();
156
+ }
140
157
  }
package/src/i18n/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ref } from 'vue';
2
+ import { DEFAULT_LANG } from '../utils/lang';
2
3
 
3
4
  // Auto-discover all locale YAML files — adding a new .yml file is all that's needed
4
5
  const localeModules = import.meta.glob<{ default: Record<string, string> }>('./locales/*.yml', { eager: true });
@@ -14,7 +15,6 @@ for (const path of Object.keys(localeModules)) {
14
15
  }
15
16
  }
16
17
 
17
- const DEFAULT_LANG = 'eng';
18
18
  const stored = typeof localStorage !== 'undefined'
19
19
  ? (localStorage.getItem('ui-lang') || DEFAULT_LANG)
20
20
  : DEFAULT_LANG;
@@ -1,11 +1,12 @@
1
1
  import { defineStore } from 'pinia';
2
- import { ref, computed, toRaw } from 'vue';
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 = ref(new GraphEngine());
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 = toRaw(graph.value);
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
- graph.value.addNode(dn);
147
+ engine.addNode(dn);
202
148
  }
203
149
  for (const edge of edges) {
204
- graph.value.addEdge(edge);
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 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
- }
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
- const concept = await adapter.fetchConcept(conceptId);
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.addNode({
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
- graph.value.addEdge(edge);
220
+ engine.addEdge(edge);
272
221
  }
273
222
 
274
223
  for (const edge of domainEdges) {
275
- graph.value.addEdge(edge);
276
- const existing = graph.value.getNode(edge.target);
224
+ engine.addEdge(edge);
225
+ const existing = engine.getNode(edge.target);
277
226
  if (!existing || !existing.loaded) {
278
- graph.value.addNode({
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
- ...graph.value.getEdges(uri),
296
- ...graph.value.getIncomingEdges(uri),
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 allHits: SearchHit[] = [];
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
- if (!adapter.index) {
282
+ if (!adapter.manifest) continue;
283
+ if (!adapter.index) {
284
+ try {
333
285
  await adapter.loadIndex();
286
+ } catch {
287
+ continue;
334
288
  }
335
- await adapter.ensureAllChunksLoaded();
336
- allHits.push(...adapter.search(query));
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
- // Deduplicate: keep the best hit per (registerId, conceptId)
341
- const best = new Map<string, SearchHit>();
342
- for (const hit of allHits) {
343
- const key = `${hit.registerId}:${hit.conceptId}`;
344
- const existing = best.get(key);
345
- if (!existing) {
346
- best.set(key, hit);
347
- } else {
348
- // Prefer designation match over id match, then prefer shorter language code (eng first)
349
- if (hit.matchField === 'designation' && existing.matchField === 'id') {
350
- best.set(key, hit);
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
- return [...best.values()];
313
+
314
+ return deduplicateSearchHits(allHits);
355
315
  }
356
316
 
357
317
  async function getRandomConcept(): Promise<{ registerId: string; conceptId: string } | null> {
@@ -1 +1,2 @@
1
1
  export { langName, langLabel, DEFAULT_LANG } from './lang';
2
+ export { deduplicateSearchHits } from './search';