@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.
- package/package.json +1 -1
- package/scripts/build-edges.js +46 -4
- package/scripts/extract-source-refs.js +32 -0
- package/scripts/generate-data.mjs +2 -0
- package/src/__tests__/citation-display.test.ts +143 -0
- package/src/__tests__/concept-helpers.test.ts +129 -0
- package/src/__tests__/designation-relationship.test.ts +35 -4
- package/src/__tests__/extract-source-refs.test.ts +136 -0
- package/src/__tests__/factory-lazy.test.ts +2 -2
- package/src/__tests__/load-source-refs.test.ts +128 -0
- package/src/__tests__/slugify.test.ts +28 -0
- package/src/__tests__/source-refs.test.ts +191 -0
- package/src/adapters/DatasetAdapter.ts +51 -19
- package/src/adapters/ReferenceResolver.ts +5 -0
- package/src/adapters/factory.ts +36 -40
- package/src/adapters/model-bridge.ts +29 -19
- package/src/adapters/ontology-registry.ts +21 -4
- package/src/adapters/types.ts +2 -0
- package/src/components/CitationDisplay.vue +123 -14
- package/src/components/ConceptDetail.vue +25 -197
- package/src/components/DesignationList.vue +3 -3
- package/src/composables/use-concept-content.ts +160 -0
- package/src/composables/use-concept-edges.ts +181 -0
- package/src/data/taxonomies.json +13 -1
- package/src/graph/GraphEngine.ts +15 -0
- package/src/utils/concept-helpers.ts +4 -7
- package/src/utils/designation-registry.ts +15 -37
- package/src/utils/index.ts +1 -0
- package/src/utils/slugify.ts +3 -0
|
@@ -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<
|
|
182
|
+
const designationTargets = new WeakMap<object, string>();
|
|
183
183
|
|
|
184
|
-
export function getDesignationTarget(rc:
|
|
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 (
|
|
217
|
-
|
|
218
|
-
const
|
|
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
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
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 =
|
|
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 (
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
package/src/adapters/types.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
//
|
|
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
|
|
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>
|