@glossarist/concept-browser 0.7.94 → 0.7.96
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 +1 -1
- package/scripts/generate-data.mjs +49 -0
- package/src/__tests__/__fixtures__/concepts.ts +31 -0
- package/src/__tests__/composables/use-concept-edges-hyperedge.test.ts +28 -0
- package/src/__tests__/sphere-math.test.ts +133 -1
- package/src/components/RelationSphere.vue +85 -9
- package/src/utils/sphere-math.ts +82 -0
- package/src/views/ConceptView.vue +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.96",
|
|
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,6 @@
|
|
|
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
|
+
import type { SphereHyperedgeLike } from '../utils/sphere-math';
|
|
3
4
|
|
|
4
5
|
describe('sphere-math', () => {
|
|
5
6
|
describe('hashSeed', () => {
|
|
@@ -53,4 +54,135 @@ describe('sphere-math', () => {
|
|
|
53
54
|
expect(idToUriGet(map, '/uri/unknown')).toBeUndefined();
|
|
54
55
|
});
|
|
55
56
|
});
|
|
57
|
+
|
|
58
|
+
describe('visibleNodeIds', () => {
|
|
59
|
+
const focus = { id: 'focus', register: 'main', depth: 0 };
|
|
60
|
+
const a = { id: 'a', register: 'main', depth: 1 };
|
|
61
|
+
const b = { id: 'b', register: 'main', depth: 1 };
|
|
62
|
+
const c = { id: 'c', register: 'main', depth: 1 };
|
|
63
|
+
const d = { id: 'd', register: 'other', depth: 1 };
|
|
64
|
+
|
|
65
|
+
const buildLinks = (links: Array<[string, string, string]>) =>
|
|
66
|
+
links.map(([source, target, type]) => ({ source, target, type }));
|
|
67
|
+
|
|
68
|
+
it('always includes the focus node (depth 0)', () => {
|
|
69
|
+
const nodes = [focus, a];
|
|
70
|
+
const links = buildLinks([['focus', 'a', 'related']]);
|
|
71
|
+
const visible = visibleNodeIds(nodes, links, new Set(), new Set());
|
|
72
|
+
expect(visible.has('focus')).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('includes a node that has at least one non-muted edge', () => {
|
|
76
|
+
const nodes = [focus, a, b];
|
|
77
|
+
const links = buildLinks([
|
|
78
|
+
['focus', 'a', 'related'],
|
|
79
|
+
['focus', 'b', 'see'],
|
|
80
|
+
]);
|
|
81
|
+
const visible = visibleNodeIds(nodes, links, new Set(), new Set());
|
|
82
|
+
expect(visible.has('a')).toBe(true);
|
|
83
|
+
expect(visible.has('b')).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('hides a node whose only edge type is muted', () => {
|
|
87
|
+
const nodes = [focus, a, b];
|
|
88
|
+
const links = buildLinks([
|
|
89
|
+
['focus', 'a', 'related'],
|
|
90
|
+
['focus', 'b', 'see'],
|
|
91
|
+
]);
|
|
92
|
+
const visible = visibleNodeIds(nodes, links, new Set(['see']), new Set());
|
|
93
|
+
expect(visible.has('a')).toBe(true);
|
|
94
|
+
expect(visible.has('b')).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('vice versa: hidden node reappears when type is unmuted', () => {
|
|
98
|
+
const nodes = [focus, a, b];
|
|
99
|
+
const links = buildLinks([
|
|
100
|
+
['focus', 'a', 'related'],
|
|
101
|
+
['focus', 'b', 'see'],
|
|
102
|
+
]);
|
|
103
|
+
const muted = new Set(['see']);
|
|
104
|
+
expect(visibleNodeIds(nodes, links, muted, new Set()).has('b')).toBe(false);
|
|
105
|
+
muted.delete('see');
|
|
106
|
+
expect(visibleNodeIds(nodes, links, muted, new Set()).has('b')).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('keeps a node visible if it has at least one unmuted edge among several', () => {
|
|
110
|
+
const nodes = [focus, a, b, c];
|
|
111
|
+
const links = buildLinks([
|
|
112
|
+
['focus', 'a', 'related'],
|
|
113
|
+
['focus', 'a', 'see'],
|
|
114
|
+
['focus', 'b', 'see'],
|
|
115
|
+
['focus', 'c', 'related'],
|
|
116
|
+
]);
|
|
117
|
+
const visible = visibleNodeIds(nodes, links, new Set(['see']), new Set());
|
|
118
|
+
expect(visible.has('a')).toBe(true);
|
|
119
|
+
expect(visible.has('b')).toBe(false);
|
|
120
|
+
expect(visible.has('c')).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('hides a node in a muted register (unless it is the focus)', () => {
|
|
124
|
+
const nodes = [focus, a, d];
|
|
125
|
+
const links = buildLinks([
|
|
126
|
+
['focus', 'a', 'related'],
|
|
127
|
+
['focus', 'd', 'related'],
|
|
128
|
+
]);
|
|
129
|
+
const visible = visibleNodeIds(nodes, links, new Set(), new Set(['other']));
|
|
130
|
+
expect(visible.has('a')).toBe(true);
|
|
131
|
+
expect(visible.has('d')).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('hides a node whose only edge endpoint is in a muted register', () => {
|
|
135
|
+
const nodes = [focus, a, d];
|
|
136
|
+
const links = buildLinks([
|
|
137
|
+
['a', 'd', 'related'],
|
|
138
|
+
]);
|
|
139
|
+
const visible = visibleNodeIds(nodes, links, new Set(), new Set(['other']));
|
|
140
|
+
expect(visible.has('a')).toBe(false);
|
|
141
|
+
expect(visible.has('d')).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('isolated non-focus node (no edges at all) is hidden', () => {
|
|
145
|
+
const nodes = [focus, a];
|
|
146
|
+
const links: Array<{ source: string; target: string; type: string }> = [];
|
|
147
|
+
const visible = visibleNodeIds(nodes, links, new Set(), new Set());
|
|
148
|
+
expect(visible.has('focus')).toBe(true);
|
|
149
|
+
expect(visible.has('a')).toBe(false);
|
|
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
|
+
|
|
176
|
+
it('all-visible when no mutes are set', () => {
|
|
177
|
+
const nodes = [focus, a, b, c, d];
|
|
178
|
+
const links = buildLinks([
|
|
179
|
+
['focus', 'a', 'related'],
|
|
180
|
+
['focus', 'b', 'see'],
|
|
181
|
+
['focus', 'c', 'related'],
|
|
182
|
+
['focus', 'd', 'references'],
|
|
183
|
+
]);
|
|
184
|
+
const visible = visibleNodeIds(nodes, links, new Set(), new Set());
|
|
185
|
+
expect([...visible].sort()).toEqual(['a', 'b', 'c', 'd', 'focus']);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
56
188
|
});
|
|
@@ -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<{
|
|
@@ -96,6 +97,13 @@ const mutedRegisters = ref<Set<string>>(new Set());
|
|
|
96
97
|
const panelOpen = ref(true);
|
|
97
98
|
const MAX_NODES = 36;
|
|
98
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
|
+
|
|
99
107
|
/* Sphere-display language. Defaults to the i18n UI locale (which reads
|
|
100
108
|
localStorage) so e.g. a user returning in French mode sees French
|
|
101
109
|
terms by default. The user can override via the Language selector. */
|
|
@@ -403,16 +411,22 @@ function buildGraph() {
|
|
|
403
411
|
nodes = Array.from(nodeMap.values());
|
|
404
412
|
links = resultLinks;
|
|
405
413
|
|
|
406
|
-
/* Ensure every PartitiveRelation member is present
|
|
407
|
-
|
|
408
|
-
focus;
|
|
409
|
-
|
|
414
|
+
/* Ensure every PartitiveRelation + GenericRelation member is present
|
|
415
|
+
in the graph so rake bundles can render. The comprehensive is
|
|
416
|
+
normally the focus; members may need to be added as depth-1
|
|
417
|
+
neighbors. */
|
|
418
|
+
const hyperedgeRelations: ReadonlyArray<PartitiveRelationWire | GenericRelationWire> = [
|
|
419
|
+
...(props.partitiveRelations ?? []),
|
|
420
|
+
...(props.genericRelations ?? []),
|
|
421
|
+
];
|
|
422
|
+
for (const rel of hyperedgeRelations) {
|
|
410
423
|
const compParsed = UriRouter.parseUri(rel.comprehensive);
|
|
411
424
|
if (!compParsed) continue;
|
|
412
425
|
const compNodeId = `${compParsed.registerId}/${compParsed.conceptId}`;
|
|
413
426
|
if (!nodeMap.has(compNodeId)) continue;
|
|
414
427
|
|
|
415
|
-
|
|
428
|
+
const members = 'partitives' in rel ? rel.partitives : rel.members;
|
|
429
|
+
for (const member of members) {
|
|
416
430
|
const parsed = UriRouter.parseUri(member.uri);
|
|
417
431
|
if (!parsed) continue;
|
|
418
432
|
const nid = `${parsed.registerId}/${parsed.conceptId}`;
|
|
@@ -691,6 +705,30 @@ function drawEdges(cx: number, cy: number) {
|
|
|
691
705
|
isMuted: mutedTypes.value.has('__partitive__'),
|
|
692
706
|
relations: props.partitiveRelations ?? [],
|
|
693
707
|
});
|
|
708
|
+
|
|
709
|
+
/* Generic relations render as rake bundles in a distinct hue (amber).
|
|
710
|
+
Member shape differs (delimitingCharacteristic instead of isDelimiting),
|
|
711
|
+
so normalize to the partitive-shaped input the renderer expects. */
|
|
712
|
+
const genericNormalized: PartitiveRelationWire[] = (props.genericRelations ?? []).map(rel => ({
|
|
713
|
+
source: rel.source,
|
|
714
|
+
comprehensive: rel.comprehensive,
|
|
715
|
+
completeness: rel.completeness,
|
|
716
|
+
register: rel.register,
|
|
717
|
+
partitives: rel.members.map(m => ({
|
|
718
|
+
uri: m.uri,
|
|
719
|
+
presence: m.presence,
|
|
720
|
+
count: m.count,
|
|
721
|
+
/* Every generic member is delimiting by definition (each carries
|
|
722
|
+
its own delimiting characteristic per ISO 704 §5.5.4.2.1). */
|
|
723
|
+
isDelimiting: true,
|
|
724
|
+
})),
|
|
725
|
+
}));
|
|
726
|
+
drawRakeBundles(svg, pos, {
|
|
727
|
+
isDark: uiStore.isDark,
|
|
728
|
+
color: uiStore.isDark ? '#fbbf24' : '#b45309',
|
|
729
|
+
isMuted: mutedTypes.value.has('__generic__'),
|
|
730
|
+
relations: genericNormalized,
|
|
731
|
+
});
|
|
694
732
|
}
|
|
695
733
|
|
|
696
734
|
/* ── Render DOM nodes ───────────────────────────────────── */
|
|
@@ -702,9 +740,34 @@ function renderDOM() {
|
|
|
702
740
|
const cx = c ? c.clientWidth / 2 : 0;
|
|
703
741
|
const cy = c ? c.clientHeight * 0.46 : 0;
|
|
704
742
|
|
|
743
|
+
/* Cards with no visible edges (after applying type + register mutes)
|
|
744
|
+
float with no connections — hide them so toggling a relation type
|
|
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
|
+
);
|
|
768
|
+
|
|
705
769
|
for (const n of nodes) {
|
|
706
|
-
|
|
707
|
-
if (n.depth !== 0 && mutedRegisters.value.has(n.register)) continue;
|
|
770
|
+
if (!visibleIds.has(n.id)) continue;
|
|
708
771
|
|
|
709
772
|
const dsColor = getColor(n.register) || '#888';
|
|
710
773
|
const el = document.createElement('div');
|
|
@@ -1020,6 +1083,16 @@ const legendItems = computed(() => {
|
|
|
1020
1083
|
count: partitiveCount,
|
|
1021
1084
|
});
|
|
1022
1085
|
}
|
|
1086
|
+
/* Synthetic entry for GenericRelations — same idea, distinct hue. */
|
|
1087
|
+
const genericCount = props.genericRelations?.length ?? 0;
|
|
1088
|
+
if (genericCount > 0) {
|
|
1089
|
+
typeMap.set('__generic__', {
|
|
1090
|
+
type: '__generic__',
|
|
1091
|
+
label: 'generic relation',
|
|
1092
|
+
color: uiStore.isDark ? '#fbbf24' : '#b45309',
|
|
1093
|
+
count: genericCount,
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1023
1096
|
return Array.from(typeMap.values()).sort((a, b) => b.count - a.count);
|
|
1024
1097
|
});
|
|
1025
1098
|
|
|
@@ -1164,6 +1237,9 @@ function toggleType(type: string) {
|
|
|
1164
1237
|
mutedTypes.value = n;
|
|
1165
1238
|
const c = canvasRef.value;
|
|
1166
1239
|
if (c) drawEdges(c.clientWidth / 2, c.clientHeight * 0.46);
|
|
1240
|
+
/* Re-render cards so nodes whose only edges were the toggled type
|
|
1241
|
+
disappear (and reappear when the type is unmuted). */
|
|
1242
|
+
renderDOM();
|
|
1167
1243
|
/* Re-heat the simulation so nodes drift to a new equilibrium with
|
|
1168
1244
|
the freed/hidden edges. The link force reads mutedTypes each tick,
|
|
1169
1245
|
so toggling effectively "removes" the edge from the force calc. */
|
package/src/utils/sphere-math.ts
CHANGED
|
@@ -52,3 +52,85 @@ 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
|
+
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
|
+
|
|
77
|
+
/**
|
|
78
|
+
* IDs of nodes that should render given the current mute state.
|
|
79
|
+
*
|
|
80
|
+
* The focus node (depth 0) is always visible. Every other node must have
|
|
81
|
+
* at least one edge whose type is not muted AND whose other endpoint is
|
|
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.
|
|
89
|
+
*
|
|
90
|
+
* Extracted as a pure function so the visibility contract can be tested
|
|
91
|
+
* without mounting the sphere component.
|
|
92
|
+
*/
|
|
93
|
+
export function visibleNodeIds(
|
|
94
|
+
nodes: readonly SphereNodeLike[],
|
|
95
|
+
links: readonly SphereLinkLike[],
|
|
96
|
+
mutedTypes: ReadonlySet<string>,
|
|
97
|
+
mutedRegisters: ReadonlySet<string>,
|
|
98
|
+
hyperedges: readonly SphereHyperedgeLike[] = [],
|
|
99
|
+
): Set<string> {
|
|
100
|
+
const byId = new Map<string, SphereNodeLike>();
|
|
101
|
+
for (const n of nodes) byId.set(n.id, n);
|
|
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
|
+
|
|
111
|
+
const visible = new Set<string>();
|
|
112
|
+
for (const n of nodes) {
|
|
113
|
+
if (n.depth === 0) {
|
|
114
|
+
visible.add(n.id);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (mutedRegisters.has(n.register)) continue;
|
|
118
|
+
if (hyperedgeNodeIds.has(n.id)) {
|
|
119
|
+
visible.add(n.id);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
for (const l of links) {
|
|
123
|
+
if (mutedTypes.has(l.type)) continue;
|
|
124
|
+
const otherId = l.source === n.id ? l.target : l.target === n.id ? l.source : null;
|
|
125
|
+
if (!otherId) continue;
|
|
126
|
+
const other = byId.get(otherId);
|
|
127
|
+
if (!other) continue;
|
|
128
|
+
if (other.depth !== 0 && mutedRegisters.has(other.register)) continue;
|
|
129
|
+
visible.add(n.id);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return visible;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
@@ -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 -->
|