@glossarist/concept-browser 0.3.7 → 0.4.0
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/README.md +3 -2
- package/cli/index.mjs +2 -1
- package/env.d.ts +5 -0
- package/package.json +4 -3
- package/scripts/build-edges.js +78 -10
- package/scripts/generate-data.mjs +152 -20
- package/scripts/generate-ontology-data.mjs +184 -0
- package/scripts/generate-ontology-schema.mjs +315 -0
- package/src/__tests__/concept-card.test.ts +1 -1
- package/src/__tests__/concept-detail-interaction.test.ts +40 -18
- package/src/__tests__/concept-formats.test.ts +32 -30
- package/src/__tests__/concept-timeline.test.ts +108 -83
- package/src/__tests__/concept-view.test.ts +15 -2
- package/src/__tests__/dataset-adapter.test.ts +172 -23
- package/src/__tests__/dataset-view.test.ts +6 -5
- package/src/__tests__/designation-registry.test.ts +161 -0
- package/src/__tests__/graph.test.ts +62 -0
- package/src/__tests__/language-detail.test.ts +117 -60
- package/src/__tests__/ontology-registry.test.ts +109 -0
- package/src/__tests__/relationship-categories.test.ts +62 -0
- package/src/__tests__/test-helpers.ts +11 -8
- package/src/adapters/DatasetAdapter.ts +171 -48
- package/src/adapters/model-bridge.ts +277 -0
- package/src/adapters/ontology-registry.ts +75 -0
- package/src/adapters/ontology-schema.ts +100 -0
- package/src/adapters/types.ts +52 -77
- package/src/components/AppSidebar.vue +1 -1
- package/src/components/CitationDisplay.vue +35 -0
- package/src/components/ConceptDetail.vue +334 -93
- package/src/components/ConceptRdfView.vue +397 -0
- package/src/components/ConceptTimeline.vue +56 -52
- package/src/components/GraphPanel.vue +96 -31
- package/src/components/LanguageDetail.vue +45 -37
- package/src/components/NavIcon.vue +1 -0
- package/src/components/NonVerbalRepDisplay.vue +38 -0
- package/src/components/RelationshipList.vue +99 -0
- package/src/config/use-site-config.ts +3 -0
- package/src/data/ontology-schema.json +1551 -0
- package/src/data/taxonomies.json +543 -0
- package/src/graph/GraphEngine.ts +7 -4
- package/src/router/index.ts +5 -0
- package/src/shims/empty.ts +1 -0
- package/src/shims/node-crypto.ts +6 -0
- package/src/shims/node-path.ts +10 -0
- package/src/stores/vocabulary.ts +75 -25
- package/src/style.css +74 -20
- package/src/utils/concept-formats.ts +22 -20
- package/src/utils/concept-helpers.ts +43 -23
- package/src/utils/designation-registry.ts +124 -0
- package/src/utils/relationship-categories.ts +84 -0
- package/src/views/OntologySchemaView.vue +302 -0
- package/src/views/PageView.vue +28 -17
- package/src/views/StatsView.vue +34 -12
- package/vite.config.ts +8 -0
|
@@ -3,6 +3,7 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick, reactive } from
|
|
|
3
3
|
import type { GraphNode, GraphEdge } from '../adapters/types';
|
|
4
4
|
import type { SimulationNodeDatum, SimulationLinkDatum } from 'd3';
|
|
5
5
|
import { useDsStyle } from '../utils/dataset-style';
|
|
6
|
+
import { useUiStore } from '../stores/ui';
|
|
6
7
|
import {
|
|
7
8
|
forceSimulation,
|
|
8
9
|
forceLink,
|
|
@@ -51,6 +52,7 @@ watch(() => props.registers, (regs) => {
|
|
|
51
52
|
});
|
|
52
53
|
|
|
53
54
|
const { getColor } = useDsStyle();
|
|
55
|
+
const uiStore = useUiStore();
|
|
54
56
|
|
|
55
57
|
const STUB_COLOR = '#b8b9cc'; // ink-200
|
|
56
58
|
const HIGHLIGHT_COLOR = '#1a1b2e'; // ink-800
|
|
@@ -81,7 +83,11 @@ const visibleNodeUris = computed(() => {
|
|
|
81
83
|
|
|
82
84
|
const visibleEdges = computed(() => {
|
|
83
85
|
const uris = visibleNodeUris.value;
|
|
84
|
-
|
|
86
|
+
const lang = uiStore.selectedLang;
|
|
87
|
+
return props.edges.filter(e =>
|
|
88
|
+
uris.has(e.source) && uris.has(e.target) &&
|
|
89
|
+
(!lang || !e.lang || e.lang === lang)
|
|
90
|
+
);
|
|
85
91
|
});
|
|
86
92
|
|
|
87
93
|
const nodeCount = computed(() => visibleNodes.value.length);
|
|
@@ -109,7 +115,9 @@ interface SimNode extends SimulationNodeDatum {
|
|
|
109
115
|
register: string;
|
|
110
116
|
conceptId: string;
|
|
111
117
|
designation: string;
|
|
118
|
+
hasDesignation: boolean;
|
|
112
119
|
loaded: boolean;
|
|
120
|
+
nodeType?: 'concept' | 'domain';
|
|
113
121
|
}
|
|
114
122
|
|
|
115
123
|
interface SimLink extends SimulationLinkDatum<SimNode> {
|
|
@@ -117,6 +125,7 @@ interface SimLink extends SimulationLinkDatum<SimNode> {
|
|
|
117
125
|
target: SimNode | string;
|
|
118
126
|
type: string;
|
|
119
127
|
label?: string;
|
|
128
|
+
lang?: string;
|
|
120
129
|
}
|
|
121
130
|
|
|
122
131
|
let simulation: ReturnType<typeof forceSimulation<SimNode>> | null = null;
|
|
@@ -166,15 +175,20 @@ function buildSimulation(width: number, height: number) {
|
|
|
166
175
|
const capped = allVisible.length > MAX_RENDER_NODES;
|
|
167
176
|
const renderNodes = capped ? allVisible.slice(0, MAX_RENDER_NODES) : allVisible;
|
|
168
177
|
|
|
169
|
-
const simNodes: SimNode[] = renderNodes.map(n =>
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
+
const simNodes: SimNode[] = renderNodes.map(n => {
|
|
179
|
+
const desig = Object.values(n.designations)[0] || '';
|
|
180
|
+
return {
|
|
181
|
+
uri: n.uri,
|
|
182
|
+
register: n.register,
|
|
183
|
+
conceptId: n.conceptId,
|
|
184
|
+
designation: desig,
|
|
185
|
+
hasDesignation: !!desig,
|
|
186
|
+
loaded: n.loaded,
|
|
187
|
+
nodeType: n.nodeType,
|
|
188
|
+
x: width / 2 + (Math.random() - 0.5) * 200,
|
|
189
|
+
y: height / 2 + (Math.random() - 0.5) * 200,
|
|
190
|
+
};
|
|
191
|
+
});
|
|
178
192
|
|
|
179
193
|
const nodeMap = new Map(simNodes.map(n => [n.uri, n]));
|
|
180
194
|
|
|
@@ -185,6 +199,7 @@ function buildSimulation(width: number, height: number) {
|
|
|
185
199
|
target: e.target,
|
|
186
200
|
type: e.type,
|
|
187
201
|
label: e.label,
|
|
202
|
+
lang: e.lang,
|
|
188
203
|
}));
|
|
189
204
|
|
|
190
205
|
g.selectAll('*').remove();
|
|
@@ -194,9 +209,10 @@ function buildSimulation(width: number, height: number) {
|
|
|
194
209
|
.selectAll('line')
|
|
195
210
|
.data(simLinks)
|
|
196
211
|
.join('line')
|
|
197
|
-
.attr('stroke', '#dddde6')
|
|
198
|
-
.attr('stroke-width', 0.8)
|
|
199
|
-
.attr('
|
|
212
|
+
.attr('stroke', l => l.type === 'domain' ? '#c4b5fd' : '#dddde6')
|
|
213
|
+
.attr('stroke-width', l => l.type === 'domain' ? 0.6 : 0.8)
|
|
214
|
+
.attr('stroke-dasharray', l => l.type === 'domain' ? '3,2' : 'none')
|
|
215
|
+
.attr('marker-end', l => l.type === 'domain' ? null : 'url(#arrowhead)');
|
|
200
216
|
|
|
201
217
|
const nodeSel = g.append('g')
|
|
202
218
|
.attr('class', 'nodes')
|
|
@@ -206,13 +222,38 @@ function buildSimulation(width: number, height: number) {
|
|
|
206
222
|
.attr('class', 'node')
|
|
207
223
|
.style('cursor', 'pointer');
|
|
208
224
|
|
|
209
|
-
nodeSel.
|
|
225
|
+
const domainNodes = nodeSel.filter(d => d.nodeType === 'domain');
|
|
226
|
+
const conceptNodes = nodeSel.filter(d => d.nodeType !== 'domain');
|
|
227
|
+
|
|
228
|
+
// Domain: rounded rectangle with standard name inside
|
|
229
|
+
domainNodes.append('rect')
|
|
230
|
+
.attr('width', d => Math.max(48, d.designation.length * 5.5 + 10))
|
|
231
|
+
.attr('height', 14)
|
|
232
|
+
.attr('rx', 3)
|
|
233
|
+
.attr('x', d => -(Math.max(48, d.designation.length * 5.5 + 10) / 2))
|
|
234
|
+
.attr('y', -7)
|
|
235
|
+
.attr('fill', '#ede9fe')
|
|
236
|
+
.attr('stroke', '#8b5cf6')
|
|
237
|
+
.attr('stroke-width', 1);
|
|
238
|
+
|
|
239
|
+
domainNodes.append('text')
|
|
240
|
+
.attr('text-anchor', 'middle')
|
|
241
|
+
.attr('dy', 4)
|
|
242
|
+
.attr('font-size', '7px')
|
|
243
|
+
.attr('font-family', '"DM Sans", system-ui, sans-serif')
|
|
244
|
+
.attr('font-weight', '600')
|
|
245
|
+
.attr('fill', '#6d28d9')
|
|
246
|
+
.attr('pointer-events', 'none')
|
|
247
|
+
.text(d => d.designation);
|
|
248
|
+
|
|
249
|
+
// Concept: circle
|
|
250
|
+
conceptNodes.append('circle')
|
|
210
251
|
.attr('r', d => d.loaded ? 5 : 3)
|
|
211
252
|
.attr('fill', d => d.loaded ? registerColor(d.register) : STUB_COLOR)
|
|
212
|
-
.attr('stroke', '#faf9f6')
|
|
253
|
+
.attr('stroke', '#faf9f6')
|
|
213
254
|
.attr('stroke-width', 1.5);
|
|
214
255
|
|
|
215
|
-
|
|
256
|
+
conceptNodes.append('text')
|
|
216
257
|
.attr('dy', -9)
|
|
217
258
|
.attr('text-anchor', 'middle')
|
|
218
259
|
.attr('font-size', '8px')
|
|
@@ -220,7 +261,15 @@ function buildSimulation(width: number, height: number) {
|
|
|
220
261
|
.attr('font-weight', '500')
|
|
221
262
|
.attr('fill', '#636588') // ink-400
|
|
222
263
|
.attr('pointer-events', 'none')
|
|
223
|
-
.text(d =>
|
|
264
|
+
.text(d => {
|
|
265
|
+
if (labelMode.value === 'identifier') return d.conceptId.slice(0, 18);
|
|
266
|
+
if (!d.hasDesignation) return d.conceptId.slice(0, 18);
|
|
267
|
+
return d.designation.slice(0, 18);
|
|
268
|
+
})
|
|
269
|
+
.attr('fill', d => {
|
|
270
|
+
if (labelMode.value === 'designation' && !d.hasDesignation) return '#c4c5d6'; // dim for fallback
|
|
271
|
+
return '#636588';
|
|
272
|
+
});
|
|
224
273
|
|
|
225
274
|
const dragBehavior = drag<SVGGElement, SimNode>()
|
|
226
275
|
.on('start', (event: D3DragEvent<SVGGElement, SimNode, SimNode>, d) => {
|
|
@@ -259,14 +308,22 @@ function buildSimulation(width: number, height: number) {
|
|
|
259
308
|
const tgt = typeof l.target === 'object' ? l.target.uri : l.target;
|
|
260
309
|
return src === d.uri || tgt === d.uri ? 1.5 : 0.8;
|
|
261
310
|
});
|
|
262
|
-
|
|
311
|
+
conceptNodes.select('circle')
|
|
263
312
|
.attr('r', n => n.uri === d.uri ? 8 : n.loaded ? 5 : 3)
|
|
264
313
|
.attr('fill', n => n.uri === d.uri ? HIGHLIGHT_COLOR : n.loaded ? registerColor(n.register) : STUB_COLOR);
|
|
314
|
+
domainNodes.select('rect')
|
|
315
|
+
.attr('stroke', n => n.uri === d.uri ? '#6d28d9' : '#8b5cf6')
|
|
316
|
+
.attr('stroke-width', n => n.uri === d.uri ? 2 : 1);
|
|
265
317
|
}).on('mouseleave', () => {
|
|
266
|
-
linkSel
|
|
267
|
-
|
|
318
|
+
linkSel
|
|
319
|
+
.attr('stroke', l => l.type === 'domain' ? '#c4b5fd' : '#dddde6')
|
|
320
|
+
.attr('stroke-width', l => l.type === 'domain' ? 0.6 : 0.8);
|
|
321
|
+
conceptNodes.select('circle')
|
|
268
322
|
.attr('r', n => n.loaded ? 5 : 3)
|
|
269
323
|
.attr('fill', n => n.loaded ? registerColor(n.register) : STUB_COLOR);
|
|
324
|
+
domainNodes.select('rect')
|
|
325
|
+
.attr('stroke', '#8b5cf6')
|
|
326
|
+
.attr('stroke-width', 1);
|
|
270
327
|
});
|
|
271
328
|
|
|
272
329
|
const count = simNodes.length;
|
|
@@ -280,7 +337,9 @@ function buildSimulation(width: number, height: number) {
|
|
|
280
337
|
.strength(count < 50 ? -200 : count < 200 ? -100 : -50)
|
|
281
338
|
)
|
|
282
339
|
.force('center', forceCenter(width / 2, height / 2))
|
|
283
|
-
.force('collide', forceCollide<SimNode>().radius(
|
|
340
|
+
.force('collide', forceCollide<SimNode>().radius(d =>
|
|
341
|
+
d.nodeType === 'domain' ? 35 : (count > 1000 ? 6 : 12)
|
|
342
|
+
))
|
|
284
343
|
.alpha(1)
|
|
285
344
|
.alphaDecay(count > 500 ? 0.05 : 0.02)
|
|
286
345
|
.on('tick', () => {
|
|
@@ -462,20 +521,26 @@ function selectedNodeColor(): string {
|
|
|
462
521
|
</div>
|
|
463
522
|
</div>
|
|
464
523
|
|
|
465
|
-
<!-- Legend
|
|
466
|
-
<div v-if="nodeCount > 0
|
|
467
|
-
<div
|
|
468
|
-
|
|
469
|
-
<
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
524
|
+
<!-- Legend -->
|
|
525
|
+
<div v-if="nodeCount > 0" class="absolute top-4 right-4 z-10 bg-surface-raised/90 backdrop-blur rounded-lg px-3 py-2.5 border border-ink-100/60 text-xs" style="box-shadow: 0 2px 6px rgba(26, 27, 46, 0.04);">
|
|
526
|
+
<div v-if="registers.length > 1">
|
|
527
|
+
<div class="font-semibold text-ink-400 text-[10px] uppercase tracking-wide mb-2">Datasets</div>
|
|
528
|
+
<div v-for="reg in registers" :key="reg.id" class="flex items-center gap-2 mb-1.5 last:mb-0">
|
|
529
|
+
<span
|
|
530
|
+
class="w-2.5 h-2.5 rounded-full inline-block flex-shrink-0"
|
|
531
|
+
:style="{ backgroundColor: registerColor(reg.id) }"
|
|
532
|
+
></span>
|
|
533
|
+
<span class="text-ink-500">{{ reg.title }}</span>
|
|
534
|
+
</div>
|
|
474
535
|
</div>
|
|
475
536
|
<div class="flex items-center gap-2 mt-2 pt-2 border-t border-ink-100/40">
|
|
476
537
|
<span class="w-2 h-2 rounded-full inline-block" :style="{ backgroundColor: STUB_COLOR }"></span>
|
|
477
538
|
<span class="text-ink-300">Stub (not loaded)</span>
|
|
478
539
|
</div>
|
|
540
|
+
<div class="flex items-center gap-2 mt-2 pt-2 border-t border-ink-100/40">
|
|
541
|
+
<span class="w-4 h-2 rounded inline-block flex-shrink-0" style="background: #ede9fe; border: 1px solid #8b5cf6;"></span>
|
|
542
|
+
<span class="text-ink-300">Domain (standard)</span>
|
|
543
|
+
</div>
|
|
479
544
|
</div>
|
|
480
545
|
|
|
481
546
|
<svg ref="svgRef" class="w-full h-full" role="img" aria-label="Concept relationship graph visualization"></svg>
|
|
@@ -509,7 +574,7 @@ function selectedNodeColor(): string {
|
|
|
509
574
|
</button>
|
|
510
575
|
</div>
|
|
511
576
|
<router-link
|
|
512
|
-
v-if="selectedNode.register"
|
|
577
|
+
v-if="selectedNode.register && selectedNode.nodeType !== 'domain'"
|
|
513
578
|
:to="{ name: 'concept', params: { registerId: selectedNode.register, conceptId: selectedNode.conceptId } }"
|
|
514
579
|
class="btn-primary text-xs mt-4 inline-block"
|
|
515
580
|
>
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type { LocalizedConcept, Designation,
|
|
2
|
+
import type { Concept, LocalizedConcept, Designation, Expression, Abbreviation as AbbreviationType } from 'glossarist';
|
|
3
3
|
import { computed } from 'vue';
|
|
4
4
|
import { langName, langLabel } from '../utils/lang';
|
|
5
5
|
import { renderMath } from '../utils/math';
|
|
6
6
|
import type { RenderOptions } from '../utils/math';
|
|
7
7
|
import { escapeAttr } from '../utils/escape';
|
|
8
|
-
import { entryStatusColor
|
|
8
|
+
import { entryStatusColor } from '../utils/concept-helpers';
|
|
9
|
+
import { designationTypeInfo, normativeStatusInfo, grammarBadges, pronunciationLabel, pronunciationTooltip } from '../utils/designation-registry';
|
|
9
10
|
import { useRouter } from 'vue-router';
|
|
10
11
|
import { useVocabularyStore } from '../stores/vocabulary';
|
|
11
12
|
import { getFactory } from '../adapters/factory';
|
|
13
|
+
import CitationDisplay from './CitationDisplay.vue';
|
|
12
14
|
|
|
13
15
|
const props = defineProps<{
|
|
14
|
-
|
|
16
|
+
concept: Concept;
|
|
15
17
|
activeLang: string;
|
|
16
18
|
}>();
|
|
17
19
|
|
|
@@ -19,41 +21,33 @@ const emit = defineEmits<{
|
|
|
19
21
|
(e: 'update:activeLang', lang: string): void;
|
|
20
22
|
}>();
|
|
21
23
|
|
|
22
|
-
const lc = computed(() => props.
|
|
23
|
-
const availableLangs = computed(() =>
|
|
24
|
+
const lc = computed(() => props.concept.localization(props.activeLang));
|
|
25
|
+
const availableLangs = computed(() => [...props.concept.languages].sort());
|
|
24
26
|
|
|
25
|
-
const designations = computed(() => lc.value?.
|
|
27
|
+
const designations = computed(() => lc.value?.terms ?? []);
|
|
26
28
|
const definition = computed(() => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (content) return content;
|
|
31
|
-
}
|
|
32
|
-
return '';
|
|
33
|
-
});
|
|
34
|
-
const notes = computed(() => {
|
|
35
|
-
return lc.value?.['gl:notes']?.map(n => n['gl:content']).filter(Boolean) ?? [];
|
|
29
|
+
if (!lc.value) return '';
|
|
30
|
+
const content = lc.value.definitions.map(d => d.content).filter(Boolean).join('\n\n');
|
|
31
|
+
return content;
|
|
36
32
|
});
|
|
37
|
-
const
|
|
38
|
-
const
|
|
33
|
+
const notes = computed(() => lc.value?.notes.map(n => n.content).filter(Boolean) ?? []);
|
|
34
|
+
const examples = computed(() => lc.value?.examples.map(e => e.content).filter(Boolean) ?? []);
|
|
35
|
+
const sources = computed(() => lc.value?.sources ?? []);
|
|
39
36
|
|
|
40
37
|
const hasContent = computed(() =>
|
|
41
38
|
definition.value || notes.value.length > 0 || examples.value.length > 0 || designations.value.length > 1
|
|
42
39
|
);
|
|
43
40
|
|
|
41
|
+
function abbrevInfo(d: Designation): { acronym: boolean; initialism: boolean; truncation: boolean } | null {
|
|
42
|
+
if (d.type !== 'abbreviation') return null;
|
|
43
|
+
const a = d as AbbreviationType;
|
|
44
|
+
return { acronym: !!a.acronym, initialism: !!a.initialism, truncation: !!a.truncation };
|
|
45
|
+
}
|
|
46
|
+
|
|
44
47
|
const isTermOnly = computed(() =>
|
|
45
48
|
!definition.value && notes.value.length === 0 && examples.value.length === 0
|
|
46
49
|
);
|
|
47
50
|
|
|
48
|
-
function normativeStatus(status: string): string {
|
|
49
|
-
return status === 'preferred' ? 'Preferred' : status;
|
|
50
|
-
}
|
|
51
|
-
function normativeColor(status: string): string {
|
|
52
|
-
if (status === 'preferred') return 'bg-emerald-50 text-emerald-700';
|
|
53
|
-
if (status === 'deprecated') return 'bg-red-50 text-red-700';
|
|
54
|
-
return 'bg-amber-50 text-amber-700';
|
|
55
|
-
}
|
|
56
|
-
|
|
57
51
|
const router = useRouter();
|
|
58
52
|
const store = useVocabularyStore();
|
|
59
53
|
|
|
@@ -109,8 +103,8 @@ function handleContentClick(e: MouseEvent) {
|
|
|
109
103
|
<!-- Content for selected language -->
|
|
110
104
|
<div v-if="lc">
|
|
111
105
|
<!-- Entry status -->
|
|
112
|
-
<div v-if="lc
|
|
113
|
-
<span class="badge" :class="entryStatusColor(lc
|
|
106
|
+
<div v-if="lc.entryStatus" class="flex items-center gap-2 mb-4">
|
|
107
|
+
<span class="badge" :class="entryStatusColor(lc.entryStatus)">{{ lc.entryStatus }}</span>
|
|
114
108
|
</div>
|
|
115
109
|
|
|
116
110
|
<!-- Designations -->
|
|
@@ -118,11 +112,26 @@ function handleContentClick(e: MouseEvent) {
|
|
|
118
112
|
<div class="section-label">Designations</div>
|
|
119
113
|
<div class="space-y-2 mt-3">
|
|
120
114
|
<div v-for="(d, i) in designations" :key="i" class="flex items-center gap-2 flex-wrap">
|
|
121
|
-
<span class="font-medium text-ink-800 text-lg" v-html="renderMath(d
|
|
122
|
-
<span class="badge text-[10px]" :class="
|
|
123
|
-
<span class="badge text-[10px]" :class="
|
|
124
|
-
<
|
|
125
|
-
|
|
115
|
+
<span class="font-medium text-ink-800 text-lg" v-html="renderMath(d.designation)"></span>
|
|
116
|
+
<span class="badge text-[10px]" :class="designationTypeInfo(d).color">{{ designationTypeInfo(d).label }}</span>
|
|
117
|
+
<span class="badge text-[10px]" :class="normativeStatusInfo(d.normativeStatus).color">{{ normativeStatusInfo(d.normativeStatus).label }}</span>
|
|
118
|
+
<template v-if="d.type === 'expression' && (d as Expression).grammarInfo?.length">
|
|
119
|
+
<template v-for="(gi, giIdx) in (d as Expression).grammarInfo" :key="giIdx">
|
|
120
|
+
<span v-for="badge in grammarBadges(gi)" :key="giIdx + '-' + badge.label"
|
|
121
|
+
class="badge text-[10px] bg-gray-50 text-gray-600">{{ badge.label }}</span>
|
|
122
|
+
</template>
|
|
123
|
+
</template>
|
|
124
|
+
<template v-if="abbrevInfo(d)" v-for="(val, key) in abbrevInfo(d)" :key="key">
|
|
125
|
+
<span v-if="val" class="badge text-[10px] bg-amber-50 text-amber-600">{{ key }}</span>
|
|
126
|
+
</template>
|
|
127
|
+
<span v-if="d.geographicalArea" class="badge text-[10px] bg-gray-50 text-gray-600">{{ d.geographicalArea }}</span>
|
|
128
|
+
<span v-if="d.international" class="badge text-[10px] bg-sky-50 text-sky-600">international</span>
|
|
129
|
+
<span v-if="d.absent" class="badge text-[10px] bg-red-50 text-red-600">absent</span>
|
|
130
|
+
<span v-if="d.usageInfo" class="text-xs text-ink-300">{{ d.usageInfo }}</span>
|
|
131
|
+
<template v-if="d.pronunciations?.length">
|
|
132
|
+
<span v-for="(p, pi) in d.pronunciations" :key="'p'+pi"
|
|
133
|
+
class="text-xs text-ink-400 font-mono" :title="pronunciationTooltip(p)">{{ pronunciationLabel(p) }}</span>
|
|
134
|
+
</template>
|
|
126
135
|
</div>
|
|
127
136
|
</div>
|
|
128
137
|
</div>
|
|
@@ -161,12 +170,11 @@ function handleContentClick(e: MouseEvent) {
|
|
|
161
170
|
<div class="space-y-3 mt-3">
|
|
162
171
|
<div v-for="(src, i) in sources" :key="i" class="text-sm">
|
|
163
172
|
<div class="flex items-center gap-1.5 flex-wrap mb-1">
|
|
164
|
-
<span v-if="src
|
|
165
|
-
<span v-if="src
|
|
173
|
+
<span v-if="src.type" class="badge badge-blue text-[10px]">{{ src.type }}</span>
|
|
174
|
+
<span v-if="src.status" class="badge badge-gray text-[10px]">{{ src.status }}</span>
|
|
166
175
|
</div>
|
|
167
176
|
<div class="text-ink-700">
|
|
168
|
-
<
|
|
169
|
-
<span v-if="src['gl:origin']?.['gl:clause']">, {{ src['gl:origin']['gl:clause'] }}</span>
|
|
177
|
+
<CitationDisplay v-if="src.origin" :citation="src.origin" />
|
|
170
178
|
</div>
|
|
171
179
|
</div>
|
|
172
180
|
</div>
|
|
@@ -8,6 +8,7 @@ const icons: Record<string, string> = {
|
|
|
8
8
|
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
|
9
9
|
chart: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
|
|
10
10
|
list: 'M4 6h16M4 10h16M4 14h16M4 18h16',
|
|
11
|
+
schema: 'M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z',
|
|
11
12
|
};
|
|
12
13
|
|
|
13
14
|
defineProps<{ name: string }>();
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { NonVerbRep, ConceptSource } from 'glossarist';
|
|
3
|
+
import { sourceStatusInfo, sourceTypeInfo } from '../utils/designation-registry';
|
|
4
|
+
|
|
5
|
+
defineProps<{
|
|
6
|
+
reps: NonVerbRep[];
|
|
7
|
+
}>();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<div v-if="reps.length" class="space-y-3">
|
|
12
|
+
<div class="section-label">Non-verbal representations</div>
|
|
13
|
+
<div v-for="(rep, i) in reps" :key="i" class="card p-4 space-y-2">
|
|
14
|
+
<div class="flex items-center gap-2">
|
|
15
|
+
<span class="badge text-[10px] bg-violet-50 text-violet-700">{{ rep.type ?? 'representation' }}</span>
|
|
16
|
+
<span v-if="rep.text" class="text-sm text-ink-700">{{ rep.text }}</span>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<!-- Image -->
|
|
20
|
+
<div v-if="rep.type === 'image' && rep.ref">
|
|
21
|
+
<img :src="rep.ref" :alt="rep.text || 'Non-verbal representation'" class="max-h-64 rounded border border-ink-100" loading="lazy" />
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<!-- Table / Formula reference -->
|
|
25
|
+
<div v-if="(rep.type === 'table' || rep.type === 'formula') && rep.ref">
|
|
26
|
+
<a :href="rep.ref" target="_blank" rel="noopener" class="text-sm concept-link break-all">{{ rep.ref }}</a>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<!-- Sources for this representation -->
|
|
30
|
+
<div v-if="rep.sources?.length" class="flex flex-wrap gap-1.5">
|
|
31
|
+
<div v-for="(src, si) in rep.sources" :key="si" class="text-xs text-ink-400">
|
|
32
|
+
<span v-if="src.type" class="badge text-[9px]" :class="sourceTypeInfo(src.type).color">{{ sourceTypeInfo(src.type).label }}</span>
|
|
33
|
+
<span v-if="src.origin?.ref" class="ml-1">{{ src.origin.ref.source }}{{ src.origin.ref.id ? ' ' + src.origin.ref.id : '' }}</span>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</template>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { GraphEdge, Manifest } from '../adapters/types';
|
|
3
|
+
import { computed } from 'vue';
|
|
4
|
+
import { categorizeRelationship, relationshipLabel, RELATIONSHIP_CATEGORIES } from '../utils/relationship-categories';
|
|
5
|
+
import { useRouter } from 'vue-router';
|
|
6
|
+
import { useVocabularyStore } from '../stores/vocabulary';
|
|
7
|
+
import { getFactory } from '../adapters/factory';
|
|
8
|
+
import { langName } from '../utils/lang';
|
|
9
|
+
|
|
10
|
+
const props = defineProps<{
|
|
11
|
+
edges: GraphEdge[];
|
|
12
|
+
manifest: Manifest;
|
|
13
|
+
registerId: string;
|
|
14
|
+
}>();
|
|
15
|
+
|
|
16
|
+
const emit = defineEmits<{
|
|
17
|
+
(e: 'navigate', registerId: string, conceptId: string): void;
|
|
18
|
+
}>();
|
|
19
|
+
|
|
20
|
+
interface GroupedEdge extends GraphEdge {
|
|
21
|
+
category: ReturnType<typeof categorizeRelationship>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const groupedEdges = computed(() => {
|
|
25
|
+
const outgoing: GroupedEdge[] = [];
|
|
26
|
+
const incoming: GroupedEdge[] = [];
|
|
27
|
+
|
|
28
|
+
// We can't distinguish incoming/outgoing from edges alone without the graph engine.
|
|
29
|
+
// For now, treat all edges as outgoing from the current concept.
|
|
30
|
+
for (const edge of props.edges) {
|
|
31
|
+
const category = categorizeRelationship(edge.type);
|
|
32
|
+
outgoing.push({ ...edge, category });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const byCategory = new Map<string, GroupedEdge[]>();
|
|
36
|
+
for (const edge of outgoing) {
|
|
37
|
+
const existing = byCategory.get(edge.category.id) ?? [];
|
|
38
|
+
existing.push(edge);
|
|
39
|
+
byCategory.set(edge.category.id, existing);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return byCategory;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const activeCategories = computed(() => {
|
|
46
|
+
const categoryIds = new Set([...groupedEdges.value.keys()]);
|
|
47
|
+
return RELATIONSHIP_CATEGORIES.filter(c => categoryIds.has(c.id))
|
|
48
|
+
.concat(
|
|
49
|
+
categoryIds.has('other')
|
|
50
|
+
? [{ id: 'other', label: 'Other', types: [], color: 'text-gray-600 bg-gray-50' }]
|
|
51
|
+
: []
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const router = useRouter();
|
|
56
|
+
const store = useVocabularyStore();
|
|
57
|
+
const factory = getFactory();
|
|
58
|
+
|
|
59
|
+
function navigate(edge: GraphEdge) {
|
|
60
|
+
const resolution = factory.resolve(edge.target);
|
|
61
|
+
if (resolution.type === 'internal') {
|
|
62
|
+
store.viewConcept(resolution.registerId, resolution.conceptId);
|
|
63
|
+
router.push({ name: 'concept', params: { registerId: resolution.registerId, conceptId: resolution.conceptId } });
|
|
64
|
+
} else if (resolution.type === 'site') {
|
|
65
|
+
window.open(resolution.baseUrl + '/' + resolution.conceptUri, '_blank');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<template>
|
|
71
|
+
<div v-if="groupedEdges.size > 0" class="space-y-4">
|
|
72
|
+
<div v-for="category in activeCategories" :key="category.id">
|
|
73
|
+
<div class="section-label flex items-center gap-1.5 mb-2">
|
|
74
|
+
<span class="inline-block w-2 h-2 rounded-full" :class="category.color"></span>
|
|
75
|
+
{{ category.label }}
|
|
76
|
+
</div>
|
|
77
|
+
<div class="space-y-1.5">
|
|
78
|
+
<button
|
|
79
|
+
v-for="(edge, i) in groupedEdges.get(category.id)"
|
|
80
|
+
:key="i"
|
|
81
|
+
@click="navigate(edge)"
|
|
82
|
+
class="block w-full text-left px-3 py-2 rounded-lg hover:bg-ink-50 transition-colors text-sm group"
|
|
83
|
+
>
|
|
84
|
+
<div class="flex items-center gap-2">
|
|
85
|
+
<span class="badge text-[10px] flex-shrink-0" :class="category.color">
|
|
86
|
+
{{ relationshipLabel(edge.type) }}
|
|
87
|
+
</span>
|
|
88
|
+
<span class="text-ink-700 group-hover:text-ink-900 truncate">
|
|
89
|
+
{{ edge.label || edge.target }}
|
|
90
|
+
</span>
|
|
91
|
+
<span v-if="edge.lang" class="text-[10px] text-ink-300 ml-auto flex-shrink-0">
|
|
92
|
+
{{ edge.lang }}
|
|
93
|
+
</span>
|
|
94
|
+
</div>
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</template>
|
|
@@ -119,6 +119,9 @@ function synthesizeGlobalPages(features?: Record<string, unknown>, pages?: PageC
|
|
|
119
119
|
if (features?.graph !== false && !declaredRoutes.has('graph')) {
|
|
120
120
|
result.push({ type: 'custom', route: 'graph', title: 'Graph', icon: 'graph' });
|
|
121
121
|
}
|
|
122
|
+
if (features?.ontology !== false && !declaredRoutes.has('ontology')) {
|
|
123
|
+
result.push({ type: 'custom', route: 'ontology', title: 'Ontology', icon: 'schema' });
|
|
124
|
+
}
|
|
122
125
|
if (features?.news && !declaredRoutes.has('news')) {
|
|
123
126
|
result.push({ type: 'news', route: 'news', title: 'News', icon: 'newspaper' });
|
|
124
127
|
}
|