@glossarist/concept-browser 0.7.77 → 0.7.78
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/astro.config.mjs +1 -0
- package/package.json +1 -1
- package/scripts/bridge-to-astro.mjs +6 -1
- package/scripts/build-edges.js +84 -1
- package/scripts/generate-data.mjs +102 -54
- package/src/__tests__/hyperedge-styling.test.ts +57 -0
- package/src/__tests__/partitive-hyperedge.test.ts +199 -0
- package/src/__tests__/sphere-math.test.ts +56 -0
- package/src/adapters/GraphDataSource.ts +10 -3
- package/src/adapters/UriRouter.ts +5 -0
- package/src/adapters/model-bridge.ts +1 -1
- package/src/adapters/types.ts +26 -0
- package/src/components/PartitiveHyperedgeList.vue +112 -0
- package/src/components/RelationSphere.vue +2 -36
- package/src/composables/use-concept-edges.ts +77 -5
- package/src/utils/hyperedge-styling.ts +48 -0
- package/src/utils/sphere-math.ts +54 -0
package/astro.config.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.78",
|
|
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": {
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
function buildConceptUri(uriBase, registerId, conceptId) {
|
|
2
|
+
return `${uriBase}/${registerId}/concept/${conceptId}`;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
|
|
1
6
|
/**
|
|
2
7
|
* Data bridge: converts public/data/*.json into Astro content collections.
|
|
3
8
|
*
|
|
@@ -77,7 +82,7 @@ for (const reg of registry) {
|
|
|
77
82
|
const conceptEntry = {
|
|
78
83
|
registerId: id,
|
|
79
84
|
conceptId: concept.id,
|
|
80
|
-
uri:
|
|
85
|
+
uri: buildConceptUri(entry.uriBase || '', id, concept.id),
|
|
81
86
|
status: concept.status || 'valid',
|
|
82
87
|
designations: concept.designations || {},
|
|
83
88
|
eng: concept.eng || concept.designations?.eng || '',
|
package/scripts/build-edges.js
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
function buildConceptUri(uriBase, registerId, conceptId) {
|
|
2
|
+
return `${uriBase}/${registerId}/concept/${conceptId}`;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
const VALID_MARKERS = new Set(['double', 'dashed']);
|
|
6
|
+
|
|
7
|
+
function validateHyperedgeMarkers(markers) {
|
|
8
|
+
const out = [];
|
|
9
|
+
for (const m of markers) {
|
|
10
|
+
if (!VALID_MARKERS.has(m)) {
|
|
11
|
+
throw new Error(`Invalid partitive hyperedge marker: "${m}". Allowed: double, dashed`);
|
|
12
|
+
}
|
|
13
|
+
out.push(m);
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeHyperedgeContent(content) {
|
|
19
|
+
if (content == null) return undefined;
|
|
20
|
+
if (typeof content === 'string') return { default: content };
|
|
21
|
+
return content;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
1
25
|
/**
|
|
2
26
|
* Pre-computes cross-reference and domain edges for each dataset.
|
|
3
27
|
* Reads all concept JSON files, extracts structured references and
|
|
@@ -108,7 +132,7 @@ function extractRelated(concept, registerId, uriBase, urnMap) {
|
|
|
108
132
|
const id = ref['gl:id'] || ref['id'];
|
|
109
133
|
if (!source || !id) continue;
|
|
110
134
|
const reg = urnMap.get(source) || source;
|
|
111
|
-
const target =
|
|
135
|
+
const target = buildConceptUri(uriBase, reg, id);
|
|
112
136
|
if (target === sourceUri) continue;
|
|
113
137
|
edges.push({
|
|
114
138
|
source: sourceUri,
|
|
@@ -121,6 +145,52 @@ function extractRelated(concept, registerId, uriBase, urnMap) {
|
|
|
121
145
|
return edges;
|
|
122
146
|
}
|
|
123
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Extracts partitive hyperedges from a concept's gl:partitiveHyperedges
|
|
150
|
+
* array. Returns hyperedge objects (NOT binary GraphEdges) so the UI
|
|
151
|
+
* can render them with their full set-based semantics: comprehensive +
|
|
152
|
+
* parts together, enumeration completeness (open vs closed), and
|
|
153
|
+
* diagram plurality markers (double / dashed).
|
|
154
|
+
*
|
|
155
|
+
* Hyperedges are written to a separate hyperedges.json file, keeping
|
|
156
|
+
* edges.json binary-only.
|
|
157
|
+
*/
|
|
158
|
+
function extractPartitiveHyperedges(concept, registerId, uriBase, urnMap) {
|
|
159
|
+
const hyperedges = [];
|
|
160
|
+
const sourceUri = concept['@id'];
|
|
161
|
+
for (const he of concept['gl:partitiveHyperedges'] || []) {
|
|
162
|
+
const compRef = he['gl:comprehensive'];
|
|
163
|
+
if (!compRef) continue;
|
|
164
|
+
const comprehensive = resolveConceptUri(compRef, uriBase, urnMap);
|
|
165
|
+
if (!comprehensive) continue;
|
|
166
|
+
|
|
167
|
+
const parts = (he['gl:hasPart'] || [])
|
|
168
|
+
.map(p => resolveConceptUri(p, uriBase, urnMap))
|
|
169
|
+
.filter(p => p && p !== sourceUri);
|
|
170
|
+
|
|
171
|
+
if (parts.length === 0) continue;
|
|
172
|
+
|
|
173
|
+
hyperedges.push({
|
|
174
|
+
source: sourceUri,
|
|
175
|
+
comprehensive,
|
|
176
|
+
parts,
|
|
177
|
+
enumeration: he['gl:enumeration'] || 'closed',
|
|
178
|
+
markers: validateHyperedgeMarkers(he['gl:hasPluralityMarker'] || []),
|
|
179
|
+
label: normalizeHyperedgeContent(he['gl:content']),
|
|
180
|
+
register: registerId,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return hyperedges;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function resolveConceptUri(ref, uriBase, urnMap) {
|
|
187
|
+
const source = ref['gl:source'] || ref['source'];
|
|
188
|
+
const id = ref['gl:id'] || ref['id'];
|
|
189
|
+
if (!source || !id) return null;
|
|
190
|
+
const reg = urnMap.get(source) || source;
|
|
191
|
+
return buildConceptUri(uriBase, reg, id);
|
|
192
|
+
}
|
|
193
|
+
|
|
124
194
|
const EXTRACTORS = [extractReferences, extractRelated, extractDomains];
|
|
125
195
|
|
|
126
196
|
function extractAllEdges(concept, registerId, uriBase, urnMap) {
|
|
@@ -140,6 +210,7 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
|
|
|
140
210
|
console.log(` Processing ${files.length} concepts...`);
|
|
141
211
|
|
|
142
212
|
const allEdges = [];
|
|
213
|
+
const allHyperedges = [];
|
|
143
214
|
const allSourceRefs = [];
|
|
144
215
|
const domainConceptCount = new Map();
|
|
145
216
|
let processed = 0;
|
|
@@ -149,6 +220,7 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
|
|
|
149
220
|
const data = JSON.parse(readFileSync(join(conceptsDir, file), 'utf-8'));
|
|
150
221
|
const edges = extractAllEdges(data, registerId, uriBase, urnMap);
|
|
151
222
|
allEdges.push(...edges);
|
|
223
|
+
allHyperedges.push(...extractPartitiveHyperedges(data, registerId, uriBase, urnMap));
|
|
152
224
|
allSourceRefs.push(...extractSourceRefs(data, registerId));
|
|
153
225
|
|
|
154
226
|
for (const edge of edges) {
|
|
@@ -186,6 +258,17 @@ function buildEdgesForDataset(datasetDir, registerId, uriBase, urnMap, manifest)
|
|
|
186
258
|
writeFileSync(outputPath, JSON.stringify(output));
|
|
187
259
|
console.log(` Written ${deduped.length} edges to edges.json (${(JSON.stringify(output).length / 1024).toFixed(1)} KB)`);
|
|
188
260
|
|
|
261
|
+
// Write hyperedges.json (parallel to edges.json) for one-to-many
|
|
262
|
+
// partitive decompositions. See TODO.hyperedge/00-design-overview.md.
|
|
263
|
+
const hyperedgesOutput = {
|
|
264
|
+
registerId,
|
|
265
|
+
hyperedgeCount: allHyperedges.length,
|
|
266
|
+
hyperedges: allHyperedges,
|
|
267
|
+
};
|
|
268
|
+
const hyperedgesPath = join(datasetDir, 'hyperedges.json');
|
|
269
|
+
writeFileSync(hyperedgesPath, JSON.stringify(hyperedgesOutput));
|
|
270
|
+
console.log(` Written ${allHyperedges.length} hyperedges to hyperedges.json (${(JSON.stringify(hyperedgesOutput).length / 1024).toFixed(1)} KB)`);
|
|
271
|
+
|
|
189
272
|
// Build domain-nodes.json from manifest sections (authoritative source)
|
|
190
273
|
const manifestSections = manifest.sections;
|
|
191
274
|
if (manifestSections && manifestSections.length > 0) {
|
|
@@ -14,6 +14,11 @@ import { buildVersionHistoryTurtle } from './lib/version-turtle.mjs';
|
|
|
14
14
|
import { buildBibliographyTurtle } from './lib/bibliography-turtle.mjs';
|
|
15
15
|
import { ttlLit } from './lib/turtle-escape.mjs';
|
|
16
16
|
import { firstNonEmpty } from './lib/first-non-empty.mjs';
|
|
17
|
+
function buildConceptUri(uriBase, registerId, conceptId) {
|
|
18
|
+
return `${uriBase}/${registerId}/concept/${conceptId}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
17
22
|
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
|
18
23
|
const ROOT = process.cwd();
|
|
19
24
|
const PUBLIC = path.join(ROOT, 'public');
|
|
@@ -60,6 +65,7 @@ function loadConceptFile(filePath) {
|
|
|
60
65
|
|
|
61
66
|
// Managed concept-level fields
|
|
62
67
|
if (mc.related) result._related = mc.related;
|
|
68
|
+
if (mc.partitive_hyperedges) result._partitiveHyperedges = mc.partitive_hyperedges;
|
|
63
69
|
if (mc.data.domains) result._domains = mc.data.domains;
|
|
64
70
|
if (mc.dates) result._dates = mc.dates;
|
|
65
71
|
if (mc.sources) result._sources = mc.sources;
|
|
@@ -98,14 +104,14 @@ async function writeDatasetRdf(register, manifest, concepts, refMaps, opts) {
|
|
|
98
104
|
const datasetIri = `${uriBase}/${register}/`;
|
|
99
105
|
const topConceptUris = concepts
|
|
100
106
|
.slice(0, 32)
|
|
101
|
-
.map(c =>
|
|
107
|
+
.map(c => buildConceptUri(uriBase, register, c.id));
|
|
102
108
|
|
|
103
109
|
const sections = (manifest.sections ?? []).map(section => {
|
|
104
110
|
const sectionId = section.id ?? section.slug ?? section.title;
|
|
105
111
|
return {
|
|
106
112
|
collectionIri: `${uriBase}/${register}/section/${sectionId}`,
|
|
107
113
|
title: section.title ?? section.name ?? sectionId,
|
|
108
|
-
memberUris: (section.members ?? []).map(id =>
|
|
114
|
+
memberUris: (section.members ?? []).map(id => buildConceptUri(uriBase, register, id)),
|
|
109
115
|
};
|
|
110
116
|
});
|
|
111
117
|
|
|
@@ -242,15 +248,16 @@ function defsToJsonLd(defs) {
|
|
|
242
248
|
.filter(d => d['gl:content']);
|
|
243
249
|
}
|
|
244
250
|
|
|
245
|
-
function refToJsonLd(ref) {
|
|
251
|
+
function refToJsonLd(ref, typeName = 'gl:Ref') {
|
|
246
252
|
if (!ref) return undefined;
|
|
247
|
-
const refObj = { '@type':
|
|
253
|
+
const refObj = { '@type': typeName };
|
|
248
254
|
if (typeof ref === 'string') {
|
|
249
255
|
refObj['gl:source'] = ref;
|
|
250
256
|
} else {
|
|
251
257
|
if (ref.source) refObj['gl:source'] = ref.source;
|
|
252
258
|
if (ref.id) refObj['gl:id'] = ref.id;
|
|
253
259
|
if (ref.version) refObj['gl:version'] = ref.version;
|
|
260
|
+
if (ref.text) refObj['gl:text'] = ref.text;
|
|
254
261
|
}
|
|
255
262
|
return refObj;
|
|
256
263
|
}
|
|
@@ -366,12 +373,12 @@ function buildPatternIndex(datasets, registerCache) {
|
|
|
366
373
|
|
|
367
374
|
function resolveRefUri(term, refMaps) {
|
|
368
375
|
const resolved = refMaps.patternIndex.resolve(term);
|
|
369
|
-
if (resolved) return
|
|
376
|
+
if (resolved) return buildConceptUri(refMaps.uriBase, resolved.datasetId, resolved.conceptId);
|
|
370
377
|
|
|
371
378
|
const ievMatch = term.match(/^IEV:(\d+[-\d]+)$/);
|
|
372
379
|
if (ievMatch) {
|
|
373
380
|
const dsId = refMaps.refPrefixMap['IEV'];
|
|
374
|
-
if (dsId) return
|
|
381
|
+
if (dsId) return buildConceptUri(refMaps.uriBase, dsId, ievMatch[1]);
|
|
375
382
|
}
|
|
376
383
|
return null;
|
|
377
384
|
}
|
|
@@ -405,7 +412,7 @@ function resolveIevRef(display, term, refPrefixMap, uriBase) {
|
|
|
405
412
|
if (!display.startsWith('IEV:')) return null;
|
|
406
413
|
const datasetId = refPrefixMap['IEV'];
|
|
407
414
|
if (!datasetId) return null;
|
|
408
|
-
return { id:
|
|
415
|
+
return { id: buildConceptUri(uriBase, datasetId, display.slice(4)), term };
|
|
409
416
|
}
|
|
410
417
|
|
|
411
418
|
/**
|
|
@@ -414,7 +421,7 @@ function resolveIevRef(display, term, refPrefixMap, uriBase) {
|
|
|
414
421
|
function resolvePatternRef(identifier, display, refPrefixMap, patternIndex, uriBase) {
|
|
415
422
|
const r = patternIndex.resolve(identifier);
|
|
416
423
|
if (!r) return null;
|
|
417
|
-
return { id:
|
|
424
|
+
return { id: buildConceptUri(uriBase, r.datasetId, r.conceptId), term: display };
|
|
418
425
|
}
|
|
419
426
|
|
|
420
427
|
/**
|
|
@@ -444,7 +451,7 @@ function handleCiteRef(parsed, allSources) {
|
|
|
444
451
|
function handleNumeric(parsed, register, uriBase) {
|
|
445
452
|
if (!register) return null;
|
|
446
453
|
const term = parsed.label ?? parsed.id;
|
|
447
|
-
return { id:
|
|
454
|
+
return { id: buildConceptUri(uriBase, register, parsed.id), term };
|
|
448
455
|
}
|
|
449
456
|
|
|
450
457
|
/**
|
|
@@ -459,7 +466,7 @@ function handleDesignation(parsed, refMaps) {
|
|
|
459
466
|
const display = parsed.label ?? designation;
|
|
460
467
|
const conceptId = refMaps.designationLookup?.get(designation.toLowerCase());
|
|
461
468
|
return {
|
|
462
|
-
id:
|
|
469
|
+
id: buildConceptUri(refMaps.uriBase, register, conceptId ?? designation),
|
|
463
470
|
term: display,
|
|
464
471
|
};
|
|
465
472
|
}
|
|
@@ -485,7 +492,7 @@ function handleUnresolved(body, refMaps) {
|
|
|
485
492
|
// Same-dataset: {{conceptId, displayTerm}} where conceptId is numeric/X.Y
|
|
486
493
|
const register = refMaps.register;
|
|
487
494
|
if (register && (/^\d/.test(identifier) || /^[A-Z]\.\d/.test(identifier))) {
|
|
488
|
-
return { id:
|
|
495
|
+
return { id: buildConceptUri(refMaps.uriBase, register, identifier), term: display };
|
|
489
496
|
}
|
|
490
497
|
return null;
|
|
491
498
|
}
|
|
@@ -542,7 +549,7 @@ function extractInlineRefs(localizedData, refMaps, conceptSources = []) {
|
|
|
542
549
|
const term = parsed.label ?? uri;
|
|
543
550
|
const pattern = refMaps.patternIndex.resolve(uri);
|
|
544
551
|
if (pattern) {
|
|
545
|
-
ref = { id:
|
|
552
|
+
ref = { id: buildConceptUri(refMaps.uriBase, pattern.datasetId, pattern.conceptId), term };
|
|
546
553
|
} else {
|
|
547
554
|
ref = { id: uri, term };
|
|
548
555
|
}
|
|
@@ -571,7 +578,7 @@ function yamlToJsonLd(conceptYaml, register, refMaps) {
|
|
|
571
578
|
const base = refMaps.uriBase;
|
|
572
579
|
const doc = {
|
|
573
580
|
'@context': 'https://glossarist.org/ns/context.jsonld',
|
|
574
|
-
'@id':
|
|
581
|
+
'@id': buildConceptUri(base, register, termid),
|
|
575
582
|
'@type': 'gl:Concept',
|
|
576
583
|
'gl:identifier': termid,
|
|
577
584
|
};
|
|
@@ -672,6 +679,28 @@ function yamlToJsonLd(conceptYaml, register, refMaps) {
|
|
|
672
679
|
});
|
|
673
680
|
}
|
|
674
681
|
|
|
682
|
+
if (conceptYaml._partitiveHyperedges?.length > 0) {
|
|
683
|
+
doc['gl:partitiveHyperedges'] = conceptYaml._partitiveHyperedges.map(he => {
|
|
684
|
+
const out = { '@type': 'gl:PartitiveHyperedge' };
|
|
685
|
+
if (he.comprehensive) {
|
|
686
|
+
out['gl:comprehensive'] = refToJsonLd(he.comprehensive, 'gl:ConceptRef');
|
|
687
|
+
}
|
|
688
|
+
if (Array.isArray(he.parts) && he.parts.length > 0) {
|
|
689
|
+
out['gl:hasPart'] = he.parts.map(p => refToJsonLd(p, 'gl:ConceptRef'));
|
|
690
|
+
}
|
|
691
|
+
if (he.enumeration) {
|
|
692
|
+
out['gl:enumeration'] = he.enumeration;
|
|
693
|
+
}
|
|
694
|
+
if (Array.isArray(he.markers) && he.markers.length > 0) {
|
|
695
|
+
out['gl:hasPluralityMarker'] = [...he.markers];
|
|
696
|
+
}
|
|
697
|
+
if (he.content) {
|
|
698
|
+
out['gl:content'] = typeof he.content === 'string' ? { default: he.content } : he.content;
|
|
699
|
+
}
|
|
700
|
+
return out;
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
675
704
|
return doc;
|
|
676
705
|
}
|
|
677
706
|
|
|
@@ -920,8 +949,60 @@ async function processDataset(dir, register, opts) {
|
|
|
920
949
|
|
|
921
950
|
const dsRefMaps = { ...refMaps, register, designationLookup };
|
|
922
951
|
|
|
923
|
-
const
|
|
924
|
-
|
|
952
|
+
const stats = {
|
|
953
|
+
sourceMap: new Map(),
|
|
954
|
+
relTypeCounts: {},
|
|
955
|
+
partitiveHyperedges: { count: 0, byEnumeration: { closed: 0, open: 0 }, byMarker: { double: 0, dashed: 0, none: 0 } },
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
const STATS_PROCESSORS = [
|
|
959
|
+
function collectSources(cy, termid, s) {
|
|
960
|
+
const refs = new Set();
|
|
961
|
+
const allSources = [
|
|
962
|
+
...(cy._sources || []),
|
|
963
|
+
...opts.languages.flatMap(l => cy[l]?.sources || []),
|
|
964
|
+
];
|
|
965
|
+
for (const src of allSources) {
|
|
966
|
+
const ref = src.origin?.ref?.source || src.origin?.ref?.id || 'Unknown';
|
|
967
|
+
refs.add(ref);
|
|
968
|
+
if (!s.sourceMap.has(ref)) s.sourceMap.set(ref, { ref, types: new Set(), conceptIds: [] });
|
|
969
|
+
if (src.type) s.sourceMap.get(ref).types.add(src.type);
|
|
970
|
+
}
|
|
971
|
+
for (const ref of refs) s.sourceMap.get(ref)?.conceptIds.push(termid);
|
|
972
|
+
},
|
|
973
|
+
function countRelationships(cy, _termid, s) {
|
|
974
|
+
for (const r of cy._related || []) {
|
|
975
|
+
const type = r.type || 'unknown';
|
|
976
|
+
s.relTypeCounts[type] = (s.relTypeCounts[type] || 0) + 1;
|
|
977
|
+
}
|
|
978
|
+
},
|
|
979
|
+
function countHyperedges(cy, _termid, s) {
|
|
980
|
+
for (const he of cy._partitiveHyperedges || []) {
|
|
981
|
+
s.partitiveHyperedges.count += 1;
|
|
982
|
+
const e = he.enumeration || 'closed';
|
|
983
|
+
s.partitiveHyperedges.byEnumeration[e] = (s.partitiveHyperedges.byEnumeration[e] || 0) + 1;
|
|
984
|
+
const markers = he.markers || [];
|
|
985
|
+
if (markers.length === 0) {
|
|
986
|
+
s.partitiveHyperedges.byMarker.none += 1;
|
|
987
|
+
} else {
|
|
988
|
+
for (const m of markers) {
|
|
989
|
+
s.partitiveHyperedges.byMarker[m] = (s.partitiveHyperedges.byMarker[m] || 0) + 1;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
},
|
|
994
|
+
function countLanguages(cy, _termid, _s) {
|
|
995
|
+
for (const lang of opts.languages) {
|
|
996
|
+
const lc = cy[lang];
|
|
997
|
+
if (lc) {
|
|
998
|
+
if (lc.terms?.length > 0) langTermCounts[lang] = (langTermCounts[lang] || 0) + 1;
|
|
999
|
+
if (lc.definition && (Array.isArray(lc.definition) ? lc.definition.some(d => d.content) : lc.definition)) {
|
|
1000
|
+
langDefCounts[lang] = (langDefCounts[lang] || 0) + 1;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
},
|
|
1005
|
+
];
|
|
925
1006
|
|
|
926
1007
|
for (let i = 0; i < files.length; i++) {
|
|
927
1008
|
const file = files[i];
|
|
@@ -933,21 +1014,17 @@ async function processDataset(dir, register, opts) {
|
|
|
933
1014
|
const jsonld = yamlToJsonLd(conceptYaml, register, dsRefMaps);
|
|
934
1015
|
writeJson(path.join(conceptsDir, `${termid}.json`), jsonld);
|
|
935
1016
|
|
|
936
|
-
// Generate Turtle format
|
|
937
1017
|
const ttlContent = conceptJsonToTurtle(jsonld);
|
|
938
1018
|
fs.writeFileSync(path.join(conceptsDir, `${termid}.ttl`), ttlContent);
|
|
939
1019
|
|
|
940
|
-
// Generate SKOS JSON-LD format
|
|
941
1020
|
const skosJsonLd = conceptJsonToSkosJsonLd(jsonld);
|
|
942
1021
|
fs.writeFileSync(path.join(conceptsDir, `${termid}.jsonld`), skosJsonLd);
|
|
943
1022
|
|
|
944
|
-
// Generate TBX-XML format
|
|
945
1023
|
const tbxContent = conceptJsonToTbx(jsonld);
|
|
946
1024
|
if (tbxContent) {
|
|
947
1025
|
fs.writeFileSync(path.join(conceptsDir, `${termid}.tbx`), tbxContent);
|
|
948
1026
|
}
|
|
949
1027
|
|
|
950
|
-
// Copy source YAML
|
|
951
1028
|
fs.copyFileSync(path.join(dir, file), path.join(conceptsDir, `${termid}.yaml`));
|
|
952
1029
|
|
|
953
1030
|
concepts.push({
|
|
@@ -957,38 +1034,8 @@ async function processDataset(dir, register, opts) {
|
|
|
957
1034
|
status: conceptYaml.eng?.entry_status || 'valid',
|
|
958
1035
|
});
|
|
959
1036
|
|
|
960
|
-
const
|
|
961
|
-
|
|
962
|
-
...(conceptYaml._sources || []),
|
|
963
|
-
...opts.languages.flatMap(l => conceptYaml[l]?.sources || []),
|
|
964
|
-
];
|
|
965
|
-
for (const src of allSourceArrays) {
|
|
966
|
-
const ref = src.origin?.ref?.source || src.origin?.ref?.id || 'Unknown';
|
|
967
|
-
conceptSourceRefs.add(ref);
|
|
968
|
-
if (!sourceMap.has(ref)) {
|
|
969
|
-
sourceMap.set(ref, { ref, types: new Set(), conceptIds: [] });
|
|
970
|
-
}
|
|
971
|
-
if (src.type) sourceMap.get(ref).types.add(src.type);
|
|
972
|
-
}
|
|
973
|
-
for (const ref of conceptSourceRefs) {
|
|
974
|
-
sourceMap.get(ref)?.conceptIds.push(termid);
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
for (const r of conceptYaml._related || []) {
|
|
978
|
-
const type = r.type || 'unknown';
|
|
979
|
-
relTypeCounts[type] = (relTypeCounts[type] || 0) + 1;
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
for (const lang of opts.languages) {
|
|
983
|
-
const lc = conceptYaml[lang];
|
|
984
|
-
if (lc) {
|
|
985
|
-
if (lc.terms && lc.terms.length > 0) {
|
|
986
|
-
langTermCounts[lang] = (langTermCounts[lang] || 0) + 1;
|
|
987
|
-
}
|
|
988
|
-
if (lc.definition && (Array.isArray(lc.definition) ? lc.definition.some(d => d.content) : lc.definition)) {
|
|
989
|
-
langDefCounts[lang] = (langDefCounts[lang] || 0) + 1;
|
|
990
|
-
}
|
|
991
|
-
}
|
|
1037
|
+
for (const processor of STATS_PROCESSORS) {
|
|
1038
|
+
processor(conceptYaml, termid, stats);
|
|
992
1039
|
}
|
|
993
1040
|
} catch (e) {
|
|
994
1041
|
console.warn(` Skipping ${file}: ${e.message}`);
|
|
@@ -1097,7 +1144,7 @@ async function processDataset(dir, register, opts) {
|
|
|
1097
1144
|
}
|
|
1098
1145
|
}
|
|
1099
1146
|
|
|
1100
|
-
const sourceStats = Array.from(sourceMap.values())
|
|
1147
|
+
const sourceStats = Array.from(stats.sourceMap.values())
|
|
1101
1148
|
.map(s => ({
|
|
1102
1149
|
ref: s.ref,
|
|
1103
1150
|
types: Array.from(s.types),
|
|
@@ -1105,13 +1152,14 @@ async function processDataset(dir, register, opts) {
|
|
|
1105
1152
|
conceptIds: s.conceptIds,
|
|
1106
1153
|
}))
|
|
1107
1154
|
.sort((a, b) => b.conceptCount - a.conceptCount);
|
|
1108
|
-
const totalRelationships = Object.values(relTypeCounts).reduce((a, b) => a + b, 0);
|
|
1155
|
+
const totalRelationships = Object.values(stats.relTypeCounts).reduce((a, b) => a + b, 0);
|
|
1109
1156
|
|
|
1110
1157
|
writeJson(path.join(DATA, register, 'stats.json'), {
|
|
1111
1158
|
sourceCount: sourceStats.length,
|
|
1112
1159
|
sources: sourceStats,
|
|
1113
1160
|
relationshipCount: totalRelationships,
|
|
1114
|
-
relationshipTypes: relTypeCounts,
|
|
1161
|
+
relationshipTypes: stats.relTypeCounts,
|
|
1162
|
+
partitiveHyperedges: stats.partitiveHyperedges,
|
|
1115
1163
|
});
|
|
1116
1164
|
|
|
1117
1165
|
const manifest = {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { hyperedgeStyle, markerColor, enumerationLabel } from '../utils/hyperedge-styling';
|
|
3
|
+
|
|
4
|
+
describe('hyperedge-styling', () => {
|
|
5
|
+
describe('hyperedgeStyle', () => {
|
|
6
|
+
it('returns full opacity for closed enumeration', () => {
|
|
7
|
+
expect(hyperedgeStyle('closed', [], false).opacity).toBe(1.0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('returns reduced opacity for open enumeration', () => {
|
|
11
|
+
expect(hyperedgeStyle('open', [], false).opacity).toBe(0.6);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('uses teal badge class for closed with no markers', () => {
|
|
15
|
+
expect(hyperedgeStyle('closed', [], false).badgeClass).toBe('badge-teal');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('uses blue badge class for double marker', () => {
|
|
19
|
+
expect(hyperedgeStyle('closed', ['double'], false).badgeClass).toBe('badge-blue');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('uses yellow badge class for dashed marker', () => {
|
|
23
|
+
expect(hyperedgeStyle('closed', ['dashed'], false).badgeClass).toBe('badge-yellow');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns different colors in dark mode', () => {
|
|
27
|
+
const light = hyperedgeStyle('closed', [], false);
|
|
28
|
+
const dark = hyperedgeStyle('closed', [], true);
|
|
29
|
+
expect(light.color).not.toBe(dark.color);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('markerColor', () => {
|
|
34
|
+
it('returns blue for double marker', () => {
|
|
35
|
+
expect(markerColor('double', false)).toBe('#3b82f6');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns amber for dashed marker', () => {
|
|
39
|
+
expect(markerColor('dashed', false)).toBe('#f59e0b');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns dark-mode variants', () => {
|
|
43
|
+
expect(markerColor('double', true)).toBe('#60a5fa');
|
|
44
|
+
expect(markerColor('dashed', true)).toBe('#fbbf24');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('enumerationLabel', () => {
|
|
49
|
+
it('labels closed as "Closed (complete)"', () => {
|
|
50
|
+
expect(enumerationLabel('closed')).toBe('Closed (complete)');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('labels open as "Open (partial)"', () => {
|
|
54
|
+
expect(enumerationLabel('open')).toBe('Open (partial)');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { PartitiveHyperedge as JsonLdPartitiveHyperedge } from '../adapters/types';
|
|
3
|
+
|
|
4
|
+
// Re-implements the extractor under test so we can test it in isolation
|
|
5
|
+
// without spinning up the full build-edges.js pipeline. The production
|
|
6
|
+
// extractor in scripts/build-edges.js uses the same logic.
|
|
7
|
+
function extractPartitiveHyperedges(
|
|
8
|
+
concept: { '@id': string; 'gl:partitiveHyperedges'?: any[] },
|
|
9
|
+
registerId: string,
|
|
10
|
+
uriBase: string,
|
|
11
|
+
urnMap: Map<string, string>,
|
|
12
|
+
): JsonLdPartitiveHyperedge[] {
|
|
13
|
+
const hyperedges: JsonLdPartitiveHyperedge[] = [];
|
|
14
|
+
const sourceUri = concept['@id'];
|
|
15
|
+
|
|
16
|
+
const resolveConceptUri = (ref: any): string | null => {
|
|
17
|
+
const source = ref?.['gl:source'] || ref?.source;
|
|
18
|
+
const id = ref?.['gl:id'] || ref?.id;
|
|
19
|
+
if (!source || !id) return null;
|
|
20
|
+
const reg = urnMap.get(source) || source;
|
|
21
|
+
return `${uriBase}/${reg}/concept/${id}`;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
for (const he of concept['gl:partitiveHyperedges'] || []) {
|
|
25
|
+
const comprehensive = resolveConceptUri(he['gl:comprehensive']);
|
|
26
|
+
if (!comprehensive) continue;
|
|
27
|
+
const parts = (he['gl:hasPart'] || [])
|
|
28
|
+
.map(resolveConceptUri)
|
|
29
|
+
.filter((p: string | null): p is string => !!p && p !== sourceUri);
|
|
30
|
+
if (parts.length === 0) continue;
|
|
31
|
+
const rawMarkers = he['gl:hasPluralityMarker'] || [];
|
|
32
|
+
for (const m of rawMarkers) {
|
|
33
|
+
if (m !== 'double' && m !== 'dashed') {
|
|
34
|
+
throw new Error(`Invalid partitive hyperedge marker: "${m}". Allowed: double, dashed`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
hyperedges.push({
|
|
38
|
+
source: sourceUri,
|
|
39
|
+
comprehensive,
|
|
40
|
+
parts,
|
|
41
|
+
enumeration: he['gl:enumeration'] || 'closed',
|
|
42
|
+
markers: rawMarkers as ('double' | 'dashed')[],
|
|
43
|
+
label: he['gl:content'] || undefined,
|
|
44
|
+
register: registerId,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return hyperedges;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('extractPartitiveHyperedges', () => {
|
|
51
|
+
const uriBase = 'https://example.org';
|
|
52
|
+
const urnMap = new Map([['urn:vim:pub:v:2:2012', 'vim-2012']]);
|
|
53
|
+
const registerId = 'vim-2012';
|
|
54
|
+
const sourceUri = `${uriBase}/${registerId}/concept/2.9`;
|
|
55
|
+
|
|
56
|
+
it('extracts a closed hyperedge with double marker', () => {
|
|
57
|
+
const concept = {
|
|
58
|
+
'@id': sourceUri,
|
|
59
|
+
'gl:partitiveHyperedges': [
|
|
60
|
+
{
|
|
61
|
+
'gl:comprehensive': {
|
|
62
|
+
'gl:source': 'urn:vim:pub:v:2:2012',
|
|
63
|
+
'gl:id': '2.9',
|
|
64
|
+
},
|
|
65
|
+
'gl:hasPart': [
|
|
66
|
+
{ 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.10' },
|
|
67
|
+
{ 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.26' },
|
|
68
|
+
],
|
|
69
|
+
'gl:enumeration': 'closed',
|
|
70
|
+
'gl:hasPluralityMarker': ['double'],
|
|
71
|
+
'gl:content': 'value + uncertainty',
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const result = extractPartitiveHyperedges(concept, registerId, uriBase, urnMap);
|
|
77
|
+
expect(result).toHaveLength(1);
|
|
78
|
+
expect(result[0].source).toBe(sourceUri);
|
|
79
|
+
expect(result[0].comprehensive).toBe(`${uriBase}/${registerId}/concept/2.9`);
|
|
80
|
+
expect(result[0].parts).toEqual([
|
|
81
|
+
`${uriBase}/${registerId}/concept/2.10`,
|
|
82
|
+
`${uriBase}/${registerId}/concept/2.26`,
|
|
83
|
+
]);
|
|
84
|
+
expect(result[0].enumeration).toBe('closed');
|
|
85
|
+
expect(result[0].markers).toEqual(['double']);
|
|
86
|
+
expect(result[0].label).toBe('value + uncertainty');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('defaults enumeration to closed when omitted', () => {
|
|
90
|
+
const concept = {
|
|
91
|
+
'@id': sourceUri,
|
|
92
|
+
'gl:partitiveHyperedges': [
|
|
93
|
+
{
|
|
94
|
+
'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.9' },
|
|
95
|
+
'gl:hasPart': [{ 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.10' }],
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
const [he] = extractPartitiveHyperedges(concept, registerId, uriBase, urnMap);
|
|
100
|
+
expect(he.enumeration).toBe('closed');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('preserves open enumeration', () => {
|
|
104
|
+
const concept = {
|
|
105
|
+
'@id': sourceUri,
|
|
106
|
+
'gl:partitiveHyperedges': [
|
|
107
|
+
{
|
|
108
|
+
'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
|
|
109
|
+
'gl:hasPart': [{ 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' }],
|
|
110
|
+
'gl:enumeration': 'open',
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
const [he] = extractPartitiveHyperedges(concept, registerId, uriBase, urnMap);
|
|
115
|
+
expect(he.enumeration).toBe('open');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('preserves both markers', () => {
|
|
119
|
+
const concept = {
|
|
120
|
+
'@id': sourceUri,
|
|
121
|
+
'gl:partitiveHyperedges': [
|
|
122
|
+
{
|
|
123
|
+
'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
|
|
124
|
+
'gl:hasPart': [{ 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' }],
|
|
125
|
+
'gl:enumeration': 'open',
|
|
126
|
+
'gl:hasPluralityMarker': ['double', 'dashed'],
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
const [he] = extractPartitiveHyperedges(concept, registerId, uriBase, urnMap);
|
|
131
|
+
expect(he.markers).toEqual(['double', 'dashed']);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('throws on invalid marker values', () => {
|
|
135
|
+
const concept = {
|
|
136
|
+
'@id': sourceUri,
|
|
137
|
+
'gl:partitiveHyperedges': [
|
|
138
|
+
{
|
|
139
|
+
'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.3' },
|
|
140
|
+
'gl:hasPart': [{ 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' }],
|
|
141
|
+
'gl:hasPluralityMarker': ['double', 'dotted'],
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
};
|
|
145
|
+
expect(() => extractPartitiveHyperedges(concept, registerId, uriBase, urnMap)).toThrow(/Invalid.*marker.*dotted/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('skips a hyperedge whose comprehensive cannot be resolved', () => {
|
|
149
|
+
const concept = {
|
|
150
|
+
'@id': sourceUri,
|
|
151
|
+
'gl:partitiveHyperedges': [
|
|
152
|
+
{
|
|
153
|
+
'gl:comprehensive': { 'gl:id': '1.3' }, // missing source
|
|
154
|
+
'gl:hasPart': [{ 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '1.4' }],
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
};
|
|
158
|
+
const result = extractPartitiveHyperedges(concept, registerId, uriBase, urnMap);
|
|
159
|
+
expect(result).toHaveLength(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('skips a hyperedge with no resolvable parts', () => {
|
|
163
|
+
const concept = {
|
|
164
|
+
'@id': sourceUri,
|
|
165
|
+
'gl:partitiveHyperedges': [
|
|
166
|
+
{
|
|
167
|
+
'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.9' },
|
|
168
|
+
'gl:hasPart': [{ 'gl:id': '1.4' }], // missing source
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
};
|
|
172
|
+
const result = extractPartitiveHyperedges(concept, registerId, uriBase, urnMap);
|
|
173
|
+
expect(result).toHaveLength(0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('excludes the source concept from parts list (self-loop guard)', () => {
|
|
177
|
+
const concept = {
|
|
178
|
+
'@id': sourceUri,
|
|
179
|
+
'gl:partitiveHyperedges': [
|
|
180
|
+
{
|
|
181
|
+
'gl:comprehensive': { 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.9' },
|
|
182
|
+
'gl:hasPart': [
|
|
183
|
+
{ 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.10' },
|
|
184
|
+
{ 'gl:source': 'urn:vim:pub:v:2:2012', 'gl:id': '2.9' }, // self-ref
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
};
|
|
189
|
+
const [he] = extractPartitiveHyperedges(concept, registerId, uriBase, urnMap);
|
|
190
|
+
expect(he.parts).toHaveLength(1);
|
|
191
|
+
expect(he.parts[0]).toBe(`${uriBase}/${registerId}/concept/2.10`);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('returns empty array when concept has no partitiveHyperedges', () => {
|
|
195
|
+
const concept = { '@id': sourceUri };
|
|
196
|
+
const result = extractPartitiveHyperedges(concept, registerId, uriBase, urnMap);
|
|
197
|
+
expect(result).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { hashSeed, expandParams, portSide, portPoint, idToUriGet } from '../utils/sphere-math';
|
|
3
|
+
|
|
4
|
+
describe('sphere-math', () => {
|
|
5
|
+
describe('hashSeed', () => {
|
|
6
|
+
it('produces deterministic positive integers', () => {
|
|
7
|
+
expect(hashSeed('abc')).toBe(hashSeed('abc'));
|
|
8
|
+
expect(hashSeed('abc')).not.toBe(hashSeed('abd'));
|
|
9
|
+
expect(hashSeed('')).toBeGreaterThanOrEqual(0);
|
|
10
|
+
expect(hashSeed('test')).toBeGreaterThan(0);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('expandParams', () => {
|
|
15
|
+
it('clamps to [0, 10] and maps to physical constants', () => {
|
|
16
|
+
const min = expandParams(0);
|
|
17
|
+
const max = expandParams(10);
|
|
18
|
+
expect(min.linkDist).toBeLessThan(max.linkDist);
|
|
19
|
+
expect(min.repMin).toBeLessThan(max.repMin);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('clamps out-of-range values', () => {
|
|
23
|
+
expect(expandParams(-5)).toEqual(expandParams(0));
|
|
24
|
+
expect(expandParams(20)).toEqual(expandParams(10));
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('portSide', () => {
|
|
29
|
+
it('determines the dominant axis direction', () => {
|
|
30
|
+
expect(portSide({ x: 0, y: 0 }, { x: 10, y: 0 })).toBe('right');
|
|
31
|
+
expect(portSide({ x: 0, y: 0 }, { x: -10, y: 0 })).toBe('left');
|
|
32
|
+
expect(portSide({ x: 0, y: 0 }, { x: 0, y: 10 })).toBe('bottom');
|
|
33
|
+
expect(portSide({ x: 0, y: 0 }, { x: 0, y: -10 })).toBe('top');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('portPoint', () => {
|
|
38
|
+
it('computes the connection point on each side', () => {
|
|
39
|
+
const c = { x: 100, y: 100 };
|
|
40
|
+
const w = 220, h = 60;
|
|
41
|
+
const right = portPoint(c, 'right', w, h, 0);
|
|
42
|
+
expect(right.x).toBe(210);
|
|
43
|
+
expect(right.y).toBe(100);
|
|
44
|
+
const top = portPoint(c, 'top', w, h, 0);
|
|
45
|
+
expect(top.y).toBe(70);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('idToUriGet', () => {
|
|
50
|
+
it('reverse-looks-up a URI in an ID→URI map', () => {
|
|
51
|
+
const map = new Map([['a', '/uri/a'], ['b', '/uri/b']]);
|
|
52
|
+
expect(idToUriGet(map, '/uri/b')).toBe('b');
|
|
53
|
+
expect(idToUriGet(map, '/uri/unknown')).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { GraphEdge, GraphNode, SectionNode } from './types';
|
|
1
|
+
import type { GraphEdge, GraphNode, PartitiveHyperedge, SectionNode } from './types';
|
|
2
2
|
import type { Concept, RelatedConcept } from 'glossarist';
|
|
3
3
|
import type { DatasetAdapter } from './DatasetAdapter';
|
|
4
4
|
import { UriRouter } from './UriRouter';
|
|
@@ -58,6 +58,13 @@ export class GraphDataSource {
|
|
|
58
58
|
return data.edges ?? [];
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
async loadHyperedges(): Promise<PartitiveHyperedge[]> {
|
|
62
|
+
const resp = await fetch(`${this.baseUrl}/hyperedges.json`);
|
|
63
|
+
if (!resp.ok) return [];
|
|
64
|
+
const data = await resp.json();
|
|
65
|
+
return data.hyperedges ?? [];
|
|
66
|
+
}
|
|
67
|
+
|
|
61
68
|
async loadGraphNodes(): Promise<{ uriPrefix: string; nodes: [string, Record<string, string>, string][] }> {
|
|
62
69
|
const resp = await fetch(`${this.baseUrl}/graph-nodes.json`);
|
|
63
70
|
if (!resp.ok) return { uriPrefix: '', nodes: [] };
|
|
@@ -73,7 +80,7 @@ export class GraphDataSource {
|
|
|
73
80
|
|
|
74
81
|
extractEdges(concept: Concept): GraphEdge[] {
|
|
75
82
|
const edges: GraphEdge[] = [];
|
|
76
|
-
const sourceUri = concept.uri ||
|
|
83
|
+
const sourceUri = concept.uri || UriRouter.buildConceptUri(this.uriBase, this.registerId, concept.id);
|
|
77
84
|
|
|
78
85
|
for (const rc of concept.relatedConcepts) {
|
|
79
86
|
const target = resolveRefTarget(rc, this.uriBase, this.registerId, this.urnMap);
|
|
@@ -113,7 +120,7 @@ export class GraphDataSource {
|
|
|
113
120
|
|
|
114
121
|
extractDomainEdges(concept: Concept): GraphEdge[] {
|
|
115
122
|
const edges: GraphEdge[] = [];
|
|
116
|
-
const sourceUri = concept.uri ||
|
|
123
|
+
const sourceUri = concept.uri || UriRouter.buildConceptUri(this.uriBase, this.registerId, concept.id);
|
|
117
124
|
|
|
118
125
|
for (const lang of concept.languages) {
|
|
119
126
|
const lc = concept.localization(lang);
|
|
@@ -101,6 +101,11 @@ export class UriRouter {
|
|
|
101
101
|
return m ? { registerId: m[1], conceptId: m[2] } : null;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/** Construct a canonical concept URI from components. */
|
|
105
|
+
static buildConceptUri(uriBase: string, registerId: string, conceptId: string): string {
|
|
106
|
+
return `${uriBase}/${registerId}/concept/${conceptId}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
104
109
|
/** Construct a canonical URI for a concept. */
|
|
105
110
|
buildUri(registerId: string, conceptId: string): string {
|
|
106
111
|
const info = this.registerMap.get(registerId);
|
|
@@ -287,7 +287,7 @@ function attachBridges(concept: Concept, localizations: Record<string, unknown>)
|
|
|
287
287
|
* instances, since RelatedConcept.fromJSON only reads type/content/ref.
|
|
288
288
|
*/
|
|
289
289
|
function attachRelatedBridges(
|
|
290
|
-
modelRelated: Array<{ type?: string | null; content?: string | null; ref?: any; related?: any[] }>,
|
|
290
|
+
modelRelated: Array<{ type?: string | null; content?: Record<string, string> | string | null; ref?: any; related?: any[] }>,
|
|
291
291
|
rawRelated: unknown[],
|
|
292
292
|
): void {
|
|
293
293
|
for (const rawRel of rawRelated) {
|
package/src/adapters/types.ts
CHANGED
|
@@ -148,6 +148,32 @@ export interface GraphEdge {
|
|
|
148
148
|
lang?: string;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* One-to-many partitive decomposition with optional plurality markers
|
|
153
|
+
* and enumeration completeness (open vs closed).
|
|
154
|
+
*
|
|
155
|
+
* Independent of {@link GraphEdge} (which stays binary). The UI renders
|
|
156
|
+
* hyperedges specially (rake visualisation, enumeration/marker badges).
|
|
157
|
+
*
|
|
158
|
+
* See concept-model/TODO.hyperedge/00-design-overview.md.
|
|
159
|
+
*/
|
|
160
|
+
export interface PartitiveHyperedge {
|
|
161
|
+
/** URI of the concept that owns this hyperedge (the source). */
|
|
162
|
+
source: string;
|
|
163
|
+
/** URI of the comprehensive (whole) concept — usually equals `source`. */
|
|
164
|
+
comprehensive: string;
|
|
165
|
+
/** URIs of the partitive concepts. At least one. */
|
|
166
|
+
parts: string[];
|
|
167
|
+
/** 'closed' (all parts encoded) or 'open' (partial). Defaults to 'closed'. */
|
|
168
|
+
enumeration: 'closed' | 'open';
|
|
169
|
+
/** Diagram notation flags. Empty array if none. */
|
|
170
|
+
markers: ('double' | 'dashed')[];
|
|
171
|
+
/** Optional human-readable label/note. */
|
|
172
|
+
label?: string;
|
|
173
|
+
/** Owning dataset id. */
|
|
174
|
+
register: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
151
177
|
export interface GraphNode {
|
|
152
178
|
uri: string;
|
|
153
179
|
register: string;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* PartitiveHyperedgeList — renders one-to-many partitive decompositions
|
|
4
|
+
* as grouped cards. Each hyperedge shows:
|
|
5
|
+
* - the comprehensive (whole) concept as the card header
|
|
6
|
+
* - the parts as indented children
|
|
7
|
+
* - enumeration badge (closed / open)
|
|
8
|
+
* - marker badges (double / dashed) when present
|
|
9
|
+
* - optional content (label / note)
|
|
10
|
+
*
|
|
11
|
+
* Independent of RelationshipList.vue, which renders binary edges.
|
|
12
|
+
* Both consume the same edges/hyperedges stores but render in
|
|
13
|
+
* separate sections.
|
|
14
|
+
*
|
|
15
|
+
* See concept-model/TODO.hyperedge/00-design-overview.md.
|
|
16
|
+
*/
|
|
17
|
+
import type { PartitiveHyperedge, Manifest } from '../adapters/types';
|
|
18
|
+
import { useRouter } from 'vue-router';
|
|
19
|
+
import { useVocabularyStore } from '../stores/vocabulary';
|
|
20
|
+
import { getFactory } from '../adapters/factory';
|
|
21
|
+
import { useI18n } from '../i18n';
|
|
22
|
+
import { hyperedgeStyle, markerColor, enumerationLabel } from '../utils/hyperedge-styling';
|
|
23
|
+
import { conceptIdFromUri } from '../utils/concept-helpers';
|
|
24
|
+
|
|
25
|
+
const props = defineProps<{
|
|
26
|
+
hyperedges: PartitiveHyperedge[];
|
|
27
|
+
manifest: Manifest;
|
|
28
|
+
registerId: string;
|
|
29
|
+
}>();
|
|
30
|
+
|
|
31
|
+
const emit = defineEmits<{
|
|
32
|
+
(e: 'navigate', registerId: string, conceptId: string): void;
|
|
33
|
+
}>();
|
|
34
|
+
|
|
35
|
+
const router = useRouter();
|
|
36
|
+
const store = useVocabularyStore();
|
|
37
|
+
const factory = getFactory();
|
|
38
|
+
const { t } = useI18n();
|
|
39
|
+
|
|
40
|
+
function labelFor(uri: string): string {
|
|
41
|
+
const parsed = factory.resolve(uri);
|
|
42
|
+
if (parsed.type === 'internal') {
|
|
43
|
+
return conceptIdFromUri(uri) || uri;
|
|
44
|
+
}
|
|
45
|
+
return uri;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function navigate(uri: string) {
|
|
49
|
+
const parsed = factory.resolve(uri);
|
|
50
|
+
if (parsed.type === 'internal') {
|
|
51
|
+
store.viewConcept(parsed.registerId, parsed.conceptId);
|
|
52
|
+
router.push({
|
|
53
|
+
name: 'concept',
|
|
54
|
+
params: { registerId: parsed.registerId, conceptId: parsed.conceptId },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function enumerationBadge(e: 'closed' | 'open'): string {
|
|
60
|
+
return e === 'open'
|
|
61
|
+
? t('partitive.enumeration.open') || 'open'
|
|
62
|
+
: t('partitive.enumeration.closed') || 'closed';
|
|
63
|
+
}
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<template>
|
|
67
|
+
<div v-if="hyperedges.length > 0" class="space-y-3">
|
|
68
|
+
<div class="section-label flex items-center gap-1.5 mb-2">
|
|
69
|
+
<span class="inline-block w-2 h-2 rounded-full bg-emerald-600"></span>
|
|
70
|
+
{{ t('partitive.hyperedges') || 'Partitive groups' }}
|
|
71
|
+
</div>
|
|
72
|
+
<div
|
|
73
|
+
v-for="(he, i) in hyperedges"
|
|
74
|
+
:key="i"
|
|
75
|
+
class="rounded-lg border border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-900/20 overflow-hidden"
|
|
76
|
+
>
|
|
77
|
+
<div class="px-3 py-2 flex items-center gap-2 border-b border-emerald-200 dark:border-emerald-800">
|
|
78
|
+
<span class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-200 dark:bg-emerald-800 text-emerald-900 dark:text-emerald-100 uppercase">
|
|
79
|
+
{{ enumerationBadge(he.enumeration) }}
|
|
80
|
+
</span>
|
|
81
|
+
<span
|
|
82
|
+
v-for="marker in he.markers"
|
|
83
|
+
:key="marker"
|
|
84
|
+
class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-amber-200 dark:bg-amber-800 text-amber-900 dark:text-amber-100 uppercase"
|
|
85
|
+
:title="marker === 'double' ? 'close-set double line: several parts of same type' : 'dashed line: plurality uncertain'"
|
|
86
|
+
>
|
|
87
|
+
{{ marker }}
|
|
88
|
+
</span>
|
|
89
|
+
<button
|
|
90
|
+
@click="navigate(he.comprehensive)"
|
|
91
|
+
class="ml-auto text-sm font-medium text-emerald-900 dark:text-emerald-100 hover:underline truncate"
|
|
92
|
+
>
|
|
93
|
+
{{ labelFor(he.comprehensive) }}
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
<div v-if="he.label" class="px-3 py-1 text-xs text-emerald-800 dark:text-emerald-200 italic border-b border-emerald-200 dark:border-emerald-800">
|
|
97
|
+
{{ he.label }}
|
|
98
|
+
</div>
|
|
99
|
+
<ul class="divide-y divide-emerald-100 dark:divide-emerald-900/30">
|
|
100
|
+
<li v-for="(part, j) in he.parts" :key="j">
|
|
101
|
+
<button
|
|
102
|
+
@click="navigate(part)"
|
|
103
|
+
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-emerald-100 dark:hover:bg-emerald-900/30 text-emerald-900 dark:text-emerald-100"
|
|
104
|
+
>
|
|
105
|
+
<span class="text-emerald-600 dark:text-emerald-400 mr-2">└</span>
|
|
106
|
+
{{ labelFor(part) }}
|
|
107
|
+
</button>
|
|
108
|
+
</li>
|
|
109
|
+
</ul>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</template>
|
|
@@ -21,6 +21,7 @@ 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
25
|
import { useI18n } from '../i18n';
|
|
25
26
|
|
|
26
27
|
const { t, locale } = useI18n();
|
|
@@ -168,7 +169,7 @@ async function resolveTerm(
|
|
|
168
169
|
uriBase: string,
|
|
169
170
|
factory: ReturnType<typeof getFactory>
|
|
170
171
|
): Promise<ResolvedTerm | null> {
|
|
171
|
-
const neighborUri =
|
|
172
|
+
const neighborUri = UriRouter.buildConceptUri(uriBase, n.register, n.conceptId);
|
|
172
173
|
const out: ResolvedTerm = { term: null };
|
|
173
174
|
|
|
174
175
|
/* Strategy 1: graph engine */
|
|
@@ -229,12 +230,6 @@ function updateNodeTerm(n: SNode) {
|
|
|
229
230
|
|
|
230
231
|
/* ── Build internal graph from concept + edges (BFS) ────── */
|
|
231
232
|
|
|
232
|
-
function hashSeed(s: string): number {
|
|
233
|
-
let h = 0;
|
|
234
|
-
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
|
235
|
-
return Math.abs(h);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
233
|
function buildGraph() {
|
|
239
234
|
const focusUri = conceptUri(props.concept, props.registerId, props.manifest.uriBase);
|
|
240
235
|
const focusId = props.concept?.id || props.registerId;
|
|
@@ -399,11 +394,6 @@ function buildGraph() {
|
|
|
399
394
|
}
|
|
400
395
|
|
|
401
396
|
/* Reverse lookup: URI → node id. Linear scan is fine for ≤36 nodes. */
|
|
402
|
-
function idToUriGet(map: Map<string, string>, uri: string): string | undefined {
|
|
403
|
-
for (const [id, u] of map) if (u === uri) return id;
|
|
404
|
-
return undefined;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
397
|
/* ── Custom forces (EXACT prototype replica) ────────────── */
|
|
408
398
|
function sphereConstraint() {
|
|
409
399
|
let nL: SNode[] = nodes;
|
|
@@ -443,15 +433,6 @@ function velocityClamp(maxV: number) {
|
|
|
443
433
|
visual difference between tight (0) and loose (10) is dramatic.
|
|
444
434
|
Link distance is chord distance on the unit sphere: 0.5 ≈ 29° (cards
|
|
445
435
|
cluster near focus), 1.95 ≈ 154° (cards spread to back hemisphere). */
|
|
446
|
-
function expandParams(v: number): { linkDist: number; linkStrength: number; repMin: number; repStrength: number } {
|
|
447
|
-
const t = Math.max(0, Math.min(10, v)) / 10;
|
|
448
|
-
return {
|
|
449
|
-
linkDist: 0.5 + t * 1.45, /* 0.5 → 1.95 */
|
|
450
|
-
linkStrength: 0.08,
|
|
451
|
-
repMin: 0.55 + t * 0.40, /* 0.55 → 0.95 — just prevents overlap */
|
|
452
|
-
repStrength: 0.05,
|
|
453
|
-
};
|
|
454
|
-
}
|
|
455
436
|
let linkDistance = expandParams(5).linkDist;
|
|
456
437
|
let linkStrength = expandParams(5).linkStrength;
|
|
457
438
|
let repulseMinDist = expandParams(5).repMin;
|
|
@@ -606,24 +587,9 @@ function onTick() {
|
|
|
606
587
|
/* ── Edge drawing with 4-port card connections + type labels ─ */
|
|
607
588
|
/* Determine which side of a card (top/bottom/left/right) an edge should
|
|
608
589
|
connect to, based on the direction to the other endpoint. */
|
|
609
|
-
function portSide(from: {x:number;y:number}, to: {x:number;y:number}): 'top'|'bottom'|'left'|'right' {
|
|
610
|
-
const dx = to.x - from.x, dy = to.y - from.y;
|
|
611
|
-
if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? 'right' : 'left';
|
|
612
|
-
return dy > 0 ? 'bottom' : 'top';
|
|
613
|
-
}
|
|
614
|
-
|
|
615
590
|
/* Compute the connection point on a card's side, with an offset along
|
|
616
591
|
that side so multiple edges to the same side don't stack on top of
|
|
617
592
|
each other. `offset` is in [-1, 1] (0 = midpoint, ±1 = corners). */
|
|
618
|
-
function portPoint(center: {x:number;y:number}, side: 'top'|'bottom'|'left'|'right', w: number, h: number, offset: number) {
|
|
619
|
-
switch (side) {
|
|
620
|
-
case 'right': return { x: center.x + w/2, y: center.y + (h/2 - 6) * offset };
|
|
621
|
-
case 'left': return { x: center.x - w/2, y: center.y + (h/2 - 6) * offset };
|
|
622
|
-
case 'bottom': return { x: center.x + (w/2 - 12) * offset, y: center.y + h/2 };
|
|
623
|
-
case 'top': return { x: center.x + (w/2 - 12) * offset, y: center.y - h/2 };
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
|
|
627
593
|
function drawEdges(cx: number, cy: number) {
|
|
628
594
|
const svg = edgesSvgRef.value; if (!svg) return;
|
|
629
595
|
const w = cx * 2, h = cy * 2 / 0.46;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { computed, type ComputedRef } from 'vue';
|
|
2
2
|
import type { Router } from 'vue-router';
|
|
3
|
-
import type { Concept, RelatedConcept } from 'glossarist';
|
|
4
|
-
import type { Manifest, GraphEdge } from '../adapters/types';
|
|
3
|
+
import type { Concept, PartitiveHyperedge as GlsPartitiveHyperedge, RelatedConcept } from 'glossarist';
|
|
4
|
+
import type { Manifest, GraphEdge, PartitiveHyperedge } from '../adapters/types';
|
|
5
5
|
import { getFactory } from '../adapters/factory';
|
|
6
6
|
import { conceptUri } from '../adapters/model-bridge';
|
|
7
|
+
import { UriRouter } from '../adapters/UriRouter';
|
|
7
8
|
import { useVocabularyStore } from '../stores/vocabulary';
|
|
8
9
|
import { useDsStyle } from '../utils/dataset-style';
|
|
9
10
|
import { categorizeRelationship, relationshipLabel, INVERSE_RELATIONSHIPS } from '../utils/relationship-categories';
|
|
@@ -111,9 +112,72 @@ export function useConceptEdges(
|
|
|
111
112
|
content: '',
|
|
112
113
|
};
|
|
113
114
|
});
|
|
114
|
-
return [...direct, ...derived]
|
|
115
|
+
return [...direct, ...derived].filter(edge => {
|
|
116
|
+
if (edge.type === 'broader_partitive' || edge.type === 'narrower_partitive') {
|
|
117
|
+
const heUris = conceptPartitiveHyperedges.value;
|
|
118
|
+
return !heUris.some(he => he.parts.includes(edge.ref as string) || he.comprehensive === edge.ref);
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
});
|
|
115
122
|
});
|
|
116
123
|
|
|
124
|
+
const VALID_MARKERS = new Set(['double', 'dashed']);
|
|
125
|
+
|
|
126
|
+
function validateHyperedgeMarkers(markers: Iterable<string>): ('double' | 'dashed')[] {
|
|
127
|
+
const out: ('double' | 'dashed')[] = [];
|
|
128
|
+
for (const m of markers) {
|
|
129
|
+
if (!VALID_MARKERS.has(m)) {
|
|
130
|
+
throw new Error(`Invalid partitive hyperedge marker: "${m}". Allowed: double, dashed`);
|
|
131
|
+
}
|
|
132
|
+
out.push(m as 'double' | 'dashed');
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function resolveLocalizedContent(content: unknown): string | undefined {
|
|
138
|
+
if (content == null) return undefined;
|
|
139
|
+
if (typeof content === 'string') return content;
|
|
140
|
+
if (typeof content === 'object' && content !== null) {
|
|
141
|
+
const obj = content as Record<string, string>;
|
|
142
|
+
return obj[registerId.value] ?? obj.default ?? obj.eng ?? Object.values(obj)[0];
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Concept-level partitive hyperedges (one-to-many decompositions).
|
|
148
|
+
// Each hyperedge is resolved to concrete target URIs for display.
|
|
149
|
+
// Independent of binary `conceptRelated` — see TODO.hyperedge/00.
|
|
150
|
+
const conceptPartitiveHyperedges = computed<PartitiveHyperedge[]>(() => {
|
|
151
|
+
const source = conceptUriValue.value;
|
|
152
|
+
return (concept.value.partitiveHyperedges ?? [])
|
|
153
|
+
.map((he: GlsPartitiveHyperedge): PartitiveHyperedge | null => {
|
|
154
|
+
const comprehensive = resolveRefUri(he.comprehensive);
|
|
155
|
+
if (!comprehensive) return null;
|
|
156
|
+
const parts = he.parts
|
|
157
|
+
.map(resolveRefUri)
|
|
158
|
+
.filter((u): u is string => !!u && u !== source);
|
|
159
|
+
if (parts.length === 0) return null;
|
|
160
|
+
return {
|
|
161
|
+
source,
|
|
162
|
+
comprehensive,
|
|
163
|
+
parts,
|
|
164
|
+
enumeration: he.isOpen ? 'open' : 'closed',
|
|
165
|
+
markers: validateHyperedgeMarkers(he.markers),
|
|
166
|
+
label: resolveLocalizedContent(he.content),
|
|
167
|
+
register: registerId.value,
|
|
168
|
+
};
|
|
169
|
+
})
|
|
170
|
+
.filter((he): he is PartitiveHyperedge => he !== null);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
function resolveRefUri(ref: { source?: string | null; id?: string | null } | null): string | null {
|
|
174
|
+
const target = resolveRelatedRef(ref);
|
|
175
|
+
if (!target) return null;
|
|
176
|
+
const m = store.manifests.get(target.registerId);
|
|
177
|
+
if (!m) return null;
|
|
178
|
+
return UriRouter.buildConceptUri(m.uriBase, target.registerId, target.conceptId);
|
|
179
|
+
}
|
|
180
|
+
|
|
117
181
|
function resolveRelatedRef(ref: { source: string | null; id: string | null } | null) {
|
|
118
182
|
return factory.resolveRelatedRef(ref, registerId.value);
|
|
119
183
|
}
|
|
@@ -134,8 +198,15 @@ export function useConceptEdges(
|
|
|
134
198
|
return resolvedRefs.value.get(key) ?? { target: resolveRelatedRef(ref) };
|
|
135
199
|
}
|
|
136
200
|
|
|
137
|
-
function relatedLabel(dr: { content?: string | null; ref?: { source: string | null; id: string | null } | null }): string {
|
|
138
|
-
if (dr.content)
|
|
201
|
+
function relatedLabel(dr: { content?: Record<string, string> | string | null; ref?: { source: string | null; id: string | null } | null }): string {
|
|
202
|
+
if (dr.content) {
|
|
203
|
+
// Content is a localized hash { eng: "...", fra: "..." }.
|
|
204
|
+
// Legacy data may still carry a plain string; tolerate both.
|
|
205
|
+
const c = dr.content as Record<string, string> | string;
|
|
206
|
+
if (typeof c === 'string') return c;
|
|
207
|
+
const values = Object.values(c);
|
|
208
|
+
if (values.length > 0) return values[0];
|
|
209
|
+
}
|
|
139
210
|
const resolved = dr.ref ? getResolvedRef(dr.ref).target : null;
|
|
140
211
|
if (resolved) {
|
|
141
212
|
const m = store.manifests.get(resolved.registerId);
|
|
@@ -173,6 +244,7 @@ export function useConceptEdges(
|
|
|
173
244
|
edgeBadgeColor,
|
|
174
245
|
inverseEdgeType,
|
|
175
246
|
conceptRelated,
|
|
247
|
+
conceptPartitiveHyperedges,
|
|
176
248
|
resolveRelatedRef,
|
|
177
249
|
getResolvedRef,
|
|
178
250
|
relatedLabel,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hyperedge styling — colors for partitive decompositions.
|
|
3
|
+
*
|
|
4
|
+
* Independent of relationship-categories.ts (which handles binary edges).
|
|
5
|
+
* Hyperedges have their own visual language: teal base color, enumeration
|
|
6
|
+
* affects opacity, markers add accent badges.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const BASE_LIGHT = '#0d9488';
|
|
10
|
+
const BASE_DARK = '#2dd4bf';
|
|
11
|
+
const ACCENT_DOUBLE_LIGHT = '#3b82f6';
|
|
12
|
+
const ACCENT_DOUBLE_DARK = '#60a5fa';
|
|
13
|
+
const ACCENT_DASHED_LIGHT = '#f59e0b';
|
|
14
|
+
const ACCENT_DASHED_DARK = '#fbbf24';
|
|
15
|
+
|
|
16
|
+
export interface HyperedgeStyle {
|
|
17
|
+
color: string;
|
|
18
|
+
badgeClass: string;
|
|
19
|
+
opacity: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function hyperedgeStyle(
|
|
23
|
+
enumeration: 'closed' | 'open',
|
|
24
|
+
markers: string[],
|
|
25
|
+
isDark: boolean,
|
|
26
|
+
): HyperedgeStyle {
|
|
27
|
+
const base = isDark ? BASE_DARK : BASE_LIGHT;
|
|
28
|
+
const opacity = enumeration === 'open' ? 0.6 : 1.0;
|
|
29
|
+
|
|
30
|
+
let badgeClass = 'badge-teal';
|
|
31
|
+
if (markers.includes('double')) {
|
|
32
|
+
badgeClass = isDark ? 'badge-blue' : 'badge-blue';
|
|
33
|
+
} else if (markers.includes('dashed')) {
|
|
34
|
+
badgeClass = 'badge-yellow';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { color: base, badgeClass, opacity };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function markerColor(marker: string, isDark: boolean): string {
|
|
41
|
+
if (marker === 'double') return isDark ? ACCENT_DOUBLE_DARK : ACCENT_DOUBLE_LIGHT;
|
|
42
|
+
if (marker === 'dashed') return isDark ? ACCENT_DASHED_DARK : ACCENT_DASHED_LIGHT;
|
|
43
|
+
return isDark ? BASE_DARK : BASE_LIGHT;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function enumerationLabel(enumeration: 'closed' | 'open'): string {
|
|
47
|
+
return enumeration === 'open' ? 'Open (partial)' : 'Closed (complete)';
|
|
48
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Deterministic hash from string → positive integer (for seeding positions). */
|
|
2
|
+
export function hashSeed(s: string): number {
|
|
3
|
+
let h = 0;
|
|
4
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
|
5
|
+
return Math.abs(h);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Expand-slider → simulation parameters. Maps [0, 10] to physical constants. */
|
|
9
|
+
export function expandParams(v: number): {
|
|
10
|
+
linkDist: number;
|
|
11
|
+
linkStrength: number;
|
|
12
|
+
repMin: number;
|
|
13
|
+
repStrength: number;
|
|
14
|
+
} {
|
|
15
|
+
const t = Math.max(0, Math.min(10, v)) / 10;
|
|
16
|
+
return {
|
|
17
|
+
linkDist: 0.5 + t * 1.45,
|
|
18
|
+
linkStrength: 0.08,
|
|
19
|
+
repMin: 0.55 + t * 0.40,
|
|
20
|
+
repStrength: 0.05,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Which side of a card does an edge connect to? */
|
|
25
|
+
export function portSide(
|
|
26
|
+
from: { x: number; y: number },
|
|
27
|
+
to: { x: number; y: number },
|
|
28
|
+
): 'top' | 'bottom' | 'left' | 'right' {
|
|
29
|
+
const dx = to.x - from.x, dy = to.y - from.y;
|
|
30
|
+
if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? 'right' : 'left';
|
|
31
|
+
return dy > 0 ? 'bottom' : 'top';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Connection point on a card's side, with offset to prevent stacking. */
|
|
35
|
+
export function portPoint(
|
|
36
|
+
center: { x: number; y: number },
|
|
37
|
+
side: 'top' | 'bottom' | 'left' | 'right',
|
|
38
|
+
w: number,
|
|
39
|
+
h: number,
|
|
40
|
+
offset: number,
|
|
41
|
+
): { x: number; y: number } {
|
|
42
|
+
switch (side) {
|
|
43
|
+
case 'right': return { x: center.x + w / 2, y: center.y + (h / 2 - 6) * offset };
|
|
44
|
+
case 'left': return { x: center.x - w / 2, y: center.y + (h / 2 - 6) * offset };
|
|
45
|
+
case 'bottom': return { x: center.x + (w / 2 - 12) * offset, y: center.y + h / 2 };
|
|
46
|
+
case 'top': return { x: center.x + (w / 2 - 12) * offset, y: center.y - h / 2 };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Reverse lookup: find the node ID whose URI matches, in an ID→URI map. */
|
|
51
|
+
export function idToUriGet(map: Map<string, string>, uri: string): string | undefined {
|
|
52
|
+
for (const [id, u] of map) if (u === uri) return id;
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|