@glossarist/concept-browser 0.7.31 → 0.7.33

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.
@@ -179,10 +179,10 @@ export function getAnnotations(lc: LocalizedConcept): DetailedDefinition[] {
179
179
  }
180
180
 
181
181
  // Designation relationship targets: RelatedConcept.target (string)
182
- const designationTargets = new WeakMap<RelatedConcept, string>();
182
+ const designationTargets = new WeakMap<object, string>();
183
183
 
184
- export function getDesignationTarget(rc: RelatedConcept): string | null {
185
- return designationTargets.get(rc) ?? null;
184
+ export function getDesignationTarget(rc: { type?: string | null; content?: string | null; target?: string | null; ref?: any }): string | null {
185
+ return designationTargets.get(rc) ?? rc.target ?? null;
186
186
  }
187
187
 
188
188
  // ConceptRef text: human-readable label alongside source/id
@@ -213,19 +213,26 @@ function attachAnnotations(concept: Concept, localizations: Record<string, unkno
213
213
  // Designation-level relationship targets and ref text
214
214
  const rawTerms = rawObj.terms;
215
215
  if (Array.isArray(rawTerms)) {
216
- for (let i = 0; i < lc.terms.length && i < rawTerms.length; i++) {
217
- const rawTerm = rawTerms[i] as Record<string, unknown>;
218
- const rawRelated = rawTerm.related;
216
+ for (const rawTerm of rawTerms) {
217
+ if (!rawTerm || typeof rawTerm !== 'object') continue;
218
+ const rawT = rawTerm as Record<string, unknown>;
219
+ const rawDesignation = rawT.designation as string | undefined;
220
+ if (!rawDesignation) continue;
221
+ const designation = lc.terms.find(d => d.designation === rawDesignation);
222
+ if (!designation) continue;
223
+ const rawRelated = rawT.related;
219
224
  if (!Array.isArray(rawRelated)) continue;
220
- const designation = lc.terms[i];
221
- for (let j = 0; j < designation.related.length && j < rawRelated.length; j++) {
222
- const rawRel = rawRelated[j] as Record<string, unknown>;
223
- const rc = designation.related[j];
224
- if (rawRel.target && typeof rawRel.target === 'string') {
225
- designationTargets.set(rc, rawRel.target);
225
+ for (const rawRel of rawRelated) {
226
+ if (!rawRel || typeof rawRel !== 'object') continue;
227
+ const rel = rawRel as Record<string, unknown>;
228
+ const relType = rel.type as string | undefined;
229
+ const rc = designation.related.find(r => r.type === relType);
230
+ if (!rc) continue;
231
+ if (rel.target && typeof rel.target === 'string') {
232
+ designationTargets.set(rc as object, rel.target);
226
233
  }
227
- if (rc.ref) {
228
- const rawRef = rawRel.ref as Record<string, unknown> | undefined;
234
+ if ('ref' in rc && rc.ref) {
235
+ const rawRef = rel.ref as Record<string, unknown> | undefined;
229
236
  if (rawRef?.text && typeof rawRef.text === 'string') {
230
237
  refTexts.set(rc.ref, rawRef.text);
231
238
  }
@@ -237,11 +244,14 @@ function attachAnnotations(concept: Concept, localizations: Record<string, unkno
237
244
  // Localization-level ref text
238
245
  const rawRelated = rawObj.related;
239
246
  if (Array.isArray(rawRelated)) {
240
- for (let i = 0; i < lc.related.length && i < rawRelated.length; i++) {
241
- const rc = lc.related[i];
242
- const rawRel = rawRelated[i] as Record<string, unknown>;
243
- const rawRef = rawRel.ref as Record<string, unknown> | undefined;
244
- if (rc.ref && rawRef?.text && typeof rawRef.text === 'string') {
247
+ for (const rawRel of rawRelated) {
248
+ if (!rawRel || typeof rawRel !== 'object') continue;
249
+ const rel = rawRel as Record<string, unknown>;
250
+ const relType = rel.type as string | undefined;
251
+ const rc = relType ? lc.related.find(r => r.type === relType) : undefined;
252
+ if (!rc || !rc.ref) continue;
253
+ const rawRef = rel.ref as Record<string, unknown> | undefined;
254
+ if (rawRef?.text && typeof rawRef.text === 'string') {
245
255
  refTexts.set(rc.ref, rawRef.text);
246
256
  }
247
257
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Ontology Registry — taxonomy-driven labels and definitions for the browser.
2
+ * Ontology Registry — taxonomy-driven labels, definitions, and colors for the browser.
3
3
  *
4
4
  * All enumeration labels, definitions, and colors come from the SKOS taxonomy
5
5
  * data extracted at build time from concept-model/ontologies/taxonomies/*.ttl.
@@ -29,10 +29,19 @@ export interface Taxonomy {
29
29
  schemeDefinition: string | null;
30
30
  categories?: Record<string, TaxonomyCategory>;
31
31
  concepts: Record<string, TaxonomyConcept>;
32
+ colors?: Record<string, string>;
33
+ }
34
+
35
+ export interface TaxonomyDisplay {
36
+ label: string;
37
+ color: string;
38
+ definition?: string;
32
39
  }
33
40
 
34
41
  type TaxonomyKey = keyof typeof taxonomyData;
35
42
 
43
+ const DEFAULT_COLOR = 'badge-gray';
44
+
36
45
  export class OntologyRegistry {
37
46
  private data: Record<string, Taxonomy>;
38
47
 
@@ -94,9 +103,17 @@ export class OntologyRegistry {
94
103
  }
95
104
 
96
105
  getColor(taxonomy: TaxonomyKey, id: string): string | null {
97
- const entry = this.data[taxonomy] as unknown as Record<string, unknown>;
98
- const colors = entry?.colors as Record<string, string> | undefined;
99
- return colors?.[id] ?? null;
106
+ return this.data[taxonomy]?.colors?.[id] ?? null;
107
+ }
108
+
109
+ getDisplay(taxonomy: TaxonomyKey, id: string | null | undefined, colorFallback?: string): TaxonomyDisplay {
110
+ if (!id) return { label: '', color: colorFallback ?? DEFAULT_COLOR };
111
+ const concept = this.getConcept(taxonomy, id);
112
+ return {
113
+ label: concept?.prefLabel ?? id,
114
+ color: this.data[taxonomy]?.colors?.[id] ?? colorFallback ?? DEFAULT_COLOR,
115
+ definition: concept?.definition ?? undefined,
116
+ };
100
117
  }
101
118
  }
102
119
 
@@ -116,6 +116,8 @@ export interface DatasetRegistry {
116
116
  datasetUri?: string;
117
117
  uriBase?: string;
118
118
  uriAliases?: string[];
119
+ ref?: string;
120
+ refAliases?: string[];
119
121
  }
120
122
 
121
123
  // ── Graph types ────────────────────────────────────────────────────────────
@@ -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);
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 }>();
@@ -17,7 +17,7 @@ const emit = defineEmits<{
17
17
  (e: 'navigate-related', ref: { source: string | null; id: string | null }): void;
18
18
  }>();
19
19
 
20
- 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 {
21
21
  if (dr.content) return dr.content;
22
22
  if (dr.ref?.source && dr.ref?.id) return `${dr.ref.source}/${dr.ref.id}`;
23
23
  return '(ref)';
@@ -65,11 +65,11 @@ function resolvedLabel(dr: { content: string | null; ref: { source: string | nul
65
65
  </div>
66
66
  <div v-if="d.related?.length" class="mt-0.5 space-y-0.5">
67
67
  <div v-for="(dr, dri) in d.related" :key="'dr'+dri" class="text-xs text-ink-400 flex items-center gap-1.5">
68
- <span class="badge text-[9px] bg-gray-50 text-gray-600">{{ relationshipLabel(dr.type) }}</span>
68
+ <span class="badge text-[9px] bg-gray-50 text-gray-600">{{ relationshipLabel(dr.type ?? '') }}</span>
69
69
  <template v-if="getDesignationTarget(dr)">
70
70
  <span class="italic">{{ getDesignationTarget(dr) }}</span>
71
71
  </template>
72
- <button v-else-if="dr.ref" @click="emit('navigate-related', dr.ref)" class="concept-link">{{ resolvedLabel(dr) }}</button>
72
+ <button v-else-if="'ref' in dr && dr.ref" @click="emit('navigate-related', dr.ref)" class="concept-link">{{ resolvedLabel(dr) }}</button>
73
73
  <span v-else>{{ resolvedLabel(dr) }}</span>
74
74
  </div>
75
75
  </div>