@glossarist/concept-browser 0.7.32 → 0.7.34

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.
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { Citation } from 'glossarist';
3
- import { computed } from 'vue';
3
+ import { computed, ref } from 'vue';
4
4
  import { getFactory } from '../adapters/factory';
5
5
  import { useRouter } from 'vue-router';
6
6
  import { useVocabularyStore } from '../stores/vocabulary';
@@ -14,16 +14,6 @@ const router = useRouter();
14
14
  const store = useVocabularyStore();
15
15
  const factory = getFactory();
16
16
 
17
- function formatRef(c: Citation): string {
18
- const ref = c.ref;
19
- if (!ref) return '';
20
- const parts: string[] = [];
21
- if (ref.source) parts.push(ref.source);
22
- if (ref.id) parts.push(ref.id);
23
- if (ref.version) parts.push(`(${ref.version})`);
24
- return parts.join(' ');
25
- }
26
-
27
17
  function resolveCitation(): { registerId: string; conceptId: string } | null {
28
18
  const ref = props.citation.ref;
29
19
  const locality = props.citation.locality;
@@ -36,6 +26,9 @@ function resolveCitation(): { registerId: string; conceptId: string } | null {
36
26
  }
37
27
 
38
28
  const resolvedTarget = computed(() => resolveCitation());
29
+ const isCrossDataset = computed(() =>
30
+ resolvedTarget.value != null && resolvedTarget.value.registerId !== props.registerId,
31
+ );
39
32
 
40
33
  async function navigateToCitation() {
41
34
  if (!resolvedTarget.value) return;
@@ -43,18 +36,80 @@ async function navigateToCitation() {
43
36
  await store.viewConcept(registerId, conceptId);
44
37
  router.push({ name: 'concept', params: { registerId, conceptId } });
45
38
  }
39
+
40
+ // --- Hover preview ---
41
+ const triggerEl = ref<HTMLElement | null>(null);
42
+ const preview = ref<{ designation: string; definition: string } | null>(null);
43
+ const previewVisible = ref(false);
44
+ let previewTimer: ReturnType<typeof setTimeout> | null = null;
45
+
46
+ const previewStyle = computed(() => {
47
+ if (!triggerEl.value) return {};
48
+ const rect = triggerEl.value.getBoundingClientRect();
49
+ return {
50
+ top: `${rect.bottom + 6}px`,
51
+ left: `${Math.max(8, Math.min(rect.left, window.innerWidth - 336))}px`,
52
+ };
53
+ });
54
+
55
+ async function loadPreview() {
56
+ if (!resolvedTarget.value || preview.value) return;
57
+ const { registerId, conceptId } = resolvedTarget.value;
58
+ const adapter = factory.getAdapter(registerId);
59
+ if (!adapter) return;
60
+
61
+ try {
62
+ const entry = adapter.getIndexEntry(conceptId);
63
+ if (entry && !preview.value) {
64
+ preview.value = {
65
+ designation: entry.designations?.eng || entry.eng || conceptId,
66
+ definition: '',
67
+ };
68
+ }
69
+
70
+ const concept = await adapter.fetchConcept(conceptId);
71
+ const lc = concept.localization('eng');
72
+ const def = lc?.definitions?.[0]?.content || concept.definition('eng');
73
+ const term = lc?.terms?.[0]?.designation || concept.primaryDesignation('eng');
74
+ preview.value = {
75
+ designation: term || entry?.designations?.eng || conceptId,
76
+ definition: typeof def === 'string' ? def.slice(0, 200) : '',
77
+ };
78
+ } catch {
79
+ // Concept not available — preview stays with index data or empty
80
+ }
81
+ }
82
+
83
+ function schedulePreview(e: MouseEvent) {
84
+ triggerEl.value = e.currentTarget as HTMLElement;
85
+ previewVisible.value = true;
86
+ previewTimer = setTimeout(loadPreview, 400);
87
+ }
88
+
89
+ function hidePreview() {
90
+ if (previewTimer) { clearTimeout(previewTimer); previewTimer = null; }
91
+ previewVisible.value = false;
92
+ }
46
93
  </script>
47
94
 
48
95
  <template>
49
- <span class="inline">
96
+ <span class="inline" @mouseleave="hidePreview">
50
97
  <template v-if="citation.ref">
51
- <button v-if="resolvedTarget" @click="navigateToCitation" class="concept-link font-medium">{{ citation.ref.source }}</button>
98
+ <button
99
+ v-if="resolvedTarget"
100
+ @click="navigateToCitation"
101
+ @mouseenter="schedulePreview"
102
+ class="concept-link font-medium inline-flex items-center gap-0.5"
103
+ >
104
+ {{ citation.ref.source }}
105
+ <span v-if="isCrossDataset" class="text-[10px] opacity-60 leading-none">↗</span>
106
+ </button>
52
107
  <span v-else-if="citation.ref.source" class="font-medium">{{ citation.ref.source }}</span>
53
108
  <span v-if="citation.ref.id"> {{ citation.ref.id }}</span>
54
109
  <span v-if="citation.ref.version" class="text-ink-400"> ({{ citation.ref.version }})</span>
55
110
  </template>
56
111
  <template v-if="citation.locality">
57
- <button v-if="resolvedTarget" @click="navigateToCitation" class="concept-link">
112
+ <button v-if="resolvedTarget" @click="navigateToCitation" @mouseenter="schedulePreview" class="concept-link">
58
113
  <span v-if="citation.locality.type" class="text-ink-400">, {{ citation.locality.type }}</span>
59
114
  <span v-if="citation.locality.referenceFrom" class="text-ink-400">
60
115
  {{ citation.locality.referenceTo ? ` ${citation.locality.referenceFrom}–${citation.locality.referenceTo}` : ` ${citation.locality.referenceFrom}` }}
@@ -70,5 +125,59 @@ async function navigateToCitation() {
70
125
  <a v-if="citation.link" :href="citation.link" target="_blank" rel="noopener" class="concept-link ml-1">[link]</a>
71
126
  <span v-if="citation.original" class="text-xs text-ink-300 ml-1">(orig: {{ citation.original }})</span>
72
127
  <span v-if="resolvedTarget" class="text-[9px] text-ink-300 ml-1">→ {{ resolvedTarget.registerId }}/{{ resolvedTarget.conceptId }}</span>
128
+
129
+ <!-- Hover preview tooltip -->
130
+ <Teleport to="body">
131
+ <div
132
+ v-if="previewVisible && preview"
133
+ class="citation-preview"
134
+ @mouseenter="previewVisible = true"
135
+ @mouseleave="hidePreview"
136
+ :style="previewStyle"
137
+ >
138
+ <div class="citation-preview-title">{{ preview.designation }}</div>
139
+ <div v-if="preview.definition" class="citation-preview-def">{{ preview.definition }}</div>
140
+ <div v-if="resolvedTarget" class="citation-preview-dataset">{{ resolvedTarget.registerId }}</div>
141
+ </div>
142
+ </Teleport>
73
143
  </span>
74
144
  </template>
145
+
146
+ <style scoped>
147
+ .citation-preview {
148
+ position: fixed;
149
+ z-index: 50;
150
+ max-width: 320px;
151
+ padding: 8px 12px;
152
+ border-radius: 6px;
153
+ font-size: 13px;
154
+ line-height: 1.4;
155
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
156
+ pointer-events: auto;
157
+ background: var(--color-bg, #fff);
158
+ color: var(--color-ink, #1a1a1a);
159
+ border: 1px solid var(--color-border, #e5e5e5);
160
+ }
161
+ :global(.dark) .citation-preview {
162
+ background: var(--color-bg, #1a1a2e);
163
+ color: var(--color-ink, #e5e5e5);
164
+ border-color: var(--color-border, #333);
165
+ }
166
+ .citation-preview-title {
167
+ font-weight: 600;
168
+ margin-bottom: 4px;
169
+ }
170
+ .citation-preview-def {
171
+ color: var(--color-ink-600, #555);
172
+ font-size: 12px;
173
+ }
174
+ :global(.dark) .citation-preview-def {
175
+ color: var(--color-ink-400, #999);
176
+ }
177
+ .citation-preview-dataset {
178
+ margin-top: 4px;
179
+ font-size: 11px;
180
+ color: var(--brand-primary);
181
+ opacity: 0.8;
182
+ }
183
+ </style>
@@ -14,7 +14,10 @@ import { useVocabularyStore } from '../stores/vocabulary';
14
14
  import { useDsStyle } from '../utils/dataset-style';
15
15
  import { getFactory } from '../adapters/factory';
16
16
  import { useRenderOptions } from '../composables/use-render-options';
17
- import { categorizeRelationship, relationshipLabel, relationshipDefinition, INVERSE_RELATIONSHIPS } from '../utils/relationship-categories';
17
+ import { useConceptEdges } from '../composables/use-concept-edges';
18
+ import { useConceptContent, type LangContent } from '../composables/use-concept-content';
19
+ import { relationshipLabel, INVERSE_RELATIONSHIPS } from '../utils/relationship-categories';
20
+ import { slugify } from '../utils/slugify';
18
21
  import { useSiteConfig } from '../config/use-site-config';
19
22
  import ConceptTimeline from './ConceptTimeline.vue';
20
23
  import ConceptRdfView from './ConceptRdfView.vue';
@@ -38,7 +41,6 @@ const router = useRouter();
38
41
  const store = useVocabularyStore();
39
42
  const { getColor } = useDsStyle();
40
43
  const { config: siteConfig, localizedDatasetField } = useSiteConfig();
41
- const factory = getFactory();
42
44
 
43
45
  const activeTab = ref<'rdf' | 'definition' | 'history'>('definition');
44
46
  const activeHistoryLang = ref('eng');
@@ -53,6 +55,25 @@ const conceptPosition = computed(() => {
53
55
  return { index: idx + 1, total: adapter.getConceptCount() };
54
56
  });
55
57
 
58
+ const conceptComputed = computed(() => props.concept);
59
+ const registerIdComputed = computed(() => props.registerId);
60
+ const manifestComputed = computed(() => props.manifest);
61
+ const edgesComputed = computed(() => props.edges);
62
+
63
+ const {
64
+ conceptUriValue,
65
+ outgoingEdges,
66
+ incomingEdges,
67
+ getEdgeDisplay,
68
+ edgeBadgeColor,
69
+ inverseEdgeType,
70
+ conceptRelated,
71
+ getResolvedRef,
72
+ relatedLabel,
73
+ navigateEdge,
74
+ navigateRelated,
75
+ } = useConceptEdges(conceptComputed, registerIdComputed, manifestComputed, edgesComputed, router);
76
+
56
77
  const uriCopied = ref(false);
57
78
  function copyUri() {
58
79
  const uri = conceptUri(props.concept, props.registerId, props.manifest.uriBase);
@@ -84,79 +105,19 @@ const engConcept = computed((): LocalizedConcept | null => {
84
105
  const primaryTerm = computed(() => getPreferredTerm(engConcept.value, conceptId.value));
85
106
  const renderedPrimaryTerm = computed(() => renderMath(primaryTerm.value));
86
107
 
87
- // Managed concept status from Concept.status (7 values from concept-status.ttl)
88
108
  const managedStatus = computed(() => props.concept.status);
89
109
 
90
- // ConceptReference domains from managed concept level
91
110
  const conceptRefDomains = computed(() => props.concept.domains);
92
111
 
93
- // Managed concept dates
94
112
  const conceptDates = computed(() => props.concept.dates);
95
113
 
96
- // Managed concept sources (distinct from localized sources)
97
114
  const conceptSources = computed(() => props.concept.sources);
98
115
 
99
- // Managed concept tags
100
116
  const conceptTags = computed(() => props.concept.tags ?? []);
101
117
 
102
- // Managed concept related (concept-level cross-references)
103
- // Derives inverse relationships from incoming edges (e.g. supersedes → superseded_by).
104
- const conceptRelated = computed(() => {
105
- const direct = props.concept.relatedConcepts?.filter(rc => !INVERSE_RELATIONSHIPS[rc.type]) ?? [];
106
- const derived = incomingEdges.value
107
- .filter(e => INVERSE_RELATIONSHIPS[e.type])
108
- .map(e => {
109
- const parsed = factory.resolve(e.source, props.registerId);
110
- const sourceUrn = parsed.type === 'internal'
111
- ? store.manifests.get(parsed.registerId)?.datasetUri
112
- : null;
113
- const conceptId = e.source.match(/\/concept\/([^/]+)$/)?.[1];
114
- return {
115
- type: INVERSE_RELATIONSHIPS[e.type],
116
- ref: sourceUrn && conceptId ? { source: sourceUrn, id: conceptId } : null,
117
- content: '',
118
- };
119
- });
120
- return [...direct, ...derived];
121
- });
122
-
123
- function resolveRelatedRef(ref: { source: string | null; id: string | null } | null): { registerId: string; conceptId: string } | null {
124
- if (!ref?.source || !ref?.id) return null;
125
- const uri = `${ref.source}/${ref.id}`;
126
- const resolution = factory.resolve(uri, props.registerId);
127
- if (resolution.type === 'internal') {
128
- const conceptId = resolution.conceptId.replace(/^\//, '');
129
- return { registerId: resolution.registerId, conceptId };
130
- }
131
- if (ref.source.startsWith('urn:')) {
132
- const directUri = ref.source + ref.id;
133
- const directRes = factory.resolve(directUri, props.registerId);
134
- if (directRes.type === 'internal') {
135
- return { registerId: directRes.registerId, conceptId: directRes.conceptId.replace(/^\//, '') };
136
- }
137
- }
138
- return null;
139
- }
140
-
141
- async function navigateRelated(ref: { source: string | null; id: string | null }) {
142
- const target = resolveRelatedRef(ref);
143
- if (!target) return;
144
- router.push({ name: 'concept', params: { registerId: target.registerId, conceptId: target.conceptId } });
145
- }
146
-
147
- function relatedLabel(dr: { content?: string | null; ref?: { source: string | null; id: string | null } | null }): string {
148
- if (dr.content) return dr.content;
149
- const resolved = dr.ref ? getResolvedRef(dr.ref).target : null;
150
- if (resolved) {
151
- const m = store.manifests.get(resolved.registerId);
152
- const dsLabel = m?.shortname || m?.title || resolved.registerId;
153
- return `${resolved.conceptId} (${dsLabel})`;
154
- }
155
- return dr.ref ? `${dr.ref.id || ''} (${dr.ref.source || ''})`.trim() : '';
156
- }
157
-
158
118
  // Cross-reference resolver: generates clickable links for inline refs
159
119
 
120
+ const factory = getFactory();
160
121
  const { ensureBibLoaded, bibResolver, figResolver } = useRenderOptions(() => props.registerId);
161
122
 
162
123
  const renderOpts: RenderOptions = {
@@ -194,30 +155,7 @@ function handleContentClick(e: MouseEvent) {
194
155
  }
195
156
  }
196
157
 
197
- // Pre-computed content for all languages (sorted eng first)
198
- interface LangContent {
199
- lang: string;
200
- lc: LocalizedConcept;
201
- renderedTerm: string;
202
- definition: string;
203
- renderedDefinition: string;
204
- annotations: string[];
205
- renderedAnnotations: string[];
206
- notes: string[];
207
- renderedNotes: string[];
208
- examples: string[];
209
- renderedExamples: string[];
210
- sources: ConceptSource[];
211
- designations: Designation[];
212
- renderedDesignations: Map<string, string>;
213
- entryStatus: string;
214
- classification: string | null;
215
- reviewType: string | null;
216
- release: string | null;
217
- lineageSourceSimilarity: number | null;
218
- lcScript: string | null;
219
- lcSystem: string | null;
220
- }
158
+ // LangContent type is imported from the composable
221
159
 
222
160
  const allLangContent = computed(() => {
223
161
  const result: LangContent[] = [];
@@ -310,112 +248,6 @@ function scrollToLang(lang: string) {
310
248
  });
311
249
  }
312
250
 
313
- const conceptUriValue = computed(() =>
314
- conceptUri(props.concept, props.registerId, props.manifest.uriBase)
315
- );
316
-
317
- const outgoingEdges = computed(() => dedupeEdges(props.edges.filter(e => e.source === conceptUriValue.value && e.type !== 'domain' && e.type !== 'section'), 'target'));
318
- const incomingEdges = computed(() => dedupeEdges(props.edges.filter(e => e.target === conceptUriValue.value && e.type !== 'domain' && e.type !== 'section'), 'source'));
319
-
320
- function dedupeEdges(edges: GraphEdge[], direction: 'source' | 'target'): GraphEdge[] {
321
- const seen = new Set<string>();
322
- return edges.filter(e => {
323
- const key = `${e[direction]}\0${e.type}`;
324
- if (seen.has(key)) return false;
325
- seen.add(key);
326
- return true;
327
- });
328
- }
329
-
330
- function inverseEdgeType(type: string): string {
331
- return INVERSE_RELATIONSHIPS[type] || type;
332
- }
333
-
334
- function edgeBadgeColor(type: string, direction: 'out' | 'in'): string {
335
- if (type === 'supersedes' || type === 'superseded_by') {
336
- return direction === 'out' ? 'text-orange-700 bg-orange-50' : 'text-red-700 bg-red-50';
337
- }
338
- return categorizeRelationship(type).color;
339
- }
340
-
341
- interface EdgeDisplay {
342
- uri: string;
343
- conceptId: string;
344
- designation: string;
345
- tooltip: string;
346
- isLocal: boolean;
347
- badge: { id: string; title: string } | null;
348
- }
349
-
350
- const edgeDisplayCache = computed(() => {
351
- const cache = new Map<string, EdgeDisplay>();
352
- for (const e of props.edges) {
353
- const uri = e.source === conceptUriValue.value ? e.target : e.source;
354
- if (cache.has(uri)) continue;
355
- const resolution = factory.resolve(uri, props.registerId);
356
- const isLocal = resolution.type === 'internal' && resolution.registerId === props.registerId;
357
- const conceptId = uri.match(/\/concept\/([^/]+)$/)?.[1] ?? uri.split('/').pop() ?? uri;
358
- const node = store.graph.getNode(uri);
359
- const designation = node
360
- ? (node.designations[locale.value] || node.designations.eng || Object.values(node.designations)[0] || '')
361
- : '';
362
- const tooltipLines: string[] = [uri];
363
- if (node) {
364
- for (const [lang, des] of Object.entries(node.designations)) {
365
- tooltipLines.push(`${langLabel(lang)}: ${des}`);
366
- }
367
- }
368
- let badge: { id: string; title: string } | null = null;
369
- if (resolution.type === 'internal' && resolution.registerId !== props.registerId) {
370
- const m = store.manifests.get(resolution.registerId);
371
- badge = { id: resolution.registerId, title: m?.shortname || m?.title || resolution.registerId };
372
- } else if (resolution.type === 'site') {
373
- badge = { id: '', title: resolution.label };
374
- } else if (resolution.type === 'url') {
375
- badge = { id: '', title: resolution.label };
376
- }
377
- cache.set(uri, { uri, conceptId, designation, tooltip: tooltipLines.join('\n'), isLocal, badge });
378
- }
379
- return cache;
380
- });
381
-
382
- function getEdgeDisplay(uri: string): EdgeDisplay {
383
- return edgeDisplayCache.value.get(uri) ?? { uri, conceptId: uri, designation: "", tooltip: uri, isLocal: false, badge: null };
384
- }
385
-
386
- interface ResolvedRef {
387
- target: { registerId: string; conceptId: string } | null;
388
- }
389
-
390
- const resolvedRefs = computed(() => {
391
- const map = new Map<string, ResolvedRef>();
392
- for (const cr of conceptRelated.value) {
393
- const key = `${cr.ref?.source ?? ''}:${cr.ref?.id ?? ''}`;
394
- if (map.has(key)) continue;
395
- map.set(key, { target: resolveRelatedRef(cr.ref) });
396
- }
397
- return map;
398
- });
399
-
400
- function getResolvedRef(ref: { source: string | null; id: string | null } | null): ResolvedRef {
401
- if (!ref) return { target: null };
402
- const key = `${ref.source ?? ''}:${ref.id ?? ''}`;
403
- return resolvedRefs.value.get(key) ?? { target: resolveRelatedRef(ref) };
404
- }
405
-
406
- async function navigateEdge(edge: GraphEdge) {
407
- const uri = edge.source === conceptUriValue.value ? edge.target : edge.source;
408
- const resolution = factory.resolve(uri);
409
-
410
- if (resolution.type === 'internal') {
411
- router.push({ name: 'concept', params: { registerId: resolution.registerId, conceptId: resolution.conceptId } });
412
- } else if (resolution.type === 'site') {
413
- window.open(`${resolution.baseUrl}/resolve/${encodeURIComponent(uri)}`, '_blank', 'noopener');
414
- } else if (resolution.type === 'url') {
415
- window.open(resolution.url, '_blank', 'noopener');
416
- }
417
- }
418
-
419
251
  function navigateDomain(domain: { slug: string; conceptId?: string }) {
420
252
  const sectionId = domain.conceptId || domain.slug;
421
253
  router.push({ name: 'dataset', params: { registerId: props.manifest.id }, query: { section: sectionId } });
@@ -455,10 +287,6 @@ function plainTruncate(html: string, max: number = 120): string {
455
287
  return text.length <= max ? text : text.slice(0, max).trimEnd() + '…';
456
288
  }
457
289
 
458
- function slugify(text: string): string {
459
- return text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/[\s/]+/g, '-');
460
- }
461
-
462
290
  // Domain rendering: merge ConceptReference domains and per-localization domain strings
463
291
  const conceptDomains = computed(() => {
464
292
  const domainMap = new Map<string, { slug: string; label: string; langs: string[]; conceptId?: string }>();
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { Designation, Expression, ConceptSource, RelatedConcept } from 'glossarist';
2
+ import type { Designation, Expression, ConceptSource } from 'glossarist';
3
3
  import { designationTypeInfo, normativeStatusInfo, abbreviationDetails, termTypeInfo, grammarBadges, pronunciationLabel, pronunciationTooltip, sourceTypeInfo } from '../utils/designation-registry';
4
4
  import { relationshipLabel } from '../utils/relationship-categories';
5
5
  import { langName } from '../utils/lang';
@@ -17,11 +17,7 @@ const emit = defineEmits<{
17
17
  (e: 'navigate-related', ref: { source: string | null; id: string | null }): void;
18
18
  }>();
19
19
 
20
- function asRelated(dr: unknown): RelatedConcept | null {
21
- return dr && typeof dr === 'object' && 'ref' in dr ? dr as RelatedConcept : null;
22
- }
23
-
24
- function resolvedLabel(dr: { content: string | null; ref: { source: string | null; id: string | null } | null }): string {
20
+ function resolvedLabel(dr: { content: string | null; ref?: { source: string | null; id: string | null } | null }): string {
25
21
  if (dr.content) return dr.content;
26
22
  if (dr.ref?.source && dr.ref?.id) return `${dr.ref.source}/${dr.ref.id}`;
27
23
  return '(ref)';
@@ -69,12 +65,12 @@ function resolvedLabel(dr: { content: string | null; ref: { source: string | nul
69
65
  </div>
70
66
  <div v-if="d.related?.length" class="mt-0.5 space-y-0.5">
71
67
  <div v-for="(dr, dri) in d.related" :key="'dr'+dri" class="text-xs text-ink-400 flex items-center gap-1.5">
72
- <span class="badge text-[9px] bg-gray-50 text-gray-600">{{ relationshipLabel(dr.type as string) }}</span>
73
- <template v-if="getDesignationTarget(dr as any)">
74
- <span class="italic">{{ getDesignationTarget(dr as any) }}</span>
68
+ <span class="badge text-[9px] bg-gray-50 text-gray-600">{{ relationshipLabel(dr.type ?? '') }}</span>
69
+ <template v-if="getDesignationTarget(dr)">
70
+ <span class="italic">{{ getDesignationTarget(dr) }}</span>
75
71
  </template>
76
- <button v-else-if="asRelated(dr)?.ref" @click="emit('navigate-related', asRelated(dr)!.ref!)" class="concept-link">{{ resolvedLabel(asRelated(dr)!) }}</button>
77
- <span v-else>{{ resolvedLabel(dr as any) }}</span>
72
+ <button v-else-if="'ref' in dr && dr.ref" @click="emit('navigate-related', dr.ref)" class="concept-link">{{ resolvedLabel(dr) }}</button>
73
+ <span v-else>{{ resolvedLabel(dr) }}</span>
78
74
  </div>
79
75
  </div>
80
76
  </div>
@@ -0,0 +1,160 @@
1
+ import { computed, ref, watch, type ComputedRef } from 'vue';
2
+ import type { Concept, LocalizedConcept, ConceptSource, Designation } from 'glossarist';
3
+ import type { Manifest } from '../adapters/types';
4
+ import type { RenderOptions } from '../utils/math';
5
+ import { renderMath, cleanContent } from '../utils/math';
6
+ import { getAnnotations } from '../adapters/model-bridge';
7
+ import { getPreferredTerm, entryStatusColor, entryStatusLabel, entryStatusDefinition } from '../utils/concept-helpers';
8
+ import { sortLanguages } from '../utils/lang';
9
+ import { useSiteConfig } from '../config/use-site-config';
10
+ import { useI18n } from '../i18n';
11
+
12
+ export interface LangContent {
13
+ lang: string;
14
+ lc: LocalizedConcept;
15
+ renderedTerm: string;
16
+ definition: string;
17
+ renderedDefinition: string;
18
+ annotations: string[];
19
+ renderedAnnotations: string[];
20
+ notes: string[];
21
+ renderedNotes: string[];
22
+ examples: string[];
23
+ renderedExamples: string[];
24
+ sources: ConceptSource[];
25
+ designations: Designation[];
26
+ renderedDesignations: Map<string, string>;
27
+ entryStatus: string;
28
+ classification: string | null;
29
+ reviewType: string | null;
30
+ release: string | null;
31
+ lineageSourceSimilarity: number | null;
32
+ lcScript: string | null;
33
+ lcSystem: string | null;
34
+ }
35
+
36
+ export function useConceptContent(
37
+ concept: ComputedRef<Concept>,
38
+ manifest: ComputedRef<Manifest>,
39
+ renderOpts: ComputedRef<RenderOptions>,
40
+ ) {
41
+ const { locale } = useI18n();
42
+ const { config: siteConfig } = useSiteConfig();
43
+
44
+ const languages = computed(() => {
45
+ const sorted = sortLanguages(concept.value.languages, manifest.value.languageOrder);
46
+ const current = locale.value;
47
+ const idx = sorted.indexOf(current);
48
+ if (idx > 0) {
49
+ sorted.splice(idx, 1);
50
+ sorted.unshift(current);
51
+ }
52
+ return sorted;
53
+ });
54
+
55
+ const allLangContent = computed(() => {
56
+ const result: LangContent[] = [];
57
+ for (const lang of languages.value) {
58
+ const lc = concept.value.localization(lang);
59
+ if (!lc) continue;
60
+
61
+ const definition = lc.definitions
62
+ .map(d => d.content).filter(Boolean).join('\n\n');
63
+ const annotations = getAnnotations(lc).map(a => a.content).filter(Boolean);
64
+ const notes = lc.notes.map(n => n.content).filter(Boolean);
65
+ const examples = lc.examples.map(e => e.content).filter(Boolean);
66
+ const opts = renderOpts.value;
67
+
68
+ result.push({
69
+ lang,
70
+ lc,
71
+ renderedTerm: renderMath(getPreferredTerm(lc, '')),
72
+ definition,
73
+ renderedDefinition: renderMath(definition, opts),
74
+ annotations,
75
+ renderedAnnotations: annotations.map((a: string) => renderMath(a, opts)),
76
+ notes,
77
+ renderedNotes: notes.map(n => renderMath(n, opts)),
78
+ examples,
79
+ renderedExamples: examples.map(e => renderMath(e, opts)),
80
+ sources: lc.sources,
81
+ designations: lc.terms,
82
+ renderedDesignations: new Map(lc.terms.map(d => [d.designation, renderMath(d.designation)])),
83
+ entryStatus: lc.entryStatus ?? '',
84
+ classification: lc.classification,
85
+ reviewType: lc.reviewType,
86
+ release: lc.release,
87
+ lineageSourceSimilarity: lc.lineageSourceSimilarity,
88
+ lcScript: lc.script,
89
+ lcSystem: lc.system,
90
+ });
91
+ }
92
+ return result;
93
+ });
94
+
95
+ const langContentMap = computed(() => {
96
+ const map = new Map<string, LangContent>();
97
+ for (const lc of allLangContent.value) map.set(lc.lang, lc);
98
+ return map;
99
+ });
100
+
101
+ function hasContent(lc: LangContent): boolean {
102
+ return !!(lc.definition || lc.annotations.length || lc.notes.length || lc.examples.length || lc.sources.length);
103
+ }
104
+
105
+ const collapsedLangs = ref(new Set<string>());
106
+
107
+ function initCollapsed() {
108
+ const mainLangs = siteConfig.value?.defaults?.mainLanguages || [];
109
+ const mainSet = new Set(mainLangs.length ? mainLangs : ['eng']);
110
+ const collapsed = new Set<string>();
111
+ for (const lc of allLangContent.value) {
112
+ if (!hasContent(lc) && !mainSet.has(lc.lang)) {
113
+ collapsed.add(lc.lang);
114
+ }
115
+ }
116
+ collapsedLangs.value = collapsed;
117
+ }
118
+
119
+ watch(languages, () => { initCollapsed(); }, { immediate: true });
120
+
121
+ const allCollapsed = computed(() => collapsedLangs.value.size === allLangContent.value.length);
122
+
123
+ function toggleLang(lang: string) {
124
+ const s = new Set(collapsedLangs.value);
125
+ if (s.has(lang)) s.delete(lang); else s.add(lang);
126
+ collapsedLangs.value = s;
127
+ }
128
+
129
+ function toggleAll() {
130
+ collapsedLangs.value = allCollapsed.value
131
+ ? new Set()
132
+ : new Set(allLangContent.value.map(lc => lc.lang));
133
+ }
134
+
135
+ function plainTruncate(html: string, max: number = 120): string {
136
+ const text = cleanContent(html).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
137
+ return text.length <= max ? text : text.slice(0, max).trimEnd() + '…';
138
+ }
139
+
140
+ function orderedDesignations(lang: string): Designation[] {
141
+ const desigs = langContentMap.value.get(lang)?.designations ?? [];
142
+ const preferred = desigs.filter(d => d.normativeStatus === 'preferred');
143
+ const admitted = desigs.filter(d => d.normativeStatus === 'admitted' || d.normativeStatus === 'deprecated');
144
+ const rest = desigs.filter(d => d.normativeStatus !== 'preferred' && d.normativeStatus !== 'admitted' && d.normativeStatus !== 'deprecated');
145
+ return [...preferred, ...admitted, ...rest];
146
+ }
147
+
148
+ return {
149
+ languages,
150
+ allLangContent,
151
+ langContentMap,
152
+ hasContent,
153
+ collapsedLangs,
154
+ allCollapsed,
155
+ toggleLang,
156
+ toggleAll,
157
+ plainTruncate,
158
+ orderedDesignations,
159
+ };
160
+ }