@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
@@ -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
  {
@@ -73,7 +73,7 @@ export const RELATIONSHIP_CATEGORIES: RelationshipCategory[] = [
73
73
 
74
74
 
75
75
  export const INVERSE_RELATIONSHIPS: Record<string, string> = {
76
- // Lifecycle
76
+ // Lifecycle (ISO 10241-1)
77
77
  supersedes: 'superseded_by',
78
78
  superseded_by: 'supersedes',
79
79
  deprecates: 'deprecated_by',
@@ -81,41 +81,50 @@ export const INVERSE_RELATIONSHIPS: Record<string, string> = {
81
81
  replaces: 'replaced_by',
82
82
  replaced_by: 'replaces',
83
83
  invalidates: 'invalidated_by',
84
+ invalidated_by: 'invalidates',
84
85
  retires: 'retired_by',
86
+ retired_by: 'retires',
85
87
 
86
- // Hierarchical (generic)
88
+ // Hierarchical (generic — SKOS)
87
89
  broader: 'narrower',
88
90
  narrower: 'broader',
89
91
  broader_generic: 'narrower_generic',
90
92
  narrower_generic: 'broader_generic',
91
93
 
92
- // Hierarchical (partitive)
94
+ // Hierarchical (partitive — ISO 25964)
93
95
  broader_partitive: 'narrower_partitive',
94
96
  narrower_partitive: 'broader_partitive',
95
97
  has_part: 'is_part_of',
96
98
  is_part_of: 'has_part',
97
99
 
98
- // Hierarchical (instantial)
100
+ // Hierarchical (instantial — ISO 25964)
99
101
  broader_instantial: 'narrower_instantial',
100
102
  narrower_instantial: 'broader_instantial',
101
103
  instance_of: 'has_instance',
102
104
  has_instance: 'instance_of',
103
105
 
104
- // ISO 19135 register relations
106
+ // ISO 19135 concept-to-concept
105
107
  has_concept: 'is_concept_of',
106
108
  is_concept_of: 'has_concept',
107
109
  inherits: 'inherited_by',
108
110
  inherited_by: 'inherits',
109
111
  has_definition: 'definition_of',
112
+ definition_of: 'has_definition',
113
+
114
+ // ISO 19135 versioning
110
115
  has_version: 'version_of',
116
+ version_of: 'has_version',
111
117
  current_version: 'current_version_of',
118
+ current_version_of: 'current_version',
112
119
 
113
120
  // Symmetric (self-inverse)
114
121
  equivalent: 'equivalent',
122
+ exact_match: 'exact_match',
115
123
  compare: 'compare',
116
124
  contrast: 'contrast',
117
125
  close_match: 'close_match',
118
126
  related_match: 'related_match',
127
+ related_concept: 'related_concept',
119
128
  };
120
129
  const CATEGORY_MAP = new Map<string, RelationshipCategory>();
121
130
  for (const cat of RELATIONSHIP_CATEGORIES) {
@@ -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, ManifestSection } from '../adapters/types';
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
- // When filtering, ensure all chunks are loaded for accurate search
91
- watch(filter, async (q) => {
125
+ async function ensureAllChunksForFilter(needsLoad: boolean) {
92
126
  page.value = 1;
93
- if (q.trim().length >= 2 && !allChunksLoaded.value && adapter.value) {
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
- 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
- }
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
- page.value = 1;
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.id, sec)) return false;
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(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.
166
+ function conceptMatchesSection(concept: import('../adapters/types').ConceptSummary, sectionPrefix: string): boolean {
146
167
  const prefix = sectionPrefix.replace(/^section-/, '');
147
- return conceptId.startsWith(prefix + '.') || conceptId.startsWith(prefix + '-');
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
- 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;
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
- {{ sectionQuery }}
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>
@@ -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: resolve(cwd, 'public'),
139
+ publicDir,
69
140
  build: {
70
141
  outDir: resolve(cwd, 'dist'),
71
142
  emptyOutDir: true,
72
143
  },
73
- plugins: [yamlPlugin(), faviconPlugin(), brandingPlugin(), vue()],
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'),