@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.
Files changed (58) hide show
  1. package/astro.config.mjs +1 -0
  2. package/package.json +2 -2
  3. package/scripts/bridge-to-astro.mjs +6 -1
  4. package/scripts/build-edges.js +154 -1
  5. package/scripts/generate-data.mjs +138 -54
  6. package/src/__tests__/build-edges-redundancy.test.ts +101 -0
  7. package/src/__tests__/composables/use-concept-diff.test.ts +240 -0
  8. package/src/__tests__/composables/use-concept-edges-v2.test.ts +132 -0
  9. package/src/__tests__/composables/use-dataset-stats.test.ts +96 -0
  10. package/src/__tests__/config/page-types.test.ts +82 -0
  11. package/src/__tests__/model-bridge-bridges.test.ts +3 -2
  12. package/src/__tests__/model-bridge-citation.test.ts +1 -1
  13. package/src/__tests__/ontology-registry.test.ts +2 -2
  14. package/src/__tests__/partitive-relation-styling.test.ts +82 -0
  15. package/src/__tests__/partitive-relation.test.ts +285 -0
  16. package/src/__tests__/sphere-math.test.ts +56 -0
  17. package/src/adapters/GraphDataSource.ts +22 -5
  18. package/src/adapters/UriRouter.ts +5 -0
  19. package/src/adapters/dataset-stats.ts +13 -0
  20. package/src/adapters/model-bridge.ts +1 -1
  21. package/src/adapters/non-verbal/glossarist-augment.d.ts +155 -4
  22. package/src/adapters/types.ts +36 -0
  23. package/src/components/AppSidebar.vue +14 -57
  24. package/src/components/ConceptDetail.vue +29 -0
  25. package/src/components/ConceptDiffModal.vue +120 -0
  26. package/src/components/ConceptDiffView.vue +192 -0
  27. package/src/components/ConceptEditionRail.vue +61 -36
  28. package/src/components/PartitiveRelationList.vue +139 -0
  29. package/src/components/RelationSphere.vue +2 -36
  30. package/src/components/groups/DatasetGroupRenderer.vue +14 -4
  31. package/src/components/groups/DefaultGroupSidebar.vue +28 -11
  32. package/src/components/groups/LineageGroupSidebar.vue +17 -29
  33. package/src/composables/use-concept-diff.ts +231 -0
  34. package/src/composables/use-concept-edges.ts +98 -5
  35. package/src/composables/use-dataset-stats.ts +52 -0
  36. package/src/config/page-types.ts +67 -0
  37. package/src/config/types.ts +6 -1
  38. package/src/config/use-site-config.ts +13 -29
  39. package/src/data/taxonomies.json +24 -1
  40. package/src/i18n/locales/eng.yml +6 -0
  41. package/src/utils/partitive-relation-styling.ts +62 -0
  42. package/src/utils/sphere-math.ts +54 -0
  43. package/src/views/DatasetView.vue +5 -3
  44. package/src/views/StatsView.vue +9 -22
  45. package/src/islands/BibliographyIsland.vue +0 -26
  46. package/src/islands/ConceptDetailIsland.vue +0 -27
  47. package/src/islands/ConceptDiffIsland.vue +0 -214
  48. package/src/islands/ConceptIsland.vue +0 -102
  49. package/src/islands/GraphIsland.vue +0 -15
  50. package/src/islands/GroupRendererIsland.vue +0 -15
  51. package/src/islands/HeaderIsland.vue +0 -10
  52. package/src/islands/NonVerbalIsland.vue +0 -75
  53. package/src/islands/RelationSphereIsland.vue +0 -20
  54. package/src/islands/SearchIsland.vue +0 -23
  55. package/src/islands/SeriesCardIsland.vue +0 -12
  56. package/src/islands/ShortcutsIsland.vue +0 -13
  57. package/src/islands/SidebarIsland.vue +0 -15
  58. package/src/islands/SupersessionDiffIsland.vue +0 -267
@@ -10,14 +10,15 @@
10
10
  // This file declares the runtime shape so consumer code can be type-checked.
11
11
  // DELETE this file when upstream ships proper declarations — tracked by
12
12
  // PR glossarist/glossarist-js#31 (targets v0.4.3+).
13
+ //
14
+ // v2 PartitiveRelation block (below) tracks glossarist 0.4.20 — the runtime
15
+ // exports PartitiveRelation/PartitiveMember/TypeSharedPlurality from
16
+ // src/models/index.js, but the d.ts has not been updated. Remove the v2
17
+ // block when upstream PRs the d.ts changes.
13
18
 
14
19
  import type { ConceptSource, Citation, GlossaristModel } from 'glossarist';
15
20
 
16
21
  declare module 'glossarist' {
17
- interface ConceptSource {
18
- sourced_from?: Citation[];
19
- }
20
-
21
22
  class RegistrableModel extends GlossaristModel {
22
23
  static register(type: string, cls: typeof RegistrableModel): void;
23
24
  static fromData(data: Record<string, unknown>): RegistrableModel;
@@ -134,4 +135,154 @@ declare module 'glossarist' {
134
135
  static fromYAML(yamlString: string): BibliographyData;
135
136
  static fromJSON(data: Record<string, unknown>): BibliographyData;
136
137
  }
138
+
139
+ // ── v2 PartitiveRelation (glossarist 0.4.20) ────────────────────────────
140
+ // Runtime exports these from src/models/index.js; d.ts is stale.
141
+
142
+ type Completeness = 'complete' | 'partial';
143
+ const COMPLETENESS: { readonly COMPLETE: 'complete'; readonly PARTIAL: 'partial' };
144
+ const COMPLETENESS_VALUES: readonly Completeness[];
145
+ const DEFAULT_COMPLETENESS: Completeness;
146
+ function isValidCompleteness(value: unknown): value is Completeness;
147
+
148
+ type MemberCertainty = 'confirmed' | 'possible';
149
+ const MEMBER_CERTAINTY: { readonly CONFIRMED: 'confirmed'; readonly POSSIBLE: 'possible' };
150
+ const MEMBER_CERTAINTY_VALUES: readonly MemberCertainty[];
151
+ const DEFAULT_MEMBER_CERTAINTY: MemberCertainty;
152
+ function isValidMemberCertainty(value: unknown): value is MemberCertainty;
153
+
154
+ class TypeSharedPlurality extends GlossaristModel {
155
+ constructor(data?: {
156
+ isShared?: boolean;
157
+ is_shared?: boolean;
158
+ isUncertain?: boolean;
159
+ is_uncertain?: boolean;
160
+ sharedType?: ConceptRef;
161
+ shared_type?: ConceptRef;
162
+ });
163
+ readonly isShared: boolean;
164
+ readonly isUncertain: boolean;
165
+ readonly sharedType: ConceptRef | null;
166
+ hasSharedType(): boolean;
167
+ toJSON(): { is_shared: boolean; is_uncertain?: boolean; shared_type?: ReturnType<ConceptRef['toJSON']> };
168
+ static fromJSON(data: Record<string, unknown>): TypeSharedPlurality;
169
+ }
170
+
171
+ class PartitiveMember extends GlossaristModel {
172
+ constructor(data?: { ref?: ConceptRef; certainty?: MemberCertainty });
173
+ readonly ref: ConceptRef;
174
+ readonly certainty: MemberCertainty;
175
+ readonly isConfirmed: boolean;
176
+ readonly isPossible: boolean;
177
+ toJSON(): { ref: ReturnType<ConceptRef['toJSON']>; certainty?: MemberCertainty };
178
+ static fromJSON(data: Record<string, unknown>): PartitiveMember;
179
+ static identityOf(value: unknown): string;
180
+ }
181
+
182
+ class PartitiveRelation extends GlossaristModel {
183
+ constructor(data?: {
184
+ comprehensive?: ConceptRef;
185
+ partitives?: PartitiveMember[];
186
+ completeness?: Completeness;
187
+ plurality?: TypeSharedPlurality;
188
+ criterion?: Record<string, string> | string;
189
+ });
190
+ readonly comprehensive: ConceptRef;
191
+ readonly partitives: PartitiveMember[];
192
+ readonly completeness: Completeness;
193
+ readonly plurality: TypeSharedPlurality | null;
194
+ readonly criterion: Record<string, string> | null;
195
+ readonly isComplete: boolean;
196
+ readonly isPartial: boolean;
197
+ readonly isCoordinate: boolean;
198
+ hasPlurality(): boolean;
199
+ hasCriterion(): boolean;
200
+ toJSON(): {
201
+ comprehensive: ReturnType<ConceptRef['toJSON']>;
202
+ partitives: ReturnType<PartitiveMember['toJSON']>[];
203
+ completeness: Completeness;
204
+ plurality?: ReturnType<TypeSharedPlurality['toJSON']>;
205
+ criterion?: Record<string, string>;
206
+ };
207
+ static fromJSON(data: Record<string, unknown>): PartitiveRelation;
208
+ static identityOf(value: unknown): string;
209
+ }
210
+
211
+ interface Concept {
212
+ readonly partitiveRelations: PartitiveRelation[];
213
+ }
214
+ }
215
+
216
+ // Re-declare the same exports under the 'glossarist/models' subpath so
217
+ // deep-import callers can use them. Upstream's d.ts is stale on this
218
+ // subpath too.
219
+ declare module 'glossarist/models' {
220
+ export type Completeness = 'complete' | 'partial';
221
+ export const COMPLETENESS: { readonly COMPLETE: 'complete'; readonly PARTIAL: 'partial' };
222
+ export const COMPLETENESS_VALUES: readonly Completeness[];
223
+ export const DEFAULT_COMPLETENESS: Completeness;
224
+ export function isValidCompleteness(value: unknown): value is Completeness;
225
+
226
+ export type MemberCertainty = 'confirmed' | 'possible';
227
+ export const MEMBER_CERTAINTY: { readonly CONFIRMED: 'confirmed'; readonly POSSIBLE: 'possible' };
228
+ export const MEMBER_CERTAINTY_VALUES: readonly MemberCertainty[];
229
+ export const DEFAULT_MEMBER_CERTAINTY: MemberCertainty;
230
+ export function isValidMemberCertainty(value: unknown): value is MemberCertainty;
231
+
232
+ export class TypeSharedPlurality extends GlossaristModel {
233
+ constructor(data?: {
234
+ isShared?: boolean;
235
+ is_shared?: boolean;
236
+ isUncertain?: boolean;
237
+ is_uncertain?: boolean;
238
+ sharedType?: ConceptRef;
239
+ shared_type?: ConceptRef;
240
+ });
241
+ readonly isShared: boolean;
242
+ readonly isUncertain: boolean;
243
+ readonly sharedType: ConceptRef | null;
244
+ hasSharedType(): boolean;
245
+ toJSON(): { is_shared: boolean; is_uncertain?: boolean; shared_type?: ReturnType<ConceptRef['toJSON']> };
246
+ static fromJSON(data: Record<string, unknown>): TypeSharedPlurality;
247
+ }
248
+
249
+ export class PartitiveMember extends GlossaristModel {
250
+ constructor(data?: { ref?: ConceptRef; certainty?: MemberCertainty });
251
+ readonly ref: ConceptRef;
252
+ readonly certainty: MemberCertainty;
253
+ readonly isConfirmed: boolean;
254
+ readonly isPossible: boolean;
255
+ toJSON(): { ref: ReturnType<ConceptRef['toJSON']>; certainty?: MemberCertainty };
256
+ static fromJSON(data: Record<string, unknown>): PartitiveMember;
257
+ static identityOf(value: unknown): string;
258
+ }
259
+
260
+ export class PartitiveRelation extends GlossaristModel {
261
+ constructor(data?: {
262
+ comprehensive?: ConceptRef;
263
+ partitives?: PartitiveMember[];
264
+ completeness?: Completeness;
265
+ plurality?: TypeSharedPlurality;
266
+ criterion?: Record<string, string> | string;
267
+ });
268
+ readonly comprehensive: ConceptRef;
269
+ readonly partitives: PartitiveMember[];
270
+ readonly completeness: Completeness;
271
+ readonly plurality: TypeSharedPlurality | null;
272
+ readonly criterion: Record<string, string> | null;
273
+ readonly isComplete: boolean;
274
+ readonly isPartial: boolean;
275
+ readonly isCoordinate: boolean;
276
+ hasPlurality(): boolean;
277
+ hasCriterion(): boolean;
278
+ toJSON(): {
279
+ comprehensive: ReturnType<ConceptRef['toJSON']>;
280
+ partitives: ReturnType<PartitiveMember['toJSON']>[];
281
+ completeness: Completeness;
282
+ plurality?: ReturnType<TypeSharedPlurality['toJSON']>;
283
+ criterion?: Record<string, string>;
284
+ };
285
+ static fromJSON(data: Record<string, unknown>): PartitiveRelation;
286
+ static identityOf(value: unknown): string;
287
+ }
137
288
  }
@@ -148,6 +148,42 @@ export interface GraphEdge {
148
148
  lang?: string;
149
149
  }
150
150
 
151
+ /**
152
+ * Wire-shape projection of a PartitiveRelation — used by:
153
+ * - build-edges.js output (partitive_relations.json)
154
+ * - GraphDataSource (loads the wire file for tools/future consumers)
155
+ * - use-concept-edges composable (projects the glossarist-js model
156
+ * into this shape for display, resolving ConceptRef → URI)
157
+ *
158
+ * The glossarist-js `PartitiveRelation` class is the model SSOT
159
+ * (ConceptRef-based). This interface is the *resolved-for-display*
160
+ * shape — keep the two concerns separate.
161
+ *
162
+ * Renamed from `PartitiveRelation` to avoid collision with the
163
+ * upstream model class. v2 shape per concept-model
164
+ * TODO.partitive-relation-v2.
165
+ */
166
+ export interface PartitiveRelationWire {
167
+ source: string;
168
+ comprehensive: string;
169
+ partitives: PartitiveMemberWire[];
170
+ completeness: 'complete' | 'partial';
171
+ plurality: TypeSharedPluralityWire | null;
172
+ criterion?: Record<string, string>;
173
+ register: string;
174
+ }
175
+
176
+ export interface PartitiveMemberWire {
177
+ uri: string;
178
+ certainty: 'confirmed' | 'possible';
179
+ }
180
+
181
+ export interface TypeSharedPluralityWire {
182
+ isShared: boolean;
183
+ isUncertain: boolean;
184
+ sharedType?: string | null;
185
+ }
186
+
151
187
  export interface GraphNode {
152
188
  uri: string;
153
189
  register: string;
@@ -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
- <div v-if="isGroupExpanded(group.id)" class="space-y-1" :class="group.label ? 'ml-1' : ''">
289
- <!-- Per-entry rendering: button is kind-specific, expansion is shared (DRY/MECE) -->
290
- <div
291
- v-for="ds in group.entries"
292
- :key="ds.id"
293
- class="rounded-lg transition-all duration-150"
294
- :class="currentDataset === ds.id ? 'bg-surface' : ''"
295
- >
296
- <!-- LINEAGE: compact timeline button -->
297
- <button
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
- </div>
414
- </div>
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>