@glossarist/concept-browser 0.7.76 → 0.7.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.76",
3
+ "version": "0.7.77",
4
4
  "description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -920,6 +920,9 @@ async function processDataset(dir, register, opts) {
920
920
 
921
921
  const dsRefMaps = { ...refMaps, register, designationLookup };
922
922
 
923
+ const sourceMap = new Map();
924
+ const relTypeCounts = {};
925
+
923
926
  for (let i = 0; i < files.length; i++) {
924
927
  const file = files[i];
925
928
  try {
@@ -954,6 +957,28 @@ async function processDataset(dir, register, opts) {
954
957
  status: conceptYaml.eng?.entry_status || 'valid',
955
958
  });
956
959
 
960
+ const conceptSourceRefs = new Set();
961
+ const allSourceArrays = [
962
+ ...(conceptYaml._sources || []),
963
+ ...opts.languages.flatMap(l => conceptYaml[l]?.sources || []),
964
+ ];
965
+ for (const src of allSourceArrays) {
966
+ const ref = src.origin?.ref?.source || src.origin?.ref?.id || 'Unknown';
967
+ conceptSourceRefs.add(ref);
968
+ if (!sourceMap.has(ref)) {
969
+ sourceMap.set(ref, { ref, types: new Set(), conceptIds: [] });
970
+ }
971
+ if (src.type) sourceMap.get(ref).types.add(src.type);
972
+ }
973
+ for (const ref of conceptSourceRefs) {
974
+ sourceMap.get(ref)?.conceptIds.push(termid);
975
+ }
976
+
977
+ for (const r of conceptYaml._related || []) {
978
+ const type = r.type || 'unknown';
979
+ relTypeCounts[type] = (relTypeCounts[type] || 0) + 1;
980
+ }
981
+
957
982
  for (const lang of opts.languages) {
958
983
  const lc = conceptYaml[lang];
959
984
  if (lc) {
@@ -1072,6 +1097,23 @@ async function processDataset(dir, register, opts) {
1072
1097
  }
1073
1098
  }
1074
1099
 
1100
+ const sourceStats = Array.from(sourceMap.values())
1101
+ .map(s => ({
1102
+ ref: s.ref,
1103
+ types: Array.from(s.types),
1104
+ conceptCount: s.conceptIds.length,
1105
+ conceptIds: s.conceptIds,
1106
+ }))
1107
+ .sort((a, b) => b.conceptCount - a.conceptCount);
1108
+ const totalRelationships = Object.values(relTypeCounts).reduce((a, b) => a + b, 0);
1109
+
1110
+ writeJson(path.join(DATA, register, 'stats.json'), {
1111
+ sourceCount: sourceStats.length,
1112
+ sources: sourceStats,
1113
+ relationshipCount: totalRelationships,
1114
+ relationshipTypes: relTypeCounts,
1115
+ });
1116
+
1075
1117
  const manifest = {
1076
1118
  id: register,
1077
1119
  datasetUri: opts.datasetUri,
@@ -1082,6 +1124,8 @@ async function processDataset(dir, register, opts) {
1082
1124
  baseUrl: `/data/${register}`,
1083
1125
  languages: opts.languages,
1084
1126
  conceptCount: concepts.length,
1127
+ sourceCount: sourceStats.length,
1128
+ relationshipCount: totalRelationships,
1085
1129
  conceptUrlTemplate: '{baseUrl}/concepts/{conceptId}.json',
1086
1130
  indexUrl: '{baseUrl}/index.json',
1087
1131
  contextUrl: 'https://glossarist.org/ns/context.jsonld',
@@ -136,7 +136,7 @@ const filteredGlobalPages = computed(() =>
136
136
 
137
137
  // Show only standard dataset pages (Concepts, Statistics, About)
138
138
  const filteredDatasetPages = computed(() =>
139
- datasetPages.value.filter(p => ['', 'stats', 'about'].includes(p.route || ''))
139
+ datasetPages.value.filter(p => ['', 'stats', 'sources', 'about'].includes(p.route || ''))
140
140
  );
141
141
 
142
142
  const currentManifest = computed(() => store.manifests.get(currentDataset.value));
@@ -9,6 +9,7 @@ const icons: Record<string, string> = {
9
9
  chart: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
10
10
  list: 'M4 6h16M4 10h16M4 14h16M4 18h16',
11
11
  schema: 'M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z',
12
+ database: 'M4 7c0 1.1 3.6 2 8 2s8-.9 8-2M4 7c0-1.1 3.6-2 8-2s8 .9 8 2M4 7v10c0 1.1 3.6 2 8 2s8-.9 8-2V7M4 12c0 1.1 3.6 2 8 2s8-.9 8-2',
12
13
  };
13
14
 
14
15
  defineProps<{ name: string }>();
@@ -157,6 +157,9 @@ function synthesizeDatasetPages(features?: Record<string, unknown>, pages?: Page
157
157
  if (features?.stats !== false && !declaredRoutes.has('stats')) {
158
158
  result.push({ type: 'stats', route: 'stats', title: 'Statistics', icon: 'chart', datasetScoped: true });
159
159
  }
160
+ if (!declaredRoutes.has('sources')) {
161
+ result.push({ type: 'sources', route: 'sources', title: 'Sources', icon: 'database', datasetScoped: true });
162
+ }
160
163
  if (features?.about !== false && !declaredRoutes.has('about')) {
161
164
  result.push({ type: 'about', route: 'about', title: 'About', icon: 'info', datasetScoped: true });
162
165
  }
@@ -9,6 +9,7 @@ nav.ontology: Ontology
9
9
  nav.concepts: Concepts
10
10
  nav.stats: Statistics
11
11
  nav.about: About
12
+ nav.sources: Sources
12
13
  nav.navigation: Navigation
13
14
  nav.datasets: Datasets
14
15
 
@@ -121,6 +122,8 @@ lang.termOnlyIn: Term only in
121
122
  # Dataset view
122
123
  dataset.concepts: concepts
123
124
  dataset.languages: languages
125
+ dataset.sources: sources
126
+ dataset.relationships: relationships
124
127
  dataset.filterPlaceholder: "Filter concepts... (press /)"
125
128
  dataset.of: of
126
129
  dataset.in: in
@@ -15,6 +15,7 @@ export async function getStaticPaths() {
15
15
  for (const ds of datasets) {
16
16
  paths.push({ params: { path: `dataset/${ds.data.id}/about` } });
17
17
  paths.push({ params: { path: `dataset/${ds.data.id}/stats` } });
18
+ paths.push({ params: { path: `dataset/${ds.data.id}/sources` } });
18
19
  for (const g of await getCollection('groups')) {
19
20
  paths.push({ params: { path: `group/${g.data.id}/about` } });
20
21
  }
@@ -31,6 +31,12 @@ export const routes: RouteRecordRaw[] = [
31
31
  component: () => import('../views/PageView.vue'),
32
32
  props: true,
33
33
  },
34
+ {
35
+ path: '/dataset/:registerId/sources',
36
+ name: 'sources',
37
+ component: () => import('../views/SourcesView.vue'),
38
+ props: true,
39
+ },
34
40
  {
35
41
  path: '/group/:groupId',
36
42
  name: 'group',
@@ -331,6 +331,10 @@ function clearSection() {
331
331
  <router-link :to="{ name: 'about', params: { registerId } }" class="badge badge-purple hover:opacity-80 transition-opacity">
332
332
  {{ t('nav.about') }}
333
333
  </router-link>
334
+ <router-link v-if="manifest.sourceCount" :to="{ name: 'sources', params: { registerId } }" class="badge badge-gray hover:opacity-80 transition-opacity">
335
+ {{ manifest.sourceCount }} {{ t('dataset.sources') }}
336
+ </router-link>
337
+ <span v-if="manifest.relationshipCount" class="badge badge-yellow">{{ manifest.relationshipCount.toLocaleString() }} {{ t('dataset.relationships') }}</span>
334
338
  </div>
335
339
  </div>
336
340
 
@@ -0,0 +1,134 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch } from 'vue';
3
+ import { useVocabularyStore } from '../stores/vocabulary';
4
+ import { useDatasetLoader } from '../composables/use-dataset-loader';
5
+ import { useDsStyle } from '../utils/dataset-style';
6
+ import { useI18n } from '../i18n';
7
+
8
+ const props = defineProps<{ registerId?: string }>();
9
+
10
+ const store = useVocabularyStore();
11
+ const { getColor } = useDsStyle();
12
+ const { loading, localError, ensureLoaded, resolvedId } = useDatasetLoader(() => props.registerId);
13
+ const { t } = useI18n();
14
+
15
+ const manifest = computed(() => store.manifests.get(resolvedId.value));
16
+
17
+ interface SourceEntry {
18
+ ref: string;
19
+ types: string[];
20
+ conceptCount: number;
21
+ conceptIds: string[];
22
+ }
23
+
24
+ const stats = ref<{ sources: SourceEntry[]; sourceCount: number; relationshipCount: number; relationshipTypes: Record<string, number> } | null>(null);
25
+ const expandedSource = ref<string | null>(null);
26
+ const filterText = ref('');
27
+
28
+ async function loadStats() {
29
+ if (!resolvedId.value) return;
30
+ const base = import.meta.env.BASE_URL;
31
+ try {
32
+ const resp = await fetch(`${base}data/${resolvedId.value}/stats.json`);
33
+ if (resp.ok) {
34
+ stats.value = await resp.json();
35
+ }
36
+ } catch (e) {
37
+ console.warn('Failed to load stats:', e);
38
+ }
39
+ }
40
+
41
+ watch(() => resolvedId.value, () => { loadStats(); }, { immediate: true });
42
+
43
+ const filteredSources = computed(() => {
44
+ if (!stats.value?.sources) return [];
45
+ const q = filterText.value.toLowerCase().trim();
46
+ if (!q) return stats.value.sources;
47
+ return stats.value.sources.filter(s =>
48
+ s.ref.toLowerCase().includes(q) ||
49
+ s.types.some(t => t.toLowerCase().includes(q))
50
+ );
51
+ });
52
+
53
+ function sourceTypeBadge(type: string): string {
54
+ if (type === 'authoritative') return 'badge-green';
55
+ if (type === 'lineage') return 'badge-yellow';
56
+ return 'badge-gray';
57
+ }
58
+ </script>
59
+
60
+ <template>
61
+ <div class="max-w-4xl mx-auto">
62
+ <div v-if="loading" class="flex items-center justify-center py-20">
63
+ <div class="skeleton h-8 w-64"></div>
64
+ </div>
65
+
66
+ <div v-else-if="localError" class="card p-8 text-center">
67
+ <p class="text-red-600">{{ localError }}</p>
68
+ </div>
69
+
70
+ <template v-else-if="manifest">
71
+ <div class="mb-6">
72
+ <div class="text-xs text-ink-400 mb-2">
73
+ <router-link :to="{ name: 'dataset', params: { registerId } }" class="hover:text-blue-600">{{ manifest.title }}</router-link>
74
+ <span class="mx-1">›</span>
75
+ <span>{{ t('nav.sources') }}</span>
76
+ </div>
77
+ <h1 class="font-serif text-3xl text-ink-800 dark:text-ink-100">{{ t('nav.sources') }}</h1>
78
+ <p class="text-ink-400 mt-1" v-if="stats">{{ stats.sourceCount }} {{ t('dataset.sources') }} · {{ manifest.conceptCount.toLocaleString() }} {{ t('dataset.concepts') }}</p>
79
+ </div>
80
+
81
+ <div v-if="!stats" class="card p-8 text-center text-ink-400">
82
+ Loading sources…
83
+ </div>
84
+
85
+ <template v-else>
86
+ <div class="card p-3 mb-4">
87
+ <input
88
+ v-model="filterText"
89
+ type="text"
90
+ :placeholder="'Filter sources...'"
91
+ class="w-full px-3 py-2 text-sm border border-ink-100 dark:border-ink-700 rounded-lg bg-surface-raised dark:bg-ink-800 text-ink-700 dark:text-ink-200 focus:outline-none focus:border-blue-400"
92
+ />
93
+ </div>
94
+
95
+ <div class="space-y-2">
96
+ <div v-for="src in filteredSources" :key="src.ref" class="card overflow-hidden">
97
+ <button
98
+ class="w-full text-left p-4 flex items-center gap-3 hover:bg-ink-50/50 dark:hover:bg-ink-800/30 transition-colors"
99
+ @click="expandedSource = expandedSource === src.ref ? null : src.ref"
100
+ >
101
+ <span
102
+ class="w-3 h-3 rounded-full flex-shrink-0"
103
+ :style="{ backgroundColor: getColor(resolvedId) }"
104
+ ></span>
105
+ <div class="flex-1 min-w-0">
106
+ <div class="font-medium text-ink-800 dark:text-ink-100 truncate">{{ src.ref }}</div>
107
+ <div class="flex items-center gap-1.5 mt-1">
108
+ <span v-for="tp in src.types" :key="tp" class="badge text-[9px]" :class="sourceTypeBadge(tp)">{{ tp }}</span>
109
+ <span class="text-xs text-ink-400">{{ src.conceptCount }} {{ t('dataset.concepts') }}</span>
110
+ </div>
111
+ </div>
112
+ <svg class="w-4 h-4 text-ink-300 transition-transform flex-shrink-0" :class="{ 'rotate-180': expandedSource === src.ref }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
113
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
114
+ </svg>
115
+ </button>
116
+
117
+ <div v-if="expandedSource === src.ref" class="border-t border-ink-100/60 dark:border-ink-700/40 p-4">
118
+ <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-1">
119
+ <router-link
120
+ v-for="cid in src.conceptIds"
121
+ :key="cid"
122
+ :to="{ name: 'concept', params: { registerId, conceptId: cid } }"
123
+ class="block px-2 py-1 rounded text-sm text-ink-600 dark:text-ink-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
124
+ >
125
+ <code class="text-xs text-ink-400 font-mono mr-1">{{ cid }}</code>
126
+ </router-link>
127
+ </div>
128
+ </div>
129
+ </div>
130
+ </div>
131
+ </template>
132
+ </template>
133
+ </div>
134
+ </template>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed } from 'vue';
2
+ import { ref, computed, watch } from 'vue';
3
3
  import { useVocabularyStore } from '../stores/vocabulary';
4
4
  import { useDsStyle } from '../utils/dataset-style';
5
5
  import { useDatasetLoader } from '../composables/use-dataset-loader';
@@ -15,51 +15,37 @@ const { t } = useI18n();
15
15
 
16
16
  const manifest = computed(() => store.manifests.get(resolvedId.value));
17
17
 
18
- interface LangStat {
19
- lang: string;
20
- terms: number;
21
- definitions: number;
22
- }
18
+ interface LangStat { lang: string; terms: number; definitions: number; }
23
19
 
24
- const stats = computed(() => {
20
+ const langStats = computed(() => {
25
21
  const m = manifest.value;
26
22
  if (!m) return { langs: [], total: 0 };
27
-
28
- // Scan the index for actual language coverage
29
23
  const adapter = store.datasets.get(resolvedId.value);
30
24
  const conceptCounts: Record<string, number> = {};
31
-
32
25
  if (adapter) {
33
- const concepts = adapter.getConcepts();
34
- for (const c of concepts) {
26
+ for (const c of adapter.getConcepts()) {
35
27
  if (!c) continue;
36
28
  for (const lang of Object.keys(c.designations)) {
37
29
  conceptCounts[lang] = (conceptCounts[lang] || 0) + 1;
38
30
  }
39
31
  }
40
32
  }
41
-
42
- // Merge with manifest languageStats (for definition counts)
43
33
  const ls = m.languageStats || {};
44
34
  const allLangs = new Set([...Object.keys(conceptCounts), ...m.languages]);
45
-
46
35
  const langs: LangStat[] = [...allLangs].map(lang => ({
47
36
  lang,
48
37
  terms: ls[lang]?.terms ?? conceptCounts[lang] ?? 0,
49
38
  definitions: ls[lang]?.definitions ?? conceptCounts[lang] ?? 0,
50
39
  }));
51
-
52
- // Sort: eng first, then by term count descending
53
40
  langs.sort((a, b) => {
54
41
  if (a.lang === 'eng') return -1;
55
42
  if (b.lang === 'eng') return 1;
56
43
  return b.terms - a.terms;
57
44
  });
58
-
59
45
  return { langs, total: m.conceptCount };
60
46
  });
61
47
 
62
- const maxTerms = computed(() => Math.max(...stats.value.langs.map(l => l.terms), 1));
48
+ const maxTerms = computed(() => Math.max(...langStats.value.langs.map(l => l.terms), 1));
63
49
 
64
50
  function coverageColor(ratio: number): string {
65
51
  if (ratio >= 0.8) return 'bg-emerald-500';
@@ -67,11 +53,49 @@ function coverageColor(ratio: number): string {
67
53
  if (ratio >= 0.25) return 'bg-amber-500';
68
54
  return 'bg-red-400';
69
55
  }
56
+
57
+ interface StatsData {
58
+ sourceCount: number;
59
+ sources: Array<{ ref: string; types: string[]; conceptCount: number }>;
60
+ relationshipCount: number;
61
+ relationshipTypes: Record<string, number>;
62
+ }
63
+
64
+ const statsData = ref<StatsData | null>(null);
65
+
66
+ async function loadStats() {
67
+ if (!resolvedId.value) return;
68
+ const base = import.meta.env.BASE_URL;
69
+ try {
70
+ const resp = await fetch(`${base}data/${resolvedId.value}/stats.json`);
71
+ if (resp.ok) statsData.value = await resp.json();
72
+ } catch {}
73
+ }
74
+
75
+ watch(() => resolvedId.value, () => { loadStats(); }, { immediate: true });
76
+
77
+ const relEntries = computed(() => {
78
+ if (!statsData.value?.relationshipTypes) return [];
79
+ return Object.entries(statsData.value.relationshipTypes)
80
+ .sort(([, a], [, b]) => b - a);
81
+ });
82
+
83
+ const maxRel = computed(() => Math.max(...relEntries.value.map(([, n]) => n), 1));
84
+
85
+ const topSources = computed(() => {
86
+ if (!statsData.value?.sources) return [];
87
+ return statsData.value.sources.slice(0, 10);
88
+ });
89
+
90
+ function sourceTypeBadge(type: string): string {
91
+ if (type === 'authoritative') return 'badge-green';
92
+ if (type === 'lineage') return 'badge-yellow';
93
+ return 'badge-gray';
94
+ }
70
95
  </script>
71
96
 
72
97
  <template>
73
98
  <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
74
- <!-- Breadcrumb -->
75
99
  <nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 mb-6">
76
100
  <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">{{ t('nav.home') }}</router-link>
77
101
  <span class="text-ink-200">/</span>
@@ -84,9 +108,7 @@ function coverageColor(ratio: number): string {
84
108
  <div class="animate-pulse space-y-6">
85
109
  <div class="h-8 bg-ink-100 rounded w-32"></div>
86
110
  <div class="h-4 bg-ink-100 rounded w-64"></div>
87
- <div class="card overflow-hidden">
88
- <div class="h-80 bg-ink-50"></div>
89
- </div>
111
+ <div class="card overflow-hidden"><div class="h-80 bg-ink-50"></div></div>
90
112
  </div>
91
113
  </template>
92
114
  <template v-else-if="localError">
@@ -99,46 +121,83 @@ function coverageColor(ratio: number): string {
99
121
  <template v-else-if="manifest">
100
122
  <h1 class="font-serif text-3xl text-ink-800 mb-2">{{ t('stats.title') }}</h1>
101
123
  <p class="text-ink-400 mb-8">
102
- {{ t('stats.summary', { count: stats.total.toLocaleString(), langCount: String(manifest.languages.length) }) }}
124
+ {{ t('stats.summary', { count: langStats.total.toLocaleString(), langCount: String(manifest.languages.length) }) }}
103
125
  </p>
104
126
 
105
- <!-- Language stats table -->
106
- <div class="card -mx-4 sm:mx-0 overflow-x-auto">
107
- <table class="w-full text-sm">
108
- <thead>
109
- <tr class="border-b border-ink-100/60 bg-ink-50">
110
- <th class="text-left px-5 py-3 text-ink-600 font-medium text-xs uppercase tracking-wide">{{ t('stats.language') }}</th>
111
- <th class="text-right px-5 py-3 text-ink-600 font-medium text-xs uppercase tracking-wide">{{ t('stats.terms') }}</th>
112
- <th class="text-right px-5 py-3 text-ink-600 font-medium text-xs uppercase tracking-wide">{{ t('stats.definitions') }}</th>
113
- <th class="px-5 py-3 text-ink-600 font-medium w-40"></th>
114
- </tr>
115
- </thead>
116
- <tbody>
117
- <tr v-for="s in stats.langs" :key="s.lang" class="border-b border-ink-50 last:border-0">
118
- <td class="px-5 py-3">
119
- <div class="flex items-center gap-2">
120
- <span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langLabel(s.lang) }}</span>
121
- <span class="font-medium text-ink-800">{{ langName(s.lang) }}</span>
122
- <span class="text-ink-300 text-xs">({{ s.lang }})</span>
123
- </div>
124
- </td>
125
- <td class="text-right px-5 py-3 font-mono text-ink-700">{{ s.terms.toLocaleString() }}</td>
126
- <td class="text-right px-5 py-3 font-mono text-ink-700">{{ s.definitions.toLocaleString() }}</td>
127
- <td class="px-5 py-3">
128
- <div class="flex items-center gap-2">
129
- <div class="h-2 rounded-full bg-ink-50 overflow-hidden flex-1">
130
- <div
131
- class="h-full rounded-full transition-all duration-500"
132
- :class="coverageColor(s.terms / maxTerms)"
133
- :style="{ width: (s.terms / maxTerms * 100) + '%' }"
134
- ></div>
135
- </div>
136
- <span class="text-xs text-ink-300 w-10 text-right tabular-nums">{{ Math.round(s.terms / maxTerms * 100) }}%</span>
137
- </div>
138
- </td>
139
- </tr>
140
- </tbody>
141
- </table>
127
+ <div v-if="statsData" class="flex flex-wrap gap-2 mb-8">
128
+ <span class="badge badge-purple">{{ statsData.sourceCount }} {{ t('dataset.sources') }}</span>
129
+ <span class="badge badge-yellow">{{ statsData.relationshipCount.toLocaleString() }} {{ t('dataset.relationships') }}</span>
130
+ </div>
131
+
132
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
133
+ <div>
134
+ <h2 class="section-label mb-3">{{ t('stats.language') }}</h2>
135
+ <div class="card overflow-x-auto">
136
+ <table class="w-full text-sm">
137
+ <thead>
138
+ <tr class="border-b border-ink-100/60 bg-ink-50">
139
+ <th class="text-left px-4 py-2.5 text-ink-600 font-medium text-xs uppercase tracking-wide">{{ t('stats.language') }}</th>
140
+ <th class="text-right px-4 py-2.5 text-ink-600 font-medium text-xs uppercase tracking-wide">{{ t('stats.terms') }}</th>
141
+ <th class="text-right px-4 py-2.5 text-ink-600 font-medium text-xs uppercase tracking-wide">{{ t('stats.definitions') }}</th>
142
+ </tr>
143
+ </thead>
144
+ <tbody>
145
+ <tr v-for="s in langStats.langs" :key="s.lang" class="border-b border-ink-50 last:border-0">
146
+ <td class="px-4 py-2.5">
147
+ <span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langLabel(s.lang) }}</span>
148
+ <span class="ml-2 font-medium text-ink-800">{{ langName(s.lang) }}</span>
149
+ </td>
150
+ <td class="text-right px-4 py-2.5 font-mono text-ink-700">{{ s.terms.toLocaleString() }}</td>
151
+ <td class="text-right px-4 py-2.5 font-mono text-ink-700">{{ s.definitions.toLocaleString() }}</td>
152
+ </tr>
153
+ </tbody>
154
+ </table>
155
+ </div>
156
+ </div>
157
+
158
+ <div v-if="relEntries.length">
159
+ <h2 class="section-label mb-3">{{ t('dataset.relationships') }}</h2>
160
+ <div class="card p-4 space-y-3">
161
+ <div v-for="[type, count] in relEntries" :key="type">
162
+ <div class="flex items-center justify-between mb-1">
163
+ <span class="text-sm font-medium text-ink-700">{{ type }}</span>
164
+ <span class="text-sm font-mono text-ink-500">{{ count.toLocaleString() }}</span>
165
+ </div>
166
+ <div class="h-2 rounded-full bg-ink-50 overflow-hidden">
167
+ <div class="h-full rounded-full bg-blue-500 transition-all duration-500" :style="{ width: (count / maxRel * 100) + '%' }"></div>
168
+ </div>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ </div>
173
+
174
+ <div v-if="topSources.length" class="mt-8">
175
+ <div class="flex items-center justify-between mb-3">
176
+ <h2 class="section-label">{{ t('dataset.sources') }}</h2>
177
+ <router-link :to="{ name: 'sources', params: { registerId: resolvedId } }" class="text-xs text-blue-600 hover:underline">
178
+ View all {{ statsData?.sourceCount }} →
179
+ </router-link>
180
+ </div>
181
+ <div class="card overflow-x-auto">
182
+ <table class="w-full text-sm">
183
+ <thead>
184
+ <tr class="border-b border-ink-100/60 bg-ink-50">
185
+ <th class="text-left px-4 py-2.5 text-ink-600 font-medium text-xs uppercase tracking-wide">Source</th>
186
+ <th class="text-left px-4 py-2.5 text-ink-600 font-medium text-xs uppercase tracking-wide">Type</th>
187
+ <th class="text-right px-4 py-2.5 text-ink-600 font-medium text-xs uppercase tracking-wide">Concepts</th>
188
+ </tr>
189
+ </thead>
190
+ <tbody>
191
+ <tr v-for="src in topSources" :key="src.ref" class="border-b border-ink-50 last:border-0">
192
+ <td class="px-4 py-2.5 font-medium text-ink-800">{{ src.ref }}</td>
193
+ <td class="px-4 py-2.5">
194
+ <span v-for="tp in src.types" :key="tp" class="badge text-[9px] mr-1" :class="sourceTypeBadge(tp)">{{ tp }}</span>
195
+ </td>
196
+ <td class="text-right px-4 py-2.5 font-mono text-ink-700">{{ src.conceptCount.toLocaleString() }}</td>
197
+ </tr>
198
+ </tbody>
199
+ </table>
200
+ </div>
142
201
  </div>
143
202
  </template>
144
203
  </div>