@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.
Files changed (54) hide show
  1. package/README.md +3 -2
  2. package/cli/index.mjs +2 -1
  3. package/env.d.ts +5 -0
  4. package/package.json +4 -3
  5. package/scripts/build-edges.js +78 -10
  6. package/scripts/generate-data.mjs +152 -20
  7. package/scripts/generate-ontology-data.mjs +184 -0
  8. package/scripts/generate-ontology-schema.mjs +315 -0
  9. package/src/__tests__/concept-card.test.ts +1 -1
  10. package/src/__tests__/concept-detail-interaction.test.ts +40 -18
  11. package/src/__tests__/concept-formats.test.ts +32 -30
  12. package/src/__tests__/concept-timeline.test.ts +108 -83
  13. package/src/__tests__/concept-view.test.ts +15 -2
  14. package/src/__tests__/dataset-adapter.test.ts +172 -23
  15. package/src/__tests__/dataset-view.test.ts +6 -5
  16. package/src/__tests__/designation-registry.test.ts +161 -0
  17. package/src/__tests__/graph.test.ts +62 -0
  18. package/src/__tests__/language-detail.test.ts +117 -60
  19. package/src/__tests__/ontology-registry.test.ts +109 -0
  20. package/src/__tests__/relationship-categories.test.ts +62 -0
  21. package/src/__tests__/test-helpers.ts +11 -8
  22. package/src/adapters/DatasetAdapter.ts +171 -48
  23. package/src/adapters/model-bridge.ts +277 -0
  24. package/src/adapters/ontology-registry.ts +75 -0
  25. package/src/adapters/ontology-schema.ts +100 -0
  26. package/src/adapters/types.ts +52 -77
  27. package/src/components/AppSidebar.vue +1 -1
  28. package/src/components/CitationDisplay.vue +35 -0
  29. package/src/components/ConceptDetail.vue +334 -93
  30. package/src/components/ConceptRdfView.vue +397 -0
  31. package/src/components/ConceptTimeline.vue +56 -52
  32. package/src/components/GraphPanel.vue +96 -31
  33. package/src/components/LanguageDetail.vue +45 -37
  34. package/src/components/NavIcon.vue +1 -0
  35. package/src/components/NonVerbalRepDisplay.vue +38 -0
  36. package/src/components/RelationshipList.vue +99 -0
  37. package/src/config/use-site-config.ts +3 -0
  38. package/src/data/ontology-schema.json +1551 -0
  39. package/src/data/taxonomies.json +543 -0
  40. package/src/graph/GraphEngine.ts +7 -4
  41. package/src/router/index.ts +5 -0
  42. package/src/shims/empty.ts +1 -0
  43. package/src/shims/node-crypto.ts +6 -0
  44. package/src/shims/node-path.ts +10 -0
  45. package/src/stores/vocabulary.ts +75 -25
  46. package/src/style.css +74 -20
  47. package/src/utils/concept-formats.ts +22 -20
  48. package/src/utils/concept-helpers.ts +43 -23
  49. package/src/utils/designation-registry.ts +124 -0
  50. package/src/utils/relationship-categories.ts +84 -0
  51. package/src/views/OntologySchemaView.vue +302 -0
  52. package/src/views/PageView.vue +28 -17
  53. package/src/views/StatsView.vue +34 -12
  54. 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
- return props.edges.filter(e => uris.has(e.source) && uris.has(e.target));
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
- uri: n.uri,
171
- register: n.register,
172
- conceptId: n.conceptId,
173
- designation: Object.values(n.designations)[0] || n.conceptId,
174
- loaded: n.loaded,
175
- x: width / 2 + (Math.random() - 0.5) * 200,
176
- y: height / 2 + (Math.random() - 0.5) * 200,
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('marker-end', 'url(#arrowhead)');
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.append('circle')
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') // surface
253
+ .attr('stroke', '#faf9f6')
213
254
  .attr('stroke-width', 1.5);
214
255
 
215
- nodeSel.append('text')
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 => (labelMode.value === 'identifier' ? d.conceptId : d.designation).slice(0, 18));
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
- nodeSel.select('circle')
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.attr('stroke', '#dddde6').attr('stroke-width', 0.8);
267
- nodeSel.select('circle')
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(count > 1000 ? 6 : 12))
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 (only when multiple registers) -->
466
- <div v-if="nodeCount > 0 && registers.length > 1" 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);">
467
- <div class="font-semibold text-ink-400 text-[10px] uppercase tracking-wide mb-2">Datasets</div>
468
- <div v-for="reg in registers" :key="reg.id" class="flex items-center gap-2 mb-1.5 last:mb-0">
469
- <span
470
- class="w-2.5 h-2.5 rounded-full inline-block flex-shrink-0"
471
- :style="{ backgroundColor: registerColor(reg.id) }"
472
- ></span>
473
- <span class="text-ink-500">{{ reg.title }}</span>
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, ConceptSource } from '../adapters/types';
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, designationTypeLabel, designationTypeColor } from '../utils/concept-helpers';
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
- localizedConcepts: Record<string, LocalizedConcept>;
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.localizedConcepts[props.activeLang]);
23
- const availableLangs = computed(() => Object.keys(props.localizedConcepts).sort());
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?.['gl:designation'] ?? []);
27
+ const designations = computed(() => lc.value?.terms ?? []);
26
28
  const definition = computed(() => {
27
- const defs = lc.value?.['gl:definition'];
28
- if (defs?.length) {
29
- const content = defs.map(d => d['gl:content']).filter(Boolean).join('\n\n');
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 examples = computed(() => lc.value?.['gl:examples']?.map(e => e['gl:content']).filter(Boolean) ?? []);
38
- const sources = computed(() => lc.value?.['gl:source'] ?? []);
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['gl:entryStatus']" class="flex items-center gap-2 mb-4">
113
- <span class="badge" :class="entryStatusColor(lc['gl:entryStatus'])">{{ lc['gl:entryStatus'] }}</span>
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['gl:term'])"></span>
122
- <span class="badge text-[10px]" :class="designationTypeColor(d['@type'])">{{ designationTypeLabel(d['@type']) }}</span>
123
- <span class="badge text-[10px]" :class="normativeColor(d['gl:normativeStatus'])">{{ normativeStatus(d['gl:normativeStatus']) }}</span>
124
- <span v-if="d['gl:gender']" class="text-xs text-ink-300">{{ d['gl:gender'] }}</span>
125
- <span v-if="d['gl:plurality']" class="text-xs text-ink-300">{{ d['gl:plurality'] }}</span>
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['gl:sourceType']" class="badge badge-blue text-[10px]">{{ src['gl:sourceType'] }}</span>
165
- <span v-if="src['gl:sourceStatus']" class="badge badge-gray text-[10px]">{{ src['gl:sourceStatus'] }}</span>
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
- <span v-if="src['gl:origin']?.['gl:ref']" class="font-medium">{{ src['gl:origin']['gl:ref'] }}</span>
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
  }