@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
@@ -1,22 +1,28 @@
1
1
  <script setup lang="ts">
2
- import type { ConceptDocument, LocalizedConcept, GraphEdge } from '../adapters/types';
3
- import type { Manifest } from '../adapters/types';
2
+ import type { Concept, LocalizedConcept, Designation, Expression, ConceptSource } from 'glossarist';
3
+ import type { Manifest, GraphEdge } from '../adapters/types';
4
4
  import { computed, ref, nextTick, watch } from 'vue';
5
5
  import { langName, langLabel } from '../utils/lang';
6
6
  import { renderMath, cleanContent } from '../utils/math';
7
7
  import type { RenderOptions } from '../utils/math';
8
8
  import { escapeAttr } from '../utils/escape';
9
- import { entryStatusColor, designationTypeLabel, designationTypeColor, getPreferredTerm } from '../utils/concept-helpers';
9
+ import { entryStatusColor, conceptStatusColor, conceptStatusLabel, conceptStatusDefinition, entryStatusLabel, entryStatusDefinition, getPreferredTerm } from '../utils/concept-helpers';
10
+ import { designationTypeInfo, normativeStatusInfo, grammarBadges, pronunciationLabel, pronunciationTooltip, abbreviationDetails, sourceTypeInfo, sourceStatusInfo, termTypeInfo } from '../utils/designation-registry';
11
+ import { conceptUri } from '../adapters/model-bridge';
10
12
  import { useRouter } from 'vue-router';
11
13
  import { useVocabularyStore } from '../stores/vocabulary';
12
14
  import { useDsStyle } from '../utils/dataset-style';
13
15
  import { getFactory } from '../adapters/factory';
14
16
  import { useRenderOptions } from '../composables/use-render-options';
17
+ import { categorizeRelationship, relationshipLabel, relationshipDefinition } from '../utils/relationship-categories';
15
18
  import ConceptTimeline from './ConceptTimeline.vue';
19
+ import ConceptRdfView from './ConceptRdfView.vue';
16
20
  import FormatDownloads from './FormatDownloads.vue';
21
+ import NonVerbalRepDisplay from './NonVerbalRepDisplay.vue';
22
+ import CitationDisplay from './CitationDisplay.vue';
17
23
 
18
24
  const props = defineProps<{
19
- concept: ConceptDocument;
25
+ concept: Concept;
20
26
  manifest: Manifest;
21
27
  edges: GraphEdge[];
22
28
  registerId: string;
@@ -28,10 +34,10 @@ const store = useVocabularyStore();
28
34
  const { getColor } = useDsStyle();
29
35
  const factory = getFactory();
30
36
 
31
- const activeTab = ref<'definition' | 'history'>('definition');
37
+ const activeTab = ref<'rdf' | 'definition' | 'history'>('definition');
32
38
  const activeHistoryLang = ref('eng');
33
39
 
34
- const conceptId = computed(() => props.concept['gl:identifier']);
40
+ const conceptId = computed(() => props.concept.id);
35
41
 
36
42
  const conceptPosition = computed(() => {
37
43
  const adapter = store.datasets.get(props.registerId);
@@ -43,7 +49,8 @@ const conceptPosition = computed(() => {
43
49
 
44
50
  const uriCopied = ref(false);
45
51
  function copyUri() {
46
- navigator.clipboard.writeText(props.concept['@id']).then(() => {
52
+ const uri = conceptUri(props.concept, props.registerId, props.manifest.uriBase);
53
+ navigator.clipboard.writeText(uri).then(() => {
47
54
  uriCopied.value = true;
48
55
  setTimeout(() => { uriCopied.value = false; }, 2000);
49
56
  });
@@ -51,16 +58,16 @@ function copyUri() {
51
58
 
52
59
  const languages = computed(() => {
53
60
  const order = props.manifest.languageOrder;
54
- const keys = Object.keys(props.concept['gl:localizedConcept'] || {});
61
+ const keys = props.concept.languages;
55
62
  if (!order) {
56
- return keys.sort((a, b) => {
63
+ return [...keys].sort((a, b) => {
57
64
  if (a === 'eng') return -1;
58
- if (b === 'eng') return 1;
65
+ if (a === 'eng') return 1;
59
66
  return a.localeCompare(b);
60
67
  });
61
68
  }
62
69
  const orderIndex = new Map(order.map((lang, i) => [lang, i]));
63
- return keys.sort((a, b) => {
70
+ return [...keys].sort((a, b) => {
64
71
  const ai = orderIndex.get(a) ?? order.length;
65
72
  const bi = orderIndex.get(b) ?? order.length;
66
73
  if (ai !== bi) return ai - bi;
@@ -80,11 +87,23 @@ function initCollapsed(langs: string[]) {
80
87
  watch(languages, (langs) => { initCollapsed(langs); }, { immediate: true });
81
88
 
82
89
  const engConcept = computed((): LocalizedConcept | null => {
83
- return props.concept['gl:localizedConcept']?.['eng'] ?? null;
90
+ return props.concept.localization('eng') ?? null;
84
91
  });
85
92
 
86
93
  const primaryTerm = computed(() => getPreferredTerm(engConcept.value, conceptId.value));
87
94
 
95
+ // Managed concept status from Concept.status (7 values from concept-status.ttl)
96
+ const managedStatus = computed(() => props.concept.status);
97
+
98
+ // ConceptReference domains from managed concept level
99
+ const conceptRefDomains = computed(() => props.concept.domains);
100
+
101
+ // Managed concept dates
102
+ const conceptDates = computed(() => props.concept.dates);
103
+
104
+ // Managed concept sources (distinct from localized sources)
105
+ const conceptSources = computed(() => props.concept.sources);
106
+
88
107
  // Cross-reference resolver: generates clickable links for inline refs
89
108
 
90
109
  const { ensureBibLoaded, bibResolver, figResolver } = useRenderOptions(() => props.registerId);
@@ -125,33 +144,45 @@ function handleContentClick(e: MouseEvent) {
125
144
  // Pre-computed content for all languages (sorted eng first)
126
145
  interface LangContent {
127
146
  lang: string;
147
+ lc: LocalizedConcept;
128
148
  definition: string;
129
149
  notes: string[];
130
150
  examples: string[];
131
- sources: any[];
132
- designations: any[];
151
+ sources: ConceptSource[];
152
+ designations: Designation[];
133
153
  entryStatus: string;
154
+ classification: string | null;
155
+ reviewType: string | null;
156
+ release: string | null;
157
+ lineageSourceSimilarity: number | null;
158
+ lcScript: string | null;
159
+ lcSystem: string | null;
134
160
  }
135
161
 
136
162
  const allLangContent = computed(() => {
137
163
  const result: LangContent[] = [];
138
164
  for (const lang of languages.value) {
139
- const lc = props.concept['gl:localizedConcept']?.[lang];
165
+ const lc = props.concept.localization(lang);
140
166
  if (!lc) continue;
141
167
 
142
- const defs = lc['gl:definition'];
143
- const definition = defs?.length
144
- ? defs.map(d => d['gl:content']).filter(Boolean).join('\n\n')
145
- : '';
168
+ const definition = lc.definitions
169
+ .map(d => d.content).filter(Boolean).join('\n\n');
146
170
 
147
171
  result.push({
148
172
  lang,
173
+ lc,
149
174
  definition,
150
- notes: lc['gl:notes']?.map((n: any) => n['gl:content']).filter(Boolean) ?? [],
151
- examples: lc['gl:examples']?.map((e: any) => e['gl:content']).filter(Boolean) ?? [],
152
- sources: lc['gl:source'] ?? [],
153
- designations: lc['gl:designation'] ?? [],
154
- entryStatus: lc['gl:entryStatus'] ?? '',
175
+ notes: lc.notes.map(n => n.content).filter(Boolean),
176
+ examples: lc.examples.map(e => e.content).filter(Boolean),
177
+ sources: lc.sources,
178
+ designations: lc.terms,
179
+ entryStatus: lc.entryStatus ?? '',
180
+ classification: lc.classification,
181
+ reviewType: lc.reviewType,
182
+ release: lc.release,
183
+ lineageSourceSimilarity: lc.lineageSourceSimilarity,
184
+ lcScript: lc.script,
185
+ lcSystem: lc.system,
155
186
  });
156
187
  }
157
188
  return result;
@@ -178,21 +209,23 @@ function toggleAll() {
178
209
  }
179
210
 
180
211
  function scrollToLang(lang: string) {
181
- // Expand if collapsed
182
212
  if (collapsedLangs.value.has(lang)) {
183
213
  const s = new Set(collapsedLangs.value);
184
214
  s.delete(lang);
185
215
  collapsedLangs.value = s;
186
216
  }
187
- // Switch to definition tab if needed
188
217
  activeTab.value = 'definition';
189
218
  nextTick(() => {
190
219
  document.getElementById(`lang-${lang}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
191
220
  });
192
221
  }
193
222
 
194
- const outgoingEdges = computed(() => props.edges.filter(e => e.source === props.concept['@id']));
195
- const incomingEdges = computed(() => props.edges.filter(e => e.target === props.concept['@id']));
223
+ const conceptUriValue = computed(() =>
224
+ conceptUri(props.concept, props.registerId, props.manifest.uriBase)
225
+ );
226
+
227
+ const outgoingEdges = computed(() => props.edges.filter(e => e.source === conceptUriValue.value));
228
+ const incomingEdges = computed(() => props.edges.filter(e => e.target === conceptUriValue.value));
196
229
 
197
230
  function isLocalRef(uri: string): boolean {
198
231
  const resolution = factory.resolve(uri, props.registerId);
@@ -231,7 +264,7 @@ function edgeDatasetBadge(uri: string): { id: string; title: string } | null {
231
264
  }
232
265
 
233
266
  async function navigateEdge(edge: GraphEdge) {
234
- const uri = edge.source === props.concept['@id'] ? edge.target : edge.source;
267
+ const uri = edge.source === conceptUriValue.value ? edge.target : edge.source;
235
268
  const resolution = factory.resolve(uri);
236
269
 
237
270
  if (resolution.type === 'internal') {
@@ -245,27 +278,27 @@ async function navigateEdge(edge: GraphEdge) {
245
278
  }
246
279
 
247
280
  function getTermForLang(lang: string): string {
248
- const lc = props.concept['gl:localizedConcept']?.[lang];
281
+ const lc = props.concept.localization(lang);
249
282
  return getPreferredTerm(lc);
250
283
  }
251
284
 
252
- function getDesignationsForLang(lang: string) {
253
- const lc = props.concept['gl:localizedConcept']?.[lang];
254
- return lc?.['gl:designation'] ?? [];
285
+ function getDesignationsForLang(lang: string): Designation[] {
286
+ const lc = props.concept.localization(lang);
287
+ return lc?.terms ?? [];
255
288
  }
256
289
 
257
- function orderedDesignations(lang: string) {
290
+ function orderedDesignations(lang: string): Designation[] {
258
291
  const desigs = getDesignationsForLang(lang);
259
- const preferred = desigs.filter(d => d['gl:normativeStatus'] === 'preferred');
260
- const admitted = desigs.filter(d => d['gl:normativeStatus'] === 'admitted' || d['gl:normativeStatus'] === 'deprecated');
261
- const rest = desigs.filter(d => d['gl:normativeStatus'] !== 'preferred' && d['gl:normativeStatus'] !== 'admitted' && d['gl:normativeStatus'] !== 'deprecated');
292
+ const preferred = desigs.filter(d => d.normativeStatus === 'preferred');
293
+ const admitted = desigs.filter(d => d.normativeStatus === 'admitted' || d.normativeStatus === 'deprecated');
294
+ const rest = desigs.filter(d => d.normativeStatus !== 'preferred' && d.normativeStatus !== 'admitted' && d.normativeStatus !== 'deprecated');
262
295
  return [...preferred, ...admitted, ...rest];
263
296
  }
264
297
 
265
298
  function hasDefinition(lang: string): boolean {
266
- const lc = props.concept['gl:localizedConcept']?.[lang];
299
+ const lc = props.concept.localization(lang);
267
300
  if (!lc) return false;
268
- return lc['gl:definition']?.some((d: any) => d['gl:content']) ?? false;
301
+ return lc.definitions.some(d => d.content);
269
302
  }
270
303
 
271
304
  function goAdjacent(id: string) {
@@ -275,8 +308,56 @@ function goAdjacent(id: string) {
275
308
 
276
309
  function plainTruncate(html: string, max: number = 120): string {
277
310
  const text = cleanContent(html).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
278
- return text.length <= max ? text : text.slice(0, max).trimEnd() + '\u2026';
311
+ return text.length <= max ? text : text.slice(0, max).trimEnd() + '';
312
+ }
313
+
314
+ function slugify(text: string): string {
315
+ return text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/[\s/]+/g, '-');
279
316
  }
317
+
318
+ // Domain rendering: merge ConceptReference domains and per-localization domain strings
319
+ const conceptDomains = computed(() => {
320
+ const domainMap = new Map<string, { slug: string; label: string; langs: string[]; conceptId?: string }>();
321
+
322
+ // Managed concept level ConceptReference domains (authoritative)
323
+ for (const ref of conceptRefDomains.value) {
324
+ const id = ref.conceptId ?? '';
325
+ const label = id || ref.urn || '';
326
+ if (label) {
327
+ const slug = slugify(label);
328
+ domainMap.set(slug, { slug, label, langs: [], conceptId: id });
329
+ }
330
+ }
331
+
332
+ // Per-localization domain strings
333
+ for (const lang of props.concept.languages) {
334
+ const lc = props.concept.localization(lang);
335
+ const domain = lc?.domain;
336
+ if (domain) {
337
+ const slug = slugify(domain);
338
+ const existing = domainMap.get(slug);
339
+ if (existing) {
340
+ if (!existing.langs.includes(lang)) existing.langs.push(lang);
341
+ } else {
342
+ domainMap.set(slug, { slug, label: domain, langs: [lang] });
343
+ }
344
+ }
345
+ }
346
+ return [...domainMap.values()].sort((a, b) => b.langs.length - a.langs.length);
347
+ });
348
+
349
+ // Non-verbal reps: aggregate across all localizations
350
+ const nonVerbalReps = computed(() => {
351
+ const reps: typeof import('glossarist').NonVerbRep.prototype[] = [];
352
+ for (const lang of props.concept.languages) {
353
+ const lc = props.concept.localization(lang);
354
+ if (lc?.nonVerbalRep?.length) {
355
+ reps.push(...lc.nonVerbalRep);
356
+ }
357
+ }
358
+ return reps;
359
+ });
360
+
280
361
  </script>
281
362
 
282
363
  <template>
@@ -300,7 +381,7 @@ function plainTruncate(html: string, max: number = 120): string {
300
381
  <button
301
382
  v-if="adjacent.prev"
302
383
  @click="goAdjacent(adjacent.prev)"
303
- class="p-1.5 rounded-md text-ink-300 hover:text-ink-600 hover:bg-ink-50 transition-colors"
384
+ class="p-2.5 rounded-lg text-ink-300 hover:text-ink-600 hover:bg-ink-50 transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
304
385
  title="Previous concept (←)"
305
386
  >
306
387
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
@@ -308,7 +389,7 @@ function plainTruncate(html: string, max: number = 120): string {
308
389
  <button
309
390
  v-if="adjacent.next"
310
391
  @click="goAdjacent(adjacent.next)"
311
- class="p-1.5 rounded-md text-ink-300 hover:text-ink-600 hover:bg-ink-50 transition-colors"
392
+ class="p-2.5 rounded-lg text-ink-300 hover:text-ink-600 hover:bg-ink-50 transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
312
393
  title="Next concept (→)"
313
394
  >
314
395
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
@@ -316,49 +397,71 @@ function plainTruncate(html: string, max: number = 120): string {
316
397
  </div>
317
398
  </div>
318
399
  <h1 class="font-serif text-2xl sm:text-3xl text-ink-800 leading-snug mb-3" v-html="renderMath(primaryTerm)"></h1>
319
- <div class="flex flex-wrap gap-2">
400
+ <div class="flex gap-2 overflow-x-auto pb-1 -mx-4 px-4 sm:flex-wrap sm:overflow-visible sm:mx-0 sm:pb-0 scrollbar-none">
320
401
  <span class="badge badge-blue font-mono">{{ conceptId }}</span>
321
- <span class="badge" :class="entryStatusColor(engConcept?.['gl:entryStatus'] ?? '')" v-if="engConcept?.['gl:entryStatus']">
322
- {{ engConcept['gl:entryStatus'] }}
402
+ <span
403
+ v-if="managedStatus"
404
+ class="badge text-[10px]"
405
+ :class="conceptStatusColor(managedStatus)"
406
+ :title="conceptStatusDefinition(managedStatus) ?? ''"
407
+ >
408
+ {{ conceptStatusLabel(managedStatus) }}
409
+ </span>
410
+ <span class="badge" :class="entryStatusColor(engConcept?.entryStatus ?? '')" v-if="engConcept?.entryStatus" :title="entryStatusDefinition(engConcept.entryStatus) ?? ''">
411
+ {{ entryStatusLabel(engConcept.entryStatus) }}
323
412
  </span>
324
413
  <span class="badge badge-gray" v-if="manifest.owner">{{ manifest.owner }}</span>
325
414
  <span class="badge badge-purple">{{ languages.length }} languages</span>
326
415
  </div>
327
416
  </div>
328
417
 
329
- <!-- Tab navigation -->
330
- <div role="tablist" class="flex border-b border-ink-100/60 mb-6">
418
+ <!-- Tab navigation: segmented control on mobile, underline on desktop -->
419
+ <div role="tablist"
420
+ class="grid grid-cols-3 rounded-xl bg-surface-alt p-1 mb-6 md:bg-transparent md:p-0 md:flex md:border-b md:border-ink-100/60 md:rounded-none">
331
421
  <button
332
422
  role="tab"
333
423
  :aria-selected="activeTab === 'definition'"
334
424
  @click="activeTab = 'definition'"
335
- :class="activeTab === 'definition' ? 'border-ink-800 text-ink-800' : 'border-transparent text-ink-400 hover:text-ink-600'"
336
- class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px"
425
+ class="py-3 text-sm font-medium rounded-lg transition-colors md:rounded-none md:border-b-2 md:-mb-px md:px-5 md:py-3"
426
+ :class="activeTab === 'definition'
427
+ ? 'bg-blue-600 text-white shadow-sm md:bg-transparent md:text-blue-600 md:border-blue-500 md:shadow-none'
428
+ : 'text-ink-500 hover:text-ink-700 md:text-ink-400 md:border-transparent md:hover:text-ink-600'"
337
429
  >
338
430
  Definition
339
431
  </button>
340
432
  <button
341
433
  role="tab"
342
- :aria-selected="activeTab === 'history'"
343
- @click="activeTab = 'history'"
344
- :class="activeTab === 'history' ? 'border-ink-800 text-ink-800' : 'border-transparent text-ink-400 hover:text-ink-600'"
345
- class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px"
434
+ :aria-selected="activeTab === 'rdf'"
435
+ @click="activeTab = 'rdf'"
436
+ class="py-3 text-sm font-medium rounded-lg transition-colors md:rounded-none md:border-b-2 md:-mb-px md:px-5 md:py-3"
437
+ :class="activeTab === 'rdf'
438
+ ? 'bg-blue-600 text-white shadow-sm md:bg-transparent md:text-blue-600 md:border-blue-500 md:shadow-none'
439
+ : 'text-ink-500 hover:text-ink-700 md:text-ink-400 md:border-transparent md:hover:text-ink-600'"
346
440
  >
347
- History
441
+ RDF
348
442
  </button>
349
- <!-- Expand/Collapse all toggle (definition tab only) -->
350
443
  <button
351
- v-if="activeTab === 'definition'"
352
- @click="toggleAll"
353
- class="ml-auto px-3 py-2 text-xs text-ink-400 hover:text-ink-600 transition-colors"
444
+ role="tab"
445
+ :aria-selected="activeTab === 'history'"
446
+ @click="activeTab = 'history'"
447
+ class="py-3 text-sm font-medium rounded-lg transition-colors md:rounded-none md:border-b-2 md:-mb-px md:px-5 md:py-3"
448
+ :class="activeTab === 'history'
449
+ ? 'bg-blue-600 text-white shadow-sm md:bg-transparent md:text-blue-600 md:border-blue-500 md:shadow-none'
450
+ : 'text-ink-500 hover:text-ink-700 md:text-ink-400 md:border-transparent md:hover:text-ink-600'"
354
451
  >
355
- {{ allCollapsed ? 'Expand all' : 'Collapse all' }}
356
- <span class="text-ink-300 ml-0.5">({{ languages.length }})</span>
452
+ History
357
453
  </button>
358
454
  </div>
359
455
 
360
456
  <!-- Tab: Definition -->
361
457
  <div v-if="activeTab === 'definition'" role="tabpanel">
458
+ <!-- Expand/Collapse all toggle -->
459
+ <div v-if="allLangContent.length > 1" class="flex items-center justify-between mb-3">
460
+ <span class="text-xs text-ink-400">{{ languages.length }} languages</span>
461
+ <button @click="toggleAll" class="text-xs text-ink-400 hover:text-ink-600 transition-colors px-3 py-2">
462
+ {{ allCollapsed ? 'Expand all' : 'Collapse all' }}
463
+ </button>
464
+ </div>
362
465
  <div class="lg:flex lg:gap-8">
363
466
  <!-- Left: all language content -->
364
467
  <div class="flex-1 min-w-0 space-y-2" @click="handleContentClick">
@@ -379,14 +482,14 @@ function plainTruncate(html: string, max: number = 120): string {
379
482
  </svg>
380
483
  <span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langName(lc.lang) }}</span>
381
484
  <span class="font-medium text-ink-800 text-sm" v-html="renderMath(getTermForLang(lc.lang))"></span>
382
- <span v-if="lc.entryStatus" class="badge text-[10px] ml-auto" :class="entryStatusColor(lc.entryStatus)">{{ lc.entryStatus }}</span>
485
+ <span v-if="lc.entryStatus" class="badge text-[10px] ml-auto" :class="entryStatusColor(lc.entryStatus)" :title="entryStatusDefinition(lc.entryStatus) ?? ''">{{ entryStatusLabel(lc.entryStatus) }}</span>
383
486
  </button>
384
487
  <!-- Non-collapsible header (designation only) -->
385
488
  <div v-else class="w-full flex items-center gap-2.5 px-3 sm:px-4 py-3">
386
489
  <span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langName(lc.lang) }}</span>
387
490
  <span class="font-medium text-ink-800 text-sm" v-html="renderMath(getTermForLang(lc.lang))"></span>
388
491
  <span class="text-xs text-ink-200 ml-2 italic">designation only</span>
389
- <span v-if="lc.entryStatus" class="badge text-[10px] ml-auto" :class="entryStatusColor(lc.entryStatus)">{{ lc.entryStatus }}</span>
492
+ <span v-if="lc.entryStatus" class="badge text-[10px] ml-auto" :class="entryStatusColor(lc.entryStatus)" :title="entryStatusDefinition(lc.entryStatus) ?? ''">{{ entryStatusLabel(lc.entryStatus) }}</span>
390
493
  </div>
391
494
  <!-- Collapsed preview -->
392
495
  <div v-if="hasContent(lc) && collapsedLangs.has(lc.lang)" class="px-3 sm:px-4 pb-3 -mt-0.5">
@@ -400,12 +503,59 @@ function plainTruncate(html: string, max: number = 120): string {
400
503
 
401
504
  <!-- Expandable content -->
402
505
  <div v-if="hasContent(lc)" v-show="!collapsedLangs.has(lc.lang)" class="lang-content px-3 sm:px-4 pb-4 space-y-3">
403
- <!-- Designations -->
404
- <div v-if="lc.designations.length > 1" class="space-y-1 pl-[22px]">
405
- <div v-for="(d, i) in orderedDesignations(lc.lang)" :key="i" class="flex items-center gap-2 text-sm">
406
- <span :class="d['gl:normativeStatus'] === 'preferred' ? 'font-bold text-ink-800' : 'font-normal text-ink-700'" v-html="renderMath(d['gl:term'])"></span>
407
- <span class="badge text-[10px] flex-shrink-0" :class="designationTypeColor(d['@type'])">{{ designationTypeLabel(d['@type']) }}</span>
408
- <span v-if="d['gl:normativeStatus'] && d['gl:normativeStatus'] !== 'preferred'" class="badge badge-yellow text-[10px] flex-shrink-0">{{ d['gl:normativeStatus'] }}</span>
506
+ <!-- Designations (show all, with full metadata) -->
507
+ <div v-if="lc.designations.length > 0" class="space-y-1.5 pl-[22px]">
508
+ <div v-for="(d, i) in orderedDesignations(lc.lang)" :key="i">
509
+ <div class="flex items-center gap-1.5 text-sm flex-wrap">
510
+ <span :class="d.normativeStatus === 'preferred' ? 'font-bold text-ink-800' : 'font-normal text-ink-700'" v-html="renderMath(d.designation)"></span>
511
+ <span class="badge text-[10px] flex-shrink-0" :class="designationTypeInfo(d).color" :title="designationTypeInfo(d).definition ?? ''">{{ designationTypeInfo(d).label }}</span>
512
+ <span class="badge text-[10px] flex-shrink-0" :class="normativeStatusInfo(d.normativeStatus).color" :title="normativeStatusInfo(d.normativeStatus).definition ?? ''">{{ normativeStatusInfo(d.normativeStatus).label }}</span>
513
+ <!-- Abbreviation details -->
514
+ <template v-if="abbreviationDetails(d).length">
515
+ <span v-for="abbr in abbreviationDetails(d)" :key="abbr" class="badge text-[10px] bg-amber-50 text-amber-600">{{ abbr }}</span>
516
+ </template>
517
+ <!-- Term type (ISO 12620) -->
518
+ <span v-if="d.termType" class="badge text-[10px] bg-gray-50 text-gray-600" :title="termTypeInfo(d.termType).definition ?? ''">{{ termTypeInfo(d.termType).label }}</span>
519
+ <!-- Grammar info -->
520
+ <template v-if="d.type === 'expression' && (d as Expression).grammarInfo?.length">
521
+ <template v-for="(gi, giIdx) in (d as Expression).grammarInfo" :key="giIdx">
522
+ <span v-for="badge in grammarBadges(gi)" :key="giIdx + '-' + badge.label"
523
+ class="badge text-[10px] bg-gray-50 text-gray-600" :title="badge.definition ?? ''">{{ badge.label }}</span>
524
+ </template>
525
+ </template>
526
+ <!-- Pronunciation -->
527
+ <template v-if="d.pronunciations?.length">
528
+ <span v-for="(p, pi) in d.pronunciations" :key="'p'+pi"
529
+ class="text-xs text-ink-400 font-mono" :title="pronunciationTooltip(p)">{{ pronunciationLabel(p) }}</span>
530
+ </template>
531
+ <!-- Flags -->
532
+ <span v-if="d.international" class="badge text-[10px] bg-sky-50 text-sky-600">international</span>
533
+ <span v-if="d.absent" class="badge text-[10px] bg-red-50 text-red-600">absent</span>
534
+ <span v-if="d.geographicalArea" class="badge text-[10px] bg-gray-50 text-gray-600">{{ d.geographicalArea }}</span>
535
+ <span v-if="d.usageInfo" class="text-xs text-ink-300">{{ d.usageInfo }}</span>
536
+ <span v-if="d.fieldOfApplication" class="text-xs text-ink-300">field: {{ d.fieldOfApplication }}</span>
537
+ <!-- Per-designation language/script/system overrides -->
538
+ <template v-if="d.language && d.language !== lc.lang">
539
+ <span class="badge text-[10px] bg-teal-50 text-teal-600">lang: {{ langName(d.language) }}</span>
540
+ </template>
541
+ <span v-if="d.script" class="badge text-[10px] bg-gray-50 text-gray-600">script: {{ d.script }}</span>
542
+ <span v-if="d.system" class="badge text-[10px] bg-gray-50 text-gray-600">system: {{ d.system }}</span>
543
+ </div>
544
+ <!-- Designation sources -->
545
+ <div v-if="d.sources?.length" class="mt-1 space-y-0.5">
546
+ <div v-for="(ds, dsi) in d.sources" :key="'ds'+dsi" class="text-xs text-ink-400 flex items-center gap-1.5">
547
+ <span v-if="ds.type" class="badge text-[9px]" :class="sourceTypeInfo(ds.type).color">{{ sourceTypeInfo(ds.type).label }}</span>
548
+ <CitationDisplay v-if="ds.origin" :citation="ds.origin" />
549
+ <span v-else-if="ds.modification" class="text-ink-300">{{ ds.modification }}</span>
550
+ </div>
551
+ </div>
552
+ <!-- Designation relationships -->
553
+ <div v-if="d.related?.length" class="mt-0.5 space-y-0.5">
554
+ <div v-for="(dr, dri) in d.related" :key="'dr'+dri" class="text-xs text-ink-400 flex items-center gap-1.5">
555
+ <span class="badge text-[9px] bg-gray-50 text-gray-600">{{ relationshipLabel(dr.type) }}</span>
556
+ <span>{{ dr.content || (dr.ref ? `${dr.ref.source || ''} ${dr.ref.id || ''}`.trim() : '') }}</span>
557
+ </div>
558
+ </div>
409
559
  </div>
410
560
  </div>
411
561
 
@@ -430,23 +580,59 @@ function plainTruncate(html: string, max: number = 120): string {
430
580
  </div>
431
581
  </div>
432
582
 
583
+ <!-- Non-verbal representations -->
584
+ <NonVerbalRepDisplay v-if="lc.lc.nonVerbalRep?.length" :reps="lc.lc.nonVerbalRep" />
585
+
433
586
  <!-- Sources -->
434
587
  <div v-if="lc.sources.length" class="space-y-2">
435
588
  <div v-for="(src, i) in lc.sources" :key="i" class="text-sm">
436
589
  <div class="flex items-center gap-1.5 flex-wrap mb-1">
437
- <span v-if="src['gl:sourceType']" class="badge badge-blue text-[10px]">{{ src['gl:sourceType'] }}</span>
438
- <span v-if="src['gl:sourceStatus']" class="badge badge-gray text-[10px]">{{ src['gl:sourceStatus'] }}</span>
590
+ <span v-if="src.type" class="badge text-[10px]" :class="sourceTypeInfo(src.type).color" :title="sourceTypeInfo(src.type).definition ?? ''">{{ sourceTypeInfo(src.type).label }}</span>
591
+ <span v-if="src.status" class="badge text-[10px]" :title="sourceStatusInfo(src.status).definition ?? ''" :class="sourceStatusInfo(src.status).color">{{ sourceStatusInfo(src.status).label }}</span>
439
592
  </div>
440
593
  <div class="text-ink-700">
441
- <span v-if="src['gl:origin']?.['gl:ref']" class="font-medium">{{ src['gl:origin']['gl:ref'] }}</span>
442
- <span v-if="src['gl:origin']?.['gl:clause']">, {{ src['gl:origin']['gl:clause'] }}</span>
443
- <a v-if="src['gl:origin']?.['gl:link']" :href="src['gl:origin']['gl:link']" target="_blank" class="concept-link ml-1">[link]</a>
594
+ <CitationDisplay v-if="src.origin" :citation="src.origin" />
595
+ <span v-if="!src.origin && src.modification" class="text-ink-400">{{ src.modification }}</span>
444
596
  </div>
445
- <div v-if="src['gl:modification']" class="text-xs text-ink-300 mt-1">{{ src['gl:modification'] }}</div>
597
+ <div v-if="src.modification" class="text-xs text-ink-300 mt-1">{{ src.modification }}</div>
446
598
  </div>
447
599
  </div>
600
+
601
+ <!-- Ontological metadata -->
602
+ <div v-if="lc.classification || lc.reviewType || lc.release || lc.lineageSourceSimilarity != null || lc.lcScript || lc.lcSystem" class="border-t border-ink-100/60 pt-2 mt-2">
603
+ <div class="text-[10px] uppercase tracking-wide text-ink-300 font-medium mb-1.5">Ontological metadata</div>
604
+ <dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
605
+ <template v-if="lc.classification">
606
+ <dt class="text-ink-300">Classification</dt>
607
+ <dd class="text-ink-700">{{ lc.classification }}</dd>
608
+ </template>
609
+ <template v-if="lc.reviewType">
610
+ <dt class="text-ink-300">Review type</dt>
611
+ <dd class="text-ink-700">{{ lc.reviewType }}</dd>
612
+ </template>
613
+ <template v-if="lc.release">
614
+ <dt class="text-ink-300">Release</dt>
615
+ <dd class="text-ink-700">{{ lc.release }}</dd>
616
+ </template>
617
+ <template v-if="lc.lineageSourceSimilarity != null">
618
+ <dt class="text-ink-300">Lineage similarity</dt>
619
+ <dd class="text-ink-700">{{ lc.lineageSourceSimilarity }}%</dd>
620
+ </template>
621
+ <template v-if="lc.lcScript">
622
+ <dt class="text-ink-300">Script</dt>
623
+ <dd class="text-ink-700 font-mono">{{ lc.lcScript }}</dd>
624
+ </template>
625
+ <template v-if="lc.lcSystem">
626
+ <dt class="text-ink-300">Conversion system</dt>
627
+ <dd class="text-ink-700 font-mono">{{ lc.lcSystem }}</dd>
628
+ </template>
629
+ </dl>
630
+ </div>
448
631
  </div>
449
632
  </div>
633
+
634
+ <!-- Non-verbal reps (concept-level) -->
635
+ <NonVerbalRepDisplay v-if="nonVerbalReps.length" :reps="nonVerbalReps" />
450
636
  </div>
451
637
 
452
638
  <!-- Right sidebar -->
@@ -455,29 +641,28 @@ function plainTruncate(html: string, max: number = 120): string {
455
641
  <div v-if="outgoingEdges.length || incomingEdges.length" class="card p-5">
456
642
  <div class="section-label">Relations</div>
457
643
  <div v-if="outgoingEdges.length" class="mt-3">
458
- <div class="text-xs text-ink-300 mb-2">References ({{ outgoingEdges.length }})</div>
459
- <div class="space-y-1 max-h-48 overflow-y-auto">
644
+ <div class="text-xs text-ink-300 mb-2">Outgoing ({{ outgoingEdges.length }})</div>
645
+ <div class="space-y-1 max-h-64 overflow-y-auto">
460
646
  <button
461
647
  v-for="edge in outgoingEdges"
462
- :key="edge.target"
648
+ :key="edge.target + edge.type"
463
649
  @click="navigateEdge(edge)"
464
650
  :title="edgeTooltip(edge.target)"
465
651
  class="text-sm concept-link block truncate w-full text-left flex items-center gap-1.5"
466
652
  :class="isLocalRef(edge.target) ? '' : 'xref-external'"
467
653
  >
468
- {{ edgeConceptId(edge.target) }}
654
+ <span class="badge text-[9px] flex-shrink-0" :class="categorizeRelationship(edge.type).color">{{ relationshipLabel(edge.type) }}</span>
655
+ {{ edge.label || edgeConceptId(edge.target) }}
469
656
  <span v-if="edgeDatasetBadge(edge.target)" class="badge badge-gray text-[9px] flex-shrink-0 truncate max-w-[100px]">{{ edgeDatasetBadge(edge.target)!.title }}</span>
470
- <span v-if="isLocalRef(edge.target)" class="text-[9px] text-ink-200 flex-shrink-0">local</span>
471
- <span v-else class="text-[9px] text-amber-500 flex-shrink-0">external</span>
472
657
  </button>
473
658
  </div>
474
659
  </div>
475
660
  <div v-if="incomingEdges.length" class="mt-3 pt-3 border-t border-ink-100/60">
476
- <div class="text-xs text-ink-300 mb-2">Referenced by ({{ incomingEdges.length }})</div>
661
+ <div class="text-xs text-ink-300 mb-2">Incoming ({{ incomingEdges.length }})</div>
477
662
  <div class="space-y-1 max-h-48 overflow-y-auto">
478
663
  <button
479
664
  v-for="edge in incomingEdges"
480
- :key="edge.source"
665
+ :key="edge.source + edge.type"
481
666
  @click="navigateEdge(edge)"
482
667
  :title="edgeTooltip(edge.source)"
483
668
  class="text-sm concept-link block truncate w-full text-left flex items-center gap-1.5"
@@ -485,13 +670,54 @@ function plainTruncate(html: string, max: number = 120): string {
485
670
  >
486
671
  {{ edgeConceptId(edge.source) }}
487
672
  <span v-if="edgeDatasetBadge(edge.source)" class="badge badge-gray text-[9px] flex-shrink-0 truncate max-w-[100px]">{{ edgeDatasetBadge(edge.source)!.title }}</span>
488
- <span v-if="isLocalRef(edge.source)" class="text-[9px] text-ink-200 flex-shrink-0">local</span>
489
- <span v-else class="text-[9px] text-amber-500 flex-shrink-0">external</span>
490
673
  </button>
491
674
  </div>
492
675
  </div>
493
676
  </div>
494
677
 
678
+ <!-- Domains -->
679
+ <div v-if="conceptDomains.length" class="card p-5">
680
+ <div class="section-label">Domains</div>
681
+ <div class="space-y-1 mt-3">
682
+ <div v-for="domain in conceptDomains" :key="domain.slug" class="flex items-center gap-1.5 text-sm">
683
+ <span class="w-2 h-1.5 rounded inline-block flex-shrink-0" style="background: #8b5cf6;"></span>
684
+ <span class="font-medium text-ink-700">{{ domain.label }}</span>
685
+ <span v-if="domain.conceptId" class="text-[10px] text-ink-300 font-mono">{{ domain.conceptId }}</span>
686
+ <span v-if="domain.langs.length > 0" class="text-[10px] text-ink-300 ml-1">
687
+ ({{ domain.langs.map(l => l.toUpperCase()).join(', ') }})
688
+ </span>
689
+ </div>
690
+ </div>
691
+ </div>
692
+
693
+ <!-- Managed concept dates -->
694
+ <div v-if="conceptDates.length" class="card p-5">
695
+ <div class="section-label">Lifecycle dates</div>
696
+ <dl class="mt-3 space-y-1.5 text-xs">
697
+ <div v-for="(d, i) in conceptDates" :key="i" class="flex gap-2">
698
+ <dt class="text-ink-300 min-w-[70px]">{{ d.type }}</dt>
699
+ <dd class="text-ink-700">{{ d.date }}</dd>
700
+ </div>
701
+ </dl>
702
+ </div>
703
+
704
+ <!-- Managed concept sources -->
705
+ <div v-if="conceptSources.length" class="card p-5">
706
+ <div class="section-label">Concept sources</div>
707
+ <div class="space-y-2 mt-3">
708
+ <div v-for="(src, i) in conceptSources" :key="i" class="text-xs">
709
+ <div class="flex items-center gap-1.5 flex-wrap mb-0.5">
710
+ <span v-if="src.type" class="badge text-[10px]" :class="sourceTypeInfo(src.type).color" :title="sourceTypeInfo(src.type).definition ?? ''">{{ sourceTypeInfo(src.type).label }}</span>
711
+ <span v-if="src.status" class="badge text-[10px]" :title="sourceStatusInfo(src.status).definition ?? ''" :class="sourceStatusInfo(src.status).color">{{ sourceStatusInfo(src.status).label }}</span>
712
+ </div>
713
+ <div class="text-ink-700">
714
+ <CitationDisplay v-if="src.origin" :citation="src.origin" />
715
+ </div>
716
+ <div v-if="src.modification" class="text-ink-300 mt-0.5">{{ src.modification }}</div>
717
+ </div>
718
+ </div>
719
+ </div>
720
+
495
721
  <!-- Language quick-jump -->
496
722
  <div class="card p-5">
497
723
  <div class="section-label">Languages ({{ languages.length }})</div>
@@ -514,11 +740,11 @@ function plainTruncate(html: string, max: number = 120): string {
514
740
  <div v-if="getDesignationsForLang(lang).length > 1" class="ml-5 mt-0.5 flex flex-wrap gap-1">
515
741
  <span
516
742
  v-for="d in getDesignationsForLang(lang)"
517
- :key="d['gl:term']"
518
- :class="d['@type'] === 'gl:Symbol' ? 'badge-purple' : 'badge-gray'"
743
+ :key="d.designation"
744
+ :class="d.type === 'symbol' ? 'badge-purple' : 'badge-gray'"
519
745
  class="badge text-[10px]"
520
746
  >
521
- {{ d['gl:term'] }}
747
+ {{ d.designation }}
522
748
  </span>
523
749
  </div>
524
750
  </button>
@@ -529,18 +755,24 @@ function plainTruncate(html: string, max: number = 120): string {
529
755
  <div class="card p-5">
530
756
  <div class="section-label">Metadata</div>
531
757
  <dl class="space-y-2 text-xs mt-3">
532
- <div v-if="engConcept?.['gl:reviewDate']">
758
+ <div v-if="managedStatus">
759
+ <dt class="text-ink-300">Status</dt>
760
+ <dd class="mt-0.5">
761
+ <span class="badge text-[10px]" :class="conceptStatusColor(managedStatus)" :title="conceptStatusDefinition(managedStatus) ?? ''">{{ conceptStatusLabel(managedStatus) }}</span>
762
+ </dd>
763
+ </div>
764
+ <div v-if="engConcept?.reviewDate">
533
765
  <dt class="text-ink-300">Review Date</dt>
534
- <dd class="text-ink-700 mt-0.5">{{ engConcept['gl:reviewDate'].slice(0, 10) }}</dd>
766
+ <dd class="text-ink-700 mt-0.5">{{ engConcept.reviewDate.slice(0, 10) }}</dd>
535
767
  </div>
536
- <div v-if="engConcept?.['gl:reviewDecisionEvent']">
768
+ <div v-if="engConcept?.reviewDecisionEvent">
537
769
  <dt class="text-ink-300">Decision</dt>
538
- <dd class="text-ink-700 mt-0.5">{{ engConcept['gl:reviewDecisionEvent'] }}</dd>
770
+ <dd class="text-ink-700 mt-0.5">{{ engConcept.reviewDecisionEvent }}</dd>
539
771
  </div>
540
772
  <div>
541
773
  <dt class="text-ink-300">URI</dt>
542
774
  <dd class="font-mono text-ink-600 break-all mt-0.5 text-[11px] flex items-start gap-1.5">
543
- <span class="break-all">{{ concept['@id'] }}</span>
775
+ <span class="break-all">{{ conceptUriValue }}</span>
544
776
  <button @click="copyUri" class="flex-shrink-0 p-0.5 rounded text-ink-300 hover:text-ink-600 hover:bg-ink-50 transition-colors" :title="uriCopied ? 'Copied!' : 'Copy URI'" :aria-label="uriCopied ? 'URI copied' : 'Copy URI to clipboard'">
545
777
  <svg v-if="!uriCopied" class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10a2 2 0 01-2-2v-1m6 4v-3a2 2 0 00-2-2H8"/></svg>
546
778
  <svg v-else class="w-3.5 h-3.5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
@@ -560,9 +792,18 @@ function plainTruncate(html: string, max: number = 120): string {
560
792
  </div>
561
793
 
562
794
  <!-- Tab: History -->
795
+ <!-- Tab: RDF -->
796
+ <div v-if="activeTab === 'rdf'" role="tabpanel">
797
+ <ConceptRdfView
798
+ :concept="concept"
799
+ :register-id="registerId"
800
+ :concept-uri-value="conceptUriValue"
801
+ />
802
+ </div>
803
+
563
804
  <div v-if="activeTab === 'history'" role="tabpanel">
564
805
  <ConceptTimeline
565
- :localized-concepts="concept['gl:localizedConcept'] || {}"
806
+ :concept="concept"
566
807
  :language-order="manifest.languageOrder"
567
808
  v-model:active-lang="activeHistoryLang"
568
809
  />