@glossarist/concept-browser 0.7.98 → 0.7.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/components/hyperedge-diagram.test.ts +55 -0
- package/src/__tests__/components/pipe-bundles.test.ts +21 -6
- package/src/__tests__/components/rake-bundles.test.ts +318 -0
- package/src/__tests__/views/relation-types-view.test.ts +85 -0
- package/src/components/HyperedgeDiagram.vue +115 -0
- package/src/components/RelationSphere.vue +30 -13
- package/src/components/relation-sphere/pipe-bundles.ts +17 -20
- package/src/config/page-types.ts +1 -0
- package/src/i18n/locales/eng.yml +13 -0
- package/src/i18n/locales/fra.yml +13 -0
- package/src/router/index.ts +5 -0
- package/src/views/RelationTypesView.vue +159 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.100",
|
|
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": {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import { createPinia, setActivePinia } from 'pinia';
|
|
4
|
+
import HyperedgeDiagram from '../../components/HyperedgeDiagram.vue';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HyperedgeDiagram mounts a small SVG and calls the actual sphere
|
|
8
|
+
* renderer (drawRakeBundles or drawGenericPipes) on mount. These
|
|
9
|
+
* tests pin the contract: each kind produces the right kind of SVG
|
|
10
|
+
* child elements, matching what the sphere itself renders.
|
|
11
|
+
*/
|
|
12
|
+
describe('HyperedgeDiagram', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
setActivePinia(createPinia());
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('renders an SVG element', () => {
|
|
18
|
+
const w = mount(HyperedgeDiagram, { props: { kind: 'partitive' } });
|
|
19
|
+
expect(w.find('svg.hyperedge-diagram').exists()).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('partitive kind calls drawRakeBundles (emits line.rake-seg + circle.rake-junction)', () => {
|
|
23
|
+
const w = mount(HyperedgeDiagram, { props: { kind: 'partitive' } });
|
|
24
|
+
const svg = w.element as SVGSVGElement;
|
|
25
|
+
/* The sample fixture has 3 members with mixed multiplicity, so the
|
|
26
|
+
rake emits ≥1 segment + exactly 1 junction circle + 1 diamond. */
|
|
27
|
+
expect(svg.querySelectorAll('line.rake-seg').length).toBeGreaterThan(0);
|
|
28
|
+
expect(svg.querySelectorAll('circle.rake-junction').length).toBe(1);
|
|
29
|
+
expect(svg.querySelectorAll('path').length).toBe(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('generic kind calls drawGenericPipes (emits line.pipe-seg + rect.pipe-junction)', () => {
|
|
33
|
+
const w = mount(HyperedgeDiagram, { props: { kind: 'generic' } });
|
|
34
|
+
const svg = w.element as SVGSVGElement;
|
|
35
|
+
/* The sample fixture has 3 members → 1 thick pipe + 3 thin threads = 4 segments,
|
|
36
|
+
plus 1 square junction and 1 diamond. */
|
|
37
|
+
expect(svg.querySelectorAll('line.pipe-seg').length).toBe(4);
|
|
38
|
+
expect(svg.querySelectorAll('rect.pipe-junction').length).toBe(1);
|
|
39
|
+
expect(svg.querySelectorAll('path').length).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('partitive kind does not leak pipe-and-thread artifacts', () => {
|
|
43
|
+
const w = mount(HyperedgeDiagram, { props: { kind: 'partitive' } });
|
|
44
|
+
const svg = w.element as SVGSVGElement;
|
|
45
|
+
expect(svg.querySelectorAll('line.pipe-seg').length).toBe(0);
|
|
46
|
+
expect(svg.querySelectorAll('rect.pipe-junction').length).toBe(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('generic kind does not leak rake artifacts', () => {
|
|
50
|
+
const w = mount(HyperedgeDiagram, { props: { kind: 'generic' } });
|
|
51
|
+
const svg = w.element as SVGSVGElement;
|
|
52
|
+
expect(svg.querySelectorAll('line.rake-seg').length).toBe(0);
|
|
53
|
+
expect(svg.querySelectorAll('circle.rake-junction').length).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -99,7 +99,7 @@ describe('drawGenericPipes — pipe-and-thread contract', () => {
|
|
|
99
99
|
expect(svg.querySelectorAll('rect.pipe-junction').length).toBe(1);
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
-
it('renders
|
|
102
|
+
it('renders ONE characteristic label (the criterion) — no per-thread labels', () => {
|
|
103
103
|
const svg = makeSvg();
|
|
104
104
|
drawGenericPipes(svg, pos, {
|
|
105
105
|
isDark: false,
|
|
@@ -109,11 +109,26 @@ describe('drawGenericPipes — pipe-and-thread contract', () => {
|
|
|
109
109
|
locale: 'eng',
|
|
110
110
|
});
|
|
111
111
|
const labels = Array.from(svg.querySelectorAll('text.pipe-label')).map(t => t.textContent);
|
|
112
|
-
/*
|
|
113
|
-
|
|
114
|
-
expect(labels).
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
/* Exactly one label — the hyperedge-level criterion. Per-member
|
|
113
|
+
delimitingCharacteristic values are NOT rendered. */
|
|
114
|
+
expect(labels).toEqual(['by magnitude relationship']);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('renders NO label when the criterion is absent', () => {
|
|
118
|
+
const svg = makeSvg();
|
|
119
|
+
const rel = makeRelation();
|
|
120
|
+
delete rel.criterion;
|
|
121
|
+
drawGenericPipes(svg, pos, {
|
|
122
|
+
isDark: false,
|
|
123
|
+
color: '#b45309',
|
|
124
|
+
isMuted: false,
|
|
125
|
+
relations: [rel],
|
|
126
|
+
locale: 'eng',
|
|
127
|
+
});
|
|
128
|
+
/* Pipe + threads + junction + diamond still render, but zero labels. */
|
|
129
|
+
expect(svg.querySelectorAll('text.pipe-label').length).toBe(0);
|
|
130
|
+
expect(svg.querySelectorAll('line.pipe-seg').length).toBe(4);
|
|
131
|
+
expect(svg.querySelectorAll('rect.pipe-junction').length).toBe(1);
|
|
117
132
|
});
|
|
118
133
|
|
|
119
134
|
it('renders a diamond at the comprehensive end (origin marker)', () => {
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { drawRakeBundles } from '../../components/relation-sphere/rake-bundles';
|
|
3
|
+
import type { PartitiveRelationWire, PartitiveMemberWire } from '../../adapters/types';
|
|
4
|
+
import {
|
|
5
|
+
NORMAL_STROKE_WIDTH,
|
|
6
|
+
DELIMITING_STROKE_WIDTH,
|
|
7
|
+
} from '../../utils/partitive-multiplicity';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Renderer tests for the partitive-relation rake bundle.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors `pipe-bundles.test.ts` — pins the visual contract so a
|
|
13
|
+
* regression in stem/spine/teeth geometry, multiplicity stroke style,
|
|
14
|
+
* or partial-completeness spine extension cannot ship silently.
|
|
15
|
+
*
|
|
16
|
+
* Layout reference (ISO 704 rake notation):
|
|
17
|
+
*
|
|
18
|
+
* comprehensive
|
|
19
|
+
* │ ← stem (FRAME_STROKE_WIDTH, single solid)
|
|
20
|
+
* ●───────── ← spine (FRAME_STROKE_WIDTH, single solid)
|
|
21
|
+
* │ │
|
|
22
|
+
* ▼ ▼ ← teeth (per-member multiplicity + delimiting style)
|
|
23
|
+
* p1 p2
|
|
24
|
+
*/
|
|
25
|
+
function makeSvg(): SVGSVGElement {
|
|
26
|
+
return document.createElementNS('http://www.w3.org/2000/svg', 'svg') as unknown as SVGSVGElement;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function member(uri: string, fields: Partial<PartitiveMemberWire> = {}): PartitiveMemberWire {
|
|
30
|
+
return {
|
|
31
|
+
uri,
|
|
32
|
+
presence: fields.presence ?? 'required',
|
|
33
|
+
count: fields.count ?? 'exactly_one',
|
|
34
|
+
isDelimiting: fields.isDelimiting ?? false,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeRelation(overrides: Partial<PartitiveRelationWire> = {}): PartitiveRelationWire {
|
|
39
|
+
return {
|
|
40
|
+
source: 'https://example.org/vim-2012/concept/1.3',
|
|
41
|
+
comprehensive: 'https://example.org/vim-2012/concept/1.3',
|
|
42
|
+
completeness: 'complete',
|
|
43
|
+
register: 'vim-2012',
|
|
44
|
+
partitives: [
|
|
45
|
+
member('https://example.org/vim-2012/concept/1.4'),
|
|
46
|
+
member('https://example.org/vim-2012/concept/1.5'),
|
|
47
|
+
member('https://example.org/vim-2012/concept/1.22'),
|
|
48
|
+
],
|
|
49
|
+
...overrides,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* Fixed position map: comp at top-center, 3 members fanned across the bottom.
|
|
54
|
+
The rake renderer projects members onto a perpendicular spine; absolute
|
|
55
|
+
coordinates don't matter for the contract assertions, only that each
|
|
56
|
+
member has a distinct position so the spine has non-zero length. */
|
|
57
|
+
const pos = new Map<string, { x: number; y: number }>([
|
|
58
|
+
['vim-2012/1.3', { x: 120, y: 20 }],
|
|
59
|
+
['vim-2012/1.4', { x: 30, y: 140 }],
|
|
60
|
+
['vim-2012/1.5', { x: 120, y: 140 }],
|
|
61
|
+
['vim-2012/1.22', { x: 210, y: 140 }],
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
describe('drawRakeBundles — rake contract', () => {
|
|
65
|
+
it('renders nothing when muted', () => {
|
|
66
|
+
const svg = makeSvg();
|
|
67
|
+
drawRakeBundles(svg, pos, {
|
|
68
|
+
isDark: false,
|
|
69
|
+
color: '#0d9488',
|
|
70
|
+
isMuted: true,
|
|
71
|
+
relations: [makeRelation()],
|
|
72
|
+
});
|
|
73
|
+
expect(svg.querySelectorAll('line.rake-seg').length).toBe(0);
|
|
74
|
+
expect(svg.querySelectorAll('circle.rake-junction').length).toBe(0);
|
|
75
|
+
expect(svg.querySelectorAll('path').length).toBe(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('renders a diamond at the comprehensive end (origin marker)', () => {
|
|
79
|
+
const svg = makeSvg();
|
|
80
|
+
drawRakeBundles(svg, pos, {
|
|
81
|
+
isDark: false,
|
|
82
|
+
color: '#0d9488',
|
|
83
|
+
isMuted: false,
|
|
84
|
+
relations: [makeRelation()],
|
|
85
|
+
});
|
|
86
|
+
/* Diamond is the only <path> element the rake emits. */
|
|
87
|
+
const paths = svg.querySelectorAll('path');
|
|
88
|
+
expect(paths.length).toBe(1);
|
|
89
|
+
/* Diamond path is centered on the comp position (120, 20).
|
|
90
|
+
The first coordinate of the diamond "M cx (cy-half)" pattern. */
|
|
91
|
+
const d = paths[0].getAttribute('d') ?? '';
|
|
92
|
+
expect(d.startsWith('M 120 ')).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('renders exactly one circular junction at the spine center', () => {
|
|
96
|
+
const svg = makeSvg();
|
|
97
|
+
drawRakeBundles(svg, pos, {
|
|
98
|
+
isDark: false,
|
|
99
|
+
color: '#0d9488',
|
|
100
|
+
isMuted: false,
|
|
101
|
+
relations: [makeRelation()],
|
|
102
|
+
});
|
|
103
|
+
expect(svg.querySelectorAll('circle.rake-junction').length).toBe(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('does NOT emit pipe-and-thread artifacts (no shape leak)', () => {
|
|
107
|
+
const svg = makeSvg();
|
|
108
|
+
drawRakeBundles(svg, pos, {
|
|
109
|
+
isDark: false,
|
|
110
|
+
color: '#0d9488',
|
|
111
|
+
isMuted: false,
|
|
112
|
+
relations: [makeRelation()],
|
|
113
|
+
});
|
|
114
|
+
expect(svg.querySelectorAll('line.pipe-seg').length).toBe(0);
|
|
115
|
+
expect(svg.querySelectorAll('rect.pipe-junction').length).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('renders frame-width stem + spine at FRAME_STROKE_WIDTH (1.4)', () => {
|
|
119
|
+
const svg = makeSvg();
|
|
120
|
+
drawRakeBundles(svg, pos, {
|
|
121
|
+
isDark: false,
|
|
122
|
+
color: '#0d9488',
|
|
123
|
+
isMuted: false,
|
|
124
|
+
relations: [makeRelation()],
|
|
125
|
+
});
|
|
126
|
+
const segs = Array.from(svg.querySelectorAll('line.rake-seg'));
|
|
127
|
+
const frameSegs = segs.filter(s => Math.abs(parseFloat(s.getAttribute('stroke-width')!) - 1.4) < 0.01);
|
|
128
|
+
/* Stem + spine = 2 frame-width segments. */
|
|
129
|
+
expect(frameSegs.length).toBe(2);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('tooth stroke width reflects isDelimiting (NORMAL vs 3× DELIMITING)', () => {
|
|
133
|
+
const svg = makeSvg();
|
|
134
|
+
/* All members required/exactly_one, but only the second is delimiting.
|
|
135
|
+
Expected tooth widths: NORMAL, DELIMITING (3×), NORMAL. */
|
|
136
|
+
const rel = makeRelation({
|
|
137
|
+
partitives: [
|
|
138
|
+
member('https://example.org/vim-2012/concept/1.4'),
|
|
139
|
+
member('https://example.org/vim-2012/concept/1.5', { isDelimiting: true }),
|
|
140
|
+
member('https://example.org/vim-2012/concept/1.22'),
|
|
141
|
+
],
|
|
142
|
+
});
|
|
143
|
+
drawRakeBundles(svg, pos, {
|
|
144
|
+
isDark: false,
|
|
145
|
+
color: '#0d9488',
|
|
146
|
+
isMuted: false,
|
|
147
|
+
relations: [rel],
|
|
148
|
+
});
|
|
149
|
+
const segs = Array.from(svg.querySelectorAll('line.rake-seg'));
|
|
150
|
+
const widths = segs.map(s => parseFloat(s.getAttribute('stroke-width')!));
|
|
151
|
+
/* Stem + spine are 1.4; non-delimiting teeth are NORMAL (1.5);
|
|
152
|
+
delimiting teeth are DELIMITING (4.5). */
|
|
153
|
+
expect(widths.filter(w => Math.abs(w - NORMAL_STROKE_WIDTH) < 0.01).length).toBe(2);
|
|
154
|
+
expect(widths.filter(w => Math.abs(w - DELIMITING_STROKE_WIDTH) < 0.01).length).toBe(1);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('count=multiple renders 2 parallel tooth lines (lineCount=2)', () => {
|
|
158
|
+
const svg = makeSvg();
|
|
159
|
+
/* One member with count=multiple. Counting segments:
|
|
160
|
+
- 1 stem (FRAME)
|
|
161
|
+
- 1 spine (FRAME) — only if spine extents > 1px; with a single
|
|
162
|
+
member the spine is degenerate (zero length). The renderer
|
|
163
|
+
guards with `if (Math.abs(maxAlong - minAlong) > 1)`, so spine
|
|
164
|
+
is skipped for single-member input.
|
|
165
|
+
- 2 tooth lines (lineCount=2 for count=multiple).
|
|
166
|
+
Total: 1 stem + 2 teeth = 3 segments. */
|
|
167
|
+
const rel = makeRelation({
|
|
168
|
+
partitives: [
|
|
169
|
+
member('https://example.org/vim-2012/concept/1.4', { count: 'multiple' }),
|
|
170
|
+
member('https://example.org/vim-2012/concept/1.5', { count: 'multiple' }),
|
|
171
|
+
],
|
|
172
|
+
});
|
|
173
|
+
drawRakeBundles(svg, pos, {
|
|
174
|
+
isDark: false,
|
|
175
|
+
color: '#0d9488',
|
|
176
|
+
isMuted: false,
|
|
177
|
+
relations: [rel],
|
|
178
|
+
});
|
|
179
|
+
const segs = svg.querySelectorAll('line.rake-seg');
|
|
180
|
+
/* 2 members × 2 lines each = 4 teeth, + 1 stem + 1 spine = 6. */
|
|
181
|
+
expect(segs.length).toBe(6);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('presence=optional makes the tooth dashed', () => {
|
|
185
|
+
const svg = makeSvg();
|
|
186
|
+
const rel = makeRelation({
|
|
187
|
+
partitives: [
|
|
188
|
+
member('https://example.org/vim-2012/concept/1.4', { presence: 'optional' }),
|
|
189
|
+
member('https://example.org/vim-2012/concept/1.5', { presence: 'required' }),
|
|
190
|
+
],
|
|
191
|
+
});
|
|
192
|
+
drawRakeBundles(svg, pos, {
|
|
193
|
+
isDark: false,
|
|
194
|
+
color: '#0d9488',
|
|
195
|
+
isMuted: false,
|
|
196
|
+
relations: [rel],
|
|
197
|
+
});
|
|
198
|
+
const segs = Array.from(svg.querySelectorAll('line.rake-seg'));
|
|
199
|
+
/* Teeth are the segments at NORMAL_STROKE_WIDTH (1.5) — frame
|
|
200
|
+
segments are at 1.4. Filter to teeth, then check dash pattern. */
|
|
201
|
+
const teeth = segs.filter(s => Math.abs(parseFloat(s.getAttribute('stroke-width')!) - NORMAL_STROKE_WIDTH) < 0.01);
|
|
202
|
+
expect(teeth.length).toBeGreaterThanOrEqual(2);
|
|
203
|
+
/* presence=optional tooth is dashed; presence=required is solid.
|
|
204
|
+
At least one dashed, at least one solid. */
|
|
205
|
+
const dashed = teeth.filter(t => t.getAttribute('stroke-dasharray'));
|
|
206
|
+
const solid = teeth.filter(t => !t.getAttribute('stroke-dasharray'));
|
|
207
|
+
expect(dashed.length).toBeGreaterThanOrEqual(1);
|
|
208
|
+
expect(solid.length).toBeGreaterThanOrEqual(1);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('partial completeness extends the spine beyond the member bounding box', () => {
|
|
212
|
+
/* Members at x = {30, 120, 210}; their bbox X is [30, 210].
|
|
213
|
+
With completeness='partial', PARTIAL_TAIL (28px) is appended to
|
|
214
|
+
the spine extent — so the spine endpoint must land OUTSIDE [30, 210].
|
|
215
|
+
With completeness='complete' the spine stays inside the bbox. */
|
|
216
|
+
const members = [
|
|
217
|
+
member('https://example.org/vim-2012/concept/1.4'),
|
|
218
|
+
member('https://example.org/vim-2012/concept/1.5'),
|
|
219
|
+
member('https://example.org/vim-2012/concept/1.22'),
|
|
220
|
+
];
|
|
221
|
+
const completeSvg = makeSvg();
|
|
222
|
+
const partialSvg = makeSvg();
|
|
223
|
+
drawRakeBundles(completeSvg, pos, {
|
|
224
|
+
isDark: false, color: '#0d9488', isMuted: false,
|
|
225
|
+
relations: [makeRelation({ completeness: 'complete', partitives: members })],
|
|
226
|
+
});
|
|
227
|
+
drawRakeBundles(partialSvg, pos, {
|
|
228
|
+
isDark: false, color: '#0d9488', isMuted: false,
|
|
229
|
+
relations: [makeRelation({ completeness: 'partial', partitives: members })],
|
|
230
|
+
});
|
|
231
|
+
/* Frame-width (1.4) segments are stem + spine. For complete, all
|
|
232
|
+
spine frame-segment endpoints lie within the member X bbox [30,210].
|
|
233
|
+
For partial, at least one endpoint lies outside. */
|
|
234
|
+
const isFrame = (s: Element) => Math.abs(parseFloat(s.getAttribute('stroke-width')!) - 1.4) < 0.01;
|
|
235
|
+
const endpointsOutsideBbox = (svg: SVGSVGElement) => {
|
|
236
|
+
return Array.from(svg.querySelectorAll('line.rake-seg'))
|
|
237
|
+
.filter(isFrame)
|
|
238
|
+
.some(s => {
|
|
239
|
+
const x1 = parseFloat(s.getAttribute('x1')!);
|
|
240
|
+
const x2 = parseFloat(s.getAttribute('x2')!);
|
|
241
|
+
return x1 < 30 || x1 > 210 || x2 < 30 || x2 > 210;
|
|
242
|
+
});
|
|
243
|
+
};
|
|
244
|
+
expect(endpointsOutsideBbox(completeSvg)).toBe(false);
|
|
245
|
+
expect(endpointsOutsideBbox(partialSvg)).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('renders a degenerate rake (stem + 1 tooth, no spine) when only 1 member has a position', () => {
|
|
249
|
+
/* The renderer does not enforce ≥2 members — the model layer does.
|
|
250
|
+
Given 1 member, the rake produces a stem and a single tooth, no
|
|
251
|
+
spine (maxAlong - minAlong = 0, so the spine is skipped). */
|
|
252
|
+
const svg = makeSvg();
|
|
253
|
+
const sparsePos = new Map<string, { x: number; y: number }>([
|
|
254
|
+
['vim-2012/1.3', { x: 120, y: 20 }],
|
|
255
|
+
['vim-2012/1.4', { x: 30, y: 140 }],
|
|
256
|
+
/* 1.5 and 1.22 missing — only 1 member with a position */
|
|
257
|
+
]);
|
|
258
|
+
drawRakeBundles(svg, sparsePos, {
|
|
259
|
+
isDark: false, color: '#0d9488', isMuted: false,
|
|
260
|
+
relations: [makeRelation()],
|
|
261
|
+
});
|
|
262
|
+
/* 1 stem (frame) + 1 tooth (normal) = 2 segments. */
|
|
263
|
+
expect(svg.querySelectorAll('line.rake-seg').length).toBe(2);
|
|
264
|
+
expect(svg.querySelectorAll('circle.rake-junction').length).toBe(1);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('skips the relation entirely when the comprehensive is missing from pos', () => {
|
|
268
|
+
const svg = makeSvg();
|
|
269
|
+
const sparsePos = new Map<string, { x: number; y: number }>([
|
|
270
|
+
['vim-2012/1.4', { x: 30, y: 140 }],
|
|
271
|
+
['vim-2012/1.5', { x: 120, y: 140 }],
|
|
272
|
+
/* comprehensive 1.3 missing — compPos lookup fails, relation skipped */
|
|
273
|
+
]);
|
|
274
|
+
drawRakeBundles(svg, sparsePos, {
|
|
275
|
+
isDark: false, color: '#0d9488', isMuted: false,
|
|
276
|
+
relations: [makeRelation()],
|
|
277
|
+
});
|
|
278
|
+
expect(svg.querySelectorAll('line.rake-seg').length).toBe(0);
|
|
279
|
+
expect(svg.querySelectorAll('circle.rake-junction').length).toBe(0);
|
|
280
|
+
expect(svg.querySelectorAll('path').length).toBe(0);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('renders a separate junction + diamond per relation when multiple are present', () => {
|
|
284
|
+
const svg = makeSvg();
|
|
285
|
+
/* Two distinct comprehensives → two rake bundles. */
|
|
286
|
+
const multiPos = new Map<string, { x: number; y: number }>([
|
|
287
|
+
['vim-2012/1.3', { x: 60, y: 20 }],
|
|
288
|
+
['vim-2012/1.4', { x: 30, y: 140 }],
|
|
289
|
+
['vim-2012/1.5', { x: 90, y: 140 }],
|
|
290
|
+
['vim-2012/2.9', { x: 180, y: 20 }],
|
|
291
|
+
['vim-2012/2.10', { x: 150, y: 140 }],
|
|
292
|
+
['vim-2012/2.26', { x: 210, y: 140 }],
|
|
293
|
+
]);
|
|
294
|
+
drawRakeBundles(svg, multiPos, {
|
|
295
|
+
isDark: false, color: '#0d9488', isMuted: false,
|
|
296
|
+
relations: [
|
|
297
|
+
makeRelation({
|
|
298
|
+
comprehensive: 'https://example.org/vim-2012/concept/1.3',
|
|
299
|
+
source: 'https://example.org/vim-2012/concept/1.3',
|
|
300
|
+
partitives: [
|
|
301
|
+
member('https://example.org/vim-2012/concept/1.4'),
|
|
302
|
+
member('https://example.org/vim-2012/concept/1.5'),
|
|
303
|
+
],
|
|
304
|
+
}),
|
|
305
|
+
makeRelation({
|
|
306
|
+
comprehensive: 'https://example.org/vim-2012/concept/2.9',
|
|
307
|
+
source: 'https://example.org/vim-2012/concept/2.9',
|
|
308
|
+
partitives: [
|
|
309
|
+
member('https://example.org/vim-2012/concept/2.10'),
|
|
310
|
+
member('https://example.org/vim-2012/concept/2.26'),
|
|
311
|
+
],
|
|
312
|
+
}),
|
|
313
|
+
],
|
|
314
|
+
});
|
|
315
|
+
expect(svg.querySelectorAll('circle.rake-junction').length).toBe(2);
|
|
316
|
+
expect(svg.querySelectorAll('path').length).toBe(2);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import { createPinia, setActivePinia } from 'pinia';
|
|
4
|
+
import { createRouter, createMemoryHistory } from 'vue-router';
|
|
5
|
+
import RelationTypesView from '../../views/RelationTypesView.vue';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Smoke + structure tests for the Relation Types teaching page.
|
|
9
|
+
*
|
|
10
|
+
* Hyperedge section: 2 cards (partitive + general) with anchors.
|
|
11
|
+
* Bilateral section: at least one category with type rows.
|
|
12
|
+
*/
|
|
13
|
+
async function makeRouter() {
|
|
14
|
+
const router = createRouter({
|
|
15
|
+
history: createMemoryHistory(),
|
|
16
|
+
routes: [
|
|
17
|
+
{ path: '/', name: 'home', component: { template: '<div/>' } },
|
|
18
|
+
{ path: '/relation-types', name: 'relation-types', component: RelationTypesView },
|
|
19
|
+
],
|
|
20
|
+
});
|
|
21
|
+
await router.push('/relation-types');
|
|
22
|
+
await router.isReady();
|
|
23
|
+
return router;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('RelationTypesView', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
setActivePinia(createPinia());
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('renders the page header with translated title', async () => {
|
|
32
|
+
const router = await makeRouter();
|
|
33
|
+
const w = mount(RelationTypesView, { global: { plugins: [router] } });
|
|
34
|
+
expect(w.find('h1').text()).toContain('Relation Types');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('renders both hyperedge cards with the expected anchors', async () => {
|
|
38
|
+
const router = await makeRouter();
|
|
39
|
+
const w = mount(RelationTypesView, { global: { plugins: [router] } });
|
|
40
|
+
const cards = w.findAll('.hyperedge-card');
|
|
41
|
+
expect(cards.length).toBe(2);
|
|
42
|
+
const ids = cards.map(c => c.attributes('id'));
|
|
43
|
+
expect(ids).toContain('__partitive__');
|
|
44
|
+
expect(ids).toContain('__generic__');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('renders a HyperedgeDiagram child for each card', async () => {
|
|
48
|
+
const router = await makeRouter();
|
|
49
|
+
const w = mount(RelationTypesView, { global: { plugins: [router] } });
|
|
50
|
+
/* The HyperedgeDiagram component renders an <svg class="hyperedge-diagram"> */
|
|
51
|
+
expect(w.findAll('svg.hyperedge-diagram').length).toBe(2);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('renders the bilateral section grouped by category', async () => {
|
|
55
|
+
const router = await makeRouter();
|
|
56
|
+
const w = mount(RelationTypesView, { global: { plugins: [router] } });
|
|
57
|
+
const bilateralArticles = w.findAll('section[aria-label="Bilateral relations (one-to-one)"] article');
|
|
58
|
+
/* The taxonomy has ≥5 categories (hierarchical, associative, lifecycle, …). */
|
|
59
|
+
expect(bilateralArticles.length).toBeGreaterThanOrEqual(5);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('every bilateral type row has an id anchor matching the type key', async () => {
|
|
63
|
+
const router = await makeRouter();
|
|
64
|
+
const w = mount(RelationTypesView, { global: { plugins: [router] } });
|
|
65
|
+
const rows = w.findAll('.bilateral-row');
|
|
66
|
+
expect(rows.length).toBeGreaterThan(10); /* taxonomy has ~40 types */
|
|
67
|
+
for (const row of rows) {
|
|
68
|
+
const id = row.attributes('id');
|
|
69
|
+
expect(id).toBeTruthy();
|
|
70
|
+
expect(id).toMatch(/^[a-z_]+$/);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('renders label + ISO standard + definition for each hyperedge card', async () => {
|
|
75
|
+
const router = await makeRouter();
|
|
76
|
+
const w = mount(RelationTypesView, { global: { plugins: [router] } });
|
|
77
|
+
const cards = w.findAll('.hyperedge-card');
|
|
78
|
+
for (const card of cards) {
|
|
79
|
+
/* Label is the h3 heading; ISO reference is a mono span; definition is a paragraph. */
|
|
80
|
+
expect(card.find('h3').text()).toBeTruthy();
|
|
81
|
+
expect(card.find('span.font-mono').text()).toMatch(/ISO 704/);
|
|
82
|
+
expect(card.find('p').text().length).toBeGreaterThan(20);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* HyperedgeDiagram — small inline SVG that calls the actual sphere
|
|
4
|
+
* renderers (drawRakeBundles / drawGenericPipes) on hand-picked
|
|
5
|
+
* sample data. Used on the Relation Types teaching page.
|
|
6
|
+
*
|
|
7
|
+
* The diagrams stay in sync with the real sphere rendering by
|
|
8
|
+
* construction — no static SVG to drift out of date.
|
|
9
|
+
*/
|
|
10
|
+
import { onMounted, ref } from 'vue';
|
|
11
|
+
import { drawRakeBundles } from './relation-sphere/rake-bundles';
|
|
12
|
+
import { drawGenericPipes } from './relation-sphere/pipe-bundles';
|
|
13
|
+
import type {
|
|
14
|
+
PartitiveRelationWire,
|
|
15
|
+
PartitiveMemberWire,
|
|
16
|
+
GenericRelationWire,
|
|
17
|
+
} from '../adapters/types';
|
|
18
|
+
import { useUiStore } from '../stores/ui';
|
|
19
|
+
|
|
20
|
+
const props = defineProps<{
|
|
21
|
+
kind: 'partitive' | 'generic';
|
|
22
|
+
}>();
|
|
23
|
+
|
|
24
|
+
const svgRef = ref<SVGSVGElement | null>(null);
|
|
25
|
+
const uiStore = useUiStore();
|
|
26
|
+
|
|
27
|
+
/* Synthetic positions: comp at top-center, members fanned across the
|
|
28
|
+
bottom. Mirrors the layout the sphere produces for a 3-member
|
|
29
|
+
hyperedge with the focus at the center. */
|
|
30
|
+
const POS = new Map<string, { x: number; y: number }>([
|
|
31
|
+
['vim-2012/1.3', { x: 120, y: 24 }],
|
|
32
|
+
['vim-2012/1.4', { x: 36, y: 132 }],
|
|
33
|
+
['vim-2012/1.5', { x: 120, y: 144 }],
|
|
34
|
+
['vim-2012/1.22', { x: 204, y: 132 }],
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const PARTITIVE: PartitiveRelationWire = {
|
|
38
|
+
source: 'https://example.org/vim-2012/concept/1.3',
|
|
39
|
+
comprehensive: 'https://example.org/vim-2012/concept/1.3',
|
|
40
|
+
completeness: 'partial',
|
|
41
|
+
register: 'vim-2012',
|
|
42
|
+
partitives: [
|
|
43
|
+
/* required + multiple → solid double-line */
|
|
44
|
+
{ uri: 'https://example.org/vim-2012/concept/1.4', presence: 'required', count: 'multiple', isDelimiting: false },
|
|
45
|
+
/* required + exactly_one + delimiting → 3× width single solid */
|
|
46
|
+
{ uri: 'https://example.org/vim-2012/concept/1.5', presence: 'required', count: 'exactly_one', isDelimiting: true },
|
|
47
|
+
/* optional + at_least_one → dashed double-line */
|
|
48
|
+
{ uri: 'https://example.org/vim-2012/concept/1.22', presence: 'optional', count: 'at_least_one', isDelimiting: false },
|
|
49
|
+
] satisfies PartitiveMemberWire[],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const GENERIC: GenericRelationWire = {
|
|
53
|
+
source: 'https://example.org/vim-2012/concept/1.9',
|
|
54
|
+
comprehensive: 'https://example.org/vim-2012/concept/1.9',
|
|
55
|
+
completeness: 'partial',
|
|
56
|
+
register: 'vim-2012',
|
|
57
|
+
criterion: { eng: 'by magnitude relationship', fra: 'selon la relation de grandeur' },
|
|
58
|
+
members: [
|
|
59
|
+
{ uri: 'https://example.org/vim-2012/concept/1.17', presence: 'required', count: 'at_least_one',
|
|
60
|
+
delimitingCharacteristic: { eng: 'multiple of a unit', fra: "multiple d'une unité" } },
|
|
61
|
+
{ uri: 'https://example.org/vim-2012/concept/1.18', presence: 'required', count: 'at_least_one',
|
|
62
|
+
delimitingCharacteristic: { eng: 'submultiple of a unit', fra: "sous-multiple d'une unité" } },
|
|
63
|
+
{ uri: 'https://example.org/vim-2012/concept/1.12', presence: 'required', count: 'at_least_one',
|
|
64
|
+
delimitingCharacteristic: { eng: 'coherent derived unit', fra: 'unité dérivée cohérente' } },
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/* Override positions for the generic sample (comp is 1.9, members are 1.17/1.18/1.12). */
|
|
69
|
+
const GENERIC_POS = new Map<string, { x: number; y: number }>([
|
|
70
|
+
['vim-2012/1.9', { x: 120, y: 24 }],
|
|
71
|
+
['vim-2012/1.17', { x: 36, y: 132 }],
|
|
72
|
+
['vim-2012/1.18', { x: 120, y: 144 }],
|
|
73
|
+
['vim-2012/1.12', { x: 204, y: 132 }],
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
onMounted(() => {
|
|
77
|
+
const svg = svgRef.value;
|
|
78
|
+
if (!svg) return;
|
|
79
|
+
if (props.kind === 'partitive') {
|
|
80
|
+
drawRakeBundles(svg, POS, {
|
|
81
|
+
isDark: uiStore.isDark,
|
|
82
|
+
color: uiStore.isDark ? '#2dd4bf' : '#0d9488',
|
|
83
|
+
isMuted: false,
|
|
84
|
+
relations: [PARTITIVE],
|
|
85
|
+
});
|
|
86
|
+
} else {
|
|
87
|
+
drawGenericPipes(svg, GENERIC_POS, {
|
|
88
|
+
isDark: uiStore.isDark,
|
|
89
|
+
color: uiStore.isDark ? '#fbbf24' : '#b45309',
|
|
90
|
+
isMuted: false,
|
|
91
|
+
relations: [GENERIC],
|
|
92
|
+
locale: 'eng',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
</script>
|
|
97
|
+
|
|
98
|
+
<template>
|
|
99
|
+
<svg
|
|
100
|
+
ref="svgRef"
|
|
101
|
+
class="hyperedge-diagram"
|
|
102
|
+
viewBox="0 0 240 168"
|
|
103
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
104
|
+
aria-hidden="true"
|
|
105
|
+
/>
|
|
106
|
+
</template>
|
|
107
|
+
|
|
108
|
+
<style scoped>
|
|
109
|
+
.hyperedge-diagram {
|
|
110
|
+
width: 100%;
|
|
111
|
+
max-width: 240px;
|
|
112
|
+
height: auto;
|
|
113
|
+
display: block;
|
|
114
|
+
}
|
|
115
|
+
</style>
|
|
@@ -1068,7 +1068,7 @@ const legendItems = computed(() => {
|
|
|
1068
1068
|
if (partitiveCount > 0) {
|
|
1069
1069
|
typeMap.set('__partitive__', {
|
|
1070
1070
|
type: '__partitive__',
|
|
1071
|
-
label: '
|
|
1071
|
+
label: 'partitive relation',
|
|
1072
1072
|
color: uiStore.isDark ? '#2dd4bf' : '#0d9488',
|
|
1073
1073
|
count: partitiveCount,
|
|
1074
1074
|
});
|
|
@@ -1078,7 +1078,7 @@ const legendItems = computed(() => {
|
|
|
1078
1078
|
if (genericCount > 0) {
|
|
1079
1079
|
typeMap.set('__generic__', {
|
|
1080
1080
|
type: '__generic__',
|
|
1081
|
-
label: '
|
|
1081
|
+
label: 'general relation',
|
|
1082
1082
|
color: uiStore.isDark ? '#fbbf24' : '#b45309',
|
|
1083
1083
|
count: genericCount,
|
|
1084
1084
|
});
|
|
@@ -1405,16 +1405,23 @@ defineExpose({ navigate });
|
|
|
1405
1405
|
<div v-if="legendItems.length" class="sp-section">
|
|
1406
1406
|
<div class="sp-section-label">{{ t('sphere.relationTypes') }}</div>
|
|
1407
1407
|
<div class="sp-legend-grid">
|
|
1408
|
-
<
|
|
1408
|
+
<div v-for="item in legendItems" :key="item.type"
|
|
1409
|
+
class="sp-legend-item"
|
|
1409
1410
|
:class="{ muted: isTypeMuted(item.type), 'is-partitive': item.type === '__partitive__' }"
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
:
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1411
|
+
>
|
|
1412
|
+
<button class="sp-legend-toggle"
|
|
1413
|
+
@click="toggleType(item.type)"
|
|
1414
|
+
:title="item.type"
|
|
1415
|
+
:aria-label="`${t('sphere.toggleMute')}: ${item.label}`">
|
|
1416
|
+
<span v-if="item.type === '__partitive__'" class="sp-swatch sp-swatch-rake"
|
|
1417
|
+
:style="{ '--rake-color': isTypeMuted(item.type) ? '#b8b9cc' : item.color }"></span>
|
|
1418
|
+
<span v-else class="sp-swatch" :style="{ background: isTypeMuted(item.type) ? '#b8b9cc' : item.color }"></span>
|
|
1419
|
+
<span class="sp-count">{{ item.count }}</span>
|
|
1420
|
+
</button>
|
|
1421
|
+
<router-link class="sp-legend-label"
|
|
1422
|
+
:to="{ name: 'relation-types', hash: '#' + item.type }"
|
|
1423
|
+
:title="t('sphere.learnAbout') + ': ' + item.label">{{ item.label }}</router-link>
|
|
1424
|
+
</div>
|
|
1418
1425
|
</div>
|
|
1419
1426
|
</div>
|
|
1420
1427
|
|
|
@@ -1653,12 +1660,22 @@ defineExpose({ navigate });
|
|
|
1653
1660
|
/* Type legend */
|
|
1654
1661
|
.sp-legend-grid { display: flex; flex-direction: column; gap: 2px; }
|
|
1655
1662
|
.sp-legend-item {
|
|
1656
|
-
display: flex; align-items: center; gap:
|
|
1657
|
-
|
|
1663
|
+
display: flex; align-items: center; gap: 6px; padding: 3px 6px; border-radius: 3px;
|
|
1664
|
+
border: 1px solid transparent; background: transparent; text-align: left;
|
|
1658
1665
|
width: 100%; font-family: inherit; font-size: 11px; color: var(--ink-mute);
|
|
1659
1666
|
}
|
|
1660
1667
|
.sp-legend-item:hover { background: var(--rule); }
|
|
1661
1668
|
.sp-legend-item.muted { opacity: 0.35; }
|
|
1669
|
+
.sp-legend-toggle {
|
|
1670
|
+
display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0;
|
|
1671
|
+
cursor: pointer; border: none; background: transparent; padding: 0;
|
|
1672
|
+
font: inherit; color: inherit;
|
|
1673
|
+
}
|
|
1674
|
+
.sp-legend-label {
|
|
1675
|
+
color: inherit; text-decoration: none; flex: 1; min-width: 0;
|
|
1676
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
1677
|
+
}
|
|
1678
|
+
.sp-legend-label:hover { text-decoration: underline; }
|
|
1662
1679
|
.sp-swatch { width: 18px; height: 2px; flex-shrink: 0; border-radius: 1px; }
|
|
1663
1680
|
/* Rake swatch — small bracket icon signaling a PartitiveRelation hyperedge
|
|
1664
1681
|
(not a single binary edge). Two parallel solid lines (close-set double)
|
|
@@ -20,10 +20,13 @@
|
|
|
20
20
|
* the rake code with isDelimiting=true on all members, which made
|
|
21
21
|
* the children THICKER than the parent — backwards.)
|
|
22
22
|
*
|
|
23
|
-
* 3.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
23
|
+
* 3. Characteristic label — the criterion is a single hyperedge-level
|
|
24
|
+
* field shared by all members (the dimension along which they
|
|
25
|
+
* differ), rendered once on the pipe body. Optional: when the
|
|
26
|
+
* criterion is absent, no label is shown at all. Per-member
|
|
27
|
+
* delimitingCharacteristic is NOT labeled here — those values
|
|
28
|
+
* distinguish each member but are not "the characteristic of
|
|
29
|
+
* the hyperedge".
|
|
27
30
|
*
|
|
28
31
|
* Pure DOM/geometry — no Vue reactivity, no d3 state. The caller passes
|
|
29
32
|
* the generic relations, position map, and rendering context.
|
|
@@ -58,8 +61,8 @@ interface NodePos { x: number; y: number }
|
|
|
58
61
|
*
|
|
59
62
|
* Each relation produces: 1 thick pipe (comp → middle), N thin threads
|
|
60
63
|
* (middle → each member), 1 junction square at the middle node, 1
|
|
61
|
-
* diamond at the comp end,
|
|
62
|
-
*
|
|
64
|
+
* diamond at the comp end, and — only when the hyperedge has a
|
|
65
|
+
* criterion — 1 characteristic label on the pipe body.
|
|
63
66
|
*/
|
|
64
67
|
export function drawGenericPipes(
|
|
65
68
|
svg: SVGSVGElement,
|
|
@@ -89,23 +92,15 @@ export function drawGenericPipes(
|
|
|
89
92
|
const layout = computePipeLayout(compPos, memberPositions);
|
|
90
93
|
if (!layout) continue;
|
|
91
94
|
|
|
92
|
-
/* 1. Thick parent pipe: comp → middle node
|
|
95
|
+
/* 1. Thick parent pipe: comp → middle node. The characteristic
|
|
96
|
+
label (criterion) sits on the pipe body — see step 4. */
|
|
93
97
|
drawSegment(svg, opts.color, layout.pipeStart, layout.pipeEnd, PIPE_WIDTH);
|
|
94
98
|
|
|
95
|
-
/* 2. Thin threads: middle → each member
|
|
99
|
+
/* 2. Thin threads: middle → each member. No per-thread labels —
|
|
100
|
+
the characteristic is a single hyperedge-level field shared
|
|
101
|
+
by all members, rendered once on the pipe body. */
|
|
96
102
|
for (const thread of layout.threads) {
|
|
97
103
|
drawSegment(svg, opts.color, thread.start, thread.end, THREAD_WIDTH);
|
|
98
|
-
const dcText = pickLocalized(thread.member.delimitingCharacteristic, opts.locale);
|
|
99
|
-
if (dcText) {
|
|
100
|
-
const midX = (thread.start.x + thread.end.x) / 2;
|
|
101
|
-
const midY = (thread.start.y + thread.end.y) / 2;
|
|
102
|
-
drawLabel(svg, midX, midY, dcText, opts.color, {
|
|
103
|
-
fontSize: 8.5,
|
|
104
|
-
italic: true,
|
|
105
|
-
bg: true,
|
|
106
|
-
isDark: opts.isDark,
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
104
|
}
|
|
110
105
|
|
|
111
106
|
/* 3. Square junction at the middle node (distinct from the rake's
|
|
@@ -113,7 +108,9 @@ export function drawGenericPipes(
|
|
|
113
108
|
circle = partitive). */
|
|
114
109
|
drawSquare(svg, layout.middleNode.x, layout.middleNode.y, JUNCTION_RADIUS, opts.color, contrastStroke);
|
|
115
110
|
|
|
116
|
-
/* 4.
|
|
111
|
+
/* 4. Characteristic label — single, hyperedge-level, optional.
|
|
112
|
+
Drawn on the pipe body (midpoint between comp and middle node).
|
|
113
|
+
When criterion is absent, no label is rendered at all. */
|
|
117
114
|
const critText = pickLocalized(rel.criterion, opts.locale);
|
|
118
115
|
if (critText) {
|
|
119
116
|
const midX = (layout.pipeStart.x + layout.pipeEnd.x) / 2;
|
package/src/config/page-types.ts
CHANGED
|
@@ -35,6 +35,7 @@ export const PAGE_TYPES: readonly PageTypeDefinition[] = [
|
|
|
35
35
|
{ type: 'stats', route: 'stats', title: 'Statistics', icon: 'chart', scope: 'dataset', featureFlag: '!stats', autoSynthesize: true },
|
|
36
36
|
{ type: 'sources', route: 'sources', title: 'Sources', icon: 'database', scope: 'dataset', autoSynthesize: true },
|
|
37
37
|
{ type: 'about', route: 'about', title: 'About', icon: 'info', scope: 'dataset', featureFlag: '!about', autoSynthesize: true },
|
|
38
|
+
{ type: 'relation-types', route: 'relation-types', title: 'Relation Types', icon: 'schema', scope: 'global', autoSynthesize: true },
|
|
38
39
|
] as const;
|
|
39
40
|
|
|
40
41
|
export type BuiltinPageType = typeof PAGE_TYPES[number]['type'];
|
package/src/i18n/locales/eng.yml
CHANGED
|
@@ -12,6 +12,17 @@ nav.about: About
|
|
|
12
12
|
nav.sources: Sources
|
|
13
13
|
nav.navigation: Navigation
|
|
14
14
|
nav.datasets: Datasets
|
|
15
|
+
nav.relationTypes: Relation Types
|
|
16
|
+
|
|
17
|
+
# Relation Types teaching page
|
|
18
|
+
relationTypes.intro: What each line shape and label means in this site. Diagrams use the same renderers as the Relation Sphere, so what you see here is what you'll see in the wild.
|
|
19
|
+
relationTypes.hyperedgeSection: Hyperedge relations (one-to-many)
|
|
20
|
+
relationTypes.bilateralSection: Bilateral relations (one-to-one)
|
|
21
|
+
relationTypes.isoStandard: ISO standard
|
|
22
|
+
relationTypes.example: Example
|
|
23
|
+
relationTypes.category: Category
|
|
24
|
+
relationTypes.diagram: Diagram
|
|
25
|
+
relationTypes.notInDataset: Not used in the current dataset
|
|
15
26
|
|
|
16
27
|
# Home page
|
|
17
28
|
home.search: Search
|
|
@@ -213,6 +224,8 @@ sphere.tight: tight
|
|
|
213
224
|
sphere.loose: loose
|
|
214
225
|
sphere.redraw: Redraw
|
|
215
226
|
sphere.focus: FOCUS
|
|
227
|
+
sphere.toggleMute: Toggle visibility
|
|
228
|
+
sphere.learnAbout: Learn about this relation type
|
|
216
229
|
concept.detailView: Detail
|
|
217
230
|
concept.relationSphere: Relation Sphere
|
|
218
231
|
concept.permalink: Permalink
|
package/src/i18n/locales/fra.yml
CHANGED
|
@@ -11,6 +11,17 @@ nav.stats: Statistiques
|
|
|
11
11
|
nav.about: À propos
|
|
12
12
|
nav.navigation: Navigation
|
|
13
13
|
nav.datasets: Jeux de données
|
|
14
|
+
nav.relationTypes: Types de relations
|
|
15
|
+
|
|
16
|
+
# Page enseignante « Types de relations »
|
|
17
|
+
relationTypes.intro: Ce que chaque forme de ligne et étiquette signifie sur ce site. Les diagrammes utilisent les mêmes rendus que la Sphère de relations — ce que vous voyez ici correspond à ce que vous verrez en navigation.
|
|
18
|
+
relationTypes.hyperedgeSection: Relations hyper-arêtes (un-plusieurs)
|
|
19
|
+
relationTypes.bilateralSection: Relations bilatérales (un-un)
|
|
20
|
+
relationTypes.isoStandard: Norme ISO
|
|
21
|
+
relationTypes.example: Exemple
|
|
22
|
+
relationTypes.category: Catégorie
|
|
23
|
+
relationTypes.diagram: Diagramme
|
|
24
|
+
relationTypes.notInDataset: Non utilisé dans le jeu de données courant
|
|
14
25
|
|
|
15
26
|
# Home page
|
|
16
27
|
home.search: Rechercher
|
|
@@ -204,6 +215,8 @@ sphere.tight: serré
|
|
|
204
215
|
sphere.loose: étendu
|
|
205
216
|
sphere.redraw: Redessiner
|
|
206
217
|
sphere.focus: FOCUS
|
|
218
|
+
sphere.toggleMute: Basculer la visibilité
|
|
219
|
+
sphere.learnAbout: En savoir plus sur ce type de relation
|
|
207
220
|
concept.detailView: Détail
|
|
208
221
|
concept.relationSphere: Sphère de relations
|
|
209
222
|
concept.permalink: Permalien
|
package/src/router/index.ts
CHANGED
|
@@ -94,6 +94,11 @@ export const routes: RouteRecordRaw[] = [
|
|
|
94
94
|
name: 'contributors',
|
|
95
95
|
component: () => import('../views/ContributorsView.vue'),
|
|
96
96
|
},
|
|
97
|
+
{
|
|
98
|
+
path: '/relation-types',
|
|
99
|
+
name: 'relation-types',
|
|
100
|
+
component: () => import('../views/RelationTypesView.vue'),
|
|
101
|
+
},
|
|
97
102
|
{
|
|
98
103
|
path: '/about',
|
|
99
104
|
name: 'about-global',
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* RelationTypesView — teaching page explaining every relation type
|
|
4
|
+
* the app renders, grouped into:
|
|
5
|
+
*
|
|
6
|
+
* 1. Hyperedge relations (one-to-many decompositions)
|
|
7
|
+
* - partitive (rake diagram) — ISO 704:2022 §5.5.3
|
|
8
|
+
* - general (pipe-and-thread diagram) — ISO 704:2022 §5.5.4
|
|
9
|
+
*
|
|
10
|
+
* 2. Bilateral relations (one-to-one) — grouped by category, labels
|
|
11
|
+
* and definitions pulled from the live ontology taxonomy. Adding
|
|
12
|
+
* a new type to src/data/taxonomies.json automatically flows
|
|
13
|
+
* through to this page (no code change).
|
|
14
|
+
*
|
|
15
|
+
* Sphere legend labels deep-link here via `#<type>` anchors
|
|
16
|
+
* (e.g. `#__generic__`, `#__partitive__`, `#broader_generic`).
|
|
17
|
+
*/
|
|
18
|
+
import { computed } from 'vue';
|
|
19
|
+
import { useI18n } from '../i18n';
|
|
20
|
+
import {
|
|
21
|
+
RELATIONSHIP_CATEGORIES,
|
|
22
|
+
relationshipLabel,
|
|
23
|
+
relationshipDefinition,
|
|
24
|
+
} from '../utils/relationship-categories';
|
|
25
|
+
import HyperedgeDiagram from '../components/HyperedgeDiagram.vue';
|
|
26
|
+
|
|
27
|
+
const { t } = useI18n();
|
|
28
|
+
|
|
29
|
+
interface HyperedgeCard {
|
|
30
|
+
readonly anchor: string;
|
|
31
|
+
readonly kind: 'partitive' | 'generic';
|
|
32
|
+
readonly label: string;
|
|
33
|
+
readonly iso: string;
|
|
34
|
+
readonly definition: string;
|
|
35
|
+
readonly example: { conceptId: string; description: string };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const HYPEREDGE_CARDS: readonly HyperedgeCard[] = [
|
|
39
|
+
{
|
|
40
|
+
anchor: '__partitive__',
|
|
41
|
+
kind: 'partitive',
|
|
42
|
+
label: 'partitive relation',
|
|
43
|
+
iso: 'ISO 704:2022 §5.5.3',
|
|
44
|
+
definition:
|
|
45
|
+
'A whole/part decomposition. The comprehensive concept is the whole; each member is a part. Per-member multiplicity (presence × count) is encoded in the tooth stroke style — solid for required, dashed for optional, double-line for multiple or at-least-one, 3× width for delimiting members.',
|
|
46
|
+
example: { conceptId: '1.3', description: 'system of quantities' },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
anchor: '__generic__',
|
|
50
|
+
kind: 'generic',
|
|
51
|
+
label: 'general relation',
|
|
52
|
+
iso: 'ISO 704:2022 §5.5.4',
|
|
53
|
+
definition:
|
|
54
|
+
'A genus/species decomposition. The comprehensive concept is the genus; each member is a species (coordinate concept) differing along a shared dimension. The thick parent pipe carries the shared criterion (the dimension); thin direct threads radiate from a middle node to each member.',
|
|
55
|
+
example: { conceptId: '1.9', description: 'measurement unit' },
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const bilateralCategories = computed(() =>
|
|
60
|
+
RELATIONSHIP_CATEGORIES.map(cat => ({
|
|
61
|
+
id: cat.id,
|
|
62
|
+
label: cat.label,
|
|
63
|
+
color: cat.color,
|
|
64
|
+
types: cat.types.map(type => ({
|
|
65
|
+
type,
|
|
66
|
+
label: relationshipLabel(type),
|
|
67
|
+
definition: relationshipDefinition(type),
|
|
68
|
+
})),
|
|
69
|
+
})),
|
|
70
|
+
);
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<template>
|
|
74
|
+
<div class="relation-types-view max-w-5xl mx-auto px-4 py-8">
|
|
75
|
+
<header class="mb-8">
|
|
76
|
+
<h1 class="text-3xl font-serif text-ink-800 dark:text-ink-50 mb-2">
|
|
77
|
+
{{ t('nav.relationTypes') }}
|
|
78
|
+
</h1>
|
|
79
|
+
<p class="text-ink-500 dark:text-ink-300">{{ t('relationTypes.intro') }}</p>
|
|
80
|
+
</header>
|
|
81
|
+
|
|
82
|
+
<!-- ── Hyperedge relations ─────────────────────────────────────── -->
|
|
83
|
+
<section class="mb-12" :aria-label="t('relationTypes.hyperedgeSection')">
|
|
84
|
+
<h2 class="text-xl font-semibold text-ink-700 dark:text-ink-100 mb-4 border-b border-ink-100 dark:border-ink-700 pb-2">
|
|
85
|
+
{{ t('relationTypes.hyperedgeSection') }}
|
|
86
|
+
</h2>
|
|
87
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
88
|
+
<article
|
|
89
|
+
v-for="card in HYPEREDGE_CARDS"
|
|
90
|
+
:key="card.anchor"
|
|
91
|
+
:id="card.anchor"
|
|
92
|
+
class="hyperedge-card card p-5 scroll-mt-20"
|
|
93
|
+
>
|
|
94
|
+
<div class="flex items-baseline justify-between mb-3 gap-3">
|
|
95
|
+
<h3 class="text-lg font-semibold text-ink-800 dark:text-ink-50">{{ card.label }}</h3>
|
|
96
|
+
<span class="text-xs font-mono text-ink-400 dark:text-ink-500">{{ card.iso }}</span>
|
|
97
|
+
</div>
|
|
98
|
+
<div class="grid grid-cols-[auto_1fr] gap-4 items-start">
|
|
99
|
+
<div class="diagram-frame bg-surface-alt dark:bg-ink-800 rounded-md p-2 border border-ink-100 dark:border-ink-700">
|
|
100
|
+
<HyperedgeDiagram :kind="card.kind" />
|
|
101
|
+
</div>
|
|
102
|
+
<div>
|
|
103
|
+
<p class="text-sm text-ink-600 dark:text-ink-200 mb-3">{{ card.definition }}</p>
|
|
104
|
+
<p class="text-xs text-ink-400 dark:text-ink-400">
|
|
105
|
+
{{ t('relationTypes.example') }}: VIM {{ card.example.conceptId }} — {{ card.example.description }}
|
|
106
|
+
</p>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</article>
|
|
110
|
+
</div>
|
|
111
|
+
</section>
|
|
112
|
+
|
|
113
|
+
<!-- ── Bilateral relations ─────────────────────────────────────── -->
|
|
114
|
+
<section :aria-label="t('relationTypes.bilateralSection')">
|
|
115
|
+
<h2 class="text-xl font-semibold text-ink-700 dark:text-ink-100 mb-4 border-b border-ink-100 dark:border-ink-700 pb-2">
|
|
116
|
+
{{ t('relationTypes.bilateralSection') }}
|
|
117
|
+
</h2>
|
|
118
|
+
<div class="space-y-6">
|
|
119
|
+
<article
|
|
120
|
+
v-for="cat in bilateralCategories"
|
|
121
|
+
:key="cat.id"
|
|
122
|
+
class="card p-4"
|
|
123
|
+
>
|
|
124
|
+
<header class="flex items-center gap-2 mb-3">
|
|
125
|
+
<span :class="['text-xs font-semibold uppercase tracking-wide px-2 py-0.5 rounded', cat.color]">
|
|
126
|
+
{{ cat.label }}
|
|
127
|
+
</span>
|
|
128
|
+
<span class="text-xs text-ink-400">{{ cat.types.length }}</span>
|
|
129
|
+
</header>
|
|
130
|
+
<ul class="space-y-2">
|
|
131
|
+
<li
|
|
132
|
+
v-for="item in cat.types"
|
|
133
|
+
:key="item.type"
|
|
134
|
+
:id="item.type"
|
|
135
|
+
class="bilateral-row scroll-mt-20"
|
|
136
|
+
>
|
|
137
|
+
<div class="flex items-baseline gap-2 flex-wrap">
|
|
138
|
+
<span class="font-medium text-ink-800 dark:text-ink-100">{{ item.label }}</span>
|
|
139
|
+
<code class="text-xs text-ink-400 dark:text-ink-500 font-mono">{{ item.type }}</code>
|
|
140
|
+
</div>
|
|
141
|
+
<p v-if="item.definition" class="text-sm text-ink-600 dark:text-ink-300 mt-0.5">
|
|
142
|
+
{{ item.definition }}
|
|
143
|
+
</p>
|
|
144
|
+
</li>
|
|
145
|
+
</ul>
|
|
146
|
+
</article>
|
|
147
|
+
</div>
|
|
148
|
+
</section>
|
|
149
|
+
</div>
|
|
150
|
+
</template>
|
|
151
|
+
|
|
152
|
+
<style scoped>
|
|
153
|
+
.hyperedge-card {
|
|
154
|
+
scroll-margin-top: 5rem;
|
|
155
|
+
}
|
|
156
|
+
.bilateral-row {
|
|
157
|
+
scroll-margin-top: 5rem;
|
|
158
|
+
}
|
|
159
|
+
</style>
|