@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.
- package/astro.config.mjs +1 -0
- package/package.json +1 -1
- package/scripts/bridge-to-astro.mjs +6 -1
- package/scripts/build-edges.js +84 -1
- package/scripts/generate-data.mjs +119 -27
- package/src/__tests__/hyperedge-styling.test.ts +57 -0
- package/src/__tests__/partitive-hyperedge.test.ts +199 -0
- package/src/__tests__/sphere-math.test.ts +56 -0
- package/src/adapters/GraphDataSource.ts +10 -3
- package/src/adapters/UriRouter.ts +5 -0
- package/src/adapters/model-bridge.ts +1 -1
- package/src/adapters/types.ts +26 -0
- package/src/components/AppSidebar.vue +1 -1
- package/src/components/NavIcon.vue +1 -0
- package/src/components/PartitiveHyperedgeList.vue +112 -0
- package/src/components/RelationSphere.vue +2 -36
- package/src/composables/use-concept-edges.ts +77 -5
- 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/utils/hyperedge-styling.ts +48 -0
- package/src/utils/sphere-math.ts +54 -0
- package/src/views/DatasetView.vue +4 -0
- package/src/views/SourcesView.vue +134 -0
- package/src/views/StatsView.vue +120 -61
|
@@ -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',
|
|
@@ -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>
|
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>
|