@glossarist/concept-browser 0.7.94 → 0.7.95

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.94",
3
+ "version": "0.7.95",
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": {
@@ -91,6 +91,7 @@ function loadConceptFile(filePath) {
91
91
  // Managed concept-level fields
92
92
  if (mc.related) result._related = mc.related;
93
93
  if (mc.partitive_relations) result._partitiveRelations = mc.partitive_relations;
94
+ if (mc.generic_relations) result._genericRelations = mc.generic_relations;
94
95
  if (mc.data.domains) result._domains = mc.data.domains;
95
96
  if (mc.dates) result._dates = mc.dates;
96
97
  if (mc.sources) result._sources = mc.sources;
@@ -769,6 +770,54 @@ function yamlToJsonLd(conceptYaml, register, refMaps) {
769
770
  });
770
771
  }
771
772
 
773
+ if (conceptYaml._genericRelations?.length > 0) {
774
+ doc['gl:genericRelations'] = conceptYaml._genericRelations.map(rel => {
775
+ const out = { '@type': 'gl:GenericRelation' };
776
+ if (rel.comprehensive) {
777
+ out['gl:comprehensive'] = refToJsonLd(rel.comprehensive, 'gl:ConceptRef');
778
+ }
779
+ if (Array.isArray(rel.members) && rel.members.length > 0) {
780
+ out['gl:hasGeneric'] = rel.members.map(member => {
781
+ const m = { '@type': 'gl:GenericMember' };
782
+ if (member.ref) {
783
+ m['gl:ref'] = refToJsonLd(member.ref, 'gl:ConceptRef');
784
+ } else {
785
+ m['gl:ref'] = refToJsonLd(member, 'gl:ConceptRef');
786
+ }
787
+ const presence = member.presence
788
+ ?? splitMultiplicity(member.multiplicity ?? '').presence;
789
+ const count = member.count
790
+ ?? splitMultiplicity(member.multiplicity ?? '').count;
791
+ m['gl:presence'] = presence;
792
+ m['gl:count'] = count;
793
+ /* ISO 704:2022 §5.5.4.2.1 — every generic member carries a
794
+ delimiting characteristic that distinguishes it from its
795
+ coordinate concepts. Required field. */
796
+ if (member.delimiting_characteristic || member.delimitingCharacteristic) {
797
+ const dc = member.delimiting_characteristic ?? member.delimitingCharacteristic;
798
+ m['gl:delimitingCharacteristic'] = typeof dc === 'string'
799
+ ? { default: dc }
800
+ : dc;
801
+ }
802
+ if (member.multiplicity) m['gl:multiplicity'] = member.multiplicity;
803
+ const legacyCertainty = member.certainty
804
+ ?? LEGACY_CERTAINTY_FROM_AXIS[presence];
805
+ if (legacyCertainty) m['gl:certainty'] = legacyCertainty;
806
+ return m;
807
+ });
808
+ }
809
+ if (rel.completeness) {
810
+ out['gl:completeness'] = rel.completeness;
811
+ }
812
+ if (rel.criterion) {
813
+ out['gl:criterion'] = typeof rel.criterion === 'string'
814
+ ? { default: rel.criterion }
815
+ : rel.criterion;
816
+ }
817
+ return out;
818
+ });
819
+ }
820
+
772
821
  return doc;
773
822
  }
774
823
 
@@ -233,6 +233,36 @@ function withPartitiveHyperedge(): Concept {
233
233
  });
234
234
  }
235
235
 
236
+ function withGenericHyperedge(): Concept {
237
+ const source = 'urn:glossarist:fixtures';
238
+ return Concept.fromJSON({
239
+ id: '1.9',
240
+ uri: `${BASE}/with-generic-hyperedge`,
241
+ status: 'valid',
242
+ localizations: {
243
+ eng: {
244
+ language_code: 'eng',
245
+ entry_status: 'valid',
246
+ terms: [{ type: 'expression', designation: 'measurement unit', normative_status: 'preferred' }],
247
+ definition: [{ content: 'A concept that has a generic (genus/species) decomposition.' }],
248
+ },
249
+ },
250
+ relations: [
251
+ {
252
+ type: 'generic_relation',
253
+ comprehensive: { source, id: '1.9' },
254
+ criterion: { eng: 'by magnitude relationship to a reference unit' },
255
+ members: [
256
+ { ref: { source, id: '1.17' }, presence: 'required', count: 'at_least_one', delimitingCharacteristic: { eng: 'multiple of a unit' } },
257
+ { ref: { source, id: '1.18' }, presence: 'required', count: 'at_least_one', delimitingCharacteristic: { eng: 'submultiple of a unit' } },
258
+ { ref: { source, id: '1.12' }, presence: 'required', count: 'at_least_one', delimitingCharacteristic: { eng: 'coherent derived unit' } },
259
+ ],
260
+ completeness: 'partial',
261
+ },
262
+ ],
263
+ });
264
+ }
265
+
236
266
  export const CONCEPT_FIXTURES: readonly ConceptFixture[] = [
237
267
  { name: 'minimal', description: 'one localization, one designation', uri: `${BASE}/minimal`, concept: minimal() },
238
268
  { name: 'multilingual', description: 'three languages across Latn and CJK scripts', uri: `${BASE}/multilingual`, concept: multilingual() },
@@ -242,6 +272,7 @@ export const CONCEPT_FIXTURES: readonly ConceptFixture[] = [
242
272
  { name: 'with-dates', description: 'accepted/amended/retired lifecycle', uri: `${BASE}/with-dates`, concept: withDates() },
243
273
  { name: 'withdrawn', description: 'withdrawn status with retirement date (J3)', uri: `${BASE}/withdrawn`, concept: withdrawnConcept() },
244
274
  { name: 'with-partitive-hyperedge', description: 'partitive decomposition in concept.relations', uri: `${BASE}/with-partitive-hyperedge`, concept: withPartitiveHyperedge() },
275
+ { name: 'with-generic-hyperedge', description: 'generic (genus/species) decomposition in concept.relations', uri: `${BASE}/with-generic-hyperedge`, concept: withGenericHyperedge() },
245
276
  ];
246
277
 
247
278
  export function fixtureByName(name: string): ConceptFixture {
@@ -112,4 +112,32 @@ describe('useConceptEdges — hyperedge projection (regression)', () => {
112
112
  );
113
113
  expect(conceptPartitiveRelations.value.length).toBe(1);
114
114
  });
115
+
116
+ it('projects a GenericHyperedge from concept.relations', () => {
117
+ const { concept } = fixtureByName('with-generic-hyperedge');
118
+ const { conceptGenericRelations } = useConceptEdges(
119
+ computed(() => concept),
120
+ computed(() => 'fixtures'),
121
+ computed(() => manifest),
122
+ computed(() => noEdges),
123
+ noopRouter,
124
+ );
125
+
126
+ const rels = conceptGenericRelations.value;
127
+ expect(rels).toHaveLength(1);
128
+
129
+ const [rel] = rels;
130
+ expect(rel.comprehensive).toBe('https://glossarist.org/fixtures/concept/1.9');
131
+ expect(rel.members).toHaveLength(3);
132
+ expect(rel.members.map(m => m.uri)).toEqual([
133
+ 'https://glossarist.org/fixtures/concept/1.17',
134
+ 'https://glossarist.org/fixtures/concept/1.18',
135
+ 'https://glossarist.org/fixtures/concept/1.12',
136
+ ]);
137
+ /* ISO 704:2022 §5.5.4.2.1 — every generic member carries a
138
+ delimiting characteristic. Pin the field is projected. */
139
+ expect(rel.members[0].delimitingCharacteristic).toEqual({ eng: 'multiple of a unit' });
140
+ expect(rel.members[1].delimitingCharacteristic).toEqual({ eng: 'submultiple of a unit' });
141
+ expect(rel.criterion).toEqual({ eng: 'by magnitude relationship to a reference unit' });
142
+ });
115
143
  });
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { hashSeed, expandParams, portSide, portPoint, idToUriGet } from '../utils/sphere-math';
2
+ import { hashSeed, expandParams, portSide, portPoint, idToUriGet, visibleNodeIds } from '../utils/sphere-math';
3
3
 
4
4
  describe('sphere-math', () => {
5
5
  describe('hashSeed', () => {
@@ -53,4 +53,111 @@ describe('sphere-math', () => {
53
53
  expect(idToUriGet(map, '/uri/unknown')).toBeUndefined();
54
54
  });
55
55
  });
56
+
57
+ describe('visibleNodeIds', () => {
58
+ const focus = { id: 'focus', register: 'main', depth: 0 };
59
+ const a = { id: 'a', register: 'main', depth: 1 };
60
+ const b = { id: 'b', register: 'main', depth: 1 };
61
+ const c = { id: 'c', register: 'main', depth: 1 };
62
+ const d = { id: 'd', register: 'other', depth: 1 };
63
+
64
+ const buildLinks = (links: Array<[string, string, string]>) =>
65
+ links.map(([source, target, type]) => ({ source, target, type }));
66
+
67
+ it('always includes the focus node (depth 0)', () => {
68
+ const nodes = [focus, a];
69
+ const links = buildLinks([['focus', 'a', 'related']]);
70
+ const visible = visibleNodeIds(nodes, links, new Set(), new Set());
71
+ expect(visible.has('focus')).toBe(true);
72
+ });
73
+
74
+ it('includes a node that has at least one non-muted edge', () => {
75
+ const nodes = [focus, a, b];
76
+ const links = buildLinks([
77
+ ['focus', 'a', 'related'],
78
+ ['focus', 'b', 'see'],
79
+ ]);
80
+ const visible = visibleNodeIds(nodes, links, new Set(), new Set());
81
+ expect(visible.has('a')).toBe(true);
82
+ expect(visible.has('b')).toBe(true);
83
+ });
84
+
85
+ it('hides a node whose only edge type is muted', () => {
86
+ const nodes = [focus, a, b];
87
+ const links = buildLinks([
88
+ ['focus', 'a', 'related'],
89
+ ['focus', 'b', 'see'],
90
+ ]);
91
+ const visible = visibleNodeIds(nodes, links, new Set(['see']), new Set());
92
+ expect(visible.has('a')).toBe(true);
93
+ expect(visible.has('b')).toBe(false);
94
+ });
95
+
96
+ it('vice versa: hidden node reappears when type is unmuted', () => {
97
+ const nodes = [focus, a, b];
98
+ const links = buildLinks([
99
+ ['focus', 'a', 'related'],
100
+ ['focus', 'b', 'see'],
101
+ ]);
102
+ const muted = new Set(['see']);
103
+ expect(visibleNodeIds(nodes, links, muted, new Set()).has('b')).toBe(false);
104
+ muted.delete('see');
105
+ expect(visibleNodeIds(nodes, links, muted, new Set()).has('b')).toBe(true);
106
+ });
107
+
108
+ it('keeps a node visible if it has at least one unmuted edge among several', () => {
109
+ const nodes = [focus, a, b, c];
110
+ const links = buildLinks([
111
+ ['focus', 'a', 'related'],
112
+ ['focus', 'a', 'see'],
113
+ ['focus', 'b', 'see'],
114
+ ['focus', 'c', 'related'],
115
+ ]);
116
+ const visible = visibleNodeIds(nodes, links, new Set(['see']), new Set());
117
+ expect(visible.has('a')).toBe(true);
118
+ expect(visible.has('b')).toBe(false);
119
+ expect(visible.has('c')).toBe(true);
120
+ });
121
+
122
+ it('hides a node in a muted register (unless it is the focus)', () => {
123
+ const nodes = [focus, a, d];
124
+ const links = buildLinks([
125
+ ['focus', 'a', 'related'],
126
+ ['focus', 'd', 'related'],
127
+ ]);
128
+ const visible = visibleNodeIds(nodes, links, new Set(), new Set(['other']));
129
+ expect(visible.has('a')).toBe(true);
130
+ expect(visible.has('d')).toBe(false);
131
+ });
132
+
133
+ it('hides a node whose only edge endpoint is in a muted register', () => {
134
+ const nodes = [focus, a, d];
135
+ const links = buildLinks([
136
+ ['a', 'd', 'related'],
137
+ ]);
138
+ const visible = visibleNodeIds(nodes, links, new Set(), new Set(['other']));
139
+ expect(visible.has('a')).toBe(false);
140
+ expect(visible.has('d')).toBe(false);
141
+ });
142
+
143
+ it('isolated non-focus node (no edges at all) is hidden', () => {
144
+ const nodes = [focus, a];
145
+ const links: Array<{ source: string; target: string; type: string }> = [];
146
+ const visible = visibleNodeIds(nodes, links, new Set(), new Set());
147
+ expect(visible.has('focus')).toBe(true);
148
+ expect(visible.has('a')).toBe(false);
149
+ });
150
+
151
+ it('all-visible when no mutes are set', () => {
152
+ const nodes = [focus, a, b, c, d];
153
+ const links = buildLinks([
154
+ ['focus', 'a', 'related'],
155
+ ['focus', 'b', 'see'],
156
+ ['focus', 'c', 'related'],
157
+ ['focus', 'd', 'references'],
158
+ ]);
159
+ const visible = visibleNodeIds(nodes, links, new Set(), new Set());
160
+ expect([...visible].sort()).toEqual(['a', 'b', 'c', 'd', 'focus']);
161
+ });
162
+ });
56
163
  });
@@ -21,11 +21,11 @@ import { easeInOutCubic, slerp, fibonacciSpherePosition, project, cardEdge, type
21
21
  import { UriRouter } from '../adapters/UriRouter';
22
22
  import { getPreferredTerm } from '../utils/concept-helpers';
23
23
  import { renderContent } from '../utils/content-renderer';
24
- import { hashSeed, expandParams, portSide, portPoint, idToUriGet } from '../utils/sphere-math';
24
+ import { hashSeed, expandParams, portSide, portPoint, idToUriGet, visibleNodeIds } from '../utils/sphere-math';
25
25
  import { useI18n } from '../i18n';
26
26
 
27
27
  const { t, locale } = useI18n();
28
- import type { Concept, Manifest, GraphEdge, PartitiveRelationWire, PartitiveMemberWire } from '../adapters/types';
28
+ import type { Concept, Manifest, GraphEdge, PartitiveRelationWire, PartitiveMemberWire, GenericRelationWire } from '../adapters/types';
29
29
  import { rakeStrokeStyle } from '../utils/partitive-multiplicity';
30
30
  import { drawRakeBundles } from './relation-sphere/rake-bundles';
31
31
  import { ensureMarkers, ensureTypeMarker } from './relation-sphere/svg-markers';
@@ -45,6 +45,7 @@ const props = defineProps<{
45
45
  registerId: string;
46
46
  edges: GraphEdge[];
47
47
  partitiveRelations?: PartitiveRelationWire[];
48
+ genericRelations?: GenericRelationWire[];
48
49
  }>();
49
50
 
50
51
  const emit = defineEmits<{
@@ -403,16 +404,22 @@ function buildGraph() {
403
404
  nodes = Array.from(nodeMap.values());
404
405
  links = resultLinks;
405
406
 
406
- /* Ensure every PartitiveRelation member is present in the graph
407
- so the rake bundle can render. The comprehensive is normally the
408
- focus; partitives may need to be added as depth-1 neighbors. */
409
- for (const rel of props.partitiveRelations ?? []) {
407
+ /* Ensure every PartitiveRelation + GenericRelation member is present
408
+ in the graph so rake bundles can render. The comprehensive is
409
+ normally the focus; members may need to be added as depth-1
410
+ neighbors. */
411
+ const hyperedgeRelations: ReadonlyArray<PartitiveRelationWire | GenericRelationWire> = [
412
+ ...(props.partitiveRelations ?? []),
413
+ ...(props.genericRelations ?? []),
414
+ ];
415
+ for (const rel of hyperedgeRelations) {
410
416
  const compParsed = UriRouter.parseUri(rel.comprehensive);
411
417
  if (!compParsed) continue;
412
418
  const compNodeId = `${compParsed.registerId}/${compParsed.conceptId}`;
413
419
  if (!nodeMap.has(compNodeId)) continue;
414
420
 
415
- for (const member of rel.partitives) {
421
+ const members = 'partitives' in rel ? rel.partitives : rel.members;
422
+ for (const member of members) {
416
423
  const parsed = UriRouter.parseUri(member.uri);
417
424
  if (!parsed) continue;
418
425
  const nid = `${parsed.registerId}/${parsed.conceptId}`;
@@ -691,6 +698,30 @@ function drawEdges(cx: number, cy: number) {
691
698
  isMuted: mutedTypes.value.has('__partitive__'),
692
699
  relations: props.partitiveRelations ?? [],
693
700
  });
701
+
702
+ /* Generic relations render as rake bundles in a distinct hue (amber).
703
+ Member shape differs (delimitingCharacteristic instead of isDelimiting),
704
+ so normalize to the partitive-shaped input the renderer expects. */
705
+ const genericNormalized: PartitiveRelationWire[] = (props.genericRelations ?? []).map(rel => ({
706
+ source: rel.source,
707
+ comprehensive: rel.comprehensive,
708
+ completeness: rel.completeness,
709
+ register: rel.register,
710
+ partitives: rel.members.map(m => ({
711
+ uri: m.uri,
712
+ presence: m.presence,
713
+ count: m.count,
714
+ /* Every generic member is delimiting by definition (each carries
715
+ its own delimiting characteristic per ISO 704 §5.5.4.2.1). */
716
+ isDelimiting: true,
717
+ })),
718
+ }));
719
+ drawRakeBundles(svg, pos, {
720
+ isDark: uiStore.isDark,
721
+ color: uiStore.isDark ? '#fbbf24' : '#b45309',
722
+ isMuted: mutedTypes.value.has('__generic__'),
723
+ relations: genericNormalized,
724
+ });
694
725
  }
695
726
 
696
727
  /* ── Render DOM nodes ───────────────────────────────────── */
@@ -702,9 +733,13 @@ function renderDOM() {
702
733
  const cx = c ? c.clientWidth / 2 : 0;
703
734
  const cy = c ? c.clientHeight * 0.46 : 0;
704
735
 
736
+ /* Cards with no visible edges (after applying type + register mutes)
737
+ 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);
740
+
705
741
  for (const n of nodes) {
706
- /* Skip muted-register nodes (except focus) */
707
- if (n.depth !== 0 && mutedRegisters.value.has(n.register)) continue;
742
+ if (!visibleIds.has(n.id)) continue;
708
743
 
709
744
  const dsColor = getColor(n.register) || '#888';
710
745
  const el = document.createElement('div');
@@ -1020,6 +1055,16 @@ const legendItems = computed(() => {
1020
1055
  count: partitiveCount,
1021
1056
  });
1022
1057
  }
1058
+ /* Synthetic entry for GenericRelations — same idea, distinct hue. */
1059
+ const genericCount = props.genericRelations?.length ?? 0;
1060
+ if (genericCount > 0) {
1061
+ typeMap.set('__generic__', {
1062
+ type: '__generic__',
1063
+ label: 'generic relation',
1064
+ color: uiStore.isDark ? '#fbbf24' : '#b45309',
1065
+ count: genericCount,
1066
+ });
1067
+ }
1023
1068
  return Array.from(typeMap.values()).sort((a, b) => b.count - a.count);
1024
1069
  });
1025
1070
 
@@ -1164,6 +1209,9 @@ function toggleType(type: string) {
1164
1209
  mutedTypes.value = n;
1165
1210
  const c = canvasRef.value;
1166
1211
  if (c) drawEdges(c.clientWidth / 2, c.clientHeight * 0.46);
1212
+ /* Re-render cards so nodes whose only edges were the toggled type
1213
+ disappear (and reappear when the type is unmuted). */
1214
+ renderDOM();
1167
1215
  /* Re-heat the simulation so nodes drift to a new equilibrium with
1168
1216
  the freed/hidden edges. The link force reads mutedTypes each tick,
1169
1217
  so toggling effectively "removes" the edge from the force calc. */
@@ -52,3 +52,57 @@ export function idToUriGet(map: Map<string, string>, uri: string): string | unde
52
52
  for (const [id, u] of map) if (u === uri) return id;
53
53
  return undefined;
54
54
  }
55
+
56
+ export interface SphereNodeLike {
57
+ id: string;
58
+ register: string;
59
+ depth: number;
60
+ }
61
+
62
+ export interface SphereLinkLike {
63
+ source: string;
64
+ target: string;
65
+ type: string;
66
+ }
67
+
68
+ /**
69
+ * IDs of nodes that should render given the current mute state.
70
+ *
71
+ * The focus node (depth 0) is always visible. Every other node must have
72
+ * 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.
75
+ *
76
+ * Extracted as a pure function so the visibility contract can be tested
77
+ * without mounting the sphere component.
78
+ */
79
+ export function visibleNodeIds(
80
+ nodes: readonly SphereNodeLike[],
81
+ links: readonly SphereLinkLike[],
82
+ mutedTypes: ReadonlySet<string>,
83
+ mutedRegisters: ReadonlySet<string>,
84
+ ): Set<string> {
85
+ const byId = new Map<string, SphereNodeLike>();
86
+ for (const n of nodes) byId.set(n.id, n);
87
+
88
+ const visible = new Set<string>();
89
+ for (const n of nodes) {
90
+ if (n.depth === 0) {
91
+ visible.add(n.id);
92
+ continue;
93
+ }
94
+ if (mutedRegisters.has(n.register)) continue;
95
+ for (const l of links) {
96
+ if (mutedTypes.has(l.type)) continue;
97
+ const otherId = l.source === n.id ? l.target : l.target === n.id ? l.source : null;
98
+ if (!otherId) continue;
99
+ const other = byId.get(otherId);
100
+ if (!other) continue;
101
+ if (other.depth !== 0 && mutedRegisters.has(other.register)) continue;
102
+ visible.add(n.id);
103
+ break;
104
+ }
105
+ }
106
+ return visible;
107
+ }
108
+
@@ -59,7 +59,7 @@ const edges = computed(() => store.conceptEdges);
59
59
  const activeRegisterId = computed(() => sphereFocusPayload.value?.registerId ?? props.registerId);
60
60
  const adjacent = ref({ prev: null as string | null, next: null as string | null });
61
61
 
62
- const { conceptPartitiveRelations } = useConceptEdges(
62
+ const { conceptPartitiveRelations, conceptGenericRelations } = useConceptEdges(
63
63
  concept,
64
64
  activeRegisterId,
65
65
  manifest,
@@ -289,6 +289,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown));
289
289
  :register-id="activeRegisterId"
290
290
  :edges="edges"
291
291
  :partitive-relations="conceptPartitiveRelations"
292
+ :generic-relations="conceptGenericRelations"
292
293
  @navigate="onSphereNavigate"
293
294
  />
294
295
  <!-- Detail mode -->