@glossarist/concept-browser 0.7.95 → 0.7.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.95",
3
+ "version": "0.7.97",
4
4
  "description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { computed } from 'vue';
3
+ import type { Router } from 'vue-router';
4
+ import { useConceptEdges } from '../../composables/use-concept-edges';
5
+ import { useVocabularyStore } from '../../stores/vocabulary';
6
+ import { conceptFromJson } from '../../adapters/model-bridge';
7
+ import type { Manifest, GraphEdge } from '../../adapters/types';
8
+ import { makeManifest, setupPinia } from '../test-helpers';
9
+ import { getFactory, resetFactory } from '../../adapters/factory';
10
+
11
+ /**
12
+ * End-to-end pipeline test using the actual live JSON-LD shape from
13
+ * the deployed oimlsmart/vocab site (vim-2012 concept 1.9).
14
+ *
15
+ * Reproduces the live bug: 1.9 has gl:genericRelations but the sphere
16
+ * renders nothing because conceptGenericRelations is empty.
17
+ */
18
+ describe('1.9 live pipeline — generic projection through model-bridge', () => {
19
+ const URN = 'urn:oiml:pub:v:2:2012';
20
+ const manifest: Manifest = makeManifest({
21
+ id: 'vim-2012',
22
+ datasetUri: URN,
23
+ uriBase: 'https://oimlsmart.github.io/vocab',
24
+ });
25
+ const noEdges: GraphEdge[] = [];
26
+ const noopRouter = { push() {} } as unknown as Router;
27
+
28
+ let store: ReturnType<typeof useVocabularyStore>;
29
+
30
+ beforeEach(() => {
31
+ setupPinia();
32
+ resetFactory();
33
+ store = useVocabularyStore();
34
+ store.manifests.set(manifest.id, manifest);
35
+
36
+ const factory = getFactory();
37
+ factory.resolveRelatedRef = vi.fn((ref: { source: string | null; id: string | null } | null) =>
38
+ ref?.source === URN && ref.id
39
+ ? { registerId: manifest.id, conceptId: ref.id }
40
+ : null,
41
+ );
42
+ });
43
+
44
+ it('projects gl:genericRelations → conceptGenericRelations through conceptFromJson', () => {
45
+ const liveJson = {
46
+ '@context': 'https://glossarist.org/ns/context.jsonld',
47
+ '@id': 'https://oimlsmart.github.io/vocab/vim-2012/concept/1.9',
48
+ '@type': 'gl:Concept',
49
+ 'gl:identifier': '1.9',
50
+ 'gl:status': 'valid',
51
+ 'gl:localizedConcept': {
52
+ eng: {
53
+ '@type': 'gl:LocalizedConcept',
54
+ 'gl:languageCode': 'eng',
55
+ 'gl:entryStatus': 'valid',
56
+ 'gl:designation': [{ '@type': 'gl:Expression', 'gl:normativeStatus': 'preferred', 'gl:term': 'measurement unit' }],
57
+ 'gl:definition': [{ '@type': 'gl:DetailedDefinition', 'gl:content': 'real scalar quantity' }],
58
+ },
59
+ },
60
+ 'gl:related': [
61
+ { '@type': 'gl:RelatedConcept', 'gl:relationshipType': 'broader_generic',
62
+ 'gl:ref': { '@type': 'gl:ConceptRef', 'gl:source': URN, 'gl:id': '1.12' } },
63
+ ],
64
+ 'gl:genericRelations': [
65
+ {
66
+ '@type': 'gl:GenericRelation',
67
+ 'gl:comprehensive': { '@type': 'gl:ConceptRef', 'gl:source': URN, 'gl:id': '1.9' },
68
+ 'gl:criterion': { eng: 'by magnitude relationship to a reference measurement unit' },
69
+ 'gl:hasGeneric': [
70
+ { '@type': 'gl:GenericMember',
71
+ 'gl:ref': { '@type': 'gl:ConceptRef', 'gl:source': URN, 'gl:id': '1.17' },
72
+ 'gl:presence': 'required', 'gl:count': 'at_least_one',
73
+ 'gl:delimitingCharacteristic': { eng: 'multiple of a unit' } },
74
+ { '@type': 'gl:GenericMember',
75
+ 'gl:ref': { '@type': 'gl:ConceptRef', 'gl:source': URN, 'gl:id': '1.18' },
76
+ 'gl:presence': 'required', 'gl:count': 'at_least_one',
77
+ 'gl:delimitingCharacteristic': { eng: 'submultiple of a unit' } },
78
+ { '@type': 'gl:GenericMember',
79
+ 'gl:ref': { '@type': 'gl:ConceptRef', 'gl:source': URN, 'gl:id': '1.12' },
80
+ 'gl:presence': 'required', 'gl:count': 'at_least_one',
81
+ 'gl:delimitingCharacteristic': { eng: 'coherent derived unit' } },
82
+ ],
83
+ 'gl:completeness': 'partial',
84
+ },
85
+ ],
86
+ };
87
+
88
+ const concept = conceptFromJson(liveJson as any);
89
+
90
+ /* Sanity: model-bridge produced a Concept with relations. */
91
+ expect(concept).toBeTruthy();
92
+
93
+ /* Project through useConceptEdges, exactly as ConceptView does. */
94
+ const { conceptGenericRelations } = useConceptEdges(
95
+ computed(() => concept),
96
+ computed(() => 'vim-2012'),
97
+ computed(() => manifest),
98
+ computed(() => noEdges),
99
+ noopRouter,
100
+ );
101
+
102
+ const rels = conceptGenericRelations.value;
103
+ /* This is the live-bug assertion: must NOT be empty. */
104
+ expect(rels).toHaveLength(1);
105
+ expect(rels[0].members).toHaveLength(3);
106
+ expect(rels[0].members.map(m => m.uri)).toEqual([
107
+ 'https://oimlsmart.github.io/vocab/vim-2012/concept/1.17',
108
+ 'https://oimlsmart.github.io/vocab/vim-2012/concept/1.18',
109
+ 'https://oimlsmart.github.io/vocab/vim-2012/concept/1.12',
110
+ ]);
111
+ });
112
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { hashSeed, expandParams, portSide, portPoint, idToUriGet, visibleNodeIds } from '../utils/sphere-math';
3
+ import type { SphereHyperedgeLike } from '../utils/sphere-math';
3
4
 
4
5
  describe('sphere-math', () => {
5
6
  describe('hashSeed', () => {
@@ -148,6 +149,30 @@ describe('sphere-math', () => {
148
149
  expect(visible.has('a')).toBe(false);
149
150
  });
150
151
 
152
+ it('hyperedge member with no bilateral edges stays visible while its bundle is not muted', () => {
153
+ const nodes = [focus, a, b];
154
+ const links: Array<{ source: string; target: string; type: string }> = [];
155
+ const hyperedges: SphereHyperedgeLike[] = [
156
+ { comprehensive: 'focus', members: ['a', 'b'], muteKey: '__partitive__' },
157
+ ];
158
+ const visible = visibleNodeIds(nodes, links, new Set(), new Set(), hyperedges);
159
+ expect(visible.has('focus')).toBe(true);
160
+ expect(visible.has('a')).toBe(true);
161
+ expect(visible.has('b')).toBe(true);
162
+ });
163
+
164
+ it('hyperedge members disappear when the bundle is muted', () => {
165
+ const nodes = [focus, a, b];
166
+ const links: Array<{ source: string; target: string; type: string }> = [];
167
+ const hyperedges: SphereHyperedgeLike[] = [
168
+ { comprehensive: 'focus', members: ['a', 'b'], muteKey: '__partitive__' },
169
+ ];
170
+ const visible = visibleNodeIds(nodes, links, new Set(['__partitive__']), new Set(), hyperedges);
171
+ expect(visible.has('focus')).toBe(true);
172
+ expect(visible.has('a')).toBe(false);
173
+ expect(visible.has('b')).toBe(false);
174
+ });
175
+
151
176
  it('all-visible when no mutes are set', () => {
152
177
  const nodes = [focus, a, b, c, d];
153
178
  const links = buildLinks([
@@ -141,6 +141,8 @@ export interface JsonLdPartitiveMember {
141
141
  'gl:count'?: string;
142
142
  /** ISO 704:2022: delimiting part (3× stroke width in diagrams) */
143
143
  'gl:isDelimiting'?: boolean;
144
+ /** ISO 704:2022 §5.5.4.2.1: GenericMember delimiting characteristic (required on generic members) */
145
+ 'gl:delimitingCharacteristic'?: Record<string, string> | string;
144
146
  /** Legacy v3 field — one-string multiplicity, split into presence+count */
145
147
  'gl:multiplicity'?: string;
146
148
  /** Legacy v2 field — migrated to presence+count */
@@ -516,6 +516,59 @@ function mapPartitiveRelationFromJsonLd(r: JsonLdPartitiveRelation): Record<stri
516
516
  return out;
517
517
  }
518
518
 
519
+ /**
520
+ * Map a GenericRelation JSON-LD entry to the dict shape that
521
+ * glossarist-js's GenericHyperedge constructor expects.
522
+ *
523
+ * Mirrors mapPartitiveRelationFromJsonLd but reads `gl:hasGeneric` and
524
+ * carries each member's `gl:delimitingCharacteristic` (ISO 704:2022
525
+ * §5.5.4.2.1 — required on every GenericMember).
526
+ */
527
+ function mapGenericRelationFromJsonLd(r: JsonLdPartitiveRelation): Record<string, unknown> | null {
528
+ const comprehensive = r[GL.COMPREHENSIVE] ? mapRefFromJsonLd(r[GL.COMPREHENSIVE]) : null;
529
+ if (!comprehensive) return null;
530
+
531
+ const members = (r[GL.HAS_GENERIC] ?? [])
532
+ .map((m): Record<string, unknown> | null => {
533
+ const ref = m[GL.REF] ? mapRefFromJsonLd(m[GL.REF]) : null;
534
+ if (!ref) return null;
535
+ const out: Record<string, unknown> = { ref };
536
+ const presence = m[GL.PRESENCE];
537
+ const count = m[GL.COUNT];
538
+ if (isPartitivePresence(presence) && isPartitiveCount(count)) {
539
+ out.presence = presence;
540
+ out.count = count;
541
+ } else {
542
+ const raw = m[GL.MULTIPLICITY] ?? splitLegacyCertainty(m[GL.CERTAINTY]);
543
+ if (raw) {
544
+ const parts = splitMultiplicity(raw);
545
+ out.presence = parts.presence;
546
+ out.count = parts.count;
547
+ }
548
+ }
549
+ const dc = m[GL.DELIMITING_CHARACTERISTIC];
550
+ if (dc) {
551
+ out.delimitingCharacteristic = typeof dc === 'string' ? { default: dc } : dc;
552
+ }
553
+ return out;
554
+ })
555
+ .filter((m): m is Record<string, unknown> => m !== null);
556
+
557
+ if (members.length < 2) return null;
558
+
559
+ const out: Record<string, unknown> = { comprehensive, members };
560
+
561
+ if (r[GL.COMPLETENESS]) out.completeness = r[GL.COMPLETENESS];
562
+
563
+ if (r[GL.CRITERION]) {
564
+ out.criterion = typeof r[GL.CRITERION] === 'string'
565
+ ? { default: r[GL.CRITERION] }
566
+ : r[GL.CRITERION];
567
+ }
568
+
569
+ return out;
570
+ }
571
+
519
572
  /** Migrate legacy v2 certainty → old v3 multiplicity string. */
520
573
  function splitLegacyCertainty(certainty: string | undefined): string | null {
521
574
  if (!certainty) return null;
@@ -549,6 +602,9 @@ function conceptFromJsonLd(doc: JsonLdConcept): Concept {
549
602
  const partitiveRelations = (doc[GL.PARTITIVE_RELATIONS] ?? [])
550
603
  .map(mapPartitiveRelationFromJsonLd)
551
604
  .filter((r): r is Record<string, unknown> => r !== null);
605
+ const genericRelations = (doc[GL.GENERIC_RELATIONS] ?? [])
606
+ .map(mapGenericRelationFromJsonLd)
607
+ .filter((r): r is Record<string, unknown> => r !== null);
552
608
  const tagsArr = doc[GL.TAGS];
553
609
  const tags = Array.isArray(tagsArr) ? [...tagsArr] : [];
554
610
 
@@ -559,6 +615,7 @@ function conceptFromJsonLd(doc: JsonLdConcept): Concept {
559
615
  localizations,
560
616
  related,
561
617
  partitive_relations: partitiveRelations,
618
+ generic_relations: genericRelations,
562
619
  tags,
563
620
  figures: normalizeEntityRefs(doc[GL.FIGURE_REF]),
564
621
  tables: normalizeEntityRefs(doc[GL.TABLE_REF]),
@@ -97,6 +97,13 @@ const mutedRegisters = ref<Set<string>>(new Set());
97
97
  const panelOpen = ref(true);
98
98
  const MAX_NODES = 36;
99
99
 
100
+ /** Convert a concept URI to the internal `${registerId}/${conceptId}` node ID. */
101
+ function uriToNodeId(uri: string): string {
102
+ const parsed = UriRouter.parseUri(uri);
103
+ if (parsed) return `${parsed.registerId}/${parsed.conceptId}`;
104
+ return uri;
105
+ }
106
+
100
107
  /* Sphere-display language. Defaults to the i18n UI locale (which reads
101
108
  localStorage) so e.g. a user returning in French mode sees French
102
109
  terms by default. The user can override via the Language selector. */
@@ -735,8 +742,29 @@ function renderDOM() {
735
742
 
736
743
  /* Cards with no visible edges (after applying type + register mutes)
737
744
  float with no connections — hide them so toggling a relation type
738
- cleans up orphaned cards. Focus node (depth 0) always renders. */
739
- const visibleIds = visibleNodeIds(nodes, links, mutedTypes.value, mutedRegisters.value);
745
+ cleans up orphaned cards. Focus node (depth 0) always renders.
746
+ Hyperedge members are always visible while their bundle is not
747
+ muted, even when they have no bilateral edges, so the rake has a
748
+ card to point at. */
749
+ const hyperedgesForVisibility = [
750
+ ...(props.partitiveRelations ?? []).map(rel => ({
751
+ comprehensive: uriToNodeId(rel.comprehensive),
752
+ members: rel.partitives.map(m => uriToNodeId(m.uri)),
753
+ muteKey: '__partitive__',
754
+ })),
755
+ ...(props.genericRelations ?? []).map(rel => ({
756
+ comprehensive: uriToNodeId(rel.comprehensive),
757
+ members: rel.members.map(m => uriToNodeId(m.uri)),
758
+ muteKey: '__generic__',
759
+ })),
760
+ ];
761
+ const visibleIds = visibleNodeIds(
762
+ nodes,
763
+ links,
764
+ mutedTypes.value,
765
+ mutedRegisters.value,
766
+ hyperedgesForVisibility,
767
+ );
740
768
 
741
769
  for (const n of nodes) {
742
770
  if (!visibleIds.has(n.id)) continue;
@@ -1050,7 +1078,7 @@ const legendItems = computed(() => {
1050
1078
  if (partitiveCount > 0) {
1051
1079
  typeMap.set('__partitive__', {
1052
1080
  type: '__partitive__',
1053
- label: 'partitive relation',
1081
+ label: 'Partitive Relation',
1054
1082
  color: uiStore.isDark ? '#2dd4bf' : '#0d9488',
1055
1083
  count: partitiveCount,
1056
1084
  });
@@ -1060,7 +1088,7 @@ const legendItems = computed(() => {
1060
1088
  if (genericCount > 0) {
1061
1089
  typeMap.set('__generic__', {
1062
1090
  type: '__generic__',
1063
- label: 'generic relation',
1091
+ label: 'General Relation',
1064
1092
  color: uiStore.isDark ? '#fbbf24' : '#b45309',
1065
1093
  count: genericCount,
1066
1094
  });
@@ -65,13 +65,27 @@ export interface SphereLinkLike {
65
65
  type: string;
66
66
  }
67
67
 
68
+ export interface SphereHyperedgeLike {
69
+ /** Node ID of the comprehensive concept. */
70
+ comprehensive: string;
71
+ /** Node IDs of the member concepts. */
72
+ members: readonly string[];
73
+ /** Mute key — when present in mutedTypes, the whole hyperedge is hidden. */
74
+ muteKey?: string;
75
+ }
76
+
68
77
  /**
69
78
  * IDs of nodes that should render given the current mute state.
70
79
  *
71
80
  * The focus node (depth 0) is always visible. Every other node must have
72
81
  * at least one edge whose type is not muted AND whose other endpoint is
73
- * not in a muted register — otherwise the card floats with no connections
74
- * and should disappear when its relations are hidden.
82
+ * not in a muted register — OR be a member of a non-muted hyperedge —
83
+ * otherwise the card floats with no connections and should disappear
84
+ * when its relations are hidden.
85
+ *
86
+ * Hyperedge membership counts as a "visible edge" because rake bundles
87
+ * are the connection: a member with no bilateral edges still needs its
88
+ * card rendered so the rake has something to point at.
75
89
  *
76
90
  * Extracted as a pure function so the visibility contract can be tested
77
91
  * without mounting the sphere component.
@@ -81,10 +95,19 @@ export function visibleNodeIds(
81
95
  links: readonly SphereLinkLike[],
82
96
  mutedTypes: ReadonlySet<string>,
83
97
  mutedRegisters: ReadonlySet<string>,
98
+ hyperedges: readonly SphereHyperedgeLike[] = [],
84
99
  ): Set<string> {
85
100
  const byId = new Map<string, SphereNodeLike>();
86
101
  for (const n of nodes) byId.set(n.id, n);
87
102
 
103
+ /* Pre-compute node IDs that participate in a non-muted hyperedge. */
104
+ const hyperedgeNodeIds = new Set<string>();
105
+ for (const he of hyperedges) {
106
+ if (he.muteKey && mutedTypes.has(he.muteKey)) continue;
107
+ hyperedgeNodeIds.add(he.comprehensive);
108
+ for (const m of he.members) hyperedgeNodeIds.add(m);
109
+ }
110
+
88
111
  const visible = new Set<string>();
89
112
  for (const n of nodes) {
90
113
  if (n.depth === 0) {
@@ -92,6 +115,10 @@ export function visibleNodeIds(
92
115
  continue;
93
116
  }
94
117
  if (mutedRegisters.has(n.register)) continue;
118
+ if (hyperedgeNodeIds.has(n.id)) {
119
+ visible.add(n.id);
120
+ continue;
121
+ }
95
122
  for (const l of links) {
96
123
  if (mutedTypes.has(l.type)) continue;
97
124
  const otherId = l.source === n.id ? l.target : l.target === n.id ? l.source : null;
@@ -106,3 +133,4 @@ export function visibleNodeIds(
106
133
  return visible;
107
134
  }
108
135
 
136
+