@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 +1 -1
- package/scripts/generate-data.mjs +44 -0
- package/src/components/AppSidebar.vue +1 -1
- package/src/components/NavIcon.vue +1 -0
- package/src/config/use-site-config.ts +3 -0
- package/src/i18n/locales/eng.yml +3 -0
- package/src/pages/[...path].astro +1 -0
- package/src/router/index.ts +6 -0
- package/src/views/DatasetView.vue +4 -0
- package/src/views/SourcesView.vue +134 -0
- package/src/views/StatsView.vue +120 -61
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
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
|
}
|
package/src/i18n/locales/eng.yml
CHANGED
|
@@ -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
|
}
|
package/src/router/index.ts
CHANGED
|
@@ -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>
|
package/src/views/StatsView.vue
CHANGED
|
@@ -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
|
|
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
|
|
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(...
|
|
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:
|
|
124
|
+
{{ t('stats.summary', { count: langStats.total.toLocaleString(), langCount: String(manifest.languages.length) }) }}
|
|
103
125
|
</p>
|
|
104
126
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
<
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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>
|