@glossarist/concept-browser 0.7.78 → 0.7.81
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 +2 -2
- package/scripts/build-edges.js +109 -39
- package/scripts/generate-data.mjs +63 -27
- 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/adapters/GraphDataSource.ts +16 -6
- package/src/adapters/dataset-stats.ts +13 -0
- package/src/adapters/non-verbal/glossarist-augment.d.ts +155 -4
- package/src/adapters/types.ts +27 -17
- 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/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 +66 -45
- 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/main.ts +1 -1
- package/src/style.css +1 -1
- package/src/utils/partitive-relation-styling.ts +62 -0
- package/src/views/DatasetView.vue +5 -3
- package/src/views/StatsView.vue +9 -22
- package/src/__tests__/hyperedge-styling.test.ts +0 -57
- package/src/__tests__/partitive-hyperedge.test.ts +0 -199
- package/src/components/PartitiveHyperedgeList.vue +0 -112
- 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
- package/src/utils/hyperedge-styling.ts +0 -48
|
@@ -6,6 +6,7 @@ import { useRoute, useRouter } from 'vue-router';
|
|
|
6
6
|
import { useDsStyle } from '../utils/dataset-style';
|
|
7
7
|
import { useSiteConfig } from '../config/use-site-config';
|
|
8
8
|
import NavIcon from './NavIcon.vue';
|
|
9
|
+
import DatasetGroupRenderer from './groups/DatasetGroupRenderer.vue';
|
|
9
10
|
import { useI18n, locale } from '../i18n';
|
|
10
11
|
import type { SectionNode } from '../adapters/types';
|
|
11
12
|
import { toSectionTree } from '../utils/section-tree';
|
|
@@ -284,61 +285,17 @@ const activeSectionId = computed(() => {
|
|
|
284
285
|
<span class="text-[9px] uppercase tracking-wide text-ink-300 dark:text-ink-500 font-sans">{{ groupTypeMeta(group).label }}</span>
|
|
285
286
|
</button>
|
|
286
287
|
|
|
287
|
-
<!-- Group entries
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
<
|
|
298
|
-
v-if="group.kind === 'lineage'"
|
|
299
|
-
@click="goToDataset(ds.id)"
|
|
300
|
-
class="series-entry w-full text-left flex items-center gap-2 pl-6 pr-3 py-1.5 rounded-md text-sm border-l-2 transition-all duration-150"
|
|
301
|
-
:class="currentDataset === ds.id
|
|
302
|
-
? 'bg-amber-50/70 dark:bg-amber-400/10 border-l-[3px] text-ink-900 dark:text-ink-50 font-semibold'
|
|
303
|
-
: 'border-transparent text-ink-600 dark:text-ink-300 hover:bg-ink-50 dark:hover:bg-ink-700/40 hover:text-ink-900 dark:hover:text-ink-50'"
|
|
304
|
-
:style="currentDataset === ds.id ? { borderLeftColor: 'var(--gold-accent, #B8935A)' } : {}"
|
|
305
|
-
>
|
|
306
|
-
<span class="flex-1 truncate text-[13.5px] font-medium leading-snug">{{ ds.ref || ds.title || ds.id }}</span>
|
|
307
|
-
<span
|
|
308
|
-
v-if="ds.status && ds.status !== 'valid'"
|
|
309
|
-
class="text-[9px] uppercase tracking-wide italic text-ink-400 dark:text-ink-400"
|
|
310
|
-
>{{ ds.status }}</span>
|
|
311
|
-
<span
|
|
312
|
-
v-if="ds.isCurrent"
|
|
313
|
-
class="current-star flex-shrink-0"
|
|
314
|
-
title="Current edition"
|
|
315
|
-
>✦</span>
|
|
316
|
-
</button>
|
|
317
|
-
|
|
318
|
-
<!-- DEFAULT/OTHER: standard entry button -->
|
|
319
|
-
<button
|
|
320
|
-
v-else
|
|
321
|
-
@click="goToDataset(ds.id)"
|
|
322
|
-
class="w-full text-left px-3 py-2 rounded-lg text-sm border-l-2 flex items-start gap-2"
|
|
323
|
-
:class="[
|
|
324
|
-
currentDataset === ds.id
|
|
325
|
-
? 'text-ink-800 dark:text-ink-50'
|
|
326
|
-
: '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'
|
|
327
|
-
]"
|
|
328
|
-
:style="{ borderLeftColor: currentDataset === ds.id ? getColor(ds.id) : 'transparent' }"
|
|
329
|
-
>
|
|
330
|
-
<span class="w-2 h-2 rounded-full flex-shrink-0 mt-1.5" :style="{ backgroundColor: getColor(ds.id) }"></span>
|
|
331
|
-
<div class="min-w-0 flex-1">
|
|
332
|
-
<div class="font-medium truncate leading-snug">{{ localizedDatasetField(ds.id, 'title', ds.title) }}</div>
|
|
333
|
-
<div v-if="ds.loaded" class="text-xs mt-0.5" :class="currentDataset === ds.id ? 'text-ink-400 dark:text-ink-300' : 'text-ink-300 dark:text-ink-400'">
|
|
334
|
-
{{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
|
|
335
|
-
</div>
|
|
336
|
-
</div>
|
|
337
|
-
</button>
|
|
338
|
-
|
|
339
|
-
<!-- SHARED expansion content: sub-pages + sections + provenance.
|
|
340
|
-
Appears for the active dataset in ALL group kinds (DRY). -->
|
|
341
|
-
<div v-if="currentDataset === ds.id && (filteredDatasetPages.length || provenance.owner)" class="px-2 pb-2">
|
|
288
|
+
<!-- Group entries: dispatch to kind-specific renderer (OCP).
|
|
289
|
+
Shared expansion content lives in the `expanded` slot so
|
|
290
|
+
it stays identical across kinds (DRY/MECE). -->
|
|
291
|
+
<DatasetGroupRenderer
|
|
292
|
+
v-if="isGroupExpanded(group.id)"
|
|
293
|
+
:kind="group.kind"
|
|
294
|
+
:entries="group.entries"
|
|
295
|
+
:current-dataset="currentDataset"
|
|
296
|
+
>
|
|
297
|
+
<template #expanded="{ entry: ds, isCurrent }">
|
|
298
|
+
<div v-if="isCurrent && (filteredDatasetPages.length || provenance.owner)" class="px-2 pb-2">
|
|
342
299
|
<nav v-if="filteredDatasetPages.length" class="space-y-0.5 mt-1">
|
|
343
300
|
<router-link
|
|
344
301
|
v-for="page in filteredDatasetPages"
|
|
@@ -410,8 +367,8 @@ const activeSectionId = computed(() => {
|
|
|
410
367
|
</div>
|
|
411
368
|
</div>
|
|
412
369
|
</div>
|
|
413
|
-
</
|
|
414
|
-
</
|
|
370
|
+
</template>
|
|
371
|
+
</DatasetGroupRenderer>
|
|
415
372
|
</div>
|
|
416
373
|
</template>
|
|
417
374
|
|
|
@@ -27,6 +27,8 @@ import FormatDownloads from './FormatDownloads.vue';
|
|
|
27
27
|
import ConceptEditionRail from './ConceptEditionRail.vue';
|
|
28
28
|
import NonVerbalRepDisplay from './NonVerbalRepDisplay.vue';
|
|
29
29
|
import NonVerbalList from './non-verbal/NonVerbalList.vue';
|
|
30
|
+
import PartitiveRelationList from './PartitiveRelationList.vue';
|
|
31
|
+
import ConceptDiffModal from './ConceptDiffModal.vue';
|
|
30
32
|
import CitationDisplay from './CitationDisplay.vue';
|
|
31
33
|
import DesignationList from './DesignationList.vue';
|
|
32
34
|
import { useI18n } from '../i18n';
|
|
@@ -72,6 +74,7 @@ const {
|
|
|
72
74
|
edgeBadgeColor,
|
|
73
75
|
inverseEdgeType,
|
|
74
76
|
conceptRelated,
|
|
77
|
+
conceptPartitiveRelations,
|
|
75
78
|
getResolvedRef,
|
|
76
79
|
relatedLabel,
|
|
77
80
|
navigateEdge,
|
|
@@ -80,6 +83,11 @@ const {
|
|
|
80
83
|
|
|
81
84
|
const hoveredEdgeDisplay = ref<{ designation: string; conceptId: string; tooltip: string } | null>(null);
|
|
82
85
|
|
|
86
|
+
const diffTarget = ref<{ registerId: string; conceptId: string; label: string } | null>(null);
|
|
87
|
+
function onCompareEdition(target: { registerId: string; conceptId: string; label: string }) {
|
|
88
|
+
diffTarget.value = target;
|
|
89
|
+
}
|
|
90
|
+
|
|
83
91
|
const uriCopied = ref(false);
|
|
84
92
|
function copyUri() {
|
|
85
93
|
const uri = conceptUri(props.concept, props.registerId, props.manifest.uriBase);
|
|
@@ -561,11 +569,32 @@ const nonVerbalReps = computed(() => {
|
|
|
561
569
|
</Transition>
|
|
562
570
|
</div>
|
|
563
571
|
|
|
572
|
+
<!-- Partitive relations — one-to-many decompositions (ISO 704) -->
|
|
573
|
+
<PartitiveRelationList
|
|
574
|
+
v-if="conceptPartitiveRelations.length"
|
|
575
|
+
:relations="conceptPartitiveRelations"
|
|
576
|
+
:manifest="manifest"
|
|
577
|
+
:register-id="registerId"
|
|
578
|
+
/>
|
|
579
|
+
|
|
564
580
|
<!-- Edition series — supersession chain across vocabulary editions -->
|
|
565
581
|
<ConceptEditionRail
|
|
566
582
|
:concept-uri="conceptUri(props.concept, props.registerId, props.manifest.uriBase)"
|
|
567
583
|
:register-id="registerId"
|
|
568
584
|
:concept-id="conceptId"
|
|
585
|
+
@compare="onCompareEdition"
|
|
586
|
+
/>
|
|
587
|
+
|
|
588
|
+
<!-- Edition diff modal — opened from the rail's compare buttons -->
|
|
589
|
+
<ConceptDiffModal
|
|
590
|
+
v-if="diffTarget"
|
|
591
|
+
:open="true"
|
|
592
|
+
:current-concept="concept"
|
|
593
|
+
:current-register-id="registerId"
|
|
594
|
+
:target-register-id="diffTarget.registerId"
|
|
595
|
+
:target-concept-id="diffTarget.conceptId"
|
|
596
|
+
:target-label="diffTarget.label"
|
|
597
|
+
@close="diffTarget = null"
|
|
569
598
|
/>
|
|
570
599
|
|
|
571
600
|
<!-- Domains -->
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ConceptDiffModal — modal wrapper around ConceptDiffView that fetches
|
|
4
|
+
* the comparison concept on demand.
|
|
5
|
+
*
|
|
6
|
+
* Triggered from ConceptEditionRail when the user clicks "compare"
|
|
7
|
+
* next to a non-current edition. The current concept (props.concept)
|
|
8
|
+
* becomes the "new" side; the selected edition becomes the "old" side.
|
|
9
|
+
*/
|
|
10
|
+
import { ref, watch, computed } from 'vue';
|
|
11
|
+
import { useVocabularyStore } from '../stores/vocabulary';
|
|
12
|
+
import ConceptDiffView from './ConceptDiffView.vue';
|
|
13
|
+
import type { ConceptLikeData } from '../composables/use-concept-diff';
|
|
14
|
+
import type { Concept } from 'glossarist';
|
|
15
|
+
import { useI18n } from '../i18n';
|
|
16
|
+
|
|
17
|
+
const props = defineProps<{
|
|
18
|
+
open: boolean;
|
|
19
|
+
/** The concept currently being viewed. */
|
|
20
|
+
currentConcept: Concept | null;
|
|
21
|
+
currentRegisterId: string;
|
|
22
|
+
/** Edition to compare against. */
|
|
23
|
+
targetRegisterId: string;
|
|
24
|
+
targetConceptId: string;
|
|
25
|
+
targetLabel?: string;
|
|
26
|
+
}>();
|
|
27
|
+
|
|
28
|
+
const emit = defineEmits<{ close: [] }>();
|
|
29
|
+
|
|
30
|
+
const store = useVocabularyStore();
|
|
31
|
+
const { t } = useI18n();
|
|
32
|
+
|
|
33
|
+
const targetConcept = ref<Concept | null>(null);
|
|
34
|
+
const loadError = ref<string | null>(null);
|
|
35
|
+
|
|
36
|
+
function conceptToData(c: Concept | null, registerId: string): ConceptLikeData | null {
|
|
37
|
+
if (!c) return null;
|
|
38
|
+
const langs = c.languages ?? [];
|
|
39
|
+
const localizations: Record<string, unknown> = {};
|
|
40
|
+
for (const lang of langs) {
|
|
41
|
+
const lc = c.localization(lang);
|
|
42
|
+
if (lc) localizations[lang] = lc;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
conceptId: c.id,
|
|
46
|
+
uri: c.uri ?? undefined,
|
|
47
|
+
status: c.status ?? undefined,
|
|
48
|
+
languages: langs,
|
|
49
|
+
localizations,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const oldData = computed(() => conceptToData(targetConcept.value, props.targetRegisterId));
|
|
54
|
+
const newData = computed(() => conceptToData(props.currentConcept, props.currentRegisterId));
|
|
55
|
+
|
|
56
|
+
async function loadTarget() {
|
|
57
|
+
if (!props.open || !props.targetRegisterId || !props.targetConceptId) return;
|
|
58
|
+
loadError.value = null;
|
|
59
|
+
targetConcept.value = null;
|
|
60
|
+
try {
|
|
61
|
+
const adapter = store.datasets.get(props.targetRegisterId);
|
|
62
|
+
if (!adapter) throw new Error(`dataset ${props.targetRegisterId} not loaded`);
|
|
63
|
+
if (!adapter.index) await store.loadDataset(props.targetRegisterId);
|
|
64
|
+
targetConcept.value = await adapter.fetchConcept(props.targetConceptId);
|
|
65
|
+
} catch (e: unknown) {
|
|
66
|
+
loadError.value = e instanceof Error ? e.message : String(e);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
watch(() => [props.open, props.targetRegisterId, props.targetConceptId] as const, () => {
|
|
71
|
+
if (props.open) loadTarget();
|
|
72
|
+
}, { immediate: true });
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<template>
|
|
76
|
+
<Teleport to="body">
|
|
77
|
+
<Transition name="fade">
|
|
78
|
+
<div
|
|
79
|
+
v-if="open"
|
|
80
|
+
class="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4"
|
|
81
|
+
@click.self="$emit('close')"
|
|
82
|
+
>
|
|
83
|
+
<div class="bg-surface rounded-lg shadow-xl max-w-3xl w-full max-h-[85vh] overflow-hidden flex flex-col">
|
|
84
|
+
<header class="px-5 py-3 border-b border-ink-100 dark:border-ink-700 flex items-center justify-between">
|
|
85
|
+
<h2 class="text-sm font-semibold text-ink-700 dark:text-ink-200">
|
|
86
|
+
{{ t('concept.compareEditions') || 'Compare editions' }}
|
|
87
|
+
</h2>
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
class="text-ink-400 hover:text-ink-700 dark:hover:text-ink-200"
|
|
91
|
+
@click="$emit('close')"
|
|
92
|
+
:aria-label="t('common.close') || 'Close'"
|
|
93
|
+
>
|
|
94
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
95
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
96
|
+
</svg>
|
|
97
|
+
</button>
|
|
98
|
+
</header>
|
|
99
|
+
<div class="p-5 overflow-y-auto">
|
|
100
|
+
<div v-if="loadError" class="card p-4 border-red-200 bg-red-50/50">
|
|
101
|
+
<p class="text-sm text-red-700">{{ loadError }}</p>
|
|
102
|
+
</div>
|
|
103
|
+
<ConceptDiffView
|
|
104
|
+
v-else
|
|
105
|
+
:old-concept="oldData"
|
|
106
|
+
:new-concept="newData"
|
|
107
|
+
:old-label="targetLabel || targetRegisterId"
|
|
108
|
+
:new-label="currentRegisterId"
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</Transition>
|
|
114
|
+
</Teleport>
|
|
115
|
+
</template>
|
|
116
|
+
|
|
117
|
+
<style scoped>
|
|
118
|
+
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; }
|
|
119
|
+
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
|
120
|
+
</style>
|
|
@@ -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">
|