@glossarist/concept-browser 0.7.22 → 0.7.24

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 (38) hide show
  1. package/index.html +2 -1
  2. package/package.json +1 -1
  3. package/scripts/build-edges.js +50 -5
  4. package/scripts/generate-data.mjs +33 -6
  5. package/src/App.vue +10 -12
  6. package/src/__tests__/concept-view.test.ts +7 -1
  7. package/src/__tests__/dataset-adapter.test.ts +87 -0
  8. package/src/__tests__/dataset-view.test.ts +1 -0
  9. package/src/__tests__/factory-lazy.test.ts +183 -0
  10. package/src/__tests__/graph-engine-fixes.test.ts +104 -0
  11. package/src/__tests__/ontology-registry.test.ts +4 -4
  12. package/src/__tests__/performance-v2.test.ts +77 -0
  13. package/src/__tests__/performance.test.ts +95 -0
  14. package/src/__tests__/search-utils.test.ts +59 -0
  15. package/src/__tests__/test-helpers.ts +4 -0
  16. package/src/__tests__/utils-barrel.test.ts +15 -0
  17. package/src/__tests__/vocabulary-layered.test.ts +291 -0
  18. package/src/adapters/DatasetAdapter.ts +41 -1
  19. package/src/adapters/factory.ts +35 -4
  20. package/src/adapters/ontology-registry.ts +1 -1
  21. package/src/adapters/types.ts +12 -0
  22. package/src/components/AppSidebar.vue +17 -343
  23. package/src/components/ConceptDetail.vue +121 -70
  24. package/src/components/GraphPanel.vue +14 -6
  25. package/src/components/OntologySidebarSection.vue +338 -0
  26. package/src/config/use-site-config.ts +20 -9
  27. package/src/data/taxonomies.json +12 -6
  28. package/src/directives/v-math.ts +2 -3
  29. package/src/graph/GraphEngine.ts +22 -5
  30. package/src/i18n/index.ts +1 -1
  31. package/src/stores/vocabulary.ts +65 -105
  32. package/src/utils/index.ts +1 -0
  33. package/src/utils/relationship-categories.ts +3 -2
  34. package/src/utils/search.ts +15 -0
  35. package/src/views/ConceptView.vue +0 -2
  36. package/src/views/DatasetView.vue +64 -39
  37. package/src/views/HomeView.vue +0 -1
  38. package/vite.config.ts +94 -6
@@ -81,6 +81,7 @@ const engConcept = computed((): LocalizedConcept | null => {
81
81
  });
82
82
 
83
83
  const primaryTerm = computed(() => getPreferredTerm(engConcept.value, conceptId.value));
84
+ const renderedPrimaryTerm = computed(() => renderMath(primaryTerm.value));
84
85
 
85
86
  // Managed concept status from Concept.status (7 values from concept-status.ttl)
86
87
  const managedStatus = computed(() => props.concept.status);
@@ -139,7 +140,6 @@ function resolveRelatedRef(ref: { source: string | null; id: string | null } | n
139
140
  async function navigateRelated(ref: { source: string | null; id: string | null }) {
140
141
  const target = resolveRelatedRef(ref);
141
142
  if (!target) return;
142
- await store.viewConcept(target.registerId, target.conceptId);
143
143
  router.push({ name: 'concept', params: { registerId: target.registerId, conceptId: target.conceptId } });
144
144
  }
145
145
 
@@ -178,7 +178,6 @@ function handleContentClick(e: MouseEvent) {
178
178
  const registerId = target.dataset.register;
179
179
  const conceptId = target.dataset.concept;
180
180
  if (registerId && conceptId) {
181
- store.viewConcept(registerId, conceptId);
182
181
  router.push({ name: 'concept', params: { registerId, conceptId } });
183
182
  }
184
183
  }
@@ -187,11 +186,16 @@ function handleContentClick(e: MouseEvent) {
187
186
  interface LangContent {
188
187
  lang: string;
189
188
  lc: LocalizedConcept;
189
+ renderedTerm: string;
190
190
  definition: string;
191
+ renderedDefinition: string;
191
192
  notes: string[];
193
+ renderedNotes: string[];
192
194
  examples: string[];
195
+ renderedExamples: string[];
193
196
  sources: ConceptSource[];
194
197
  designations: Designation[];
198
+ renderedDesignations: Map<string, string>;
195
199
  entryStatus: string;
196
200
  classification: string | null;
197
201
  reviewType: string | null;
@@ -209,15 +213,22 @@ const allLangContent = computed(() => {
209
213
 
210
214
  const definition = lc.definitions
211
215
  .map(d => d.content).filter(Boolean).join('\n\n');
216
+ const notes = lc.notes.map(n => n.content).filter(Boolean);
217
+ const examples = lc.examples.map(e => e.content).filter(Boolean);
212
218
 
213
219
  result.push({
214
220
  lang,
215
221
  lc,
222
+ renderedTerm: renderMath(getPreferredTerm(lc, '')),
216
223
  definition,
217
- notes: lc.notes.map(n => n.content).filter(Boolean),
218
- examples: lc.examples.map(e => e.content).filter(Boolean),
224
+ renderedDefinition: renderMath(definition, renderOpts),
225
+ notes,
226
+ renderedNotes: notes.map(n => renderMath(n, renderOpts)),
227
+ examples,
228
+ renderedExamples: examples.map(e => renderMath(e, renderOpts)),
219
229
  sources: lc.sources,
220
230
  designations: lc.terms,
231
+ renderedDesignations: new Map(lc.terms.map(d => [d.designation, renderMath(d.designation)])),
221
232
  entryStatus: lc.entryStatus ?? '',
222
233
  classification: lc.classification,
223
234
  reviewType: lc.reviewType,
@@ -230,6 +241,12 @@ const allLangContent = computed(() => {
230
241
  return result;
231
242
  });
232
243
 
244
+ const langContentMap = computed(() => {
245
+ const map = new Map<string, LangContent>();
246
+ for (const lc of allLangContent.value) map.set(lc.lang, lc);
247
+ return map;
248
+ });
249
+
233
250
  function hasContent(lc: LangContent): boolean {
234
251
  return !!(lc.definition || lc.notes.length || lc.examples.length || lc.sources.length);
235
252
  }
@@ -280,43 +297,83 @@ const conceptUriValue = computed(() =>
280
297
  conceptUri(props.concept, props.registerId, props.manifest.uriBase)
281
298
  );
282
299
 
283
- const outgoingEdges = computed(() => props.edges.filter(e => e.source === conceptUriValue.value));
284
- const incomingEdges = computed(() => props.edges.filter(e => e.target === conceptUriValue.value));
300
+ const outgoingEdges = computed(() => props.edges.filter(e => e.source === conceptUriValue.value && e.type !== 'domain' && e.type !== 'section'));
301
+ const incomingEdges = computed(() => props.edges.filter(e => e.target === conceptUriValue.value && e.type !== 'domain' && e.type !== 'section'));
285
302
 
286
- function isLocalRef(uri: string): boolean {
287
- const resolution = factory.resolve(uri, props.registerId);
288
- return resolution.type === 'internal' && resolution.registerId === props.registerId;
303
+ function inverseEdgeType(type: string): string {
304
+ return INVERSE_RELATIONSHIPS[type] || type;
289
305
  }
290
306
 
291
- function edgeConceptId(uri: string): string {
292
- const m = uri.match(/\/concept\/([^/]+)$/);
293
- return m ? m[1] : uri.split('/').pop() || uri;
307
+ function edgeBadgeColor(type: string, direction: 'out' | 'in'): string {
308
+ if (type === 'supersedes' || type === 'superseded_by') {
309
+ return direction === 'out' ? 'text-orange-700 bg-orange-50' : 'text-red-700 bg-red-50';
310
+ }
311
+ return categorizeRelationship(type).color;
294
312
  }
295
313
 
296
- function edgeNodeData(uri: string) {
297
- return store.graph.getNode(uri);
314
+ interface EdgeDisplay {
315
+ uri: string;
316
+ conceptId: string;
317
+ designation: string;
318
+ tooltip: string;
319
+ isLocal: boolean;
320
+ badge: { id: string; title: string } | null;
298
321
  }
299
322
 
300
- function edgeTooltip(uri: string): string {
301
- const node = edgeNodeData(uri);
302
- const lines: string[] = [uri];
303
- if (node) {
304
- for (const [lang, designation] of Object.entries(node.designations)) {
305
- lines.push(`${langLabel(lang)}: ${designation}`);
323
+ const edgeDisplayCache = computed(() => {
324
+ const cache = new Map<string, EdgeDisplay>();
325
+ for (const e of props.edges) {
326
+ const uri = e.source === conceptUriValue.value ? e.target : e.source;
327
+ if (cache.has(uri)) continue;
328
+ const resolution = factory.resolve(uri, props.registerId);
329
+ const isLocal = resolution.type === 'internal' && resolution.registerId === props.registerId;
330
+ const conceptId = uri.match(/\/concept\/([^/]+)$/)?.[1] ?? uri.split('/').pop() ?? uri;
331
+ const node = store.graph.getNode(uri);
332
+ const designation = node
333
+ ? (node.designations[locale.value] || node.designations.eng || Object.values(node.designations)[0] || '')
334
+ : '';
335
+ const tooltipLines: string[] = [uri];
336
+ if (node) {
337
+ for (const [lang, des] of Object.entries(node.designations)) {
338
+ tooltipLines.push(`${langLabel(lang)}: ${des}`);
339
+ }
340
+ }
341
+ let badge: { id: string; title: string } | null = null;
342
+ if (resolution.type === 'internal' && resolution.registerId !== props.registerId) {
343
+ const m = store.manifests.get(resolution.registerId);
344
+ badge = { id: resolution.registerId, title: m?.shortname || m?.title || resolution.registerId };
345
+ } else if (resolution.type === 'site') {
346
+ badge = { id: '', title: resolution.label };
347
+ } else if (resolution.type === 'url') {
348
+ badge = { id: '', title: resolution.label };
306
349
  }
350
+ cache.set(uri, { uri, conceptId, designation, tooltip: tooltipLines.join('\n'), isLocal, badge });
307
351
  }
308
- return lines.join('\n');
352
+ return cache;
353
+ });
354
+
355
+ function getEdgeDisplay(uri: string): EdgeDisplay {
356
+ return edgeDisplayCache.value.get(uri) ?? { uri, conceptId: uri, tooltip: uri, isLocal: false, badge: null };
309
357
  }
310
358
 
311
- function edgeDatasetBadge(uri: string): { id: string; title: string } | null {
312
- const resolution = factory.resolve(uri, props.registerId);
313
- if (resolution.type === 'internal' && resolution.registerId !== props.registerId) {
314
- const m = store.manifests.get(resolution.registerId);
315
- return { id: resolution.registerId, title: m?.shortname || m?.title || resolution.registerId };
359
+ interface ResolvedRef {
360
+ target: { registerId: string; conceptId: string } | null;
361
+ }
362
+
363
+ const resolvedRefs = computed(() => {
364
+ const map = new Map<string, ResolvedRef>();
365
+ for (const cr of conceptRelated.value) {
366
+ const key = `${cr.ref?.source ?? ''}:${cr.ref?.id ?? ''}`;
367
+ if (map.has(key)) continue;
368
+ map.set(key, { target: resolveRelatedRef(cr.ref) });
316
369
  }
317
- if (resolution.type === 'site') return { id: '', title: resolution.label };
318
- if (resolution.type === 'url') return { id: '', title: resolution.label };
319
- return null;
370
+ return map;
371
+ });
372
+
373
+ function getResolvedRef(ref: { source: string | null; id: string | null } | null): ResolvedRef {
374
+ if (!ref) return { target: null };
375
+ const key = `${ref.source ?? ''}:${ref.id ?? ''}`;
376
+ return resolvedRefs.value.get(key) ?? { target: resolveRelatedRef(ref) };
320
377
  }
321
378
 
322
379
  async function navigateEdge(edge: GraphEdge) {
@@ -324,7 +381,6 @@ async function navigateEdge(edge: GraphEdge) {
324
381
  const resolution = factory.resolve(uri);
325
382
 
326
383
  if (resolution.type === 'internal') {
327
- await store.viewConcept(resolution.registerId, resolution.conceptId);
328
384
  router.push({ name: 'concept', params: { registerId: resolution.registerId, conceptId: resolution.conceptId } });
329
385
  } else if (resolution.type === 'site') {
330
386
  window.open(`${resolution.baseUrl}/resolve/${encodeURIComponent(uri)}`, '_blank', 'noopener');
@@ -333,6 +389,11 @@ async function navigateEdge(edge: GraphEdge) {
333
389
  }
334
390
  }
335
391
 
392
+ function navigateDomain(domain: { slug: string; conceptId?: string }) {
393
+ const sectionId = domain.conceptId || domain.slug;
394
+ router.push({ name: 'dataset', params: { registerId: manifest.id }, query: { section: sectionId } });
395
+ }
396
+
336
397
  function getTermForLang(lang: string): string {
337
398
  const lc = props.concept.localization(lang);
338
399
  return getPreferredTerm(lc);
@@ -452,7 +513,7 @@ const nonVerbalReps = computed(() => {
452
513
  </button>
453
514
  </div>
454
515
  </div>
455
- <h1 class="font-serif text-2xl sm:text-3xl text-ink-800 leading-snug mb-3" v-html="renderMath(primaryTerm)"></h1>
516
+ <h1 class="font-serif text-2xl sm:text-3xl text-ink-800 leading-snug mb-3" v-html="renderedPrimaryTerm"></h1>
456
517
  <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">
457
518
  <span class="badge badge-blue font-mono">{{ conceptId }}</span>
458
519
  <span
@@ -537,13 +598,13 @@ const nonVerbalReps = computed(() => {
537
598
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
538
599
  </svg>
539
600
  <span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langName(lc.lang) }}</span>
540
- <span class="font-medium text-ink-800 text-sm" v-html="renderMath(getTermForLang(lc.lang))"></span>
601
+ <span class="font-medium text-ink-800 text-sm" v-html="lc.renderedTerm"></span>
541
602
  <span v-if="lc.entryStatus" class="badge text-[10px] ml-auto" :class="entryStatusColor(lc.entryStatus)" :title="entryStatusDefinition(lc.entryStatus) ?? ''">{{ entryStatusLabel(lc.entryStatus) }}</span>
542
603
  </button>
543
604
  <!-- Non-collapsible header (designation only) -->
544
605
  <div v-else class="w-full flex items-center gap-2.5 px-3 sm:px-4 py-3">
545
606
  <span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langName(lc.lang) }}</span>
546
- <span class="font-medium text-ink-800 text-sm" v-html="renderMath(getTermForLang(lc.lang))"></span>
607
+ <span class="font-medium text-ink-800 text-sm" v-html="lc.renderedTerm"></span>
547
608
  <span class="text-xs text-ink-200 ml-2 italic">{{ t('concept.designationOnly') }}</span>
548
609
  <span v-if="lc.entryStatus" class="badge text-[10px] ml-auto" :class="entryStatusColor(lc.entryStatus)" :title="entryStatusDefinition(lc.entryStatus) ?? ''">{{ entryStatusLabel(lc.entryStatus) }}</span>
549
610
  </div>
@@ -563,7 +624,7 @@ const nonVerbalReps = computed(() => {
563
624
  <div v-if="lc.designations.length > 0" class="space-y-1.5 pl-[22px]">
564
625
  <div v-for="(d, i) in orderedDesignations(lc.lang)" :key="i">
565
626
  <div class="flex items-center gap-1.5 text-sm flex-wrap">
566
- <span :class="d.normativeStatus === 'preferred' ? 'font-bold text-ink-800' : 'font-normal text-ink-700'" v-html="renderMath(d.designation)"></span>
627
+ <span :class="d.normativeStatus === 'preferred' ? 'font-bold text-ink-800' : 'font-normal text-ink-700'" v-html="lc.renderedDesignations.get(d.designation) ?? d.designation"></span>
567
628
  <span class="badge text-[10px] flex-shrink-0" :class="designationTypeInfo(d).color" :title="designationTypeInfo(d).definition ?? ''">{{ designationTypeInfo(d).label }}</span>
568
629
  <span class="badge text-[10px] flex-shrink-0" :class="normativeStatusInfo(d.normativeStatus).color" :title="normativeStatusInfo(d.normativeStatus).definition ?? ''">{{ normativeStatusInfo(d.normativeStatus).label }}</span>
569
630
  <!-- Abbreviation details -->
@@ -609,7 +670,7 @@ const nonVerbalReps = computed(() => {
609
670
  <div v-if="d.related?.length" class="mt-0.5 space-y-0.5">
610
671
  <div v-for="(dr, dri) in d.related" :key="'dr'+dri" class="text-xs text-ink-400 flex items-center gap-1.5">
611
672
  <span class="badge text-[9px] bg-gray-50 text-gray-600">{{ relationshipLabel(dr.type) }}</span>
612
- <button v-if="resolveRelatedRef(dr.ref)" @click="navigateRelated(dr.ref!)" class="concept-link">{{ dr.content || (dr.ref ? `${dr.ref.source || ''} ${dr.ref.id || ''}`.trim() : '') }}</button>
673
+ <button v-if="getResolvedRef(dr.ref).target" @click="navigateRelated(dr.ref!)" class="concept-link">{{ dr.content || (dr.ref ? `${dr.ref.source || ''} ${dr.ref.id || ''}`.trim() : '') }}</button>
613
674
  <span v-else>{{ dr.content || (dr.ref ? `${dr.ref.source || ''} ${dr.ref.id || ''}`.trim() : '') }}</span>
614
675
  </div>
615
676
  </div>
@@ -618,22 +679,22 @@ const nonVerbalReps = computed(() => {
618
679
 
619
680
  <!-- Definition -->
620
681
  <div v-if="lc.definition" class="p-4 rounded-lg bg-surface border-l-2" :style="{ borderLeftColor: getColor(manifest.id) }">
621
- <div class="text-ink-800 leading-relaxed" v-html="renderMath(lc.definition, renderOpts)"></div>
682
+ <div class="text-ink-800 leading-relaxed" v-html="lc.renderedDefinition"></div>
622
683
  </div>
623
684
 
624
685
  <!-- Notes -->
625
686
  <div v-if="lc.notes.length" class="space-y-2">
626
- <div v-for="(note, i) in lc.notes" :key="i" class="text-ink-600 text-sm leading-relaxed">
687
+ <div v-for="(_, i) in lc.notes" :key="i" class="text-ink-600 text-sm leading-relaxed">
627
688
  <span class="font-medium text-ink-400 text-xs uppercase tracking-wide">{{ t('concept.note') }} {{ i + 1 }}</span>
628
- <div class="mt-1" v-html="renderMath(note, renderOpts)"></div>
689
+ <div class="mt-1" v-html="lc.renderedNotes[i]"></div>
629
690
  </div>
630
691
  </div>
631
692
 
632
693
  <!-- Examples -->
633
694
  <div v-if="lc.examples.length" class="space-y-2">
634
- <div v-for="(ex, i) in lc.examples" :key="i" class="text-ink-600 text-sm leading-relaxed">
695
+ <div v-for="(_, i) in lc.examples" :key="i" class="text-ink-600 text-sm leading-relaxed">
635
696
  <span class="font-medium text-ink-400 text-xs uppercase tracking-wide">{{ t('concept.example') }} {{ i + 1 }}</span>
636
- <div class="mt-1" v-html="renderMath(ex, renderOpts)"></div>
697
+ <div class="mt-1" v-html="lc.renderedExamples[i]"></div>
637
698
  </div>
638
699
  </div>
639
700
 
@@ -704,13 +765,14 @@ const nonVerbalReps = computed(() => {
704
765
  v-for="edge in outgoingEdges"
705
766
  :key="edge.target + edge.type"
706
767
  @click="navigateEdge(edge)"
707
- :title="edgeTooltip(edge.target)"
768
+ :title="getEdgeDisplay(edge.target).tooltip"
708
769
  class="text-sm concept-link block truncate w-full text-left flex items-center gap-1.5"
709
- :class="isLocalRef(edge.target) ? '' : 'xref-external'"
770
+ :class="getEdgeDisplay(edge.target).isLocal ? '' : 'xref-external'"
710
771
  >
711
- <span class="badge text-[9px] flex-shrink-0" :class="categorizeRelationship(edge.type).color">{{ relationshipLabel(edge.type) }}</span>
712
- {{ edge.label || edgeConceptId(edge.target) }}
713
- <span v-if="edgeDatasetBadge(edge.target)" class="badge badge-gray text-[9px] flex-shrink-0 truncate max-w-[100px]">{{ edgeDatasetBadge(edge.target)!.title }}</span>
772
+ <span class="badge text-[9px] flex-shrink-0" :class="edgeBadgeColor(edge.type, 'out')">{{ relationshipLabel(edge.type) }} →</span>
773
+ <span class="truncate">{{ getEdgeDisplay(edge.target).designation || edge.label || getEdgeDisplay(edge.target).conceptId }}</span>
774
+ <span class="text-[9px] text-ink-300 flex-shrink-0 font-mono">{{ getEdgeDisplay(edge.target).conceptId }}</span>
775
+ <span v-if="getEdgeDisplay(edge.target).badge" class="badge badge-gray text-[9px] flex-shrink-0 truncate max-w-[100px]">{{ getEdgeDisplay(edge.target).badge!.title }}</span>
714
776
  </button>
715
777
  </div>
716
778
  </div>
@@ -721,47 +783,36 @@ const nonVerbalReps = computed(() => {
721
783
  v-for="edge in incomingEdges"
722
784
  :key="edge.source + edge.type"
723
785
  @click="navigateEdge(edge)"
724
- :title="edgeTooltip(edge.source)"
786
+ :title="getEdgeDisplay(edge.source).tooltip"
725
787
  class="text-sm concept-link block truncate w-full text-left flex items-center gap-1.5"
726
- :class="isLocalRef(edge.source) ? '' : 'xref-external'"
788
+ :class="getEdgeDisplay(edge.source).isLocal ? '' : 'xref-external'"
727
789
  >
728
- {{ edgeConceptId(edge.source) }}
729
- <span v-if="edgeDatasetBadge(edge.source)" class="badge badge-gray text-[9px] flex-shrink-0 truncate max-w-[100px]">{{ edgeDatasetBadge(edge.source)!.title }}</span>
790
+ <span class="badge text-[9px] flex-shrink-0" :class="edgeBadgeColor(edge.type, 'in')">← {{ relationshipLabel(inverseEdgeType(edge.type)) }}</span>
791
+ <span class="truncate">{{ getEdgeDisplay(edge.source).designation || edge.label || getEdgeDisplay(edge.source).conceptId }}</span>
792
+ <span class="text-[9px] text-ink-300 flex-shrink-0 font-mono">{{ getEdgeDisplay(edge.source).conceptId }}</span>
793
+ <span v-if="getEdgeDisplay(edge.source).badge" class="badge badge-gray text-[9px] flex-shrink-0 truncate max-w-[100px]">{{ getEdgeDisplay(edge.source).badge!.title }}</span>
730
794
  </button>
731
795
  </div>
732
796
  </div>
733
797
  </div>
734
798
 
735
- <!-- Cross-references (concept-level related) -->
736
- <div v-if="conceptRelated.length" class="card p-5">
737
- <div class="section-label">{{ t('concept.relations') }}</div>
738
- <div class="mt-3 space-y-1">
739
- <button
740
- v-for="(cr, cri) in conceptRelated"
741
- :key="'cr'+cri"
742
- @click="navigateRelated(cr.ref!)"
743
- class="text-sm concept-link block truncate w-full text-left flex items-center gap-1.5"
744
- >
745
- <span class="badge text-[9px] bg-gray-50 text-gray-600">{{ relationshipLabel(cr.type) }}</span>
746
- <span v-if="resolveRelatedRef(cr.ref)" class="text-ink-600">{{ resolveRelatedRef(cr.ref)!.conceptId }}</span>
747
- <span v-else class="text-ink-400">{{ cr.content || (cr.ref ? `${cr.ref.source || ''} ${cr.ref.id || ''}`.trim() : '') }}</span>
748
- <span v-if="resolveRelatedRef(cr.ref) && resolveRelatedRef(cr.ref)!.registerId !== manifest.id" class="badge badge-gray text-[9px] flex-shrink-0">{{ resolveRelatedRef(cr.ref)!.registerId }}</span>
749
- </button>
750
- </div>
751
- </div>
752
-
753
799
  <!-- Domains -->
754
800
  <div v-if="conceptDomains.length" class="card p-5">
755
801
  <div class="section-label">{{ t('concept.domains') }}</div>
756
802
  <div class="space-y-1 mt-3">
757
- <div v-for="domain in conceptDomains" :key="domain.slug" class="flex items-center gap-1.5 text-sm">
803
+ <button
804
+ v-for="domain in conceptDomains"
805
+ :key="domain.slug"
806
+ @click="navigateDomain(domain)"
807
+ class="flex items-center gap-1.5 text-sm concept-link w-full text-left"
808
+ >
758
809
  <span class="w-2 h-1.5 rounded inline-block flex-shrink-0" style="background: #8b5cf6;"></span>
759
810
  <span class="font-medium text-ink-700">{{ domain.label }}</span>
760
811
  <span v-if="domain.conceptId" class="text-[10px] text-ink-300 font-mono">{{ domain.conceptId }}</span>
761
812
  <span v-if="domain.langs.length > 0" class="text-[10px] text-ink-300 ml-1">
762
813
  ({{ domain.langs.map(l => l.toUpperCase()).join(', ') }})
763
814
  </span>
764
- </div>
815
+ </button>
765
816
  </div>
766
817
  </div>
767
818
 
@@ -818,7 +869,7 @@ const nonVerbalReps = computed(() => {
818
869
  :class="hasDefinition(lang) ? 'bg-emerald-400' : 'bg-ink-200'"
819
870
  :title="hasDefinition(lang) ? t('concept.hasDefinition') : t('concept.designationOnlyTitle')"
820
871
  ></span>
821
- <span class="text-sm font-medium text-ink-800 group-hover:text-ink-900 transition-colors" v-html="renderMath(getTermForLang(lang))"></span>
872
+ <span class="text-sm font-medium text-ink-800 group-hover:text-ink-900 transition-colors" v-html="langContentMap.get(lang)?.renderedTerm ?? getTermForLang(lang)"></span>
822
873
  </div>
823
874
  <div v-if="getDesignationsForLang(lang).length > 1" class="ml-5 mt-0.5 flex flex-wrap gap-1">
824
875
  <span
@@ -399,24 +399,32 @@ watch(selectedNode, (node) => {
399
399
  }
400
400
  });
401
401
 
402
- // Rebuild when data or filters change
402
+ // Rebuild when data or filters change — coalesce into single rebuild
403
+ let rebuildScheduled = false;
404
+ function scheduleRebuild() {
405
+ if (rebuildScheduled) return;
406
+ rebuildScheduled = true;
407
+ nextTick(() => {
408
+ rebuildScheduled = false;
409
+ rebuildGraph();
410
+ });
411
+ }
412
+
403
413
  let prevDataKey = '';
404
414
  watch([() => props.nodes.length, () => props.edges.length], ([nn, ne]) => {
405
415
  const key = `${nn}:${ne}`;
406
416
  if (key !== prevDataKey && nn > 0) {
407
417
  prevDataKey = key;
408
- nextTick(rebuildGraph);
418
+ scheduleRebuild();
409
419
  }
410
420
  });
411
421
 
412
- // Rebuild when register filters change
413
422
  watch(registerEnabled, () => {
414
- nextTick(rebuildGraph);
423
+ scheduleRebuild();
415
424
  });
416
425
 
417
- // Rebuild when language changes (designation labels depend on language)
418
426
  watch(() => uiStore.selectedLang, () => {
419
- nextTick(rebuildGraph);
427
+ scheduleRebuild();
420
428
  });
421
429
 
422
430
  onMounted(() => {