@glossarist/concept-browser 0.1.0

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 (68) hide show
  1. package/README.md +319 -0
  2. package/cli/index.mjs +119 -0
  3. package/env.d.ts +7 -0
  4. package/index.html +16 -0
  5. package/package.json +78 -0
  6. package/postcss.config.js +6 -0
  7. package/scripts/build-edges.js +112 -0
  8. package/scripts/fetch-datasets.mjs +195 -0
  9. package/scripts/generate-404.js +15 -0
  10. package/scripts/generate-data.mjs +606 -0
  11. package/scripts/load-site-config.mjs +56 -0
  12. package/src/App.vue +98 -0
  13. package/src/__tests__/data-integration.test.ts +135 -0
  14. package/src/__tests__/data-integrity.test.ts +101 -0
  15. package/src/__tests__/dataset-adapter.test.ts +336 -0
  16. package/src/__tests__/dataset-style.test.ts +37 -0
  17. package/src/__tests__/graph.test.ts +187 -0
  18. package/src/__tests__/lang.test.ts +29 -0
  19. package/src/__tests__/math.test.ts +113 -0
  20. package/src/__tests__/reference-resolver.test.ts +122 -0
  21. package/src/__tests__/site-config.test.ts +52 -0
  22. package/src/__tests__/uri-router.test.ts +76 -0
  23. package/src/adapters/DatasetAdapter.ts +270 -0
  24. package/src/adapters/ReferenceResolver.ts +95 -0
  25. package/src/adapters/UriRouter.ts +41 -0
  26. package/src/adapters/factory.ts +78 -0
  27. package/src/adapters/types.ts +162 -0
  28. package/src/components/AppHeader.vue +99 -0
  29. package/src/components/AppSidebar.vue +133 -0
  30. package/src/components/ConceptCard.vue +65 -0
  31. package/src/components/ConceptDetail.vue +540 -0
  32. package/src/components/ConceptTimeline.vue +410 -0
  33. package/src/components/FormatDownloads.vue +46 -0
  34. package/src/components/GraphPanel.vue +499 -0
  35. package/src/components/LanguageDetail.vue +211 -0
  36. package/src/components/NavIcon.vue +20 -0
  37. package/src/components/SearchBar.vue +241 -0
  38. package/src/composables/use-dataset-loader.ts +27 -0
  39. package/src/config/types.ts +130 -0
  40. package/src/config/use-site-config.ts +144 -0
  41. package/src/graph/GraphEngine.ts +137 -0
  42. package/src/graph/index.ts +1 -0
  43. package/src/main.ts +11 -0
  44. package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  45. package/src/router/index.ts +43 -0
  46. package/src/router/page-routes.ts +35 -0
  47. package/src/stores/ui.ts +59 -0
  48. package/src/stores/vocabulary.ts +309 -0
  49. package/src/style.css +314 -0
  50. package/src/utils/asciidoc-lite.ts +123 -0
  51. package/src/utils/concept-formats.ts +157 -0
  52. package/src/utils/dataset-style.ts +54 -0
  53. package/src/utils/index.ts +1 -0
  54. package/src/utils/lang.ts +32 -0
  55. package/src/utils/math.ts +100 -0
  56. package/src/views/AboutView.vue +122 -0
  57. package/src/views/ConceptView.vue +119 -0
  58. package/src/views/ContributorsView.vue +110 -0
  59. package/src/views/DatasetView.vue +249 -0
  60. package/src/views/GraphView.vue +65 -0
  61. package/src/views/HomeView.vue +168 -0
  62. package/src/views/NewsView.vue +146 -0
  63. package/src/views/ResolveView.vue +63 -0
  64. package/src/views/SearchView.vue +33 -0
  65. package/src/views/StatsView.vue +121 -0
  66. package/tailwind.config.js +43 -0
  67. package/tsconfig.json +24 -0
  68. package/vite.config.ts +27 -0
@@ -0,0 +1,119 @@
1
+ <script setup lang="ts">
2
+ import { computed, watch, ref } from 'vue';
3
+ import { useVocabularyStore } from '../stores/vocabulary';
4
+ import ConceptDetail from '../components/ConceptDetail.vue';
5
+
6
+ const props = defineProps<{
7
+ registerId: string;
8
+ conceptId: string;
9
+ }>();
10
+
11
+ const store = useVocabularyStore();
12
+ const conceptLoading = ref(false);
13
+ const localError = ref<string | null>(null);
14
+
15
+ async function loadConcept(regId: string, cId: string) {
16
+ conceptLoading.value = true;
17
+ localError.value = null;
18
+ store.error = null;
19
+ try {
20
+ // Ensure dataset is loaded (index + chunks) before fetching concept
21
+ const adapter = store.datasets.get(regId);
22
+ if (!adapter?.index) {
23
+ await store.loadDataset(regId);
24
+ }
25
+ await store.viewConcept(regId, cId);
26
+ } catch (e: any) {
27
+ localError.value = e.message || 'Unknown error';
28
+ } finally {
29
+ conceptLoading.value = false;
30
+ }
31
+ }
32
+
33
+ watch(
34
+ () => [props.registerId, props.conceptId],
35
+ async ([regId, cId]) => {
36
+ await loadConcept(regId as string, cId as string);
37
+ loadAdjacent();
38
+ },
39
+ { immediate: true }
40
+ );
41
+
42
+ const concept = computed(() => store.currentConcept);
43
+ const manifest = computed(() => store.currentManifest);
44
+ const edges = computed(() => store.conceptEdges);
45
+ const adjacent = ref({ prev: null as string | null, next: null as string | null });
46
+
47
+ async function loadAdjacent() {
48
+ const adapter = store.datasets.get(props.registerId);
49
+ if (!adapter?.index) return;
50
+ const idx = adapter.getConceptPosition(props.conceptId);
51
+ if (idx >= 0) {
52
+ await adapter.ensureChunksForRange(Math.max(0, idx - 1), 3);
53
+ }
54
+ adjacent.value = adapter.getAdjacentConcepts(props.conceptId);
55
+ }
56
+
57
+ watch(() => props.conceptId, () => { loadAdjacent(); });
58
+ </script>
59
+
60
+ <template>
61
+ <div class="px-4 sm:px-6 lg:px-8 py-8">
62
+ <div v-if="conceptLoading" class="max-w-5xl mx-auto py-8 space-y-5">
63
+ <!-- Breadcrumb skeleton -->
64
+ <div class="flex items-center gap-1.5">
65
+ <div class="skeleton h-3 w-24"></div>
66
+ <div class="skeleton h-3 w-4"></div>
67
+ <div class="skeleton h-3 w-16"></div>
68
+ </div>
69
+ <!-- Title skeleton -->
70
+ <div class="skeleton h-10 w-72"></div>
71
+ <!-- Badge skeleton -->
72
+ <div class="flex gap-2">
73
+ <div class="skeleton h-5 w-20"></div>
74
+ <div class="skeleton h-5 w-16"></div>
75
+ <div class="skeleton h-5 w-28"></div>
76
+ </div>
77
+ <!-- Language section skeleton -->
78
+ <div class="border border-ink-100/80 rounded-lg p-4 space-y-3">
79
+ <div class="flex items-center gap-2">
80
+ <div class="skeleton h-4 w-4"></div>
81
+ <div class="skeleton h-5 w-40"></div>
82
+ <div class="skeleton h-3 w-12"></div>
83
+ </div>
84
+ <div class="skeleton h-20 w-full"></div>
85
+ <div class="skeleton h-4 w-3/4"></div>
86
+ </div>
87
+ </div>
88
+ <div v-else-if="localError" class="max-w-xl mx-auto text-center py-20">
89
+ <div class="card p-8 border-red-200 bg-red-50/50">
90
+ <p class="text-red-700 font-medium mb-1">Failed to load concept</p>
91
+ <p class="text-sm text-red-600/80 mb-4">{{ localError }}</p>
92
+ <div class="flex gap-2 justify-center">
93
+ <button @click="loadConcept(registerId, conceptId)" class="btn-primary">Retry</button>
94
+ <router-link :to="{ name: 'dataset', params: { registerId } }" class="btn-secondary">
95
+ Back to dataset
96
+ </router-link>
97
+ </div>
98
+ </div>
99
+ </div>
100
+ <div v-else-if="!concept" class="max-w-xl mx-auto text-center py-20">
101
+ <div class="card p-8">
102
+ <div class="text-ink-200 text-5xl mb-3 font-serif">?</div>
103
+ <h3 class="text-lg font-medium text-ink-700 mb-2">Concept not found</h3>
104
+ <p class="text-sm text-ink-400 mb-4">The concept "{{ conceptId }}" does not exist in this dataset.</p>
105
+ <router-link :to="{ name: 'dataset', params: { registerId } }" class="btn-primary">
106
+ Back to dataset
107
+ </router-link>
108
+ </div>
109
+ </div>
110
+ <ConceptDetail
111
+ v-else-if="concept && manifest"
112
+ :concept="concept"
113
+ :manifest="manifest"
114
+ :edges="edges"
115
+ :adjacent="adjacent"
116
+ :register-id="registerId"
117
+ />
118
+ </div>
119
+ </template>
@@ -0,0 +1,110 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted } from 'vue';
3
+ import { useSiteConfig } from '../config/use-site-config';
4
+
5
+ interface Contributor {
6
+ language: string;
7
+ registerName: string;
8
+ organization: string;
9
+ contact: string;
10
+ email: string;
11
+ uri: string;
12
+ country: string;
13
+ }
14
+
15
+ interface ContributorsData {
16
+ register: string;
17
+ owner: string;
18
+ manager: string;
19
+ contributors: Contributor[];
20
+ }
21
+
22
+ const { config } = useSiteConfig();
23
+ const data = ref<ContributorsData | null>(null);
24
+ const loading = ref(true);
25
+ const error = ref<string | null>(null);
26
+
27
+ onMounted(async () => {
28
+ try {
29
+ const resp = await fetch('/contributors.json');
30
+ if (resp.ok) data.value = await resp.json();
31
+ } catch (e: any) {
32
+ error.value = e.message;
33
+ }
34
+ loading.value = false;
35
+ });
36
+ </script>
37
+
38
+ <template>
39
+ <div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
40
+ <h1 class="font-serif text-3xl text-ink-800 mb-2">Contributors</h1>
41
+ <p class="text-ink-400 mb-8">
42
+ Organizations and individuals contributing to {{ config?.branding?.ownerName || config?.title || 'this glossary' }}.
43
+ </p>
44
+
45
+ <template v-if="loading">
46
+ <div class="animate-pulse space-y-6">
47
+ <div class="card p-6 space-y-3">
48
+ <div class="h-4 bg-ink-100 rounded w-40"></div>
49
+ <div class="h-6 bg-ink-100 rounded w-3/4"></div>
50
+ <div class="h-4 bg-ink-100 rounded w-1/2"></div>
51
+ </div>
52
+ </div>
53
+ </template>
54
+
55
+ <template v-else-if="error">
56
+ <div class="card p-8 text-center">
57
+ <p class="text-ink-500">Failed to load contributors.</p>
58
+ </div>
59
+ </template>
60
+
61
+ <template v-else-if="data">
62
+ <!-- Register metadata -->
63
+ <div v-if="data.owner" class="card p-6 mb-6">
64
+ <h2 class="section-label">Register Information</h2>
65
+ <dl class="space-y-3 mt-3">
66
+ <div v-if="data.owner" class="flex items-start gap-4">
67
+ <dt class="text-ink-400 text-sm w-40 flex-shrink-0 pt-0.5">Owner</dt>
68
+ <dd class="text-ink-800 font-medium">{{ data.owner }}</dd>
69
+ </div>
70
+ <div v-if="data.manager" class="flex items-start gap-4">
71
+ <dt class="text-ink-400 text-sm w-40 flex-shrink-0 pt-0.5">Manager</dt>
72
+ <dd class="text-ink-800">{{ data.manager }}</dd>
73
+ </div>
74
+ </dl>
75
+ </div>
76
+
77
+ <!-- Per-language contributors -->
78
+ <div v-if="data.contributors.length" class="card overflow-x-auto">
79
+ <table class="w-full text-sm">
80
+ <thead>
81
+ <tr class="border-b border-ink-100/60 bg-ink-50/50">
82
+ <th class="text-left px-5 py-3 text-ink-500 font-medium">Language</th>
83
+ <th class="text-left px-5 py-3 text-ink-500 font-medium">Organization</th>
84
+ <th class="text-left px-5 py-3 text-ink-500 font-medium">Contact</th>
85
+ </tr>
86
+ </thead>
87
+ <tbody>
88
+ <tr v-for="c in data.contributors" :key="c.language" class="border-b border-ink-50 last:border-0">
89
+ <td class="px-5 py-3">
90
+ <span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ c.language.toUpperCase() }}</span>
91
+ <span v-if="c.registerName" class="ml-2 text-ink-600 text-xs">{{ c.registerName }}</span>
92
+ </td>
93
+ <td class="px-5 py-3 text-ink-700">
94
+ <span v-if="c.uri"><a :href="c.uri" target="_blank" class="concept-link">{{ c.organization }}</a></span>
95
+ <span v-else>{{ c.organization }}</span>
96
+ </td>
97
+ <td class="px-5 py-3 text-ink-500 text-xs">{{ c.contact }}</td>
98
+ </tr>
99
+ </tbody>
100
+ </table>
101
+ </div>
102
+ </template>
103
+
104
+ <template v-else>
105
+ <div class="card p-8 text-center">
106
+ <p class="text-ink-500">No contributor information available.</p>
107
+ </div>
108
+ </template>
109
+ </div>
110
+ </template>
@@ -0,0 +1,249 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch, onMounted, onUnmounted } from 'vue';
3
+ import { useVocabularyStore } from '../stores/vocabulary';
4
+ import { useDsStyle } from '../utils/dataset-style';
5
+ import { useDatasetLoader } from '../composables/use-dataset-loader';
6
+ import ConceptCard from '../components/ConceptCard.vue';
7
+
8
+ const props = defineProps<{ registerId: string }>();
9
+
10
+ const store = useVocabularyStore();
11
+ const { getStyle } = useDsStyle();
12
+ const { loading, localError, ensureLoaded } = useDatasetLoader(() => props.registerId);
13
+
14
+ const manifest = computed(() => store.manifests.get(props.registerId));
15
+ const adapter = computed(() => store.datasets.get(props.registerId));
16
+ const chunkLoading = ref(false);
17
+
18
+ const totalConceptCount = computed(() => adapter.value?.getConceptCount() ?? 0);
19
+
20
+ const filter = ref('');
21
+ const filterInput = ref<HTMLInputElement | null>(null);
22
+ const allChunksLoaded = ref(false);
23
+
24
+ function onGlobalKeydown(e: KeyboardEvent) {
25
+ if (e.key === '/' && document.activeElement?.tagName !== 'INPUT' && document.activeElement?.tagName !== 'TEXTAREA') {
26
+ e.preventDefault();
27
+ filterInput.value?.focus();
28
+ }
29
+ }
30
+
31
+ onMounted(() => window.addEventListener('keydown', onGlobalKeydown));
32
+ onUnmounted(() => window.removeEventListener('keydown', onGlobalKeydown));
33
+
34
+ // When filtering, ensure all chunks are loaded for accurate search
35
+ watch(filter, async (q) => {
36
+ page.value = 1;
37
+ if (q.trim().length >= 2 && !allChunksLoaded.value && adapter.value) {
38
+ chunkLoading.value = true;
39
+ await adapter.value.ensureAllChunksLoaded();
40
+ allChunksLoaded.value = true;
41
+ chunkLoading.value = false;
42
+ }
43
+ });
44
+
45
+ // Dense array: only loaded (non-undefined) entries
46
+ const loadedConcepts = computed(() => {
47
+ const arr = adapter.value?.getConcepts() as (import('../adapters/types').ConceptSummary | undefined)[] | undefined;
48
+ if (!arr) return [];
49
+ return arr.filter((c): c is import('../adapters/types').ConceptSummary => c != null);
50
+ });
51
+
52
+ const filtered = computed(() => {
53
+ const q = filter.value.trim().toLowerCase();
54
+ if (!q) return loadedConcepts.value;
55
+ return loadedConcepts.value.filter(c => {
56
+ return (c.eng || '').toLowerCase().includes(q) || c.id.toLowerCase().includes(q);
57
+ });
58
+ });
59
+
60
+ const page = ref(1);
61
+ const perPage = 50;
62
+
63
+ // Check if the current page range is loaded in the index
64
+ const pageLoaded = computed(() => {
65
+ if (!adapter.value) return false;
66
+ const start = (page.value - 1) * perPage;
67
+ return adapter.value.isRangeLoaded(start, perPage);
68
+ });
69
+
70
+ const paged = computed(() => {
71
+ // When filtering, paginate over filtered dense results (all chunks loaded)
72
+ if (filter.value.trim()) {
73
+ const start = (page.value - 1) * perPage;
74
+ return filtered.value.slice(start, start + perPage);
75
+ }
76
+ // When not filtering, slice directly from the pre-allocated index (may contain undefined)
77
+ const start = (page.value - 1) * perPage;
78
+ const arr = adapter.value?.getConcepts() as (import('../adapters/types').ConceptSummary | undefined)[] | undefined;
79
+ if (!arr) return [];
80
+ return arr.slice(start, start + perPage).filter((c): c is import('../adapters/types').ConceptSummary => c != null);
81
+ });
82
+
83
+ const totalPages = computed(() => {
84
+ if (filter.value.trim()) {
85
+ return Math.max(1, Math.ceil(filtered.value.length / perPage));
86
+ }
87
+ return Math.max(1, Math.ceil(totalConceptCount.value / perPage));
88
+ });
89
+
90
+ // Load chunks needed for current page
91
+ watch(page, async () => {
92
+ if (!adapter.value || filter.value.trim()) return;
93
+ const start = (page.value - 1) * perPage;
94
+ if (!adapter.value.isRangeLoaded(start, perPage)) {
95
+ chunkLoading.value = true;
96
+ await adapter.value.ensureChunksForRange(start, perPage);
97
+ chunkLoading.value = false;
98
+ }
99
+ }, { immediate: true });
100
+
101
+ // Visible page numbers for pagination (avoids iterating 445+ pages)
102
+ const visiblePages = computed(() => {
103
+ const total = totalPages.value;
104
+ const current = page.value;
105
+ if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
106
+ const pages: number[] = [1];
107
+ const rangeStart = Math.max(2, current - 2);
108
+ const rangeEnd = Math.min(total - 1, current + 2);
109
+ if (rangeStart > 2) pages.push(-1);
110
+ for (let p = rangeStart; p <= rangeEnd; p++) pages.push(p);
111
+ if (rangeEnd < total - 1) pages.push(-2);
112
+ pages.push(total);
113
+ return pages;
114
+ });
115
+
116
+ function goToPage(p: number) {
117
+ page.value = Math.max(1, Math.min(p, totalPages.value));
118
+ window.scrollTo({ top: 0, behavior: 'smooth' });
119
+ }
120
+ </script>
121
+
122
+ <template>
123
+ <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
124
+ <!-- Breadcrumb -->
125
+ <nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 mb-6">
126
+ <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">Home</router-link>
127
+ <span class="text-ink-200">/</span>
128
+ <span class="text-ink-700">{{ manifest?.title || registerId }}</span>
129
+ </nav>
130
+
131
+ <!-- Header -->
132
+ <div v-if="manifest" class="mb-8">
133
+ <h1 class="font-serif text-3xl text-ink-800 mb-2">{{ manifest.title }}</h1>
134
+ <p class="text-ink-400 leading-relaxed max-w-2xl">{{ manifest.description }}</p>
135
+ <div class="flex flex-wrap gap-2 mt-4">
136
+ <span class="badge" :style="{ backgroundColor: getStyle(registerId).light, color: getStyle(registerId).dark }">{{ manifest.conceptCount.toLocaleString() }} concepts</span>
137
+ <span class="badge badge-gray">{{ manifest.languages.length }} languages</span>
138
+ <span class="badge badge-green">{{ manifest.owner }}</span>
139
+ <router-link :to="{ name: 'stats', params: { registerId } }" class="badge badge-blue hover:opacity-80 transition-opacity">
140
+ Statistics
141
+ </router-link>
142
+ <router-link :to="{ name: 'about', params: { registerId } }" class="badge badge-purple hover:opacity-80 transition-opacity">
143
+ About
144
+ </router-link>
145
+ </div>
146
+ </div>
147
+
148
+ <!-- Loading state (initial dataset load) -->
149
+ <div v-if="loading || (!adapter?.index && !localError)" class="space-y-4 py-4">
150
+ <div class="space-y-2">
151
+ <div class="skeleton h-3 w-32"></div>
152
+ <div class="skeleton h-8 w-64"></div>
153
+ <div class="skeleton h-4 w-96"></div>
154
+ </div>
155
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 mt-6">
156
+ <div v-for="i in 6" :key="i" class="skeleton h-20"></div>
157
+ </div>
158
+ </div>
159
+
160
+ <!-- Error state -->
161
+ <div v-else-if="localError" class="max-w-xl mx-auto text-center py-20">
162
+ <div class="card p-8 border-red-200 bg-red-50/50">
163
+ <p class="text-red-700 font-medium mb-1">Failed to load dataset</p>
164
+ <p class="text-sm text-red-600/80 mb-4">{{ localError }}</p>
165
+ <div class="flex gap-2 justify-center">
166
+ <button @click="ensureLoaded" class="btn-primary">Retry</button>
167
+ <router-link :to="{ name: 'home' }" class="btn-secondary">Back to home</router-link>
168
+ </div>
169
+ </div>
170
+ </div>
171
+
172
+ <template v-else>
173
+ <!-- Filters -->
174
+ <div class="flex flex-wrap items-center gap-3 mb-5">
175
+ <div class="relative">
176
+ <input
177
+ ref="filterInput"
178
+ v-model="filter"
179
+ type="text"
180
+ aria-label="Filter concepts"
181
+ placeholder="Filter concepts... (press /)"
182
+ class="pl-9 pr-3 py-2 text-sm bg-surface border border-ink-100 rounded-lg focus:ring-2 focus:ring-ink-200 focus:border-ink-400 outline-none placeholder:text-ink-300 transition-all w-full sm:w-64"
183
+ />
184
+ <svg class="absolute left-3 top-2.5 w-4 h-4 text-ink-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
185
+ <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"/>
186
+ </svg>
187
+ </div>
188
+ <span class="text-sm text-ink-400">
189
+ {{ filter.trim()
190
+ ? `${filtered.length.toLocaleString()} of ${totalConceptCount.toLocaleString()} concepts`
191
+ : `${totalConceptCount.toLocaleString()} concepts`
192
+ }}
193
+ </span>
194
+ </div>
195
+
196
+ <!-- Chunk loading skeleton -->
197
+ <div v-if="chunkLoading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
198
+ <div v-for="i in 6" :key="i" class="skeleton h-20"></div>
199
+ </div>
200
+
201
+ <!-- Concept grid -->
202
+ <div v-else-if="paged.length" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
203
+ <ConceptCard
204
+ v-for="(entry, idx) in paged"
205
+ :key="entry.id"
206
+ :entry="entry"
207
+ :register-id="registerId"
208
+ class="animate-entrance"
209
+ :style="{ animationDelay: `${Math.min(idx, 20) * 30}ms` }"
210
+ />
211
+ </div>
212
+
213
+ <!-- Empty state -->
214
+ <div v-else class="text-center py-20">
215
+ <div class="text-ink-200 text-5xl mb-4 font-serif">&empty;</div>
216
+ <template v-if="filter.trim()">
217
+ <p class="text-ink-500 font-medium mb-1">No concepts match your filter</p>
218
+ <button @click="filter = ''" class="text-sm concept-link mt-1">Clear filter</button>
219
+ </template>
220
+ <template v-else>
221
+ <p class="text-ink-500 font-medium mb-1">This dataset has no concepts</p>
222
+ </template>
223
+ </div>
224
+
225
+ <!-- Pagination -->
226
+ <div v-if="totalPages > 1" class="flex items-center justify-center gap-1.5 mt-8 pt-6 border-t border-ink-100/60">
227
+ <button
228
+ :disabled="page <= 1"
229
+ @click="goToPage(page - 1)"
230
+ class="btn-secondary disabled:opacity-30 text-xs"
231
+ >&larr; Prev</button>
232
+ <template v-for="p in visiblePages" :key="p">
233
+ <span v-if="p < 0" class="text-ink-300 px-0.5">&hellip;</span>
234
+ <button
235
+ v-else
236
+ @click="goToPage(p)"
237
+ :class="p === page ? 'bg-ink-800 text-white' : 'bg-surface-raised text-ink-600 hover:bg-ink-50 border border-ink-100'"
238
+ class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
239
+ >{{ p }}</button>
240
+ </template>
241
+ <button
242
+ :disabled="page >= totalPages"
243
+ @click="goToPage(page + 1)"
244
+ class="btn-secondary disabled:opacity-30 text-xs"
245
+ >Next &rarr;</button>
246
+ </div>
247
+ </template>
248
+ </div>
249
+ </template>
@@ -0,0 +1,65 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref } from 'vue';
3
+ import { useVocabularyStore } from '../stores/vocabulary';
4
+ import GraphPanel from '../components/GraphPanel.vue';
5
+
6
+ const store = useVocabularyStore();
7
+ const graphLoading = ref(true);
8
+
9
+ // Depend on graphVersion so computed re-evaluates when edges are added
10
+ const graphNodes = computed(() => {
11
+ store.graphVersion; // reactivity dependency
12
+ return store.graph.getAllNodes();
13
+ });
14
+ const graphEdges = computed(() => {
15
+ store.graphVersion; // reactivity dependency
16
+ return store.graph.getEdges();
17
+ });
18
+
19
+ const registers = computed(() =>
20
+ store.datasetList.map(ds => ({
21
+ id: ds.id,
22
+ title: ds.manifest.title,
23
+ }))
24
+ );
25
+
26
+ const totalEdges = computed(() => {
27
+ let sum = 0;
28
+ for (const s of Object.values(store.edgeStatus)) {
29
+ sum += s.count;
30
+ }
31
+ return sum;
32
+ });
33
+
34
+ onMounted(async () => {
35
+ try {
36
+ await store.loadAllGraphData();
37
+ } finally {
38
+ graphLoading.value = false;
39
+ }
40
+ });
41
+ </script>
42
+
43
+ <template>
44
+ <div class="flex flex-col" style="height: calc(100vh - 56px)">
45
+ <div class="px-4 sm:px-6 py-3 border-b border-ink-100/60 bg-surface-raised flex items-center gap-3 flex-shrink-0">
46
+ <nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm">
47
+ <router-link :to="{ name: 'home' }" class="text-ink-400 hover:text-ink-700 transition-colors">Home</router-link>
48
+ <span class="text-ink-200">/</span>
49
+ <span class="text-ink-700 font-medium">Graph View</span>
50
+ </nav>
51
+ <span class="text-xs text-ink-300 ml-1">
52
+ {{ graphEdges.length.toLocaleString() }} edges
53
+ </span>
54
+ </div>
55
+ <div class="flex-1 min-h-0">
56
+ <div v-if="graphLoading" class="flex items-center justify-center h-full">
57
+ <div class="text-center">
58
+ <div class="w-8 h-8 border-2 border-ink-200 border-t-ink-600 rounded-full animate-spin mx-auto mb-3"></div>
59
+ <p class="text-sm text-ink-400">Loading graph data&hellip;</p>
60
+ </div>
61
+ </div>
62
+ <GraphPanel v-else :nodes="graphNodes" :edges="graphEdges" :registers="registers" />
63
+ </div>
64
+ </div>
65
+ </template>