@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.98",
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 a delimiting-characteristic label per thread', () => {
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
- /* 3 per-member characteristics + 1 group-level criterion = 4 labels */
113
- expect(labels).toContain('multiple of a unit');
114
- expect(labels).toContain('submultiple of a unit');
115
- expect(labels).toContain('coherent derived unit');
116
- expect(labels).toContain('by magnitude relationship');
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: 'Partitive Relation',
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: 'General Relation',
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
- <button v-for="item in legendItems" :key="item.type" class="sp-legend-item"
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
- @click="toggleType(item.type)"
1411
- :title="item.type">
1412
- <span v-if="item.type === '__partitive__'" class="sp-swatch sp-swatch-rake"
1413
- :style="{ '--rake-color': isTypeMuted(item.type) ? '#b8b9cc' : item.color }"></span>
1414
- <span v-else class="sp-swatch" :style="{ background: isTypeMuted(item.type) ? '#b8b9cc' : item.color }"></span>
1415
- <span>{{ item.label }}</span>
1416
- <span class="sp-count">{{ item.count }}</span>
1417
- </button>
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: 8px; padding: 3px 6px; border-radius: 3px;
1657
- cursor: pointer; border: 1px solid transparent; background: transparent; text-align: left;
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. Per-member delimiting characteristic text is rendered along each
24
- * thread (ISO 704 §5.5.4.2.1 — the intension difference that
25
- * distinguishes each coordinate concept). The optional group-level
26
- * `criterion` is shown at the middle node.
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, optional criterion label at the middle,
62
- * optional delimiting-characteristic label per thread.
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, with characteristic label */
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. Group-level criterion label, if present */
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;
@@ -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'];
@@ -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
@@ -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
@@ -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>