@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 CHANGED
@@ -20,6 +20,7 @@ export default defineConfig({
20
20
  base,
21
21
  output: 'static',
22
22
  publicDir: resolve(process.cwd(), 'public'),
23
+ outDir: resolve(process.cwd(), 'dist'),
23
24
  integrations: [
24
25
  vue({ appEntrypoint: '/src/islands/app-entry' }),
25
26
  sitemap(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.77",
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: `${entry.uriBase || ''}/${id}/concept/${concept.id}`,
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 || '',
@@ -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 = `${uriBase}/${reg}/concept/${id}`;
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 => `${uriBase}/${register}/concept/${c.id}`);
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 => `${uriBase}/${register}/concept/${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': 'gl:Ref' };
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 `${refMaps.uriBase}/${resolved.datasetId}/concept/${resolved.conceptId}`;
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 `${refMaps.uriBase}/${dsId}/concept/${ievMatch[1]}`;
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: `${uriBase}/${datasetId}/concept/${display.slice(4)}`, term };
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: `${uriBase}/${r.datasetId}/concept/${r.conceptId}`, term: display };
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: `${uriBase}/${register}/concept/${parsed.id}`, term };
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: `${refMaps.uriBase}/${register}/concept/${conceptId ?? designation}`,
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: `${refMaps.uriBase}/${register}/concept/${identifier}`, term: display };
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: `${refMaps.uriBase}/${pattern.datasetId}/concept/${pattern.conceptId}`, term };
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': `${base}/${register}/concept/${termid}`,
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 sourceMap = new Map();
924
- const relTypeCounts = {};
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 conceptSourceRefs = new Set();
961
- const allSourceArrays = [
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 || `${this.uriBase}/${this.registerId}/concept/${concept.id}`;
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 || `${this.uriBase}/${this.registerId}/concept/${concept.id}`;
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) {
@@ -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 = `${uriBase}/${n.register}/concept/${n.conceptId}`;
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) return 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
+ }