@glossarist/concept-browser 0.7.76 → 0.7.78

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.
@@ -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',
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Hyperedge styling — colors for partitive decompositions.
3
+ *
4
+ * Independent of relationship-categories.ts (which handles binary edges).
5
+ * Hyperedges have their own visual language: teal base color, enumeration
6
+ * affects opacity, markers add accent badges.
7
+ */
8
+
9
+ const BASE_LIGHT = '#0d9488';
10
+ const BASE_DARK = '#2dd4bf';
11
+ const ACCENT_DOUBLE_LIGHT = '#3b82f6';
12
+ const ACCENT_DOUBLE_DARK = '#60a5fa';
13
+ const ACCENT_DASHED_LIGHT = '#f59e0b';
14
+ const ACCENT_DASHED_DARK = '#fbbf24';
15
+
16
+ export interface HyperedgeStyle {
17
+ color: string;
18
+ badgeClass: string;
19
+ opacity: number;
20
+ }
21
+
22
+ export function hyperedgeStyle(
23
+ enumeration: 'closed' | 'open',
24
+ markers: string[],
25
+ isDark: boolean,
26
+ ): HyperedgeStyle {
27
+ const base = isDark ? BASE_DARK : BASE_LIGHT;
28
+ const opacity = enumeration === 'open' ? 0.6 : 1.0;
29
+
30
+ let badgeClass = 'badge-teal';
31
+ if (markers.includes('double')) {
32
+ badgeClass = isDark ? 'badge-blue' : 'badge-blue';
33
+ } else if (markers.includes('dashed')) {
34
+ badgeClass = 'badge-yellow';
35
+ }
36
+
37
+ return { color: base, badgeClass, opacity };
38
+ }
39
+
40
+ export function markerColor(marker: string, isDark: boolean): string {
41
+ if (marker === 'double') return isDark ? ACCENT_DOUBLE_DARK : ACCENT_DOUBLE_LIGHT;
42
+ if (marker === 'dashed') return isDark ? ACCENT_DASHED_DARK : ACCENT_DASHED_LIGHT;
43
+ return isDark ? BASE_DARK : BASE_LIGHT;
44
+ }
45
+
46
+ export function enumerationLabel(enumeration: 'closed' | 'open'): string {
47
+ return enumeration === 'open' ? 'Open (partial)' : 'Closed (complete)';
48
+ }
@@ -0,0 +1,54 @@
1
+ /** Deterministic hash from string → positive integer (for seeding positions). */
2
+ export function hashSeed(s: string): number {
3
+ let h = 0;
4
+ for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
5
+ return Math.abs(h);
6
+ }
7
+
8
+ /** Expand-slider → simulation parameters. Maps [0, 10] to physical constants. */
9
+ export function expandParams(v: number): {
10
+ linkDist: number;
11
+ linkStrength: number;
12
+ repMin: number;
13
+ repStrength: number;
14
+ } {
15
+ const t = Math.max(0, Math.min(10, v)) / 10;
16
+ return {
17
+ linkDist: 0.5 + t * 1.45,
18
+ linkStrength: 0.08,
19
+ repMin: 0.55 + t * 0.40,
20
+ repStrength: 0.05,
21
+ };
22
+ }
23
+
24
+ /** Which side of a card does an edge connect to? */
25
+ export function portSide(
26
+ from: { x: number; y: number },
27
+ to: { x: number; y: number },
28
+ ): 'top' | 'bottom' | 'left' | 'right' {
29
+ const dx = to.x - from.x, dy = to.y - from.y;
30
+ if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? 'right' : 'left';
31
+ return dy > 0 ? 'bottom' : 'top';
32
+ }
33
+
34
+ /** Connection point on a card's side, with offset to prevent stacking. */
35
+ export function portPoint(
36
+ center: { x: number; y: number },
37
+ side: 'top' | 'bottom' | 'left' | 'right',
38
+ w: number,
39
+ h: number,
40
+ offset: number,
41
+ ): { x: number; y: number } {
42
+ switch (side) {
43
+ case 'right': return { x: center.x + w / 2, y: center.y + (h / 2 - 6) * offset };
44
+ case 'left': return { x: center.x - w / 2, y: center.y + (h / 2 - 6) * offset };
45
+ case 'bottom': return { x: center.x + (w / 2 - 12) * offset, y: center.y + h / 2 };
46
+ case 'top': return { x: center.x + (w / 2 - 12) * offset, y: center.y - h / 2 };
47
+ }
48
+ }
49
+
50
+ /** Reverse lookup: find the node ID whose URI matches, in an ID→URI map. */
51
+ export function idToUriGet(map: Map<string, string>, uri: string): string | undefined {
52
+ for (const [id, u] of map) if (u === uri) return id;
53
+ return undefined;
54
+ }
@@ -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>