@glossarist/concept-browser 0.7.82 → 0.7.84

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.82",
3
+ "version": "0.7.84",
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": {
@@ -166,4 +166,83 @@ describe('PartitiveRelationDiagram — ISO 704 rake rendering', () => {
166
166
  expect(w.emitted('navigate')).toBeTruthy();
167
167
  });
168
168
  });
169
+
170
+ describe('designation-aware sizing', () => {
171
+ it('uses MIN_NODE_W when labels are short', () => {
172
+ const w = mount(PartitiveRelationDiagram, {
173
+ props: {
174
+ comprehensiveLabel: '1.3',
175
+ partitives: members(2),
176
+ completeness: 'complete',
177
+ plurality: null,
178
+ },
179
+ });
180
+ const svg = w.find('svg');
181
+ // Short labels → MIN_NODE_W=110, total = 2*110 + 16 + 40 = 276
182
+ const viewBox = svg.attributes('viewBox') || '';
183
+ const width = Number(viewBox.split(' ')[2]);
184
+ expect(width).toBeLessThan(300);
185
+ });
186
+
187
+ it('grows nodes for long designations', () => {
188
+ const longMembers: PartitiveMemberLabeled[] = [
189
+ { uri: 'u1', label: 'system of quantities', certainty: 'confirmed' },
190
+ { uri: 'u2', label: 'International System of Units', certainty: 'confirmed' },
191
+ { uri: 'u3', label: 'base quantity dimension', certainty: 'confirmed' },
192
+ ];
193
+ const w = mount(PartitiveRelationDiagram, {
194
+ props: {
195
+ comprehensiveLabel: 'coordinate system of quantities',
196
+ partitives: longMembers,
197
+ completeness: 'complete',
198
+ plurality: null,
199
+ },
200
+ });
201
+ const svg = w.find('svg');
202
+ const viewBox = svg.attributes('viewBox') || '';
203
+ const width = Number(viewBox.split(' ')[2]);
204
+ // Each node wider than MIN_NODE_W → total > 3*110 = 330
205
+ expect(width).toBeGreaterThan(330);
206
+ // Capped: node width ≤ MAX_NODE_W=220 → total ≤ 3*220 + 2*16 + 40 = 732
207
+ expect(width).toBeLessThanOrEqual(732);
208
+ });
209
+
210
+ it('wraps long labels to 2 lines via tspan when needed', () => {
211
+ const longLabel = 'International System of Quantities and Units';
212
+ const w = mount(PartitiveRelationDiagram, {
213
+ props: {
214
+ comprehensiveLabel: longLabel,
215
+ partitives: [
216
+ { uri: 'u1', label: 'short', certainty: 'confirmed' },
217
+ { uri: 'u2', label: 'short', certainty: 'confirmed' },
218
+ ],
219
+ completeness: 'complete',
220
+ plurality: null,
221
+ },
222
+ });
223
+ // The comprehensive text should have ≥ 2 tspans (wrapped)
224
+ const texts = w.findAll('text');
225
+ const compText = texts[0]; // first text is the comprehensive
226
+ const tspans = compText.findAll('tspan');
227
+ expect(tspans.length).toBeGreaterThanOrEqual(2);
228
+ });
229
+
230
+ it('renders the designation (not the conceptId) as the label', () => {
231
+ const w = mount(PartitiveRelationDiagram, {
232
+ props: {
233
+ comprehensiveLabel: 'system of quantities',
234
+ partitives: [
235
+ { uri: 'u1', label: 'base quantity', certainty: 'confirmed' },
236
+ { uri: 'u2', label: 'derived quantity', certainty: 'confirmed' },
237
+ ],
238
+ completeness: 'complete',
239
+ plurality: null,
240
+ },
241
+ });
242
+ const allText = w.findAll('text').map(t => t.text());
243
+ expect(allText).toContain('system of quantities');
244
+ expect(allText).toContain('base quantity');
245
+ expect(allText).toContain('derived quantity');
246
+ });
247
+ });
169
248
  });
@@ -19,11 +19,12 @@
19
19
  * on stem + spine (drops stay single)
20
20
  * - plurality.isShared && isUncertain : one solid + one DASHED line
21
21
  * - completeness === 'partial' : spine extends past the last
22
- * tooth (continued backline —
23
- * "more exist but aren't shown")
22
+ * tooth (continued backline)
24
23
  *
25
- * The diagram is pure SVG — no JS layout engine. Coordinates are
26
- * computed from the partitive count.
24
+ * Node sizing is designation-aware: the longest label drives the node
25
+ * width (clamped to [MIN_NODE_W, MAX_NODE_W]); labels that don't fit
26
+ * wrap to a second line via tspan. This lets the rake show actual
27
+ * designations ('system of quantities') instead of bare IDs ('1.3').
27
28
  */
28
29
  import { computed } from 'vue';
29
30
  import type { TypeSharedPluralityWire } from '../adapters/types';
@@ -48,17 +49,21 @@ const emit = defineEmits<{
48
49
  (e: 'navigate', uri: string): void;
49
50
  }>();
50
51
 
51
- // Layout constants — chosen for readability at 100% zoom.
52
- const NODE_W = 120;
53
- const NODE_H = 32;
52
+ // Layout constants
53
+ const MIN_NODE_W = 110;
54
+ const MAX_NODE_W = 220;
55
+ const SINGLE_LINE_H = 32;
56
+ const DOUBLE_LINE_H = 50;
54
57
  const STEM_LEN = 28;
55
58
  const SPINE_TO_NODE = 32;
56
59
  const GAP_X = 16;
57
- const PADDING_X = 24;
58
- const PADDING_Y = 16;
59
- const DOUBLE_GAP = 3; // gap between the two lines of a close-set double
60
- const PARTIAL_TAIL = 32; // how far the spine extends past the last tooth for partial
60
+ const PADDING_X = 20;
61
+ const PADDING_Y = 14;
62
+ const DOUBLE_GAP = 3;
63
+ const PARTIAL_TAIL = 32;
61
64
  const DASH_PATTERN = '4 3';
65
+ const CHAR_W = 6.2; // approx for 11px sans-serif
66
+ const NODE_TEXT_PAD = 16; // horizontal padding inside node rect
62
67
 
63
68
  type LineVariant = 'single' | 'double-solid' | 'solid-plus-dashed';
64
69
 
@@ -67,19 +72,67 @@ const lineVariant = computed<LineVariant>(() => {
67
72
  return props.plurality.isUncertain ? 'solid-plus-dashed' : 'double-solid';
68
73
  });
69
74
 
75
+ /** Estimated pixel width of a string at 11px sans-serif. */
76
+ function estimateTextWidth(text: string): number {
77
+ return text.length * CHAR_W;
78
+ }
79
+
80
+ /**
81
+ * Wrap a label to at most 2 lines, breaking on word boundaries.
82
+ * If a single word exceeds the limit, it stays on its own line and
83
+ * gets truncated with an ellipsis.
84
+ */
85
+ function wrapLabel(text: string, maxCharsPerLine: number): string[] {
86
+ if (text.length <= maxCharsPerLine) return [text];
87
+ const words = text.split(/\s+/);
88
+ const lines: string[] = [];
89
+ let current = '';
90
+ for (const w of words) {
91
+ if (w.length > maxCharsPerLine) {
92
+ // word itself too long — flush current, push truncated word
93
+ if (current) { lines.push(current); current = ''; }
94
+ lines.push(w.slice(0, Math.max(1, maxCharsPerLine - 1)) + '…');
95
+ continue;
96
+ }
97
+ if (!current) {
98
+ current = w;
99
+ } else if ((current + ' ' + w).length <= maxCharsPerLine) {
100
+ current += ' ' + w;
101
+ } else {
102
+ lines.push(current);
103
+ current = w;
104
+ }
105
+ }
106
+ if (current) lines.push(current);
107
+ if (lines.length > 2) {
108
+ return [lines[0], lines.slice(1).join(' ').slice(0, maxCharsPerLine - 1) + '…'];
109
+ }
110
+ return lines;
111
+ }
112
+
70
113
  const layout = computed(() => {
114
+ const allLabels = [props.comprehensiveLabel, ...props.partitives.map(p => p.label)];
115
+ const longestWidth = Math.max(...allLabels.map(estimateTextWidth));
116
+ const nodeW = Math.min(MAX_NODE_W, Math.max(MIN_NODE_W, Math.ceil(longestWidth) + NODE_TEXT_PAD));
117
+ const charsPerLine = Math.max(10, Math.floor((nodeW - NODE_TEXT_PAD) / CHAR_W));
118
+
119
+ const wrapped = allLabels.map(label => wrapLabel(label, charsPerLine));
120
+ const anyWrapped = wrapped.some(lines => lines.length > 1);
121
+ const nodeH = anyWrapped ? DOUBLE_LINE_H : SINGLE_LINE_H;
122
+
71
123
  const n = props.partitives.length;
72
- const totalWidth = PADDING_X * 2 + n * NODE_W + (n - 1) * GAP_X;
73
- const totalHeight = PADDING_Y * 2 + NODE_H + STEM_LEN + SPINE_TO_NODE + NODE_H;
124
+ const totalWidth = PADDING_X * 2 + n * nodeW + (n - 1) * GAP_X;
125
+ const totalHeight = PADDING_Y * 2 + nodeH + STEM_LEN + SPINE_TO_NODE + nodeH;
74
126
 
75
127
  const centerX = totalWidth / 2;
76
- const compY = PADDING_Y + NODE_H / 2;
77
- const spineY = compY + NODE_H / 2 + STEM_LEN;
128
+ const compY = PADDING_Y + nodeH / 2;
129
+ const spineY = compY + nodeH / 2 + STEM_LEN;
78
130
  const partY = spineY + SPINE_TO_NODE;
79
131
 
80
132
  const partitiveSlots = props.partitives.map((member, i) => {
81
- const x = PADDING_X + NODE_W / 2 + i * (NODE_W + GAP_X);
82
- return { member, x };
133
+ const x = PADDING_X + nodeW / 2 + i * (nodeW + GAP_X);
134
+ const labelLines = wrapped[i + 1]; // index 0 is comprehensive
135
+ return { member, x, labelLines };
83
136
  });
84
137
 
85
138
  const leftX = partitiveSlots[0]?.x ?? centerX;
@@ -89,6 +142,8 @@ const layout = computed(() => {
89
142
  : lastX;
90
143
 
91
144
  return {
145
+ nodeW,
146
+ nodeH,
92
147
  totalWidth,
93
148
  totalHeight,
94
149
  centerX,
@@ -96,16 +151,20 @@ const layout = computed(() => {
96
151
  spineY,
97
152
  partY,
98
153
  partitiveSlots,
154
+ comprehensiveLines: wrapped[0],
99
155
  leftX,
100
156
  spineEndX,
101
157
  };
102
158
  });
103
159
 
104
- function strokeAttr(variant: LineVariant, opacity = 1) {
105
- if (variant === 'solid-plus-dashed') {
106
- return { stroke: 'currentColor', 'stroke-width': 1.4, 'stroke-dasharray': DASH_PATTERN, opacity };
107
- }
108
- return { stroke: 'currentColor', 'stroke-width': 1.4, opacity };
160
+ function strokeAttr(variant: LineVariant, opacity = 1, override?: 'dash') {
161
+ const useDash = override === 'dash' || variant === 'solid-plus-dashed';
162
+ return {
163
+ stroke: 'currentColor',
164
+ 'stroke-width': 1.4,
165
+ opacity,
166
+ ...(useDash ? { 'stroke-dasharray': DASH_PATTERN } : {}),
167
+ };
109
168
  }
110
169
 
111
170
  function emitNav(uri: string) {
@@ -126,28 +185,29 @@ function emitNav(uri: string) {
126
185
  <!-- Comprehensive label -->
127
186
  <g>
128
187
  <rect
129
- :x="layout.centerX - NODE_W / 2"
130
- :y="layout.compY - NODE_H / 2"
131
- :width="NODE_W"
132
- :height="NODE_H"
188
+ :x="layout.centerX - layout.nodeW / 2"
189
+ :y="layout.compY - layout.nodeH / 2"
190
+ :width="layout.nodeW"
191
+ :height="layout.nodeH"
133
192
  rx="6"
134
193
  class="fill-emerald-50 dark:fill-emerald-900/30 stroke-current"
135
194
  stroke-width="1.2"
136
195
  />
137
- <text
138
- :x="layout.centerX"
139
- :y="layout.compY"
140
- text-anchor="middle"
141
- dominant-baseline="middle"
142
- class="text-[11px] fill-emerald-900 dark:fill-emerald-100"
143
- >{{ comprehensiveLabel }}</text>
196
+ <text :x="layout.centerX" text-anchor="middle" class="text-[11px] fill-emerald-900 dark:fill-emerald-100">
197
+ <tspan
198
+ v-for="(line, i) in layout.comprehensiveLines"
199
+ :key="i"
200
+ :x="layout.centerX"
201
+ :y="layout.compY - (layout.comprehensiveLines.length - 1) * 7 + i * 14"
202
+ >{{ line }}</tspan>
203
+ </text>
144
204
  </g>
145
205
 
146
206
  <!-- Criterion italic under the comprehensive -->
147
207
  <text
148
208
  v-if="criterion"
149
209
  :x="layout.centerX"
150
- :y="layout.compY + NODE_H / 2 + 10"
210
+ :y="layout.compY + layout.nodeH / 2 + 10"
151
211
  text-anchor="middle"
152
212
  class="text-[9px] italic fill-emerald-800 dark:fill-emerald-200/80"
153
213
  >{{ criterion }}</text>
@@ -155,20 +215,20 @@ function emitNav(uri: string) {
155
215
  <!-- Stem (vertical from comprehensive to spine) -->
156
216
  <line
157
217
  v-if="lineVariant === 'single'"
158
- :x1="layout.centerX" :y1="layout.compY + NODE_H / 2"
218
+ :x1="layout.centerX" :y1="layout.compY + layout.nodeH / 2"
159
219
  :x2="layout.centerX" :y2="layout.spineY"
160
220
  v-bind="strokeAttr('single')"
161
221
  />
162
222
  <template v-else>
163
223
  <line
164
- :x1="layout.centerX - DOUBLE_GAP / 2" :y1="layout.compY + NODE_H / 2"
224
+ :x1="layout.centerX - DOUBLE_GAP / 2" :y1="layout.compY + layout.nodeH / 2"
165
225
  :x2="layout.centerX - DOUBLE_GAP / 2" :y2="layout.spineY"
166
226
  v-bind="strokeAttr('double-solid')"
167
227
  />
168
228
  <line
169
- :x1="layout.centerX + DOUBLE_GAP / 2" :y1="layout.compY + NODE_H / 2"
229
+ :x1="layout.centerX + DOUBLE_GAP / 2" :y1="layout.compY + layout.nodeH / 2"
170
230
  :x2="layout.centerX + DOUBLE_GAP / 2" :y2="layout.spineY"
171
- v-bind="strokeAttr(lineVariant === 'solid-plus-dashed' ? 'solid-plus-dashed' : 'double-solid')"
231
+ v-bind="strokeAttr(lineVariant, 1, lineVariant === 'solid-plus-dashed' ? 'dash' : undefined)"
172
232
  />
173
233
  </template>
174
234
 
@@ -188,26 +248,23 @@ function emitNav(uri: string) {
188
248
  <line
189
249
  :x1="layout.leftX" :y1="layout.spineY + DOUBLE_GAP / 2"
190
250
  :x2="layout.spineEndX" :y2="layout.spineY + DOUBLE_GAP / 2"
191
- v-bind="strokeAttr(lineVariant === 'solid-plus-dashed' ? 'solid-plus-dashed' : 'double-solid')"
251
+ v-bind="strokeAttr(lineVariant, 1, lineVariant === 'solid-plus-dashed' ? 'dash' : undefined)"
192
252
  />
193
253
  </template>
194
254
 
195
255
  <!-- Drops + partitive nodes -->
196
256
  <g v-for="(slot, i) in layout.partitiveSlots" :key="i">
197
- <!-- drop from spine to node (always single line per ISO 704) -->
198
257
  <line
199
258
  :x1="slot.x" :y1="layout.spineY"
200
- :x2="slot.x" :y2="layout.partY - NODE_H / 2"
201
- v-bind="strokeAttr('single', slot.member.certainty === 'possible' ? 0.5 : 1)"
202
- :stroke-dasharray="slot.member.certainty === 'possible' ? DASH_PATTERN : undefined"
259
+ :x2="slot.x" :y2="layout.partY - layout.nodeH / 2"
260
+ v-bind="strokeAttr('single', slot.member.certainty === 'possible' ? 0.5 : 1, slot.member.certainty === 'possible' ? 'dash' : undefined)"
203
261
  />
204
- <!-- node -->
205
262
  <g class="cursor-pointer" @click="emitNav(slot.member.uri)">
206
263
  <rect
207
- :x="slot.x - NODE_W / 2"
208
- :y="layout.partY - NODE_H / 2"
209
- :width="NODE_W"
210
- :height="NODE_H"
264
+ :x="slot.x - layout.nodeW / 2"
265
+ :y="layout.partY - layout.nodeH / 2"
266
+ :width="layout.nodeW"
267
+ :height="layout.nodeH"
211
268
  rx="6"
212
269
  :class="slot.member.certainty === 'possible'
213
270
  ? 'fill-emerald-50/60 dark:fill-emerald-900/20 stroke-current'
@@ -217,13 +274,18 @@ function emitNav(uri: string) {
217
274
  />
218
275
  <text
219
276
  :x="slot.x"
220
- :y="layout.partY"
221
277
  text-anchor="middle"
222
- dominant-baseline="middle"
223
278
  :class="slot.member.certainty === 'possible'
224
279
  ? 'text-[11px] fill-emerald-700/70 dark:fill-emerald-200/60 italic'
225
280
  : 'text-[11px] fill-emerald-900 dark:fill-emerald-100'"
226
- >{{ slot.member.label }}</text>
281
+ >
282
+ <tspan
283
+ v-for="(line, j) in slot.labelLines"
284
+ :key="j"
285
+ :x="slot.x"
286
+ :y="layout.partY - (slot.labelLines.length - 1) * 7 + j * 14"
287
+ >{{ line }}</tspan>
288
+ </text>
227
289
  </g>
228
290
  </g>
229
291
  </svg>
@@ -15,7 +15,7 @@ import type { PartitiveRelationWire, Manifest } from '../adapters/types';
15
15
  import { useRouter } from 'vue-router';
16
16
  import { useVocabularyStore } from '../stores/vocabulary';
17
17
  import { getFactory } from '../adapters/factory';
18
- import { useI18n } from '../i18n';
18
+ import { useI18n, locale } from '../i18n';
19
19
  import {
20
20
  completenessLabel,
21
21
  certaintyLabel,
@@ -35,12 +35,26 @@ const store = useVocabularyStore();
35
35
  const factory = getFactory();
36
36
  const { t } = useI18n();
37
37
 
38
- function labelFor(uri: string): string {
39
- const parsed = factory.resolve(uri);
40
- if (parsed.type === 'internal') {
41
- return parsed.conceptId || uri;
38
+ function designationFor(uri: string): string {
39
+ const node = store.graph.getNode(uri);
40
+ if (node) {
41
+ const des = node.designations[locale.value]
42
+ || node.designations.eng
43
+ || Object.values(node.designations)[0];
44
+ if (des) return des;
45
+ }
46
+ const resolution = factory.resolve(uri);
47
+ if (resolution.type === 'internal') {
48
+ const adapter = store.datasets.get(resolution.registerId);
49
+ const entry = adapter?.getIndexEntry(resolution.conceptId);
50
+ if (entry) {
51
+ const des = entry.designations[locale.value]
52
+ || entry.designations.eng
53
+ || Object.values(entry.designations)[0];
54
+ if (des) return des;
55
+ }
42
56
  }
43
- return uri;
57
+ return resolution.type === 'internal' ? resolution.conceptId : uri;
44
58
  }
45
59
 
46
60
  function navigate(uri: string) {
@@ -78,7 +92,7 @@ function pluralityBadge(plurality: NonNullable<PartitiveRelationWire['plurality'
78
92
  function labeledMembers(rel: PartitiveRelationWire): PartitiveMemberLabeled[] {
79
93
  return rel.partitives.map(m => ({
80
94
  uri: m.uri,
81
- label: labelFor(m.uri),
95
+ label: designationFor(m.uri),
82
96
  certainty: m.certainty,
83
97
  }));
84
98
  }
@@ -110,7 +124,7 @@ function labeledMembers(rel: PartitiveRelationWire): PartitiveMemberLabeled[] {
110
124
  @click="navigate(rel.comprehensive)"
111
125
  class="ml-auto text-sm font-medium text-emerald-900 dark:text-emerald-100 hover:underline truncate"
112
126
  >
113
- {{ labelFor(rel.comprehensive) }}
127
+ {{ designationFor(rel.comprehensive) }}
114
128
  </button>
115
129
  </div>
116
130
  <div
@@ -122,7 +136,7 @@ function labeledMembers(rel: PartitiveRelationWire): PartitiveMemberLabeled[] {
122
136
  <!-- Rake diagram (ISO 704 partitive-relation convention) -->
123
137
  <div class="px-3 py-3 overflow-x-auto">
124
138
  <PartitiveRelationDiagram
125
- :comprehensive-label="labelFor(rel.comprehensive)"
139
+ :comprehensive-label="designationFor(rel.comprehensive)"
126
140
  :partitives="labeledMembers(rel)"
127
141
  :completeness="rel.completeness"
128
142
  :plurality="rel.plurality"
@@ -25,7 +25,7 @@ import { hashSeed, expandParams, portSide, portPoint, idToUriGet } from '../util
25
25
  import { useI18n } from '../i18n';
26
26
 
27
27
  const { t, locale } = useI18n();
28
- import type { Concept, Manifest, GraphEdge } from '../adapters/types';
28
+ import type { Concept, Manifest, GraphEdge, PartitiveRelationWire } from '../adapters/types';
29
29
  import { conceptUri } from '../adapters/model-bridge';
30
30
 
31
31
  const props = defineProps<{
@@ -33,6 +33,7 @@ const props = defineProps<{
33
33
  manifest: Manifest;
34
34
  registerId: string;
35
35
  edges: GraphEdge[];
36
+ partitiveRelations?: PartitiveRelationWire[];
36
37
  }>();
37
38
 
38
39
  const emit = defineEmits<{
@@ -390,6 +391,38 @@ function buildGraph() {
390
391
 
391
392
  nodes = Array.from(nodeMap.values());
392
393
  links = resultLinks;
394
+
395
+ /* Ensure every PartitiveRelation member is present in the graph
396
+ so the rake bundle can render. The comprehensive is normally the
397
+ focus; partitives may need to be added as depth-1 neighbors. */
398
+ for (const rel of props.partitiveRelations ?? []) {
399
+ const compParsed = UriRouter.parseUri(rel.comprehensive);
400
+ if (!compParsed) continue;
401
+ const compNodeId = `${compParsed.registerId}/${compParsed.conceptId}`;
402
+ if (!nodeMap.has(compNodeId)) continue;
403
+
404
+ for (const member of rel.partitives) {
405
+ const parsed = UriRouter.parseUri(member.uri);
406
+ if (!parsed) continue;
407
+ const nid = `${parsed.registerId}/${parsed.conceptId}`;
408
+ if (nodeMap.has(nid)) continue;
409
+ if (nodeMap.size >= MAX_NODES) break;
410
+ const pos = fibonacciSpherePosition(1, nodeMap.size, MAX_NODES, hashSeed(parsed.conceptId));
411
+ nodeMap.set(nid, {
412
+ id: nid,
413
+ term: parsed.conceptId,
414
+ ref: parsed.registerId,
415
+ register: parsed.registerId,
416
+ conceptId: parsed.conceptId,
417
+ depth: 1,
418
+ x: pos.x, y: pos.y, z: pos.z,
419
+ vx: 0, vy: 0, vz: 0,
420
+ });
421
+ idToUri.set(nid, member.uri);
422
+ nodes = Array.from(nodeMap.values());
423
+ }
424
+ }
425
+
393
426
  graphVersion.value++;
394
427
  }
395
428
 
@@ -597,8 +630,10 @@ function drawEdges(cx: number, cy: number) {
597
630
  svg.setAttribute('height', String(h));
598
631
  svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
599
632
  svg.setAttribute('preserveAspectRatio', 'none');
600
- /* Remove only path + text + rect (edges), keep defs (markers) */
601
- svg.querySelectorAll('path:not(defs path), text.edge-label, rect.edge-label-bg').forEach(el => el.remove());
633
+ /* Remove only path + text + rect (edges), keep defs (markers).
634
+ Also remove <line> elements (rake-bundle segments) and <circle>
635
+ (rake junction markers). */
636
+ svg.querySelectorAll('path:not(defs path), line.rake-seg, circle.rake-junction, text.edge-label, rect.edge-label-bg').forEach(el => el.remove());
602
637
  if (!svg.querySelector('defs')) ensureMarkers(svg);
603
638
 
604
639
  /* Compute visible node positions */
@@ -712,6 +747,182 @@ function drawEdges(cx: number, cy: number) {
712
747
  label.textContent = labelText;
713
748
  svg.appendChild(label);
714
749
  }
750
+
751
+ drawRakeBundles(svg, pos, cx, cy);
752
+ }
753
+
754
+ /**
755
+ * Render PartitiveRelations as ISO 704 rake/bracket bundles in the sphere.
756
+ *
757
+ * Each relation renders as a real bracket:
758
+ *
759
+ * [comprehensive]
760
+ * |
761
+ * | ← single stem (vertical/radial)
762
+ * |
763
+ * +---•---+ ← spine midpoint (junction)
764
+ * | | |
765
+ * | | | ← N branches from spine midpoint
766
+ * [p1] [p2] [p3] (one per partitive)
767
+ *
768
+ * In 3D the bracket's "vertical" direction is along the line from
769
+ * comprehensive to the centroid of partitives. The branches fan out
770
+ * from the spine midpoint to each partitive. Right angles are not
771
+ * enforceable in a force-directed layout; instead we use a single
772
+ * shared junction point so the eye reads "ONE relation, N branches".
773
+ *
774
+ * ISO 704 line conventions applied (each segment: stem + spine + branches):
775
+ * - default : single solid line
776
+ * - plurality.isShared && !isUncertain : two parallel solid lines
777
+ * (close-set; offset ±DOUBLE_GAP/2)
778
+ * - plurality.isShared && isUncertain : one solid + one dashed line
779
+ *
780
+ * Per-member certainty:
781
+ * - confirmed : branch is solid (with the plurality style above)
782
+ * - possible : branch is dashed + reduced opacity (overrides plurality
783
+ * on that branch only; the stem + spine keep the relation
784
+ * style)
785
+ *
786
+ * A diamond marker at the comprehensive end signals the hyperedge origin.
787
+ */
788
+ function drawRakeBundles(
789
+ svg: SVGSVGElement,
790
+ pos: Map<string, {x: number; y: number}>,
791
+ _cx: number,
792
+ _cy: number,
793
+ ) {
794
+ const color = uiStore.isDark ? '#2dd4bf' : '#0d9488';
795
+ const DOUBLE_GAP = 4;
796
+ const DASH = '4 3';
797
+
798
+ for (const rel of props.partitiveRelations ?? []) {
799
+ const compParsed = UriRouter.parseUri(rel.comprehensive);
800
+ if (!compParsed) continue;
801
+ const compNodeId = `${compParsed.registerId}/${compParsed.conceptId}`;
802
+ const compPos = pos.get(compNodeId);
803
+ if (!compPos) continue;
804
+
805
+ const memberPositions = rel.partitives
806
+ .map(member => {
807
+ const parsed = UriRouter.parseUri(member.uri);
808
+ if (!parsed) return null;
809
+ const nid = `${parsed.registerId}/${parsed.conceptId}`;
810
+ const p = pos.get(nid);
811
+ return p ? { member, pos: p } : null;
812
+ })
813
+ .filter((x): x is { member: { uri: string; certainty: 'confirmed' | 'possible' }; pos: {x: number; y: number} } => x !== null);
814
+
815
+ if (memberPositions.length === 0) continue;
816
+
817
+ // Spine midpoint = centroid of comprehensive + all partitives.
818
+ // This is the junction where the stem meets the branches.
819
+ const cx_part = (compPos.x + memberPositions.reduce((s, m) => s + m.pos.x, 0)) / (memberPositions.length + 1);
820
+ const cy_part = (compPos.y + memberPositions.reduce((s, m) => s + m.pos.y, 0)) / (memberPositions.length + 1);
821
+
822
+ const isShared = !!rel.plurality?.isShared;
823
+ const isUncertain = !!rel.plurality?.isUncertain;
824
+
825
+ /**
826
+ * Draw a single straight line segment with optional parallel offset
827
+ * and dash style. Used for both stem and branches.
828
+ */
829
+ const drawSegment = (
830
+ ax: number, ay: number, bx: number, by: number,
831
+ opts: { offset?: number; dashed?: boolean; opacity?: number } = {},
832
+ ) => {
833
+ const offset = opts.offset ?? 0;
834
+ const dashed = opts.dashed ?? false;
835
+ const opacity = opts.opacity ?? 0.85;
836
+ if (offset === 0) {
837
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
838
+ line.setAttribute('class', 'rake-seg');
839
+ line.setAttribute('x1', String(ax));
840
+ line.setAttribute('y1', String(ay));
841
+ line.setAttribute('x2', String(bx));
842
+ line.setAttribute('y2', String(by));
843
+ line.setAttribute('stroke', color);
844
+ line.setAttribute('stroke-width', '1.6');
845
+ line.setAttribute('opacity', String(opacity));
846
+ line.setAttribute('stroke-linecap', 'round');
847
+ if (dashed) line.setAttribute('stroke-dasharray', DASH);
848
+ svg.appendChild(line);
849
+ } else {
850
+ // Compute perpendicular unit vector for parallel offset
851
+ const dx = bx - ax, dy = by - ay;
852
+ const len = Math.hypot(dx, dy) || 1;
853
+ const px = -dy / len, py = dx / len;
854
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
855
+ line.setAttribute('class', 'rake-seg');
856
+ line.setAttribute('x1', String(ax + px * offset));
857
+ line.setAttribute('y1', String(ay + py * offset));
858
+ line.setAttribute('x2', String(bx + px * offset));
859
+ line.setAttribute('y2', String(by + py * offset));
860
+ line.setAttribute('stroke', color);
861
+ line.setAttribute('stroke-width', '1.6');
862
+ line.setAttribute('opacity', String(opacity));
863
+ line.setAttribute('stroke-linecap', 'round');
864
+ if (dashed) line.setAttribute('stroke-dasharray', DASH);
865
+ svg.appendChild(line);
866
+ }
867
+ };
868
+
869
+ /**
870
+ * Draw a segment with the relation's plurality style:
871
+ * single solid (default), double solid (isShared),
872
+ * or solid + dashed (isShared + isUncertain).
873
+ */
874
+ const drawSegmentWithPlurality = (
875
+ ax: number, ay: number, bx: number, by: number,
876
+ opts: { opacity?: number; perMemberPossible?: boolean } = {},
877
+ ) => {
878
+ const opacity = opts.opacity ?? 0.85;
879
+ if (!isShared) {
880
+ drawSegment(ax, ay, bx, by, { opacity, dashed: opts.perMemberPossible });
881
+ } else {
882
+ // Close-set double: two parallel lines offset ±DOUBLE_GAP/2
883
+ drawSegment(ax, ay, bx, by, { offset: -DOUBLE_GAP / 2, opacity });
884
+ drawSegment(ax, ay, bx, by, {
885
+ offset: DOUBLE_GAP / 2,
886
+ opacity,
887
+ dashed: isUncertain || opts.perMemberPossible,
888
+ });
889
+ }
890
+ };
891
+
892
+ // 1. Stem: comprehensive → spine midpoint
893
+ drawSegmentWithPlurality(compPos.x, compPos.y, cx_part, cy_part);
894
+
895
+ // 2. Branches: spine midpoint → each partitive
896
+ for (const { member, pos: pPos } of memberPositions) {
897
+ drawSegmentWithPlurality(cx_part, cy_part, pPos.x, pPos.y, {
898
+ opacity: member.certainty === 'possible' ? 0.45 : 0.85,
899
+ perMemberPossible: member.certainty === 'possible',
900
+ });
901
+ }
902
+
903
+ // 3. Junction marker at spine midpoint (small filled circle)
904
+ const junction = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
905
+ junction.setAttribute('class', 'rake-junction');
906
+ junction.setAttribute('cx', String(cx_part));
907
+ junction.setAttribute('cy', String(cy_part));
908
+ junction.setAttribute('r', '3');
909
+ junction.setAttribute('fill', color);
910
+ junction.setAttribute('opacity', '0.85');
911
+ junction.setAttribute('stroke', uiStore.isDark ? '#0a1f1c' : '#ffffff');
912
+ junction.setAttribute('stroke-width', '1');
913
+ svg.appendChild(junction);
914
+
915
+ // 4. Diamond marker at comprehensive end (hyperedge origin signal)
916
+ const diamond = document.createElementNS('http://www.w3.org/2000/svg', 'path');
917
+ const dsize = 5;
918
+ diamond.setAttribute('d',
919
+ `M ${compPos.x} ${compPos.y - dsize} L ${compPos.x + dsize} ${compPos.y} L ${compPos.x} ${compPos.y + dsize} L ${compPos.x - dsize} ${compPos.y} Z`);
920
+ diamond.setAttribute('fill', color);
921
+ diamond.setAttribute('opacity', '0.9');
922
+ diamond.setAttribute('stroke', uiStore.isDark ? '#0a1f1c' : '#ffffff');
923
+ diamond.setAttribute('stroke-width', '1');
924
+ svg.appendChild(diamond);
925
+ }
715
926
  }
716
927
 
717
928
  function ensureMarkers(svg: SVGSVGElement) {
@@ -97,6 +97,41 @@ export function useConceptEdges(
97
97
  return edgeDisplayCache.value.get(uri) ?? { uri, conceptId: uri, designation: '', tooltip: uri, isLocal: false, badge: null };
98
98
  }
99
99
 
100
+ /**
101
+ * Resolve a concept's designation in the current UI locale.
102
+ * Fallback chain: locale → eng → any available.
103
+ *
104
+ * Sources tried in order:
105
+ * 1. BFS graph node (already loaded as a neighbor)
106
+ * 2. Dataset adapter index entry (every concept in the dataset)
107
+ * 3. conceptId as last-resort fallback
108
+ *
109
+ * Used by PartitiveRelationList (sidebar rake diagram) so the rake
110
+ * shows designations like 'system of quantities' instead of bare
111
+ * IDs like '1.3'.
112
+ */
113
+ function designationFor(uri: string): string {
114
+ const node = store.graph.getNode(uri);
115
+ if (node) {
116
+ const des = node.designations[locale.value]
117
+ || node.designations.eng
118
+ || Object.values(node.designations)[0];
119
+ if (des) return des;
120
+ }
121
+ const resolution = factory.resolve(uri);
122
+ if (resolution.type === 'internal') {
123
+ const adapter = store.datasets.get(resolution.registerId);
124
+ const entry = adapter?.getIndexEntry(resolution.conceptId);
125
+ if (entry) {
126
+ const des = entry.designations[locale.value]
127
+ || entry.designations.eng
128
+ || Object.values(entry.designations)[0];
129
+ if (des) return des;
130
+ }
131
+ }
132
+ return resolution.type === 'internal' ? resolution.conceptId : uri;
133
+ }
134
+
100
135
  function edgeBadgeColor(type: string, direction: 'out' | 'in'): string {
101
136
  if (type === 'supersedes' || type === 'superseded_by') {
102
137
  return direction === 'out' ? 'text-orange-700 bg-orange-50' : 'text-red-700 bg-red-50';
@@ -262,6 +297,7 @@ export function useConceptEdges(
262
297
  incomingEdges,
263
298
  edgeDisplayCache,
264
299
  getEdgeDisplay,
300
+ designationFor,
265
301
  edgeBadgeColor,
266
302
  inverseEdgeType,
267
303
  conceptRelated,
@@ -3,6 +3,8 @@ import { computed, watch, ref, onMounted, onUnmounted } from 'vue';
3
3
  import { useRouter } from 'vue-router';
4
4
  import { useVocabularyStore } from '../stores/vocabulary';
5
5
  import { conceptUri } from '../adapters/model-bridge';
6
+ import { UriRouter } from '../adapters/UriRouter';
7
+ import type { PartitiveRelationWire, PartitiveMemberWire } from '../adapters/types';
6
8
  import ConceptDetail from '../components/ConceptDetail.vue';
7
9
  import RelationSphere from '../components/RelationSphere.vue';
8
10
  import ShortcutsModal from '../components/ShortcutsModal.vue';
@@ -58,6 +60,55 @@ const manifest = computed(() => store.currentManifest);
58
60
  const edges = computed(() => store.conceptEdges);
59
61
  const activeRegisterId = computed(() => sphereFocusPayload.value?.registerId ?? props.registerId);
60
62
  const adjacent = ref({ prev: null as string | null, next: null as string | null });
63
+
64
+ /**
65
+ * Project concept.partitiveRelations (glossarist-js v2 model) into the
66
+ * URI-resolved wire shape the sphere consumes. Mirrors the projection
67
+ * in use-concept-edges.ts but inline here so the sphere can render rake
68
+ * bundles without a full composable setup.
69
+ */
70
+ const partitiveRelationsForSphere = computed<PartitiveRelationWire[]>(() => {
71
+ const c = concept.value;
72
+ const m = manifest.value;
73
+ if (!c || !m) return [];
74
+ const sourceUri = conceptUri(c, activeRegisterId.value, m.uriBase);
75
+ const out: PartitiveRelationWire[] = [];
76
+ for (const rel of c.partitiveRelations ?? []) {
77
+ const compRef = rel.comprehensive;
78
+ const compTarget = store.manifests.get(activeRegisterId.value)
79
+ ? UriRouter.buildConceptUri(m.uriBase, activeRegisterId.value, compRef.id ?? '')
80
+ : null;
81
+ if (!compRef.id || !compTarget) continue;
82
+ const partitives: PartitiveMemberWire[] = rel.partitives
83
+ .map(member => {
84
+ const ref = member.ref;
85
+ if (!ref?.id) return null;
86
+ const targetRegister = store.manifests.has(activeRegisterId.value)
87
+ ? activeRegisterId.value
88
+ : activeRegisterId.value;
89
+ const uri = UriRouter.buildConceptUri(m.uriBase, targetRegister, ref.id);
90
+ if (uri === sourceUri) return null;
91
+ return { uri, certainty: member.certainty };
92
+ })
93
+ .filter((x): x is PartitiveMemberWire => x !== null);
94
+ if (partitives.length === 0) continue;
95
+ out.push({
96
+ source: sourceUri,
97
+ comprehensive: compTarget,
98
+ partitives,
99
+ completeness: rel.completeness,
100
+ plurality: rel.plurality
101
+ ? {
102
+ isShared: rel.plurality.isShared,
103
+ isUncertain: rel.plurality.isUncertain,
104
+ sharedType: null,
105
+ }
106
+ : null,
107
+ register: activeRegisterId.value,
108
+ });
109
+ }
110
+ return out;
111
+ });
61
112
  const viewMode = ref<'detail' | 'sphere'>(
62
113
  router.currentRoute.value.query.view === 'sphere' ? 'sphere' : 'detail'
63
114
  );
@@ -280,6 +331,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown));
280
331
  :manifest="manifest"
281
332
  :register-id="activeRegisterId"
282
333
  :edges="edges"
334
+ :partitive-relations="partitiveRelationsForSphere"
283
335
  @navigate="onSphereNavigate"
284
336
  />
285
337
  <!-- Detail mode -->