@glossarist/concept-browser 0.7.77 → 0.7.80
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 +2 -2
- package/scripts/bridge-to-astro.mjs +6 -1
- package/scripts/build-edges.js +154 -1
- package/scripts/generate-data.mjs +138 -54
- package/src/__tests__/build-edges-redundancy.test.ts +101 -0
- package/src/__tests__/composables/use-concept-diff.test.ts +240 -0
- package/src/__tests__/composables/use-concept-edges-v2.test.ts +132 -0
- package/src/__tests__/composables/use-dataset-stats.test.ts +96 -0
- package/src/__tests__/config/page-types.test.ts +82 -0
- package/src/__tests__/model-bridge-bridges.test.ts +3 -2
- package/src/__tests__/model-bridge-citation.test.ts +1 -1
- package/src/__tests__/ontology-registry.test.ts +2 -2
- package/src/__tests__/partitive-relation-styling.test.ts +82 -0
- package/src/__tests__/partitive-relation.test.ts +285 -0
- package/src/__tests__/sphere-math.test.ts +56 -0
- package/src/adapters/GraphDataSource.ts +22 -5
- package/src/adapters/UriRouter.ts +5 -0
- package/src/adapters/dataset-stats.ts +13 -0
- package/src/adapters/model-bridge.ts +1 -1
- package/src/adapters/non-verbal/glossarist-augment.d.ts +155 -4
- package/src/adapters/types.ts +36 -0
- package/src/components/AppSidebar.vue +14 -57
- package/src/components/ConceptDetail.vue +29 -0
- package/src/components/ConceptDiffModal.vue +120 -0
- package/src/components/ConceptDiffView.vue +192 -0
- package/src/components/ConceptEditionRail.vue +61 -36
- package/src/components/PartitiveRelationList.vue +139 -0
- package/src/components/RelationSphere.vue +2 -36
- package/src/components/groups/DatasetGroupRenderer.vue +14 -4
- package/src/components/groups/DefaultGroupSidebar.vue +28 -11
- package/src/components/groups/LineageGroupSidebar.vue +17 -29
- package/src/composables/use-concept-diff.ts +231 -0
- package/src/composables/use-concept-edges.ts +98 -5
- package/src/composables/use-dataset-stats.ts +52 -0
- package/src/config/page-types.ts +67 -0
- package/src/config/types.ts +6 -1
- package/src/config/use-site-config.ts +13 -29
- package/src/data/taxonomies.json +24 -1
- package/src/i18n/locales/eng.yml +6 -0
- package/src/utils/partitive-relation-styling.ts +62 -0
- package/src/utils/sphere-math.ts +54 -0
- package/src/views/DatasetView.vue +5 -3
- package/src/views/StatsView.vue +9 -22
- package/src/islands/BibliographyIsland.vue +0 -26
- package/src/islands/ConceptDetailIsland.vue +0 -27
- package/src/islands/ConceptDiffIsland.vue +0 -214
- package/src/islands/ConceptIsland.vue +0 -102
- package/src/islands/GraphIsland.vue +0 -15
- package/src/islands/GroupRendererIsland.vue +0 -15
- package/src/islands/HeaderIsland.vue +0 -10
- package/src/islands/NonVerbalIsland.vue +0 -75
- package/src/islands/RelationSphereIsland.vue +0 -20
- package/src/islands/SearchIsland.vue +0 -23
- package/src/islands/SeriesCardIsland.vue +0 -12
- package/src/islands/ShortcutsIsland.vue +0 -13
- package/src/islands/SidebarIsland.vue +0 -15
- package/src/islands/SupersessionDiffIsland.vue +0 -267
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ConceptDiffView — render a side-by-side diff between two concepts.
|
|
4
|
+
*
|
|
5
|
+
* Replaces the two orphan islands (ConceptDiffIsland,
|
|
6
|
+
* SupersessionDiffIsland). One component, driven by props:
|
|
7
|
+
* - `oldConcept` / `newConcept` — wire shapes (ConceptLikeData)
|
|
8
|
+
* - `lang` — which language's diff to render (default 'eng')
|
|
9
|
+
*
|
|
10
|
+
* The diff itself is computed by the parent (via useConceptDiff)
|
|
11
|
+
* and passed in as `diff`, OR computed here from the two concepts.
|
|
12
|
+
* The first form is preferred when the parent already has the diff
|
|
13
|
+
* cached; the second form is for one-shot comparisons.
|
|
14
|
+
*/
|
|
15
|
+
import { computed, watchEffect } from 'vue';
|
|
16
|
+
import type { ConceptDiff } from 'glossarist/diff';
|
|
17
|
+
import type { ConceptLikeData } from '../composables/use-concept-diff';
|
|
18
|
+
import {
|
|
19
|
+
useConceptDiff,
|
|
20
|
+
deriveDiffSections,
|
|
21
|
+
computeSimilarity,
|
|
22
|
+
} from '../composables/use-concept-diff';
|
|
23
|
+
|
|
24
|
+
const props = withDefaults(defineProps<{
|
|
25
|
+
oldConcept: ConceptLikeData | null;
|
|
26
|
+
newConcept: ConceptLikeData | null;
|
|
27
|
+
lang?: string;
|
|
28
|
+
/** Optional precomputed diff (skips internal diffConcepts call). */
|
|
29
|
+
diff?: ConceptDiff | null;
|
|
30
|
+
/** Optional label for the comparison direction. */
|
|
31
|
+
oldLabel?: string;
|
|
32
|
+
newLabel?: string;
|
|
33
|
+
}>(), {
|
|
34
|
+
lang: 'eng',
|
|
35
|
+
diff: null,
|
|
36
|
+
oldLabel: 'Previous',
|
|
37
|
+
newLabel: 'Current',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const internal = useConceptDiff();
|
|
41
|
+
|
|
42
|
+
watchEffect(() => {
|
|
43
|
+
if (props.diff) {
|
|
44
|
+
internal.clear();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (props.oldConcept && props.newConcept) {
|
|
48
|
+
internal.diff(props.oldConcept, props.newConcept);
|
|
49
|
+
} else {
|
|
50
|
+
internal.clear();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const effectiveDiff = computed(() => props.diff ?? internal.diffResult.value);
|
|
55
|
+
const sections = computed(() => deriveDiffSections(effectiveDiff.value, props.lang));
|
|
56
|
+
const similarity = computed(() => computeSimilarity(effectiveDiff.value));
|
|
57
|
+
const hasChanges = computed(() => effectiveDiff.value?.hasChanges === true);
|
|
58
|
+
const loading = computed(() => internal.loading.value);
|
|
59
|
+
const error = computed(() => internal.error.value);
|
|
60
|
+
|
|
61
|
+
function similarityBarColor(score: number): string {
|
|
62
|
+
if (score >= 0.8) return 'bg-emerald-500';
|
|
63
|
+
if (score >= 0.5) return 'bg-amber-500';
|
|
64
|
+
return 'bg-red-500';
|
|
65
|
+
}
|
|
66
|
+
function similarityTextColor(score: number): string {
|
|
67
|
+
if (score >= 0.8) return 'text-emerald-600 dark:text-emerald-400';
|
|
68
|
+
if (score >= 0.5) return 'text-amber-600 dark:text-amber-400';
|
|
69
|
+
return 'text-red-600 dark:text-red-400';
|
|
70
|
+
}
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<template>
|
|
74
|
+
<div class="concept-diff-view space-y-4">
|
|
75
|
+
<header v-if="oldLabel || newLabel" class="flex items-center justify-between text-xs">
|
|
76
|
+
<span class="text-ink-400">{{ oldLabel }} → {{ newLabel }}</span>
|
|
77
|
+
</header>
|
|
78
|
+
|
|
79
|
+
<div v-if="error" class="card p-4 border-red-200 bg-red-50/50">
|
|
80
|
+
<p class="text-sm text-red-700">{{ error }}</p>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div v-if="loading" class="text-center py-6 text-sm text-ink-400">Comparing…</div>
|
|
84
|
+
|
|
85
|
+
<template v-else-if="effectiveDiff">
|
|
86
|
+
<!-- Similarity score -->
|
|
87
|
+
<div v-if="similarity != null" class="flex items-center gap-3">
|
|
88
|
+
<span class="text-xs text-ink-400 w-20">Similarity</span>
|
|
89
|
+
<div class="flex-1 max-w-xs bg-ink-100 dark:bg-ink-800 rounded-full h-2.5 overflow-hidden">
|
|
90
|
+
<div
|
|
91
|
+
class="h-full rounded-full transition-all duration-300"
|
|
92
|
+
:class="similarityBarColor(similarity)"
|
|
93
|
+
:style="{ width: `${Math.round(similarity * 100)}%` }"
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
<span class="text-sm font-medium" :class="similarityTextColor(similarity)">
|
|
97
|
+
{{ Math.round(similarity * 100) }}%
|
|
98
|
+
</span>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<p v-if="!hasChanges" class="text-center py-4 text-sm text-ink-400 italic">
|
|
102
|
+
No differences between these versions.
|
|
103
|
+
</p>
|
|
104
|
+
|
|
105
|
+
<!-- Definition -->
|
|
106
|
+
<section v-if="sections.definition" class="card p-4">
|
|
107
|
+
<h3 class="text-xs font-semibold text-ink-400 uppercase tracking-wider mb-2">Definition</h3>
|
|
108
|
+
<div class="font-mono text-xs leading-relaxed">
|
|
109
|
+
<template v-if="sections.definition.hunks">
|
|
110
|
+
<span
|
|
111
|
+
v-for="(hunk, i) in sections.definition.hunks"
|
|
112
|
+
:key="i"
|
|
113
|
+
:class="{
|
|
114
|
+
'bg-emerald-100/70 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300': hunk.type === 'added',
|
|
115
|
+
'bg-red-100/70 dark:bg-red-900/20 text-red-700 dark:text-red-300 line-through': hunk.type === 'removed',
|
|
116
|
+
'text-ink-500': hunk.type === 'equal',
|
|
117
|
+
}"
|
|
118
|
+
dir="auto"
|
|
119
|
+
>{{ hunk.text }} </span>
|
|
120
|
+
</template>
|
|
121
|
+
<span v-else-if="sections.definition.type === 'added'" class="text-emerald-600 dark:text-emerald-400" dir="auto">
|
|
122
|
+
{{ sections.definition.value }}
|
|
123
|
+
</span>
|
|
124
|
+
<span v-else-if="sections.definition.type === 'removed'" class="text-red-500 dark:text-red-400 line-through" dir="auto">
|
|
125
|
+
{{ sections.definition.value }}
|
|
126
|
+
</span>
|
|
127
|
+
</div>
|
|
128
|
+
</section>
|
|
129
|
+
|
|
130
|
+
<!-- Designations -->
|
|
131
|
+
<section v-if="sections.designations?.items?.length" class="card p-4">
|
|
132
|
+
<h3 class="text-xs font-semibold text-ink-400 uppercase tracking-wider mb-2">Designations</h3>
|
|
133
|
+
<div class="space-y-1">
|
|
134
|
+
<div
|
|
135
|
+
v-for="(item, i) in sections.designations.items"
|
|
136
|
+
:key="i"
|
|
137
|
+
class="flex items-baseline gap-2 text-sm"
|
|
138
|
+
>
|
|
139
|
+
<span
|
|
140
|
+
:class="{
|
|
141
|
+
'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300': item.type === 'added',
|
|
142
|
+
'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300': item.type === 'removed',
|
|
143
|
+
}"
|
|
144
|
+
class="text-xs px-1.5 py-0.5 rounded font-medium"
|
|
145
|
+
>{{ item.type ?? 'same' }}</span>
|
|
146
|
+
<span :class="{ 'line-through opacity-60': item.type === 'removed' }" dir="auto">{{ item.text }}</span>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
</section>
|
|
150
|
+
|
|
151
|
+
<!-- Notes -->
|
|
152
|
+
<section v-if="sections.notes?.items?.length" class="card p-4">
|
|
153
|
+
<h3 class="text-xs font-semibold text-ink-400 uppercase tracking-wider mb-2">Notes</h3>
|
|
154
|
+
<div class="space-y-1">
|
|
155
|
+
<div
|
|
156
|
+
v-for="(item, i) in sections.notes.items"
|
|
157
|
+
:key="i"
|
|
158
|
+
class="text-sm flex items-baseline gap-2"
|
|
159
|
+
>
|
|
160
|
+
<span
|
|
161
|
+
class="text-xs px-1.5 py-0.5 rounded"
|
|
162
|
+
:class="item.type === 'added' ? 'bg-emerald-100 text-emerald-600' : 'bg-red-100 text-red-600'"
|
|
163
|
+
>{{ item.type }}</span>
|
|
164
|
+
<span :class="{ 'line-through opacity-60': item.type === 'removed' }" dir="auto">{{ item.text }}</span>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
</section>
|
|
168
|
+
|
|
169
|
+
<!-- Examples -->
|
|
170
|
+
<section v-if="sections.examples?.items?.length" class="card p-4">
|
|
171
|
+
<h3 class="text-xs font-semibold text-ink-400 uppercase tracking-wider mb-2">Examples</h3>
|
|
172
|
+
<div class="space-y-1">
|
|
173
|
+
<div
|
|
174
|
+
v-for="(item, i) in sections.examples.items"
|
|
175
|
+
:key="i"
|
|
176
|
+
class="text-sm flex items-baseline gap-2"
|
|
177
|
+
>
|
|
178
|
+
<span
|
|
179
|
+
class="text-xs px-1.5 py-0.5 rounded"
|
|
180
|
+
:class="item.type === 'added' ? 'bg-emerald-100 text-emerald-600' : 'bg-red-100 text-red-600'"
|
|
181
|
+
>{{ item.type }}</span>
|
|
182
|
+
<span :class="{ 'line-through opacity-60': item.type === 'removed' }" dir="auto">{{ item.text }}</span>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
</section>
|
|
186
|
+
</template>
|
|
187
|
+
|
|
188
|
+
<div v-else class="text-center py-6 text-sm text-ink-400">
|
|
189
|
+
Select an edition to compare.
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</template>
|
|
@@ -23,6 +23,10 @@ const props = defineProps<{
|
|
|
23
23
|
conceptId: string;
|
|
24
24
|
}>();
|
|
25
25
|
|
|
26
|
+
const emit = defineEmits<{
|
|
27
|
+
(e: 'compare', target: { registerId: string; conceptId: string; label: string }): void;
|
|
28
|
+
}>();
|
|
29
|
+
|
|
26
30
|
const store = useVocabularyStore();
|
|
27
31
|
const router = useRouter();
|
|
28
32
|
|
|
@@ -148,6 +152,15 @@ function navigate(entry: EditionEntry) {
|
|
|
148
152
|
});
|
|
149
153
|
}
|
|
150
154
|
|
|
155
|
+
function compare(entry: EditionEntry) {
|
|
156
|
+
if (entry.edgeType === 'self') return;
|
|
157
|
+
emit('compare', {
|
|
158
|
+
registerId: entry.member.id,
|
|
159
|
+
conceptId: entry.conceptId,
|
|
160
|
+
label: entry.member.ref || entry.member.id,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
151
164
|
function edgeLabel(entry: EditionEntry): string {
|
|
152
165
|
switch (entry.edgeType) {
|
|
153
166
|
case 'supersedes': return t('edge.supersedes');
|
|
@@ -163,49 +176,61 @@ function edgeLabel(entry: EditionEntry): string {
|
|
|
163
176
|
<div class="mt-1 text-xs text-ink-400 italic">{{ series.title }}</div>
|
|
164
177
|
|
|
165
178
|
<div class="mt-3 space-y-1">
|
|
166
|
-
<
|
|
179
|
+
<div
|
|
167
180
|
v-for="entry in editionChain"
|
|
168
181
|
:key="entry.conceptUri"
|
|
169
|
-
|
|
170
|
-
class="concept-link block w-full text-left rounded-md px-1.5 py-1.5 transition-colors"
|
|
182
|
+
class="rounded-md transition-colors group"
|
|
171
183
|
:class="entry.edgeType === 'self'
|
|
172
184
|
? 'bg-blue-50 dark:bg-blue-900/20'
|
|
173
185
|
: 'hover:bg-ink-50 dark:hover:bg-ink-700/40'"
|
|
174
|
-
:disabled="entry.edgeType === 'self'"
|
|
175
|
-
@click="navigate(entry)"
|
|
176
186
|
>
|
|
177
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
style="background: rgba(184, 147, 90, 0.18); color: #8C6A3A; border: 1px solid rgba(184, 147, 90, 0.35);"
|
|
183
|
-
:title="t('concept.currentEdition')"
|
|
184
|
-
>✦ {{ t('concept.currentEdition') }}</span>
|
|
185
|
-
<span
|
|
186
|
-
v-if="entry.edgeType !== 'self'"
|
|
187
|
-
class="badge text-[9px] flex-shrink-0 badge-gray"
|
|
188
|
-
>{{ edgeLabel(entry) }}</span>
|
|
189
|
-
<span
|
|
190
|
-
v-if="entry.edgeType === 'self'"
|
|
191
|
-
class="badge text-[9px] flex-shrink-0 badge-blue"
|
|
192
|
-
>{{ t('concept.viewing') }}</span>
|
|
193
|
-
</div>
|
|
194
|
-
<div class="flex items-baseline gap-2">
|
|
195
|
-
<span class="font-mono text-xs text-ink-500 dark:text-ink-400 flex-shrink-0">
|
|
196
|
-
{{ entry.member.year ?? '—' }}
|
|
197
|
-
</span>
|
|
198
|
-
<span class="text-sm text-ink-700 dark:text-ink-200 leading-snug truncate">
|
|
199
|
-
{{ entry.member.ref }}
|
|
200
|
-
</span>
|
|
201
|
-
</div>
|
|
202
|
-
<div
|
|
203
|
-
v-if="entry.edgeType !== 'self' || entry.conceptId !== props.conceptId"
|
|
204
|
-
class="font-mono text-[10px] text-ink-300 dark:text-ink-500 mt-0.5 leading-tight"
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
class="concept-link block w-full text-left px-1.5 py-1.5"
|
|
190
|
+
:disabled="entry.edgeType === 'self'"
|
|
191
|
+
@click="navigate(entry)"
|
|
205
192
|
>
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
193
|
+
<div class="flex items-center gap-1 mb-0.5">
|
|
194
|
+
<span
|
|
195
|
+
v-if="entry.isCurrentEdition"
|
|
196
|
+
class="badge text-[9px] flex-shrink-0"
|
|
197
|
+
:class="entry.edgeType === 'self' ? 'badge-blue' : 'badge-gray'"
|
|
198
|
+
style="background: rgba(184, 147, 90, 0.18); color: #8C6A3A; border: 1px solid rgba(184, 147, 90, 0.35);"
|
|
199
|
+
:title="t('concept.currentEdition')"
|
|
200
|
+
>✦ {{ t('concept.currentEdition') }}</span>
|
|
201
|
+
<span
|
|
202
|
+
v-if="entry.edgeType !== 'self'"
|
|
203
|
+
class="badge text-[9px] flex-shrink-0 badge-gray"
|
|
204
|
+
>{{ edgeLabel(entry) }}</span>
|
|
205
|
+
<span
|
|
206
|
+
v-if="entry.edgeType === 'self'"
|
|
207
|
+
class="badge text-[9px] flex-shrink-0 badge-blue"
|
|
208
|
+
>{{ t('concept.viewing') }}</span>
|
|
209
|
+
</div>
|
|
210
|
+
<div class="flex items-baseline gap-2">
|
|
211
|
+
<span class="font-mono text-xs text-ink-500 dark:text-ink-400 flex-shrink-0">
|
|
212
|
+
{{ entry.member.year ?? '—' }}
|
|
213
|
+
</span>
|
|
214
|
+
<span class="text-sm text-ink-700 dark:text-ink-200 leading-snug truncate">
|
|
215
|
+
{{ entry.member.ref }}
|
|
216
|
+
</span>
|
|
217
|
+
</div>
|
|
218
|
+
<div
|
|
219
|
+
v-if="entry.edgeType !== 'self' || entry.conceptId !== props.conceptId"
|
|
220
|
+
class="font-mono text-[10px] text-ink-300 dark:text-ink-500 mt-0.5 leading-tight"
|
|
221
|
+
>
|
|
222
|
+
{{ entry.member.id }} · {{ entry.conceptId }}
|
|
223
|
+
</div>
|
|
224
|
+
</button>
|
|
225
|
+
<button
|
|
226
|
+
v-if="entry.edgeType !== 'self'"
|
|
227
|
+
type="button"
|
|
228
|
+
class="block w-full text-left px-1.5 pb-1.5 text-[10px] text-blue-600 dark:text-blue-400 hover:underline opacity-0 group-hover:opacity-100 transition-opacity"
|
|
229
|
+
@click="compare(entry)"
|
|
230
|
+
>
|
|
231
|
+
⟷ {{ t('concept.compareWith') || 'Compare with this edition' }}
|
|
232
|
+
</button>
|
|
233
|
+
</div>
|
|
209
234
|
</div>
|
|
210
235
|
|
|
211
236
|
<div v-if="!hasChain" class="mt-3 pt-3 border-t border-ink-100/60 dark:border-ink-700/40">
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* PartitiveRelationList — renders one-to-many partitive decompositions
|
|
4
|
+
* as grouped cards. Each relation shows:
|
|
5
|
+
* - the comprehensive (whole) concept as the card header
|
|
6
|
+
* - each partitive member as an indented child with a certainty badge
|
|
7
|
+
* - completeness badge (complete / partial)
|
|
8
|
+
* - plurality badge (shared / uncertain / sharedType) when present
|
|
9
|
+
* - criterion as italic text under the title
|
|
10
|
+
*
|
|
11
|
+
* Independent of RelationshipList.vue, which renders binary edges.
|
|
12
|
+
* Both consume the same stores but render in separate sections.
|
|
13
|
+
*
|
|
14
|
+
* v2 shape per concept-model/TODO.partitive-relation-v2.
|
|
15
|
+
*/
|
|
16
|
+
import type { PartitiveRelationWire, Manifest } from '../adapters/types';
|
|
17
|
+
import { useRouter } from 'vue-router';
|
|
18
|
+
import { useVocabularyStore } from '../stores/vocabulary';
|
|
19
|
+
import { getFactory } from '../adapters/factory';
|
|
20
|
+
import { useI18n } from '../i18n';
|
|
21
|
+
import {
|
|
22
|
+
partitiveRelationStyle,
|
|
23
|
+
completenessLabel,
|
|
24
|
+
certaintyLabel,
|
|
25
|
+
} from '../utils/partitive-relation-styling';
|
|
26
|
+
|
|
27
|
+
const props = defineProps<{
|
|
28
|
+
relations: PartitiveRelationWire[];
|
|
29
|
+
manifest: Manifest;
|
|
30
|
+
registerId: string;
|
|
31
|
+
}>();
|
|
32
|
+
|
|
33
|
+
const emit = defineEmits<{
|
|
34
|
+
(e: 'navigate', registerId: string, conceptId: string): void;
|
|
35
|
+
}>();
|
|
36
|
+
|
|
37
|
+
const router = useRouter();
|
|
38
|
+
const store = useVocabularyStore();
|
|
39
|
+
const factory = getFactory();
|
|
40
|
+
const { t } = useI18n();
|
|
41
|
+
|
|
42
|
+
function labelFor(uri: string): string {
|
|
43
|
+
const parsed = factory.resolve(uri);
|
|
44
|
+
if (parsed.type === 'internal') {
|
|
45
|
+
return parsed.conceptId || uri;
|
|
46
|
+
}
|
|
47
|
+
return uri;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function navigate(uri: string) {
|
|
51
|
+
const parsed = factory.resolve(uri);
|
|
52
|
+
if (parsed.type === 'internal') {
|
|
53
|
+
store.viewConcept(parsed.registerId, parsed.conceptId);
|
|
54
|
+
router.push({
|
|
55
|
+
name: 'concept',
|
|
56
|
+
params: { registerId: parsed.registerId, conceptId: parsed.conceptId },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function criterionText(criterion?: Record<string, string>): string | null {
|
|
62
|
+
if (!criterion) return null;
|
|
63
|
+
return criterion[props.manifest.id]
|
|
64
|
+
?? criterion.default
|
|
65
|
+
?? criterion.eng
|
|
66
|
+
?? Object.values(criterion)[0]
|
|
67
|
+
?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function pluralityBadge(plurality: NonNullable<PartitiveRelationWire['plurality']>): string {
|
|
71
|
+
if (!plurality.isShared) return '';
|
|
72
|
+
let label = t('partitive.plurality.shared') || 'shared type';
|
|
73
|
+
if (plurality.isUncertain) {
|
|
74
|
+
label = `${label} (${t('partitive.plurality.uncertain') || 'uncertain'})`;
|
|
75
|
+
}
|
|
76
|
+
if (plurality.sharedType) {
|
|
77
|
+
label = `${label}: ${plurality.sharedType}`;
|
|
78
|
+
}
|
|
79
|
+
return label;
|
|
80
|
+
}
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<template>
|
|
84
|
+
<div v-if="relations.length > 0" class="space-y-3">
|
|
85
|
+
<div class="section-label flex items-center gap-1.5 mb-2">
|
|
86
|
+
<span class="inline-block w-2 h-2 rounded-full bg-emerald-600"></span>
|
|
87
|
+
{{ t('partitive.relations') || 'Partitive relations' }}
|
|
88
|
+
</div>
|
|
89
|
+
<div
|
|
90
|
+
v-for="(rel, i) in relations"
|
|
91
|
+
:key="i"
|
|
92
|
+
class="rounded-lg border border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-900/20 overflow-hidden"
|
|
93
|
+
>
|
|
94
|
+
<div class="px-3 py-2 flex items-center gap-2 border-b border-emerald-200 dark:border-emerald-800">
|
|
95
|
+
<span class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-200 dark:bg-emerald-800 text-emerald-900 dark:text-emerald-100 uppercase">
|
|
96
|
+
{{ completenessLabel(rel.completeness) }}
|
|
97
|
+
</span>
|
|
98
|
+
<span
|
|
99
|
+
v-if="rel.plurality && rel.plurality.isShared"
|
|
100
|
+
class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-amber-200 dark:bg-amber-800 text-amber-900 dark:text-amber-100 uppercase"
|
|
101
|
+
:title="pluralityBadge(rel.plurality)"
|
|
102
|
+
>
|
|
103
|
+
{{ pluralityBadge(rel.plurality) }}
|
|
104
|
+
</span>
|
|
105
|
+
<button
|
|
106
|
+
@click="navigate(rel.comprehensive)"
|
|
107
|
+
class="ml-auto text-sm font-medium text-emerald-900 dark:text-emerald-100 hover:underline truncate"
|
|
108
|
+
>
|
|
109
|
+
{{ labelFor(rel.comprehensive) }}
|
|
110
|
+
</button>
|
|
111
|
+
</div>
|
|
112
|
+
<div
|
|
113
|
+
v-if="criterionText(rel.criterion)"
|
|
114
|
+
class="px-3 py-1 text-xs text-emerald-800 dark:text-emerald-200 italic border-b border-emerald-200 dark:border-emerald-800"
|
|
115
|
+
>
|
|
116
|
+
{{ criterionText(rel.criterion) }}
|
|
117
|
+
</div>
|
|
118
|
+
<ul class="divide-y divide-emerald-100 dark:divide-emerald-900/30">
|
|
119
|
+
<li v-for="(member, j) in rel.partitives" :key="j">
|
|
120
|
+
<button
|
|
121
|
+
@click="navigate(member.uri)"
|
|
122
|
+
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-emerald-100 dark:hover:bg-emerald-900/30 text-emerald-900 dark:text-emerald-100"
|
|
123
|
+
:class="{ 'opacity-60': member.certainty === 'possible' }"
|
|
124
|
+
>
|
|
125
|
+
<span class="text-emerald-600 dark:text-emerald-400 mr-2">└</span>
|
|
126
|
+
{{ labelFor(member.uri) }}
|
|
127
|
+
<span
|
|
128
|
+
v-if="member.certainty === 'possible'"
|
|
129
|
+
class="ml-2 text-[10px] font-mono px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/50 text-amber-800 dark:text-amber-200 uppercase"
|
|
130
|
+
:title="'plurality uncertain (ISO 704 broken line)'"
|
|
131
|
+
>
|
|
132
|
+
{{ certaintyLabel(member.certainty) }}
|
|
133
|
+
</span>
|
|
134
|
+
</button>
|
|
135
|
+
</li>
|
|
136
|
+
</ul>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
</template>
|
|
@@ -21,6 +21,7 @@ import { easeInOutCubic, slerp, fibonacciSpherePosition, project, cardEdge, type
|
|
|
21
21
|
import { UriRouter } from '../adapters/UriRouter';
|
|
22
22
|
import { getPreferredTerm } from '../utils/concept-helpers';
|
|
23
23
|
import { renderContent } from '../utils/content-renderer';
|
|
24
|
+
import { hashSeed, expandParams, portSide, portPoint, idToUriGet } from '../utils/sphere-math';
|
|
24
25
|
import { useI18n } from '../i18n';
|
|
25
26
|
|
|
26
27
|
const { t, locale } = useI18n();
|
|
@@ -168,7 +169,7 @@ async function resolveTerm(
|
|
|
168
169
|
uriBase: string,
|
|
169
170
|
factory: ReturnType<typeof getFactory>
|
|
170
171
|
): Promise<ResolvedTerm | null> {
|
|
171
|
-
const neighborUri =
|
|
172
|
+
const neighborUri = UriRouter.buildConceptUri(uriBase, n.register, n.conceptId);
|
|
172
173
|
const out: ResolvedTerm = { term: null };
|
|
173
174
|
|
|
174
175
|
/* Strategy 1: graph engine */
|
|
@@ -229,12 +230,6 @@ function updateNodeTerm(n: SNode) {
|
|
|
229
230
|
|
|
230
231
|
/* ── Build internal graph from concept + edges (BFS) ────── */
|
|
231
232
|
|
|
232
|
-
function hashSeed(s: string): number {
|
|
233
|
-
let h = 0;
|
|
234
|
-
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
|
235
|
-
return Math.abs(h);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
233
|
function buildGraph() {
|
|
239
234
|
const focusUri = conceptUri(props.concept, props.registerId, props.manifest.uriBase);
|
|
240
235
|
const focusId = props.concept?.id || props.registerId;
|
|
@@ -399,11 +394,6 @@ function buildGraph() {
|
|
|
399
394
|
}
|
|
400
395
|
|
|
401
396
|
/* Reverse lookup: URI → node id. Linear scan is fine for ≤36 nodes. */
|
|
402
|
-
function idToUriGet(map: Map<string, string>, uri: string): string | undefined {
|
|
403
|
-
for (const [id, u] of map) if (u === uri) return id;
|
|
404
|
-
return undefined;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
397
|
/* ── Custom forces (EXACT prototype replica) ────────────── */
|
|
408
398
|
function sphereConstraint() {
|
|
409
399
|
let nL: SNode[] = nodes;
|
|
@@ -443,15 +433,6 @@ function velocityClamp(maxV: number) {
|
|
|
443
433
|
visual difference between tight (0) and loose (10) is dramatic.
|
|
444
434
|
Link distance is chord distance on the unit sphere: 0.5 ≈ 29° (cards
|
|
445
435
|
cluster near focus), 1.95 ≈ 154° (cards spread to back hemisphere). */
|
|
446
|
-
function expandParams(v: number): { linkDist: number; linkStrength: number; repMin: number; repStrength: number } {
|
|
447
|
-
const t = Math.max(0, Math.min(10, v)) / 10;
|
|
448
|
-
return {
|
|
449
|
-
linkDist: 0.5 + t * 1.45, /* 0.5 → 1.95 */
|
|
450
|
-
linkStrength: 0.08,
|
|
451
|
-
repMin: 0.55 + t * 0.40, /* 0.55 → 0.95 — just prevents overlap */
|
|
452
|
-
repStrength: 0.05,
|
|
453
|
-
};
|
|
454
|
-
}
|
|
455
436
|
let linkDistance = expandParams(5).linkDist;
|
|
456
437
|
let linkStrength = expandParams(5).linkStrength;
|
|
457
438
|
let repulseMinDist = expandParams(5).repMin;
|
|
@@ -606,24 +587,9 @@ function onTick() {
|
|
|
606
587
|
/* ── Edge drawing with 4-port card connections + type labels ─ */
|
|
607
588
|
/* Determine which side of a card (top/bottom/left/right) an edge should
|
|
608
589
|
connect to, based on the direction to the other endpoint. */
|
|
609
|
-
function portSide(from: {x:number;y:number}, to: {x:number;y:number}): 'top'|'bottom'|'left'|'right' {
|
|
610
|
-
const dx = to.x - from.x, dy = to.y - from.y;
|
|
611
|
-
if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? 'right' : 'left';
|
|
612
|
-
return dy > 0 ? 'bottom' : 'top';
|
|
613
|
-
}
|
|
614
|
-
|
|
615
590
|
/* Compute the connection point on a card's side, with an offset along
|
|
616
591
|
that side so multiple edges to the same side don't stack on top of
|
|
617
592
|
each other. `offset` is in [-1, 1] (0 = midpoint, ±1 = corners). */
|
|
618
|
-
function portPoint(center: {x:number;y:number}, side: 'top'|'bottom'|'left'|'right', w: number, h: number, offset: number) {
|
|
619
|
-
switch (side) {
|
|
620
|
-
case 'right': return { x: center.x + w/2, y: center.y + (h/2 - 6) * offset };
|
|
621
|
-
case 'left': return { x: center.x - w/2, y: center.y + (h/2 - 6) * offset };
|
|
622
|
-
case 'bottom': return { x: center.x + (w/2 - 12) * offset, y: center.y + h/2 };
|
|
623
|
-
case 'top': return { x: center.x + (w/2 - 12) * offset, y: center.y - h/2 };
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
|
|
627
593
|
function drawEdges(cx: number, cy: number) {
|
|
628
594
|
const svg = edgesSvgRef.value; if (!svg) return;
|
|
629
595
|
const w = cx * 2, h = cy * 2 / 0.46;
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
3
|
* DatasetGroupRenderer — OCP dispatcher. Maps group.kind to the
|
|
4
|
-
* appropriate sidebar renderer component.
|
|
5
|
-
*
|
|
4
|
+
* appropriate sidebar renderer component.
|
|
5
|
+
*
|
|
6
|
+
* Adding a new kind: add an entry to GROUP_RENDERERS + create the new
|
|
7
|
+
* sidebar component. Zero edits to existing components or to
|
|
8
|
+
* AppSidebar.
|
|
9
|
+
*
|
|
10
|
+
* Forwards the `expanded` slot scoped per-entry so callers can render
|
|
11
|
+
* shared per-entry content (sub-pages, sections, etc.) regardless of kind.
|
|
6
12
|
*/
|
|
7
13
|
import { computed } from 'vue';
|
|
8
14
|
import { groupRendererFor } from '../../config/group-renderers';
|
|
@@ -28,5 +34,9 @@ const renderer = computed(() => groupRendererFor(resolveGroupKind({ kind: props.
|
|
|
28
34
|
</script>
|
|
29
35
|
|
|
30
36
|
<template>
|
|
31
|
-
<component :is="renderer" :entries="entries" :current-dataset="currentDataset"
|
|
32
|
-
|
|
37
|
+
<component :is="renderer" :entries="entries" :current-dataset="currentDataset">
|
|
38
|
+
<template #expanded="slotProps">
|
|
39
|
+
<slot name="expanded" v-bind="slotProps" />
|
|
40
|
+
</template>
|
|
41
|
+
</component>
|
|
42
|
+
</template>
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
|
-
* DefaultGroupSidebar — flat list of dataset entries with expansion.
|
|
3
|
+
* DefaultGroupSidebar — flat list of dataset entries with expansion slot.
|
|
4
4
|
* Used for topic, family, collection, and default group kinds.
|
|
5
|
-
*
|
|
5
|
+
* Invoked via DatasetGroupRenderer dispatch (OCP).
|
|
6
|
+
*
|
|
7
|
+
* Slot `expanded` receives `{ entry, isCurrent }` per entry so callers
|
|
8
|
+
* can render shared per-entry content (sub-pages, sections, etc.)
|
|
9
|
+
* without touching this component.
|
|
6
10
|
*/
|
|
7
11
|
import { useRouter } from 'vue-router';
|
|
8
12
|
import { useI18n } from '../../i18n';
|
|
13
|
+
import { useDsStyle } from '../../utils/dataset-style';
|
|
14
|
+
import { useSiteConfig } from '../../config/use-site-config';
|
|
9
15
|
|
|
10
|
-
|
|
16
|
+
defineProps<{
|
|
11
17
|
entries: Array<{
|
|
12
18
|
id: string;
|
|
13
19
|
title: string;
|
|
@@ -19,9 +25,11 @@ const props = defineProps<{
|
|
|
19
25
|
|
|
20
26
|
const router = useRouter();
|
|
21
27
|
const { t } = useI18n();
|
|
28
|
+
const { getColor } = useDsStyle();
|
|
29
|
+
const { localizedDatasetField } = useSiteConfig();
|
|
22
30
|
|
|
23
|
-
function navigate(id: string) {
|
|
24
|
-
if (id ===
|
|
31
|
+
function navigate(id: string, current: string) {
|
|
32
|
+
if (id === current) return;
|
|
25
33
|
router.push({ name: 'dataset', params: { registerId: id } });
|
|
26
34
|
}
|
|
27
35
|
</script>
|
|
@@ -35,16 +43,25 @@ function navigate(id: string) {
|
|
|
35
43
|
>
|
|
36
44
|
<button
|
|
37
45
|
type="button"
|
|
38
|
-
class="w-full text-left px-3 py-2 rounded-lg text-sm border-l-2"
|
|
46
|
+
class="w-full text-left px-3 py-2 rounded-lg text-sm border-l-2 flex items-start gap-2"
|
|
39
47
|
:class="currentDataset === ds.id
|
|
40
48
|
? 'text-ink-800 dark:text-ink-50'
|
|
41
49
|
: 'border-transparent text-ink-600 dark:text-ink-300 hover:bg-ink-50 dark:hover:bg-ink-700 hover:text-ink-800 dark:hover:text-ink-50'"
|
|
42
|
-
|
|
50
|
+
:style="{ borderLeftColor: currentDataset === ds.id ? getColor(ds.id) : 'transparent' }"
|
|
51
|
+
@click="navigate(ds.id, currentDataset)"
|
|
43
52
|
>
|
|
44
|
-
<
|
|
45
|
-
<div
|
|
46
|
-
|
|
53
|
+
<span class="w-2 h-2 rounded-full flex-shrink-0 mt-1.5" :style="{ backgroundColor: getColor(ds.id) }"></span>
|
|
54
|
+
<div class="min-w-0 flex-1">
|
|
55
|
+
<div class="font-medium truncate leading-snug">{{ localizedDatasetField(ds.id, 'title', ds.title) }}</div>
|
|
56
|
+
<div
|
|
57
|
+
v-if="ds.loaded"
|
|
58
|
+
class="text-xs mt-0.5"
|
|
59
|
+
:class="currentDataset === ds.id ? 'text-ink-400 dark:text-ink-300' : 'text-ink-300 dark:text-ink-400'"
|
|
60
|
+
>
|
|
61
|
+
{{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
|
|
62
|
+
</div>
|
|
47
63
|
</div>
|
|
48
64
|
</button>
|
|
65
|
+
<slot name="expanded" :entry="ds" :is-current="currentDataset === ds.id" />
|
|
49
66
|
</div>
|
|
50
|
-
</template>
|
|
67
|
+
</template>
|