@glossarist/concept-browser 0.7.76 → 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 +119 -27
- 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/AppSidebar.vue +1 -1
- package/src/components/NavIcon.vue +1 -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/config/use-site-config.ts +3 -0
- package/src/i18n/locales/eng.yml +3 -0
- package/src/pages/[...path].astro +1 -0
- package/src/router/index.ts +6 -0
- package/src/utils/hyperedge-styling.ts +48 -0
- package/src/utils/sphere-math.ts +54 -0
- package/src/views/DatasetView.vue +4 -0
- package/src/views/SourcesView.vue +134 -0
- package/src/views/StatsView.vue +120 -61
|
@@ -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;
|
|
@@ -136,7 +136,7 @@ const filteredGlobalPages = computed(() =>
|
|
|
136
136
|
|
|
137
137
|
// Show only standard dataset pages (Concepts, Statistics, About)
|
|
138
138
|
const filteredDatasetPages = computed(() =>
|
|
139
|
-
datasetPages.value.filter(p => ['', 'stats', 'about'].includes(p.route || ''))
|
|
139
|
+
datasetPages.value.filter(p => ['', 'stats', 'sources', 'about'].includes(p.route || ''))
|
|
140
140
|
);
|
|
141
141
|
|
|
142
142
|
const currentManifest = computed(() => store.manifests.get(currentDataset.value));
|
|
@@ -9,6 +9,7 @@ const icons: Record<string, string> = {
|
|
|
9
9
|
chart: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
|
|
10
10
|
list: 'M4 6h16M4 10h16M4 14h16M4 18h16',
|
|
11
11
|
schema: 'M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z',
|
|
12
|
+
database: 'M4 7c0 1.1 3.6 2 8 2s8-.9 8-2M4 7c0-1.1 3.6-2 8-2s8 .9 8 2M4 7v10c0 1.1 3.6 2 8 2s8-.9 8-2V7M4 12c0 1.1 3.6 2 8 2s8-.9 8-2',
|
|
12
13
|
};
|
|
13
14
|
|
|
14
15
|
defineProps<{ name: 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,
|