@glossarist/concept-browser 0.7.89 → 0.7.91

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.
@@ -0,0 +1,1921 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * RelationSphere — 3D sphere visualization of a concept's neighborhood.
4
+ *
5
+ * Self-contained: receives concept + edges as props, builds its own internal
6
+ * graph with simple string IDs (like the prototype). No dependency on the
7
+ * graph engine's URI scheme.
8
+ *
9
+ * Physics: EXACT replica of the prototype — only 3 forces:
10
+ * sphereConstraint (normalize ‖p‖=1 + tangent velocity projection),
11
+ * velocityClamp(0.20), navForce (SLERP-eased target tracking).
12
+ */
13
+ import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
14
+ import { forceSimulation, zoom, select, zoomIdentity } from 'd3';
15
+ import { useUiStore } from '../stores/ui';
16
+ import { useVocabularyStore } from '../stores/vocabulary';
17
+ import { useDsStyle } from '../utils/dataset-style';
18
+ import { getFactory } from '../adapters/factory';
19
+ import { SPHERE_RELATION_CATEGORIES as RELATION_CATEGORIES, sphereCategoryForType as categoryForType, categorizeRelationForSphere as categorizeRelation, colorForTypeInMode as colorForTypeRaw, relationLabel as relationTypeLabel } from '../utils/relation-sphere-styling';
20
+ import { easeInOutCubic, slerp, fibonacciSpherePosition, project, cardEdge, type Vec3 } from '../composables/useSphereProjection';
21
+ import { UriRouter } from '../adapters/UriRouter';
22
+ import { getPreferredTerm } from '../utils/concept-helpers';
23
+ import { renderContent } from '../utils/content-renderer';
24
+ import { hashSeed, expandParams, portSide, portPoint, idToUriGet } from '../utils/sphere-math';
25
+ import { useI18n } from '../i18n';
26
+
27
+ const { t, locale } = useI18n();
28
+ import type { Concept, Manifest, GraphEdge, PartitiveRelationWire, PartitiveMemberWire } from '../adapters/types';
29
+ import { rakeStrokeStyle } from '../utils/partitive-multiplicity';
30
+ import { drawRakeBundles } from './relation-sphere/rake-bundles';
31
+ import { ensureMarkers, ensureTypeMarker } from './relation-sphere/svg-markers';
32
+ import { conceptUri } from '../adapters/model-bridge';
33
+
34
+ const props = defineProps<{
35
+ concept: Concept;
36
+ manifest: Manifest;
37
+ registerId: string;
38
+ edges: GraphEdge[];
39
+ partitiveRelations?: PartitiveRelationWire[];
40
+ }>();
41
+
42
+ const emit = defineEmits<{
43
+ navigate: [payload: { registerId: string; conceptId: string }];
44
+ }>();
45
+
46
+ const uiStore = useUiStore();
47
+ const store = useVocabularyStore();
48
+ const { getColor } = useDsStyle();
49
+
50
+ /* ── Types ──────────────────────────────────────────────── */
51
+ interface SNode {
52
+ id: string;
53
+ term: string;
54
+ definition?: string;
55
+ languages?: string[]; /* languages available on this concept */
56
+ ref: string;
57
+ register: string;
58
+ conceptId: string;
59
+ depth: number;
60
+ x: number; y: number; z: number;
61
+ vx: number; vy: number; vz: number;
62
+ }
63
+ interface SLink {
64
+ source: string; target: string;
65
+ type: string; category: string;
66
+ depth: number;
67
+ }
68
+
69
+ /* ── State ──────────────────────────────────────────────── */
70
+ const canvasRef = ref<HTMLDivElement | null>(null);
71
+ const viewportRef = ref<HTMLDivElement | null>(null);
72
+ const nodesLayerRef = ref<HTMLDivElement | null>(null);
73
+ const edgesSvgRef = ref<SVGSVGElement | null>(null);
74
+
75
+ let nodes: SNode[] = [];
76
+ let links: SLink[] = [];
77
+ const graphVersion = ref(0); /* bump when nodes/links change → reactive computeds re-evaluate */
78
+ let sim: ReturnType<typeof forceSimulation<any>> | null = null;
79
+ let zoomBehavior: any = null;
80
+ const nodeEls = new Map<string, HTMLElement>();
81
+ let isFirstRender = true;
82
+ let hoverTimer: ReturnType<typeof setTimeout> | null = null;
83
+ const hoveredNode = ref<SNode | null>(null);
84
+ const degree = ref<1 | 2 | 3>(1);
85
+ const expandValue = ref(5); /* 0–10 slider, 5 = default */
86
+ const mutedTypes = ref<Set<string>>(new Set());
87
+ const mutedRegisters = ref<Set<string>>(new Set());
88
+ const panelOpen = ref(true);
89
+ const MAX_NODES = 36;
90
+
91
+ /* Sphere-display language. Defaults to the i18n UI locale (which reads
92
+ localStorage) so e.g. a user returning in French mode sees French
93
+ terms by default. The user can override via the Language selector. */
94
+ const sphereLang = ref<string>(locale.value || 'eng');
95
+ const availableLangs = ref<Set<string>>(new Set());
96
+ const previewVersion = ref(0); /* bump to refresh the preview card */
97
+
98
+ /* When the user mutes a register or changes degree, we keep the simulation
99
+ running so nodes can reflow into their new positions. For dataset mute
100
+ we just hide the affected DOM + edges — no graph rebuild. For degree
101
+ change we rebuild the graph entirely. */
102
+
103
+ /* Nav tween state */
104
+ let navActive = false;
105
+ let navStart: Record<string, Vec3> | null = null;
106
+ let navEnd: Record<string, Vec3> | null = null;
107
+ let navOldDepths: Record<string, number> | null = null;
108
+ let navStartTime = 0;
109
+ let navT = 1;
110
+ let navDuration = 2200; /* mutable — shorter for expand changes than for concept navigation */
111
+
112
+ /* ── Async-load neighbor designations ───────────────────── */
113
+ /* Three strategies, cheapest first:
114
+ * 1. Graph engine node (already loaded with designations)
115
+ * 2. Adapter's index entry (loaded via loadDataset → has designations)
116
+ * 3. Full concept fetch (last resort, hits the network per-concept)
117
+ */
118
+ async function loadNeighborTerms() {
119
+ const factory = getFactory();
120
+ const lang = sphereLang.value;
121
+ const uriBase = props.manifest.uriBase;
122
+ if (!uriBase) throw new Error('RelationSphere: manifest.uriBase is required');
123
+ const neighborRegisters = new Set<string>();
124
+
125
+ /* Phase 1: collect which neighbor datasets we need to load */
126
+ for (const n of nodes) {
127
+ if (n.depth === 0) continue;
128
+ if (n.register && n.register !== props.registerId) neighborRegisters.add(n.register);
129
+ }
130
+
131
+ /* Phase 2: ensure all neighbor datasets are loaded (parallel) */
132
+ await Promise.allSettled(
133
+ [...neighborRegisters].map(async (reg) => {
134
+ const existing = factory.getAdapter(reg) ?? store.datasets.get(reg);
135
+ if (existing?.index) return;
136
+ try { await store.loadDataset(reg); } catch { /* ignore */ }
137
+ })
138
+ );
139
+
140
+ /* Phase 3: for each neighbor, resolve designation + definition + languages */
141
+ const promises: Promise<void>[] = [];
142
+ for (const n of nodes) {
143
+ if (n.depth === 0) continue;
144
+ promises.push((async () => {
145
+ const info = await resolveTerm(n, lang, uriBase, factory);
146
+ if (!info) return;
147
+ if (info.term && info.term !== n.term) {
148
+ n.term = info.term;
149
+ updateNodeTerm(n);
150
+ }
151
+ if (info.definition) n.definition = info.definition;
152
+ if (info.languages?.length) n.languages = info.languages;
153
+ /* Update preview if this is the hovered node */
154
+ if (hoveredNode.value?.id === n.id) previewVersion.value++;
155
+ /* Refresh the available-languages set */
156
+ if (info.languages?.length) {
157
+ for (const l of info.languages) availableLangs.value.add(l);
158
+ }
159
+ })());
160
+ }
161
+ await Promise.allSettled(promises);
162
+ }
163
+
164
+ interface ResolvedTerm {
165
+ term: string | null;
166
+ definition?: string;
167
+ languages?: string[];
168
+ }
169
+
170
+ async function resolveTerm(
171
+ n: SNode,
172
+ lang: string,
173
+ uriBase: string,
174
+ factory: ReturnType<typeof getFactory>
175
+ ): Promise<ResolvedTerm | null> {
176
+ const neighborUri = UriRouter.buildConceptUri(uriBase, n.register, n.conceptId);
177
+ const out: ResolvedTerm = { term: null };
178
+
179
+ /* Strategy 1: graph engine */
180
+ const gn = store.graph.getNode(neighborUri);
181
+ if (gn?.designations) {
182
+ const term = gn.designations[lang] ?? gn.designations.eng ?? Object.values(gn.designations)[0];
183
+ if (term) out.term = term;
184
+ out.languages = Object.keys(gn.designations);
185
+ }
186
+
187
+ /* Strategy 2: adapter's index entry */
188
+ const adapter = factory.getAdapter(n.register) ?? store.datasets.get(n.register);
189
+ if (adapter?.index) {
190
+ const entry = adapter.getIndexEntry(n.conceptId);
191
+ if (entry?.designations) {
192
+ if (!out.term) {
193
+ const term = entry.designations[lang] ?? entry.designations.eng ?? Object.values(entry.designations)[0];
194
+ if (term) out.term = term;
195
+ }
196
+ if (!out.languages?.length) out.languages = Object.keys(entry.designations);
197
+ }
198
+ }
199
+
200
+ /* Strategy 3: full concept fetch — gives us the definition too */
201
+ if (adapter) {
202
+ try {
203
+ const concept = await adapter.fetchConcept(n.conceptId);
204
+ if (concept?.languages?.length && !out.languages?.length) {
205
+ out.languages = [...concept.languages];
206
+ }
207
+ const langs = concept?.languages ?? [];
208
+ const lc =
209
+ concept?.localization?.(lang) ??
210
+ concept?.localization?.('eng') ??
211
+ (langs.length > 0 ? concept?.localization?.(langs[0]) : undefined);
212
+ if (!out.term) {
213
+ const term = getPreferredTerm(lc ?? null, n.conceptId);
214
+ if (term && term !== n.conceptId) out.term = term;
215
+ }
216
+ if (lc?.definitions?.[0]?.content) {
217
+ out.definition = lc.definitions[0].content;
218
+ } else if (lc?.primaryDefinition) {
219
+ out.definition = lc.primaryDefinition;
220
+ }
221
+ } catch { /* keep what we have */ }
222
+ }
223
+
224
+ return out.term ? out : null;
225
+ }
226
+
227
+ function updateNodeTerm(n: SNode) {
228
+ const el = nodeEls.get(n.id);
229
+ if (el) {
230
+ const termEl = el.querySelector('.sp-term');
231
+ if (termEl) termEl.textContent = n.term;
232
+ }
233
+ }
234
+
235
+ /* ── Build internal graph from concept + edges (BFS) ────── */
236
+
237
+ function buildGraph() {
238
+ const focusUri = conceptUri(props.concept, props.registerId, props.manifest.uriBase);
239
+ const focusId = props.concept?.id || props.registerId;
240
+ const focusNodeId = `${props.registerId}/${focusId}`;
241
+ const lang = sphereLang.value;
242
+ const maxDepth = degree.value;
243
+
244
+ /* Seed availableLangs from the focus concept's languages */
245
+ if (props.concept?.languages?.length) {
246
+ for (const l of props.concept.languages) availableLangs.value.add(l);
247
+ }
248
+
249
+ /* Focus node — pinned at north pole. Try requested lang, then English,
250
+ then any available language (e.g. viml-1968 is French-only — falling
251
+ back to 'fra' is much better than showing the raw conceptId). */
252
+ const conceptLangs = props.concept?.languages ?? [];
253
+ const focusLc =
254
+ props.concept?.localization?.(lang) ??
255
+ props.concept?.localization?.('eng') ??
256
+ (conceptLangs.length > 0 ? props.concept?.localization?.(conceptLangs[0]) : undefined);
257
+ const focusNode: SNode = {
258
+ id: focusNodeId,
259
+ term: getPreferredTerm(focusLc ?? null, focusId),
260
+ ref: props.manifest.ref || props.registerId,
261
+ register: props.registerId,
262
+ conceptId: focusId,
263
+ depth: 0,
264
+ x: 0, y: 0, z: 1,
265
+ vx: 0, vy: 0, vz: 0,
266
+ };
267
+
268
+ const nodeMap = new Map<string, SNode>([[focusNodeId, focusNode]]);
269
+ const visited: Map<string, number> = new Map([[focusUri, 0]]); // uri → depth
270
+ const idToUri = new Map<string, string>([[focusNodeId, focusUri]]); // for inter-neighbor edge pass
271
+ const resultLinks: SLink[] = [];
272
+ const linkKeys = new Set<string>(); /* dedupe edges */
273
+ const queue: Array<{ uri: string; depth: number; id: string }> = [];
274
+
275
+ /* First pass: collect depth-1 neighbors (deduped) so we know N for even
276
+ ring placement. */
277
+ const depth1List: Array<{ uri: string; parsed: { registerId: string; conceptId: string }; isOutgoing: boolean; edge: GraphEdge }> = [];
278
+ for (const edge of props.edges) {
279
+ const isOutgoing = edge.source === focusUri || edge.source === focusId;
280
+ const otherUri = isOutgoing ? edge.target : edge.source;
281
+ if (!otherUri || visited.has(otherUri)) continue;
282
+ const parsed = UriRouter.parseUri(otherUri);
283
+ if (!parsed || (parsed.conceptId === focusId && parsed.registerId === props.registerId)) continue;
284
+ visited.set(otherUri, 1);
285
+ depth1List.push({ uri: otherUri, parsed, isOutgoing, edge });
286
+ }
287
+
288
+ /* Second pass: place depth-1 nodes on an even ring around the focus. */
289
+ const depth1Total = depth1List.length;
290
+ depth1List.forEach(({ uri, parsed, isOutgoing, edge }, i) => {
291
+ const nid = `${parsed.registerId}/${parsed.conceptId}`;
292
+ const pos = fibonacciSpherePosition(1, i, depth1Total, hashSeed(parsed.conceptId));
293
+ nodeMap.set(nid, {
294
+ id: nid,
295
+ term: edge.label || parsed.conceptId,
296
+ ref: parsed.registerId,
297
+ register: parsed.registerId,
298
+ conceptId: parsed.conceptId,
299
+ depth: 1,
300
+ x: pos.x, y: pos.y, z: pos.z,
301
+ vx: 0, vy: 0, vz: 0,
302
+ });
303
+ idToUri.set(nid, uri);
304
+ const src = isOutgoing ? focusNodeId : nid;
305
+ const tgt = isOutgoing ? nid : focusNodeId;
306
+ const key = `${src}\0${tgt}\0${edge.type}`;
307
+ if (!linkKeys.has(key)) {
308
+ linkKeys.add(key);
309
+ resultLinks.push({
310
+ source: src,
311
+ target: tgt,
312
+ type: edge.type,
313
+ category: categorizeRelation(edge.type),
314
+ depth: 1,
315
+ });
316
+ }
317
+ queue.push({ uri, depth: 1, id: `${parsed.registerId}/${parsed.conceptId}` });
318
+ });
319
+
320
+ /* BFS deeper levels using graph engine (has cross-dataset edges) */
321
+ while (queue.length > 0 && nodeMap.size < MAX_NODES) {
322
+ const { uri, depth, id } = queue.shift()!;
323
+ if (depth >= maxDepth) continue;
324
+
325
+ const outEdges = store.graph.getEdges(uri);
326
+ const inEdges = store.graph.getIncomingEdges(uri);
327
+ /* Collect this node's children first so we can place them as an even
328
+ sub-ring around their parent. */
329
+ const children: Array<{ uri: string; parsed: { registerId: string; conceptId: string }; isOutgoing: boolean; edge: GraphEdge }> = [];
330
+ for (const edge of [...outEdges, ...inEdges]) {
331
+ if (nodeMap.size + children.length >= MAX_NODES) break;
332
+ const isOutgoing = edge.source === uri;
333
+ const otherUri = isOutgoing ? edge.target : edge.source;
334
+ if (!otherUri || visited.has(otherUri)) continue;
335
+ const parsed = UriRouter.parseUri(otherUri);
336
+ if (!parsed) continue;
337
+ const childId = `${parsed.registerId}/${parsed.conceptId}`;
338
+ if (childId === focusNodeId || nodeMap.has(childId)) continue;
339
+ visited.set(otherUri, depth + 1);
340
+ children.push({ uri: otherUri, parsed, isOutgoing, edge });
341
+ }
342
+ children.forEach(({ uri: cUri, parsed, isOutgoing, edge }, i) => {
343
+ const cid = `${parsed.registerId}/${parsed.conceptId}`;
344
+ const pos = fibonacciSpherePosition(depth + 1, i, children.length, hashSeed(parsed.conceptId));
345
+ nodeMap.set(cid, {
346
+ id: cid,
347
+ term: parsed.conceptId,
348
+ ref: parsed.registerId,
349
+ register: parsed.registerId,
350
+ conceptId: parsed.conceptId,
351
+ depth: depth + 1,
352
+ x: pos.x, y: pos.y, z: pos.z,
353
+ vx: 0, vy: 0, vz: 0,
354
+ });
355
+ idToUri.set(cid, cUri);
356
+ const src = isOutgoing ? id : cid;
357
+ const tgt = isOutgoing ? cid : id;
358
+ const key = `${src}\0${tgt}\0${edge.type}`;
359
+ if (!linkKeys.has(key)) {
360
+ linkKeys.add(key);
361
+ resultLinks.push({
362
+ source: src,
363
+ target: tgt,
364
+ type: edge.type,
365
+ category: categorizeRelation(edge.type),
366
+ depth: depth + 1,
367
+ });
368
+ }
369
+ queue.push({ uri: cUri, depth: depth + 1, id: cid });
370
+ });
371
+ }
372
+
373
+ /* Third pass: find inter-neighbor edges (connections between nodes that
374
+ aren't the BFS parent→child relationship). Adds structural fidelity —
375
+ e.g. two depth-2 nodes that reference each other get an edge drawn. */
376
+ for (const [aId, aUri] of idToUri) {
377
+ const outEdges = store.graph.getEdges(aUri);
378
+ for (const edge of outEdges) {
379
+ if (edge.target === aUri) continue;
380
+ const bId = idToUriGet(idToUri, edge.target);
381
+ if (!bId || bId === aId) continue;
382
+ const key = `${aId}\0${bId}\0${edge.type}`;
383
+ if (linkKeys.has(key)) continue;
384
+ linkKeys.add(key);
385
+ resultLinks.push({
386
+ source: aId,
387
+ target: bId,
388
+ type: edge.type,
389
+ category: categorizeRelation(edge.type),
390
+ depth: Math.max(nodeMap.get(aId)?.depth ?? 0, nodeMap.get(bId)?.depth ?? 0),
391
+ });
392
+ }
393
+ }
394
+
395
+ nodes = Array.from(nodeMap.values());
396
+ links = resultLinks;
397
+
398
+ /* Ensure every PartitiveRelation member is present in the graph
399
+ so the rake bundle can render. The comprehensive is normally the
400
+ focus; partitives may need to be added as depth-1 neighbors. */
401
+ for (const rel of props.partitiveRelations ?? []) {
402
+ const compParsed = UriRouter.parseUri(rel.comprehensive);
403
+ if (!compParsed) continue;
404
+ const compNodeId = `${compParsed.registerId}/${compParsed.conceptId}`;
405
+ if (!nodeMap.has(compNodeId)) continue;
406
+
407
+ for (const member of rel.partitives) {
408
+ const parsed = UriRouter.parseUri(member.uri);
409
+ if (!parsed) continue;
410
+ const nid = `${parsed.registerId}/${parsed.conceptId}`;
411
+ if (nodeMap.has(nid)) continue;
412
+ if (nodeMap.size >= MAX_NODES) break;
413
+ const pos = fibonacciSpherePosition(1, nodeMap.size, MAX_NODES, hashSeed(parsed.conceptId));
414
+ nodeMap.set(nid, {
415
+ id: nid,
416
+ term: parsed.conceptId,
417
+ ref: parsed.registerId,
418
+ register: parsed.registerId,
419
+ conceptId: parsed.conceptId,
420
+ depth: 1,
421
+ x: pos.x, y: pos.y, z: pos.z,
422
+ vx: 0, vy: 0, vz: 0,
423
+ });
424
+ idToUri.set(nid, member.uri);
425
+ nodes = Array.from(nodeMap.values());
426
+ }
427
+ }
428
+
429
+ graphVersion.value++;
430
+ }
431
+
432
+ /* Reverse lookup: URI → node id. Linear scan is fine for ≤36 nodes. */
433
+ /* ── Custom forces (EXACT prototype replica) ────────────── */
434
+ function sphereConstraint() {
435
+ let nL: SNode[] = nodes;
436
+ function force() {
437
+ for (const n of nL) {
438
+ const len = Math.sqrt(n.x*n.x + n.y*n.y + n.z*n.z);
439
+ if (len > 0.001) {
440
+ const nx = n.x/len, ny = n.y/len, nz = n.z/len;
441
+ n.x = nx; n.y = ny; n.z = nz;
442
+ const rad = n.vx*nx + n.vy*ny + n.vz*nz;
443
+ n.vx -= rad*nx; n.vy -= rad*ny; n.vz -= rad*nz;
444
+ }
445
+ }
446
+ }
447
+ (force as any).initialize = (n: SNode[]) => { nL = n; };
448
+ return force;
449
+ }
450
+ function velocityClamp(maxV: number) {
451
+ let nL: SNode[] = nodes;
452
+ function force() {
453
+ for (const n of nL) {
454
+ const v2 = n.vx*n.vx + n.vy*n.vy + n.vz*n.vz;
455
+ if (v2 > maxV*maxV) { const s = maxV/Math.sqrt(v2); n.vx*=s; n.vy*=s; n.vz*=s; }
456
+ }
457
+ }
458
+ (force as any).initialize = (n: SNode[]) => { nL = n; };
459
+ return force;
460
+ }
461
+ /* Pairwise repulsion — pushes nodes apart on the sphere surface so cards
462
+ don't stack. Operates in 3D (chord distance), with the sphereConstraint
463
+ normalizing positions back to the unit sphere each tick. Focus node is
464
+ immovable: its position is reset every tick by `tickFocusReset`.
465
+ `minDist` and `strength` are mutable so the user can change the spread
466
+ via the Expand selector without rebuilding the force. */
467
+ /* Expand parameters — driven by a 0–10 slider. We map the slider value
468
+ to a link (spring) distance continuously across a wide range so the
469
+ visual difference between tight (0) and loose (10) is dramatic.
470
+ Link distance is chord distance on the unit sphere: 0.5 ≈ 29° (cards
471
+ cluster near focus), 1.95 ≈ 154° (cards spread to back hemisphere). */
472
+ let linkDistance = expandParams(5).linkDist;
473
+ let linkStrength = expandParams(5).linkStrength;
474
+ let repulseMinDist = expandParams(5).repMin;
475
+ let repulseStrength = expandParams(5).repStrength;
476
+
477
+ function repulsion() {
478
+ let nL: SNode[] = nodes;
479
+ function force() {
480
+ const minDist = repulseMinDist;
481
+ const strength = repulseStrength;
482
+ const min2 = minDist * minDist;
483
+ for (let i = 0; i < nL.length; i++) {
484
+ const a = nL[i];
485
+ if (a.depth === 0) continue; /* focus pinned */
486
+ for (let j = i + 1; j < nL.length; j++) {
487
+ const b = nL[j];
488
+ const dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z;
489
+ const d2 = dx*dx + dy*dy + dz*dz;
490
+ if (d2 >= min2 || d2 < 1e-6) continue;
491
+ const d = Math.sqrt(d2);
492
+ const f = strength * (minDist - d) / d;
493
+ const fx = dx * f, fy = dy * f, fz = dz * f;
494
+ a.vx += fx; a.vy += fy; a.vz += fz;
495
+ if (b.depth !== 0) { b.vx -= fx; b.vy -= fy; b.vz -= fz; }
496
+ }
497
+ }
498
+ }
499
+ (force as any).initialize = (n: SNode[]) => { nL = n; };
500
+ return force;
501
+ }
502
+ const navForce = (() => {
503
+ let nL: SNode[] = nodes;
504
+ function force() {
505
+ if (!navActive || !navStart || !navEnd) return;
506
+ const t = Math.min((performance.now() - navStartTime) / navDuration, 1);
507
+ const e = easeInOutCubic(t);
508
+ navT = t;
509
+ for (const n of nL) {
510
+ const s = navStart[n.id], en = navEnd[n.id];
511
+ if (!s || !en) continue;
512
+ const target = slerp(s, en, e);
513
+ let dx = target.x - n.x, dy = target.y - n.y, dz = target.z - n.z;
514
+ const rad = dx*n.x + dy*n.y + dz*n.z;
515
+ dx -= rad*n.x; dy -= rad*n.y; dz -= rad*n.z;
516
+ const k = 0.18;
517
+ n.vx += dx*k; n.vy += dy*k; n.vz += dz*k;
518
+ }
519
+ if (t >= 1) { navActive = false; navStart = null; navEnd = null; navOldDepths = null; navT = 1; }
520
+ }
521
+ (force as any).initialize = (n: SNode[]) => { nL = n; };
522
+ return force;
523
+ })();
524
+ const sphereF = sphereConstraint();
525
+ const clampF = velocityClamp(0.20);
526
+ /* minDist=0.95 ≈ 56° separation on the unit sphere — strong spread so
527
+ cards don't cluster around the focus. strength is high enough that
528
+ nodes reach their spread positions within ~2 seconds of sim start. */
529
+ const repulseF = repulsion();
530
+
531
+ /* 3D spring (link) force — pulls connected nodes toward a target chord
532
+ distance. This is what actually spreads cards apart: longer springs
533
+ (higher Expand level) push the graph outward along its edges. Stock
534
+ d3-forceLink only handles x/y; this custom version operates in full
535
+ 3D so the sphereConstraint can renormalize positions correctly. */
536
+ function linkForce() {
537
+ let nL: SNode[] = nodes;
538
+ let idToIdx = new Map<string, number>();
539
+ function force() {
540
+ if (links.length === 0) return;
541
+ const target = linkDistance;
542
+ const strength = linkStrength;
543
+ /* Read muted sets each tick so toggleType/toggleRegister take effect
544
+ immediately on the next simulation step. */
545
+ const mutedT = mutedTypes.value;
546
+ const mutedR = mutedRegisters.value;
547
+ for (const link of links) {
548
+ if (mutedT.has(link.type)) continue;
549
+ const ai = idToIdx.get(link.source);
550
+ const bi = idToIdx.get(link.target);
551
+ if (ai === undefined || bi === undefined) continue;
552
+ const a = nL[ai], b = nL[bi];
553
+ /* Skip edges involving a muted register (unless the node is the focus) */
554
+ if (a.depth !== 0 && mutedR.has(a.register)) continue;
555
+ if (b.depth !== 0 && mutedR.has(b.register)) continue;
556
+ const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
557
+ const d2 = dx*dx + dy*dy + dz*dz;
558
+ if (d2 < 1e-6) continue;
559
+ const d = Math.sqrt(d2);
560
+ const diff = (d - target) / d * strength;
561
+ const fx = dx * diff, fy = dy * diff, fz = dz * diff;
562
+ if (a.depth !== 0) { a.vx += fx; a.vy += fy; a.vz += fz; }
563
+ if (b.depth !== 0) { b.vx -= fx; b.vy -= fy; b.vz -= fz; }
564
+ }
565
+ }
566
+ (force as any).initialize = (n: SNode[]) => {
567
+ nL = n;
568
+ idToIdx = new Map();
569
+ for (let i = 0; i < n.length; i++) idToIdx.set(n[i].id, i);
570
+ };
571
+ return force;
572
+ }
573
+ const linkF = linkForce();
574
+ /* Pin the focus node at (0, 0, 1) so neighbor repulsion doesn't shove it.
575
+ Without this, the focus drifts and the whole sphere wobbles. */
576
+ function focusPin() {
577
+ let nL: SNode[] = nodes;
578
+ function force() {
579
+ for (const n of nL) {
580
+ if (n.depth === 0) { n.x = 0; n.y = 0; n.z = 1; n.vx = 0; n.vy = 0; n.vz = 0; }
581
+ }
582
+ }
583
+ (force as any).initialize = (n: SNode[]) => { nL = n; };
584
+ return force;
585
+ }
586
+ const focusPinF = focusPin();
587
+
588
+ /* ── Simulation ─────────────────────────────────────────── */
589
+ function setupSim() {
590
+ if (sim) sim.stop();
591
+ if (nodes.length === 0) return;
592
+ sim = forceSimulation(nodes as any)
593
+ .force('nav', navForce as any)
594
+ .force('link', linkF as any)
595
+ .force('repulse', repulseF as any)
596
+ .force('sphere', sphereF as any)
597
+ .force('clamp', clampF as any)
598
+ .force('pin', focusPinF as any)
599
+ .alpha(1)
600
+ .alphaDecay(0.05)
601
+ .alphaMin(0.001)
602
+ .velocityDecay(0.55)
603
+ .on('tick', onTick)
604
+ .on('end', autoFitZoom);
605
+ }
606
+
607
+ /* ── Tick: imperative DOM updates ───────────────────────── */
608
+ function onTick() {
609
+ const c = canvasRef.value; if (!c) return;
610
+ const cx = c.clientWidth / 2;
611
+ const cy = c.clientHeight * 0.46;
612
+ for (const n of nodes) {
613
+ const el = nodeEls.get(n.id); if (!el) continue;
614
+ const p = project({x: n.x, y: n.y, z: n.z});
615
+ const x = p.x + cx, y = p.y + cy;
616
+ const ds = (n.depth === 0 || n.depth === 1) ? 0.95 : n.depth === 2 ? 0.78 : 0.62;
617
+ const isHovered = el.classList.contains('hovered');
618
+ const baseOp = n.depth === 0 ? 1.0 : n.depth === 1 ? 0.92 : n.depth === 2 ? 0.80 : 0.68;
619
+ const zFade = (p.z + 1) / 2;
620
+ const op = isHovered ? 1.0 : Math.max(baseOp * 0.85, baseOp * (0.7 + zFade * 0.3));
621
+ el.style.transform = `translate3d(${x}px,${y}px,0) translate(-50%,-50%) scale(${ds})`;
622
+ el.style.opacity = op.toFixed(3);
623
+ /* Don't clobber z-index=999 set on hover */
624
+ if (!isHovered) {
625
+ el.style.zIndex = String(Math.round(p.z * 10 + (n.depth === 0 ? 20 : 5)));
626
+ }
627
+ }
628
+ drawEdges(cx, cy);
629
+ }
630
+
631
+ /* ── Edge drawing with 4-port card connections + type labels ─ */
632
+ /* Determine which side of a card (top/bottom/left/right) an edge should
633
+ connect to, based on the direction to the other endpoint. */
634
+ /* Compute the connection point on a card's side, with an offset along
635
+ that side so multiple edges to the same side don't stack on top of
636
+ each other. `offset` is in [-1, 1] (0 = midpoint, ±1 = corners). */
637
+ function drawEdges(cx: number, cy: number) {
638
+ const svg = edgesSvgRef.value; if (!svg) return;
639
+ const w = cx * 2, h = cy * 2 / 0.46;
640
+ svg.setAttribute('width', String(w));
641
+ svg.setAttribute('height', String(h));
642
+ svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
643
+ svg.setAttribute('preserveAspectRatio', 'none');
644
+ /* Remove only path + text + rect (edges), keep defs (markers).
645
+ Also remove <line> elements (rake-bundle segments) and <circle>
646
+ (rake junction markers). */
647
+ svg.querySelectorAll('path:not(defs path), line.rake-seg, circle.rake-junction, text.edge-label, rect.edge-label-bg').forEach(el => el.remove());
648
+ if (!svg.querySelector('defs')) ensureMarkers(svg);
649
+
650
+ /* Compute visible node positions */
651
+ const pos = new Map<string, {x: number; y: number}>();
652
+ for (const n of nodes) {
653
+ if (n.depth !== 0 && mutedRegisters.value.has(n.register)) continue;
654
+ const p = project({x: n.x, y: n.y, z: n.z});
655
+ pos.set(n.id, {x: p.x + cx, y: p.y + cy});
656
+ }
657
+
658
+ /* Pre-compute visible edges */
659
+ const visibleLinks = links.filter(l => {
660
+ if (mutedTypes.value.has(l.type)) return false;
661
+ return pos.has(l.source) && pos.has(l.target);
662
+ });
663
+
664
+ const CARD_W = 220, CARD_H = 70;
665
+
666
+ /* For each card, collect all edges that touch it, grouped by side.
667
+ Then distribute connection points along each side so arrows don't
668
+ overlap. Returns: Map<edgeIdx + endpoint, {x,y}> */
669
+ const portMap = new Map<string, {x: number; y: number}>();
670
+ const cardsEdges = new Map<string, Array<{ idx: number; otherId: string; side: 'top'|'bottom'|'left'|'right'; isSource: boolean }>>();
671
+ visibleLinks.forEach((link, idx) => {
672
+ const a = pos.get(link.source)!, b = pos.get(link.target)!;
673
+ const sideA = portSide(a, b);
674
+ const sideB = portSide(b, a);
675
+ if (!cardsEdges.has(link.source)) cardsEdges.set(link.source, []);
676
+ if (!cardsEdges.has(link.target)) cardsEdges.set(link.target, []);
677
+ cardsEdges.get(link.source)!.push({ idx, otherId: link.target, side: sideA, isSource: true });
678
+ cardsEdges.get(link.target)!.push({ idx, otherId: link.source, side: sideB, isSource: false });
679
+ });
680
+ for (const [cardId, list] of cardsEdges) {
681
+ const cardCenter = pos.get(cardId)!;
682
+ /* Group by side */
683
+ const bySide: Record<string, typeof list> = {};
684
+ for (const e of list) {
685
+ if (!bySide[e.side]) bySide[e.side] = [];
686
+ bySide[e.side].push(e);
687
+ }
688
+ for (const sideKey of Object.keys(bySide)) {
689
+ const side = sideKey as 'top'|'bottom'|'left'|'right';
690
+ const items = bySide[side];
691
+ /* Sort by perpendicular coordinate of the OTHER endpoint so edges
692
+ are visually ordered along the side. */
693
+ items.sort((a, b) => {
694
+ const ao = pos.get(a.otherId)!, bo = pos.get(b.otherId)!;
695
+ if (side === 'top' || side === 'bottom') return ao.x - bo.x;
696
+ return ao.y - bo.y;
697
+ });
698
+ const n = items.length;
699
+ items.forEach((item, i) => {
700
+ /* Even distribution: offsets at (i - (n-1)/2) / n.
701
+ n=1 → 0; n=2 → ±0.25; n=3 → -1/3, 0, +1/3; etc. */
702
+ const offset = n === 1 ? 0 : (i - (n - 1) / 2) / n;
703
+ const key = `${item.idx}:${item.isSource ? 'src' : 'tgt'}`;
704
+ portMap.set(key, portPoint(cardCenter, side, CARD_W, CARD_H, offset));
705
+ });
706
+ }
707
+ }
708
+
709
+ for (let i = 0; i < visibleLinks.length; i++) {
710
+ const link = visibleLinks[i];
711
+ const eA = portMap.get(`${i}:src`)!;
712
+ const eB = portMap.get(`${i}:tgt`)!;
713
+ const mid_x = (eA.x + eB.x) / 2;
714
+ const mid_y = (eA.y + eB.y) / 2;
715
+ const edgeColor = colorForTypeRaw(link.type || 'unknown', uiStore.isDark);
716
+ const cat = RELATION_CATEGORIES.find(c => c.key === link.category) ?? categoryForType(link.type || 'related');
717
+
718
+ /* Edge path — straight line from port to port for clean port connection */
719
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
720
+ path.setAttribute('d', `M ${eA.x} ${eA.y} L ${eB.x} ${eB.y}`);
721
+ path.setAttribute('stroke', edgeColor);
722
+ path.setAttribute('stroke-width', '1.5');
723
+ path.setAttribute('opacity', '0.7');
724
+ path.setAttribute('fill', 'none');
725
+ path.setAttribute('stroke-linecap', 'round');
726
+ if (cat.dasharray !== 'none') path.setAttribute('stroke-dasharray', cat.dasharray);
727
+ const markerId = ensureTypeMarker(svg, link.type || 'unknown', edgeColor);
728
+ path.setAttribute('marker-end', `url(#${markerId})`);
729
+ svg.appendChild(path);
730
+
731
+ /* Type label at midpoint with background for readability.
732
+ Translated via i18n so French users see "référence" not "references". */
733
+ const rawType = link.type || '?';
734
+ const labelText = relationTypeLabel(rawType);
735
+ const labelWidth = labelText.length * 5.5 + 6;
736
+ const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
737
+ bg.setAttribute('class', 'edge-label-bg');
738
+ bg.setAttribute('x', String(mid_x - labelWidth/2));
739
+ bg.setAttribute('y', String(mid_y - 7));
740
+ bg.setAttribute('width', String(labelWidth));
741
+ bg.setAttribute('height', '13');
742
+ bg.setAttribute('rx', '2');
743
+ bg.setAttribute('fill', 'rgba(255,255,255,0.92)');
744
+ bg.setAttribute('stroke', edgeColor);
745
+ bg.setAttribute('stroke-width', '0.5');
746
+ bg.setAttribute('opacity', '0.9');
747
+ svg.appendChild(bg);
748
+
749
+ const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
750
+ label.setAttribute('class', 'edge-label');
751
+ label.setAttribute('x', String(mid_x));
752
+ label.setAttribute('y', String(mid_y + 3));
753
+ label.setAttribute('text-anchor', 'middle');
754
+ label.setAttribute('fill', edgeColor);
755
+ label.setAttribute('font-size', '8.5');
756
+ label.setAttribute('font-family', 'JetBrains Mono, monospace');
757
+ label.setAttribute('font-weight', '600');
758
+ label.textContent = labelText;
759
+ svg.appendChild(label);
760
+ }
761
+
762
+ drawRakeBundles(svg, pos, {
763
+ isDark: uiStore.isDark,
764
+ color: uiStore.isDark ? '#2dd4bf' : '#0d9488',
765
+ isMuted: mutedTypes.value.has('__partitive__'),
766
+ relations: props.partitiveRelations ?? [],
767
+ });
768
+ }
769
+
770
+ /**
771
+ * Render PartitiveRelations as ISO 704:2022 rake brackets in the sphere.
772
+ *
773
+ * Each relation renders as a right-angle bracket with stem + spine + drops
774
+ * (geometry described in detail above). Stem and spine are the structural
775
+ * frame — always single solid at FRAME_STROKE_WIDTH.
776
+ *
777
+ * Per-member multiplicity + is_delimiting is encoded on each DROP via
778
+ * rakeStrokeStyle() (see utils/partitive-multiplicity.ts):
779
+ *
780
+ * multiplicity is_delimiting drop visual
781
+ * ------------------- -------------- --------------------------------
782
+ * compulsory false 1 solid @ 1.5px
783
+ * compulsory true 1 solid @ 4.5px (3× bold)
784
+ * optional false 1 dashed @ 1.5px
785
+ * optional true 1 dashed @ 4.5px
786
+ * compulsory_multiple false 2 solid @ 1.5px
787
+ * compulsory_multiple true 2 solid @ 4.5px
788
+ * optional_multiple false 2 dashed @ 1.5px
789
+ * optional_multiple true 2 dashed @ 4.5px
790
+ * compulsory_at_least_one false 1 solid + 1 dashed @ 1.5px
791
+ * compulsory_at_least_one true 1 solid + 1 dashed @ 4.5px
792
+ *
793
+ * completeness === 'partial' : spine extends past last tooth (continued
794
+ * backline — "more exist but not shown")
795
+ *
796
+ * Diamond marker at the comprehensive end signals the hyperedge origin.
797
+ * Junction marker at the spine midpoint signals the right-angle corner.
798
+ */
799
+ function drawRakeBundles(
800
+ svg: SVGSVGElement,
801
+ pos: Map<string, {x: number; y: number}>,
802
+ _cx: number,
803
+ _cy: number,
804
+ ) {
805
+ if (mutedTypes.value.has('__partitive__')) return;
806
+ const color = uiStore.isDark ? '#2dd4bf' : '#0d9488';
807
+ const DOUBLE_GAP = 4;
808
+ const DASH = '4 3';
809
+ const FRAME_STROKE_WIDTH = 1.4;
810
+ const SPINE_FRACTION = 0.55; // spine sits 55% from comp toward centroid
811
+ const PARTIAL_TAIL = 28; // px extension for completeness: 'partial'
812
+
813
+ for (const rel of props.partitiveRelations ?? []) {
814
+ const compParsed = UriRouter.parseUri(rel.comprehensive);
815
+ if (!compParsed) continue;
816
+ const compNodeId = `${compParsed.registerId}/${compParsed.conceptId}`;
817
+ const compPos = pos.get(compNodeId);
818
+ if (!compPos) continue;
819
+
820
+ const memberPositions = rel.partitives
821
+ .map(member => {
822
+ const parsed = UriRouter.parseUri(member.uri);
823
+ if (!parsed) return null;
824
+ const nid = `${parsed.registerId}/${parsed.conceptId}`;
825
+ const p = pos.get(nid);
826
+ return p ? { member, pos: p } : null;
827
+ })
828
+ .filter((x): x is { member: PartitiveMemberWire; pos: {x: number; y: number} } => x !== null);
829
+
830
+ if (memberPositions.length === 0) continue;
831
+
832
+ // 1. Compute stem-axis (comp → centroid of partitives) and spine-axis (perpendicular)
833
+ const centroidX = memberPositions.reduce((s, m) => s + m.pos.x, 0) / memberPositions.length;
834
+ const centroidY = memberPositions.reduce((s, m) => s + m.pos.y, 0) / memberPositions.length;
835
+ const stemDx = centroidX - compPos.x;
836
+ const stemDy = centroidY - compPos.y;
837
+ const stemLen = Math.hypot(stemDx, stemDy) || 1;
838
+ const stemDirX = stemDx / stemLen; // unit vector along stem
839
+ const stemDirY = stemDy / stemLen;
840
+ const spineDirX = -stemDirY; // perpendicular = spine direction
841
+ const spineDirY = stemDirX;
842
+
843
+ // 2. Spine center: along stem-axis at SPINE_FRACTION from comp
844
+ const spineCx = compPos.x + stemDirX * stemLen * SPINE_FRACTION;
845
+ const spineCy = compPos.y + stemDirY * stemLen * SPINE_FRACTION;
846
+
847
+ // 3. Tooth positions = projection of each partitive onto spine line
848
+ const teeth = memberPositions.map(({ member, pos: pPos }) => {
849
+ const vx = pPos.x - spineCx;
850
+ const vy = pPos.y - spineCy;
851
+ const along = vx * spineDirX + vy * spineDirY;
852
+ const toothX = spineCx + spineDirX * along;
853
+ const toothY = spineCy + spineDirY * along;
854
+ return {
855
+ member,
856
+ partitivePos: pPos,
857
+ toothX,
858
+ toothY,
859
+ along,
860
+ style: rakeStrokeStyle(member.presence, member.count, member.isDelimiting),
861
+ };
862
+ });
863
+
864
+ // 4. Spine extents (leftmost to rightmost tooth along spine-axis)
865
+ let minAlong = teeth[0].along;
866
+ let maxAlong = teeth[0].along;
867
+ for (const t of teeth) {
868
+ if (t.along < minAlong) minAlong = t.along;
869
+ if (t.along > maxAlong) maxAlong = t.along;
870
+ }
871
+ // For completeness: 'partial', extend past last tooth (continued backline)
872
+ if (rel.completeness === 'partial') maxAlong += PARTIAL_TAIL;
873
+ const spineStartX = spineCx + spineDirX * minAlong;
874
+ const spineStartY = spineCy + spineDirY * minAlong;
875
+ const spineEndX = spineCx + spineDirX * maxAlong;
876
+ const spineEndY = spineCy + spineDirY * maxAlong;
877
+
878
+ /**
879
+ * Draw a single straight segment.
880
+ *
881
+ * Stem + spine use FRAME_STROKE_WIDTH (single solid frame).
882
+ * Drops use the per-member multiplicity + delimiting style from
883
+ * rakeStrokeStyle().
884
+ */
885
+ const drawSegment = (
886
+ ax: number, ay: number, bx: number, by: number,
887
+ opts: { width: number; dashed?: boolean; offset?: number; opacity?: number },
888
+ ) => {
889
+ const opacity = opts.opacity ?? 0.85;
890
+ const offset = opts.offset ?? 0;
891
+ const dashed = opts.dashed ?? false;
892
+ const dx = bx - ax, dy = by - ay;
893
+ const len = Math.hypot(dx, dy) || 1;
894
+ const px = -dy / len, py = dx / len;
895
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
896
+ line.setAttribute('class', 'rake-seg');
897
+ line.setAttribute('x1', String(ax + px * offset));
898
+ line.setAttribute('y1', String(ay + py * offset));
899
+ line.setAttribute('x2', String(bx + px * offset));
900
+ line.setAttribute('y2', String(by + py * offset));
901
+ line.setAttribute('stroke', color);
902
+ line.setAttribute('stroke-width', String(opts.width));
903
+ line.setAttribute('opacity', String(opacity));
904
+ line.setAttribute('stroke-linecap', 'square');
905
+ if (dashed) line.setAttribute('stroke-dasharray', DASH);
906
+ svg.appendChild(line);
907
+ };
908
+
909
+ // 5. Stem: comp → spine center (frame — single solid)
910
+ drawSegment(compPos.x, compPos.y, spineCx, spineCy, { width: FRAME_STROKE_WIDTH });
911
+
912
+ // 6. Spine: leftmost tooth → rightmost tooth (frame — single solid)
913
+ if (Math.abs(maxAlong - minAlong) > 1) {
914
+ drawSegment(spineStartX, spineStartY, spineEndX, spineEndY, { width: FRAME_STROKE_WIDTH });
915
+ }
916
+
917
+ // 7. Drops: tooth → partitive (per-member multiplicity + delimiting)
918
+ // Draw 1 or 2 parallel lines per the style.
919
+ for (const tooth of teeth) {
920
+ drawSegment(tooth.toothX, tooth.toothY, tooth.partitivePos.x, tooth.partitivePos.y, {
921
+ width: tooth.style.strokeWidth,
922
+ dashed: tooth.style.primaryDashed,
923
+ });
924
+ if (tooth.style.lineCount === 2) {
925
+ drawSegment(tooth.toothX, tooth.toothY, tooth.partitivePos.x, tooth.partitivePos.y, {
926
+ width: tooth.style.strokeWidth,
927
+ dashed: tooth.style.secondaryDashed,
928
+ offset: DOUBLE_GAP,
929
+ });
930
+ }
931
+ }
932
+
933
+ // 8. Junction marker at spine center (the right-angle corner)
934
+ const junction = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
935
+ junction.setAttribute('class', 'rake-junction');
936
+ junction.setAttribute('cx', String(spineCx));
937
+ junction.setAttribute('cy', String(spineCy));
938
+ junction.setAttribute('r', '2.5');
939
+ junction.setAttribute('fill', color);
940
+ junction.setAttribute('opacity', '0.9');
941
+ junction.setAttribute('stroke', uiStore.isDark ? '#0a1f1c' : '#ffffff');
942
+ junction.setAttribute('stroke-width', '1');
943
+ svg.appendChild(junction);
944
+
945
+ // 9. Diamond marker at comprehensive end (hyperedge origin)
946
+ const diamond = document.createElementNS('http://www.w3.org/2000/svg', 'path');
947
+ const dsize = 5;
948
+ diamond.setAttribute('d',
949
+ `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`);
950
+ diamond.setAttribute('fill', color);
951
+ diamond.setAttribute('opacity', '0.9');
952
+ diamond.setAttribute('stroke', uiStore.isDark ? '#0a1f1c' : '#ffffff');
953
+ diamond.setAttribute('stroke-width', '1');
954
+ svg.appendChild(diamond);
955
+ }
956
+ }
957
+
958
+ function ensureMarkers(svg: SVGSVGElement) {
959
+ let defs = svg.querySelector('defs');
960
+ if (!defs) { defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); svg.appendChild(defs); }
961
+ }
962
+
963
+ /* Lazy per-type marker — arrow color matches the edge's per-type color
964
+ (which may differ from the category color via TYPE_COLOR_OVERRIDE). */
965
+ function ensureTypeMarker(svg: SVGSVGElement, typeId: string, color: string): string {
966
+ const safeId = typeId.replace(/[^a-zA-Z0-9_-]/g, '_');
967
+ const markerId = `rel-arrow-t-${safeId}`;
968
+ if (svg.querySelector(`#${markerId}`)) return markerId;
969
+ let defs = svg.querySelector('defs');
970
+ if (!defs) { defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); svg.appendChild(defs); }
971
+ const m = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
972
+ m.setAttribute('id', markerId);
973
+ m.setAttribute('viewBox', '0 0 8 8'); m.setAttribute('refX', '6'); m.setAttribute('refY', '4');
974
+ m.setAttribute('markerWidth', '5'); m.setAttribute('markerHeight', '5'); m.setAttribute('orient', 'auto');
975
+ const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
976
+ p.setAttribute('d', 'M 0 0 L 6 4 L 0 8 z'); p.setAttribute('fill', color);
977
+ m.appendChild(p); defs.appendChild(m);
978
+ return markerId;
979
+ }
980
+
981
+ /* ── Render DOM nodes ───────────────────────────────────── */
982
+ function renderDOM() {
983
+ const layer = nodesLayerRef.value; if (!layer) return;
984
+ layer.innerHTML = '';
985
+ nodeEls.clear();
986
+ const c = canvasRef.value;
987
+ const cx = c ? c.clientWidth / 2 : 0;
988
+ const cy = c ? c.clientHeight * 0.46 : 0;
989
+
990
+ for (const n of nodes) {
991
+ /* Skip muted-register nodes (except focus) */
992
+ if (n.depth !== 0 && mutedRegisters.value.has(n.register)) continue;
993
+
994
+ const dsColor = getColor(n.register) || '#888';
995
+ const el = document.createElement('div');
996
+ el.className = `sp-node d-${n.depth}${n.depth === 0 ? ' focus' : ''}`;
997
+ el.dataset.register = n.register;
998
+ el.dataset.id = n.id;
999
+ /* Initial position inline */
1000
+ const p = project({x: n.x, y: n.y, z: n.z});
1001
+ const ds = (n.depth === 0 || n.depth === 1) ? 0.95 : n.depth === 2 ? 0.78 : 0.62;
1002
+ el.style.transform = `translate3d(${p.x + cx}px,${p.y + cy}px,0) translate(-50%,-50%) scale(${ds})`;
1003
+ el.style.opacity = n.depth === 0 ? '1' : '0.92';
1004
+ el.style.zIndex = String(Math.round(p.z * 10 + (n.depth === 0 ? 20 : 5)));
1005
+
1006
+ /* Per-dataset color treatment — bold top bar (4px) + subtle bg tint.
1007
+ The top bar gives instant dataset identification at any angle; the
1008
+ tinted background ties same-dataset cards together visually. */
1009
+ el.style.setProperty('--ds-color', dsColor);
1010
+ el.style.borderTop = `4px solid ${dsColor}`;
1011
+ el.style.background = `linear-gradient(180deg, color-mix(in srgb, ${dsColor} 10%, var(--surface)) 0%, var(--surface) 30%)`;
1012
+
1013
+ el.innerHTML = `
1014
+ <div class="sp-ref">${n.register} · ${n.conceptId}</div>
1015
+ <div class="sp-term">${n.term}</div>
1016
+ <div class="sp-meta">
1017
+ <span class="sp-badge" style="--ds-color: ${dsColor};">${n.register}</span>
1018
+ <span class="sp-rel">◈ ${links.filter(l => l.source === n.id || l.target === n.id).length}</span>
1019
+ </div>
1020
+ `;
1021
+ if (n.depth === 0) {
1022
+ const fb = document.createElement('div');
1023
+ fb.className = 'sp-focus-badge';
1024
+ fb.textContent = `◆ ${t('sphere.focus')}`;
1025
+ el.appendChild(fb);
1026
+ }
1027
+
1028
+ /* Hover — bring to front, opacity 100%, amber glow. */
1029
+ el.addEventListener('mouseenter', () => {
1030
+ if (hoverTimer) clearTimeout(hoverTimer);
1031
+ hoverTimer = setTimeout(() => { hoveredNode.value = n; }, 120);
1032
+ el.classList.add('hovered');
1033
+ el.style.zIndex = '999';
1034
+ el.style.opacity = '1';
1035
+ });
1036
+ el.addEventListener('mouseleave', () => {
1037
+ if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
1038
+ hoveredNode.value = null;
1039
+ el.classList.remove('hovered');
1040
+ const pn = project({x: n.x, y: n.y, z: n.z});
1041
+ el.style.zIndex = String(Math.round(pn.z * 10 + (n.depth === 0 ? 20 : 5)));
1042
+ /* Opacity will be recomputed by onTick; nudge a default in case sim is idle */
1043
+ const baseOp = n.depth === 0 ? 1.0 : n.depth === 1 ? 0.92 : n.depth === 2 ? 0.80 : 0.68;
1044
+ const zFade = (pn.z + 1) / 2;
1045
+ el.style.opacity = Math.max(baseOp * 0.85, baseOp * (0.7 + zFade * 0.3)).toFixed(3);
1046
+ });
1047
+
1048
+ /* Click → navigate (non-focus only) */
1049
+ if (n.depth !== 0) {
1050
+ el.style.cursor = 'pointer';
1051
+ el.addEventListener('click', () => {
1052
+ emit('navigate', { registerId: n.register, conceptId: n.conceptId });
1053
+ });
1054
+ }
1055
+
1056
+ layer.appendChild(el);
1057
+ nodeEls.set(n.id, el);
1058
+ }
1059
+ }
1060
+
1061
+ /* ── Reset pan/zoom — ease back to center on navigation ── */
1062
+ function resetZoom() {
1063
+ if (!zoomBehavior || !canvasRef.value) return;
1064
+ /* Animate the zoom transform back to identity over 800ms,
1065
+ synced with the SLERP tween duration. */
1066
+ select(canvasRef.value)
1067
+ .transition()
1068
+ .duration(800)
1069
+ .ease((t: number) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2)
1070
+ .call(zoomBehavior.transform as any, zoomIdentity);
1071
+ }
1072
+
1073
+ /* Auto-fit — compute the bounding box of all visible cards and adjust the
1074
+ pan/zoom so they all fit in the viewport with padding. Clamped to a
1075
+ minimum scale (0.45) so very large graphs don't shrink to illegibility.
1076
+ Reserves space for the View options panel (top-right) and preview card
1077
+ (bottom-right) so cards don't slide under them. */
1078
+ function autoFitZoom(opts: { immediate?: boolean; bboxScale?: number } = {}) {
1079
+ const { immediate = false, bboxScale = 1.0 } = opts;
1080
+ if (!zoomBehavior || !canvasRef.value) return;
1081
+ const c = canvasRef.value;
1082
+ const cw = c.clientWidth;
1083
+ const ch = c.clientHeight;
1084
+ if (cw === 0 || ch === 0) return;
1085
+
1086
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
1087
+ let count = 0;
1088
+ for (const n of nodes) {
1089
+ if (n.depth !== 0 && mutedRegisters.value.has(n.register)) continue;
1090
+ const p = project({x: n.x, y: n.y, z: n.z});
1091
+ const halfW = 115 * p.scale;
1092
+ const halfH = 40 * p.scale;
1093
+ minX = Math.min(minX, p.x - halfW);
1094
+ maxX = Math.max(maxX, p.x + halfW);
1095
+ minY = Math.min(minY, p.y - halfH);
1096
+ maxY = Math.max(maxY, p.y + halfH);
1097
+ count++;
1098
+ }
1099
+ if (count === 0) return;
1100
+
1101
+ /* Scale the bounding box around its center to predict the layout's final
1102
+ extent — used by changeExpand to zoom out BEFORE the sim has finished
1103
+ moving cards to their new spacing. */
1104
+ if (bboxScale !== 1.0) {
1105
+ const bcx = (minX + maxX) / 2;
1106
+ const bcy = (minY + maxY) / 2;
1107
+ const halfW = (maxX - minX) / 2 * bboxScale;
1108
+ const halfH = (maxY - minY) / 2 * bboxScale;
1109
+ minX = bcx - halfW; maxX = bcx + halfW;
1110
+ minY = bcy - halfH; maxY = bcy + halfH;
1111
+ }
1112
+
1113
+ const cx = cw / 2;
1114
+ const cy = ch * 0.46;
1115
+ const bboxW = maxX - minX;
1116
+ const bboxH = maxY - minY;
1117
+ const bboxCx = (minX + maxX) / 2 + cx;
1118
+ const bboxCy = (minY + maxY) / 2 + cy;
1119
+
1120
+ /* Asymmetric padding: reserve right side for the View options panel
1121
+ (~280px wide when collapsed or open) and bottom-right for preview. */
1122
+ const panelW = 280;
1123
+ const previewW = 304;
1124
+ const padTop = 24;
1125
+ const padBottom = 32;
1126
+ const padLeft = 32;
1127
+ const panelH = panelOpen.value ? 360 : 56;
1128
+ const padRight = Math.max(panelW, previewW) + 8;
1129
+
1130
+ const usableLeft = padLeft;
1131
+ const usableRight = cw - padRight;
1132
+ const usableTop = padTop;
1133
+ const usableBottom = ch - padBottom;
1134
+ const usableCx = (usableLeft + usableRight) / 2;
1135
+ const usableCy = (usableTop + usableBottom) / 2;
1136
+ const usableW = usableRight - usableLeft;
1137
+ const usableH = usableBottom - usableTop;
1138
+
1139
+ const scaleX = usableW / bboxW;
1140
+ const scaleY = usableH / bboxH;
1141
+ const scale = Math.max(0.3, Math.min(scaleX, scaleY, 1.0));
1142
+
1143
+ const tx = usableCx - scale * bboxCx;
1144
+ const ty = usableCy - scale * bboxCy;
1145
+
1146
+ if (immediate) {
1147
+ select(c).call(zoomBehavior.transform as any, zoomIdentity.translate(tx, ty).scale(scale));
1148
+ } else {
1149
+ select(c)
1150
+ .transition()
1151
+ .duration(700)
1152
+ .ease((t: number) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2)
1153
+ .call(zoomBehavior.transform as any, zoomIdentity.translate(tx, ty).scale(scale));
1154
+ }
1155
+ }
1156
+
1157
+ /* ── Navigation tween ───────────────────────────────────── */
1158
+ function navigate(newConceptId: string, newRegisterId: string) {
1159
+ /* Snapshot current positions */
1160
+ navStart = {};
1161
+ navOldDepths = {};
1162
+ for (const n of nodes) {
1163
+ navStart[n.id] = {x: n.x, y: n.y, z: n.z};
1164
+ navOldDepths[n.id] = n.depth;
1165
+ }
1166
+ /* Build new graph — uses updated props (concept/edges change via watch) */
1167
+ buildGraph();
1168
+ navEnd = {};
1169
+ for (const n of nodes) {
1170
+ navEnd[n.id] = {x: n.x, y: n.y, z: n.z};
1171
+ }
1172
+ /* Reset to start positions */
1173
+ for (const n of nodes) {
1174
+ const s = navStart[n.id];
1175
+ if (s) { n.x = s.x; n.y = s.y; n.z = s.z; }
1176
+ n.vx = 0; n.vy = 0; n.vz = 0;
1177
+ }
1178
+ navStartTime = performance.now();
1179
+ navDuration = 2200; /* concept navigation: long for the cinematic feel */
1180
+ navActive = true;
1181
+ renderDOM();
1182
+ setupSim();
1183
+ }
1184
+
1185
+ /* ── Watch concept change ───────────────────────────────── */
1186
+ /* When props.concept changes (via store.viewConcept from sphere click,
1187
+ or external navigation), animate the transition with a SLERP tween. */
1188
+ let lastConceptKey = '';
1189
+
1190
+ /* UI language change → sync sphere language (unless user has explicitly
1191
+ chosen a different one via the Language selector). We track that with
1192
+ `userOverrodeLang`. */
1193
+ let userOverrodeLang = false;
1194
+ watch(locale, (newLang) => {
1195
+ if (userOverrodeLang) return;
1196
+ if (!newLang || newLang === sphereLang.value) return;
1197
+ sphereLang.value = newLang;
1198
+ rebuildForLangChange();
1199
+ });
1200
+
1201
+ /* User picks a language in the View options panel */
1202
+ function changeSphereLang(l: string) {
1203
+ if (l === sphereLang.value) return;
1204
+ userOverrodeLang = true;
1205
+ sphereLang.value = l;
1206
+ rebuildForLangChange();
1207
+ }
1208
+
1209
+ /* Re-render cards with the new language's designations + definitions. */
1210
+ async function rebuildForLangChange() {
1211
+ /* Reset focus node's term in-place */
1212
+ const lang = sphereLang.value;
1213
+ const focus = nodes.find(n => n.depth === 0);
1214
+ if (focus) {
1215
+ const langs = props.concept?.languages ?? [];
1216
+ const lc =
1217
+ props.concept?.localization?.(lang) ??
1218
+ props.concept?.localization?.('eng') ??
1219
+ (langs.length > 0 ? props.concept?.localization?.(langs[0]) : undefined);
1220
+ focus.term = getPreferredTerm(lc ?? null, focus.id);
1221
+ if (lc?.primaryDefinition) focus.definition = lc.primaryDefinition;
1222
+ }
1223
+ renderDOM();
1224
+ await loadNeighborTerms();
1225
+ }
1226
+
1227
+ watch(() => [props.concept?.id, props.registerId] as const, ([newId, newReg]) => {
1228
+ if (!newId) return;
1229
+ const key = `${newReg}/${newId}`;
1230
+ if (key === lastConceptKey) return;
1231
+ const wasFirst = !lastConceptKey;
1232
+ lastConceptKey = key;
1233
+
1234
+ if (wasFirst) {
1235
+ /* Initial load — fresh placement, no tween */
1236
+ buildGraph();
1237
+ nextTick(() => {
1238
+ renderDOM();
1239
+ /* Snap the viewport to a good fit BEFORE the sim starts, so cards
1240
+ are correctly framed from first paint and don't slide under the
1241
+ View options panel. */
1242
+ autoFitZoom({ immediate: true });
1243
+ setupSim();
1244
+ const c = canvasRef.value;
1245
+ if (c) drawEdges(c.clientWidth / 2, c.clientHeight * 0.46);
1246
+ loadNeighborTerms();
1247
+ });
1248
+ } else {
1249
+ /* Concept changed — SLERP tween from old positions to new.
1250
+ Snap to fit immediately so the new card appears centered in the
1251
+ usable region (left of the View options panel), not in the
1252
+ geometric middle where it'd be obscured. */
1253
+ navStart = {};
1254
+ navOldDepths = {};
1255
+ for (const n of nodes) {
1256
+ navStart[n.id] = { x: n.x, y: n.y, z: n.z };
1257
+ navOldDepths[n.id] = n.depth;
1258
+ }
1259
+ buildGraph();
1260
+ navEnd = {};
1261
+ for (const n of nodes) {
1262
+ navEnd[n.id] = { x: n.x, y: n.y, z: n.z };
1263
+ }
1264
+ for (const n of nodes) {
1265
+ const s = navStart[n.id];
1266
+ if (s) { n.x = s.x; n.y = s.y; n.z = s.z; }
1267
+ n.vx = 0; n.vy = 0; n.vz = 0;
1268
+ }
1269
+ navStartTime = performance.now();
1270
+ navDuration = 2200;
1271
+ navActive = true;
1272
+ renderDOM();
1273
+ autoFitZoom({ immediate: true });
1274
+ setupSim();
1275
+ loadNeighborTerms();
1276
+ }
1277
+ });
1278
+
1279
+ /* ── Legend — grouped by relation TYPE (not category) ───── */
1280
+ const legendItems = computed(() => {
1281
+ graphVersion.value; /* reactive dependency */
1282
+ uiStore.isDark; /* re-evaluate when dark mode toggles (color depends on it) */
1283
+ mutedTypes.value; /* re-evaluate when mute set changes */
1284
+ const typeMap = new Map<string, { type: string; label: string; color: string; count: number }>();
1285
+ for (const link of links) {
1286
+ /* Per-type color (with override) so e.g. 'see' and 'references' differ */
1287
+ const color = colorForTypeRaw(link.type || 'unknown', uiStore.isDark);
1288
+ const key = link.type || 'unknown';
1289
+ if (!typeMap.has(key)) {
1290
+ /* Translated label for display; raw type kept for mute/unmute keying */
1291
+ const label = relationTypeLabel(key);
1292
+ typeMap.set(key, { type: key, label, color, count: 0 });
1293
+ }
1294
+ typeMap.get(key)!.count++;
1295
+ }
1296
+ /* Synthetic entry for PartitiveRelations (hyperedge rake bundles).
1297
+ These don't appear in `links` (they're rendered separately), so we
1298
+ inject a legend entry when any are present. */
1299
+ const partitiveCount = props.partitiveRelations?.length ?? 0;
1300
+ if (partitiveCount > 0) {
1301
+ typeMap.set('__partitive__', {
1302
+ type: '__partitive__',
1303
+ label: 'partitive relation',
1304
+ color: uiStore.isDark ? '#2dd4bf' : '#0d9488',
1305
+ count: partitiveCount,
1306
+ });
1307
+ }
1308
+ return Array.from(typeMap.values()).sort((a, b) => b.count - a.count);
1309
+ });
1310
+
1311
+ /* ── Datasets present in the current graph ──────────────── */
1312
+ const datasetItems = computed(() => {
1313
+ graphVersion.value;
1314
+ const map = new Map<string, { register: string; color: string; count: number }>();
1315
+ for (const n of nodes) {
1316
+ if (n.depth === 0) continue;
1317
+ if (!map.has(n.register)) {
1318
+ map.set(n.register, { register: n.register, color: getColor(n.register) || '#888', count: 0 });
1319
+ }
1320
+ map.get(n.register)!.count++;
1321
+ }
1322
+ return Array.from(map.values()).sort((a, b) => b.count - a.count);
1323
+ });
1324
+
1325
+ function toggleRegister(reg: string) {
1326
+ const n = new Set(mutedRegisters.value);
1327
+ if (n.has(reg)) n.delete(reg); else n.add(reg);
1328
+ mutedRegisters.value = n;
1329
+ renderDOM();
1330
+ const c = canvasRef.value;
1331
+ if (c) drawEdges(c.clientWidth / 2, c.clientHeight * 0.46);
1332
+ /* Re-heat the simulation so nodes "feel" the freed/hidden edges and
1333
+ drift to a new equilibrium instead of staying frozen. */
1334
+ if (sim) sim.alpha(0.5).restart();
1335
+ }
1336
+
1337
+ async function changeDegree(d: 1 | 2 | 3) {
1338
+ if (degree.value === d) return;
1339
+ degree.value = d;
1340
+
1341
+ /* For deeper BFS we need every neighbor dataset's edges loaded into
1342
+ store.graph — by default only the active dataset's edges are loaded
1343
+ (via ensureEdgesForDataset in viewConcept). Load the rest in parallel. */
1344
+ if (d > 1) {
1345
+ const neighborRegisters = new Set<string>();
1346
+ for (const n of nodes) {
1347
+ if (n.depth !== 0 && n.register) neighborRegisters.add(n.register);
1348
+ }
1349
+ await Promise.allSettled(
1350
+ [...neighborRegisters].map(reg => store.ensureEdgesForDataset(reg))
1351
+ );
1352
+ }
1353
+
1354
+ /* Full re-place — don't preserve positions. Going from 1° to 2° needs
1355
+ the layout to visibly EXPAND: depth-1 nodes move outward to make room
1356
+ for the new depth-2 ring. Preserving positions would cram everything
1357
+ into the old 1° footprint. */
1358
+ buildGraph();
1359
+ renderDOM();
1360
+ /* Snap to fit immediately so the new bigger graph is framed correctly
1361
+ before the sim starts expending ticks on a cramped layout. */
1362
+ autoFitZoom({ immediate: true });
1363
+ setupSim();
1364
+ loadNeighborTerms();
1365
+ }
1366
+
1367
+ /* Manual redraw — reshuffles initial positions (with fresh jitter from
1368
+ Date.now()) and restarts the simulation. Useful when the user wants a
1369
+ different layout, e.g. after muting/unmuting several cards. */
1370
+ function redraw() {
1371
+ const counters: Record<number, number> = {};
1372
+ const totals: Record<number, number> = {};
1373
+ for (const n of nodes) totals[n.depth] = (totals[n.depth] ?? 0) + 1;
1374
+ const entropy = Date.now();
1375
+ for (const n of nodes) {
1376
+ if (n.depth === 0) {
1377
+ n.x = 0; n.y = 0; n.z = 1;
1378
+ } else {
1379
+ const idx = counters[n.depth] ?? 0;
1380
+ counters[n.depth] = idx + 1;
1381
+ const pos = fibonacciSpherePosition(n.depth, idx, totals[n.depth], hashSeed(n.id) + entropy);
1382
+ n.x = pos.x; n.y = pos.y; n.z = pos.z;
1383
+ }
1384
+ n.vx = 0; n.vy = 0; n.vz = 0;
1385
+ }
1386
+ renderDOM();
1387
+ autoFitZoom({ immediate: true });
1388
+ setupSim();
1389
+ }
1390
+
1391
+ /* Expand level — controls how far apart cards sit on the sphere. Higher
1392
+ levels increase the repulsion's minDist + strength so cards push each
1393
+ other further away. Cheap to apply: just mutates the closure variables
1394
+ the repulsion force reads each tick, then nudges alpha to reheat the
1395
+ simulation. Auto-fit fires after the sim settles to reframe. */
1396
+ function changeExpand(v: number) {
1397
+ const prev = expandValue.value;
1398
+ if (prev === v) return;
1399
+ expandValue.value = v;
1400
+ const params = expandParams(v);
1401
+ linkDistance = params.linkDist;
1402
+ linkStrength = params.linkStrength;
1403
+ repulseMinDist = params.repMin;
1404
+ repulseStrength = params.repStrength;
1405
+
1406
+ /* Compute new target positions by scaling each non-focus node's angle
1407
+ from the focus. slider 0 → angles shrink (cluster near focus);
1408
+ slider 10 → angles grow (spread to back hemisphere). Then SLERP-tween
1409
+ from current positions to targets so the user sees the spread happen
1410
+ immediately — the link force alone is too weak to overcome the
1411
+ sphereConstraint's tangent projection for large displacements. */
1412
+ const t = v / 10;
1413
+ const thetaScale = 0.4 + t * 1.4; /* 0.4 at slider 0, 1.8 at slider 10 */
1414
+ const newEnd: Record<string, Vec3> = {};
1415
+ navStart = {};
1416
+ for (const n of nodes) {
1417
+ navStart[n.id] = { x: n.x, y: n.y, z: n.z };
1418
+ if (n.depth === 0) {
1419
+ newEnd[n.id] = { x: 0, y: 0, z: 1 };
1420
+ continue;
1421
+ }
1422
+ /* Current theta from focus (focus is at (0,0,1), so cos(theta) = z) */
1423
+ const z = Math.max(-1, Math.min(1, n.z));
1424
+ const currentTheta = Math.acos(z);
1425
+ const newTheta = Math.max(0.05, currentTheta * thetaScale);
1426
+ const phi = Math.atan2(n.y, n.x);
1427
+ newEnd[n.id] = {
1428
+ x: Math.sin(newTheta) * Math.cos(phi),
1429
+ y: Math.sin(newTheta) * Math.sin(phi),
1430
+ z: Math.cos(newTheta),
1431
+ };
1432
+ }
1433
+ navEnd = newEnd;
1434
+ navStartTime = performance.now();
1435
+ navDuration = 900; /* snappier than concept navigation */
1436
+ navActive = true;
1437
+ const prevParams = expandParams(prev);
1438
+ const ratio = params.linkDist / prevParams.linkDist;
1439
+ autoFitZoom({ bboxScale: ratio });
1440
+
1441
+ if (sim) sim.alpha(1).restart();
1442
+ }
1443
+
1444
+ function toggleType(type: string) {
1445
+ /* Mute individual types — 'see' and 'references' can be toggled
1446
+ independently even though they share the 'associative' category. */
1447
+ const n = new Set(mutedTypes.value);
1448
+ if (n.has(type)) n.delete(type); else n.add(type);
1449
+ mutedTypes.value = n;
1450
+ const c = canvasRef.value;
1451
+ if (c) drawEdges(c.clientWidth / 2, c.clientHeight * 0.46);
1452
+ /* Re-heat the simulation so nodes drift to a new equilibrium with
1453
+ the freed/hidden edges. The link force reads mutedTypes each tick,
1454
+ so toggling effectively "removes" the edge from the force calc. */
1455
+ if (sim) sim.alpha(0.5).restart();
1456
+ }
1457
+
1458
+ function isTypeMuted(type: string): boolean {
1459
+ return mutedTypes.value.has(type);
1460
+ }
1461
+
1462
+ /* ── Preview ────────────────────────────────────────────── */
1463
+ /* Preview card — term + definition (refreshes when previewVersion bumps
1464
+ so async-loaded definitions appear without re-hovering). */
1465
+ const previewRef = computed(() => {
1466
+ previewVersion.value;
1467
+ return hoveredNode.value ? `${hoveredNode.value.register} · ${hoveredNode.value.conceptId}` : '';
1468
+ });
1469
+ const previewTerm = computed(() => {
1470
+ previewVersion.value;
1471
+ return hoveredNode.value?.term ?? '';
1472
+ });
1473
+ /* Resolve glossarist cross-ref markup ({{id, display}}, <<ref,title>>,
1474
+ {urn:...}, etc.) to plain text for the preview card. We use the same
1475
+ renderContent machinery as ConceptDetail but with plain-text resolvers
1476
+ (no anchor tags) since the preview is transient and not navigable.
1477
+ Stripping remaining HTML tags (from bib formatting etc.) keeps the
1478
+ display clean. */
1479
+ function previewDefinitionText(text: string | undefined): string {
1480
+ if (!text) return '';
1481
+ const html = renderContent(text, {
1482
+ xrefResolver: (_uri: string, term: string) => term,
1483
+ conceptRefResolver: (_id: string, term: string) => term,
1484
+ });
1485
+ return html
1486
+ .replace(/<[^>]+>/g, '') /* strip HTML tags */
1487
+ .replace(/\s+/g, ' ') /* collapse whitespace */
1488
+ .trim();
1489
+ }
1490
+
1491
+ const previewDefinition = computed(() => {
1492
+ previewVersion.value;
1493
+ return previewDefinitionText(hoveredNode.value?.definition);
1494
+ });
1495
+
1496
+ /* Languages available across loaded concepts — for the Language selector */
1497
+ const langOptions = computed(() => {
1498
+ const langs = new Set(availableLangs.value);
1499
+ /* Always include eng + the active UI language as fallbacks */
1500
+ langs.add('eng');
1501
+ langs.add(locale.value || 'eng');
1502
+ return [...langs].sort();
1503
+ });
1504
+
1505
+ /* ── Resize ─────────────────────────────────────────────── */
1506
+ function onResize() {
1507
+ const c = canvasRef.value; if (!c) return;
1508
+ drawEdges(c.clientWidth / 2, c.clientHeight * 0.46);
1509
+ }
1510
+
1511
+ /* ── Lifecycle ──────────────────────────────────────────── */
1512
+ async function waitForCanvas(): Promise<void> {
1513
+ const start = performance.now();
1514
+ while (performance.now() - start < 2000) {
1515
+ const c = canvasRef.value;
1516
+ if (c && c.clientWidth > 0 && c.clientHeight > 0) return;
1517
+ await new Promise(r => requestAnimationFrame(() => r(null)));
1518
+ }
1519
+ }
1520
+
1521
+ onMounted(async () => {
1522
+ window.addEventListener('resize', onResize);
1523
+ lastConceptKey = `${props.registerId}/${props.concept?.id || ''}`;
1524
+ await waitForCanvas();
1525
+
1526
+ /* Setup pan/zoom via d3-zoom on the canvas.
1527
+ The zoom transform is applied as CSS transform on the viewport wrapper.
1528
+ Node drag handlers call stopPropagation so panning only starts on empty space. */
1529
+ if (canvasRef.value && viewportRef.value) {
1530
+ zoomBehavior = zoom<HTMLDivElement, unknown>()
1531
+ .scaleExtent([0.3, 3])
1532
+ .on('zoom', (event: any) => {
1533
+ if (viewportRef.value) {
1534
+ const t = event.transform;
1535
+ viewportRef.value.style.transform = `translate(${t.x}px, ${t.y}px) scale(${t.k})`;
1536
+ }
1537
+ });
1538
+ select(canvasRef.value).call(zoomBehavior as any);
1539
+ }
1540
+
1541
+ buildGraph();
1542
+ await nextTick();
1543
+ renderDOM();
1544
+ autoFitZoom({ immediate: true });
1545
+ setupSim();
1546
+ const c = canvasRef.value;
1547
+ if (c) drawEdges(c.clientWidth / 2, c.clientHeight * 0.46);
1548
+ loadNeighborTerms();
1549
+ await nextTick();
1550
+ isFirstRender = false;
1551
+ });
1552
+
1553
+ onBeforeUnmount(() => {
1554
+ if (sim) sim.stop();
1555
+ if (hoverTimer) clearTimeout(hoverTimer);
1556
+ nodeEls.clear();
1557
+ window.removeEventListener('resize', onResize);
1558
+ });
1559
+
1560
+ /* Expose navigate for parent to call on internal navigation */
1561
+ defineExpose({ navigate });
1562
+ </script>
1563
+
1564
+ <template>
1565
+ <div class="rel-sphere" :class="{ dark: uiStore.isDark }">
1566
+ <div class="sp-canvas" ref="canvasRef">
1567
+ <div class="sp-viewport" ref="viewportRef">
1568
+ <svg ref="edgesSvgRef" class="sp-edges"></svg>
1569
+ <div ref="nodesLayerRef" class="sp-nodes"></div>
1570
+ </div>
1571
+ </div>
1572
+
1573
+ <!-- View options panel — top-right, collapsible. Houses degree selector,
1574
+ dataset filter, and relation-type legend in one unified control. -->
1575
+ <div class="sp-panel sp-panel-top-right" :class="{ collapsed: !panelOpen }">
1576
+ <button class="sp-panel-head" @click="panelOpen = !panelOpen">
1577
+ <span class="sp-panel-title">{{ t('sphere.viewOptions') }}</span>
1578
+ <span class="sp-panel-count">{{ nodes.length - 1 }}</span>
1579
+ <span class="sp-panel-chevron">{{ panelOpen ? '▾' : '▸' }}</span>
1580
+ </button>
1581
+
1582
+ <div v-show="panelOpen" class="sp-panel-body">
1583
+ <!-- Degree selector -->
1584
+ <div class="sp-section">
1585
+ <div class="sp-section-label">{{ t('sphere.degree') }}</div>
1586
+ <div class="sp-degree-seg">
1587
+ <button v-for="d in ([1, 2, 3] as const)" :key="d"
1588
+ :class="['sp-degree-btn', { active: degree === d }]"
1589
+ @click="changeDegree(d)">
1590
+ {{ d }}°
1591
+ </button>
1592
+ </div>
1593
+ </div>
1594
+
1595
+ <!-- Language selector -->
1596
+ <div v-if="langOptions.length > 1" class="sp-section">
1597
+ <div class="sp-section-label">{{ t('sphere.language') }}</div>
1598
+ <div class="sp-lang-seg">
1599
+ <button v-for="l in langOptions" :key="l"
1600
+ :class="['sp-lang-btn', { active: sphereLang === l }]"
1601
+ @click="changeSphereLang(l)">
1602
+ {{ l }}
1603
+ </button>
1604
+ </div>
1605
+ </div>
1606
+
1607
+ <!-- Datasets filter -->
1608
+ <div v-if="datasetItems.length" class="sp-section">
1609
+ <div class="sp-section-label">{{ t('sphere.datasets') }}</div>
1610
+ <div class="sp-chips">
1611
+ <button v-for="ds in datasetItems" :key="ds.register"
1612
+ class="sp-chip"
1613
+ :class="{ muted: mutedRegisters.has(ds.register) }"
1614
+ :style="{ '--chip-color': ds.color }"
1615
+ @click="toggleRegister(ds.register)">
1616
+ <span class="sp-chip-dot"></span>
1617
+ <span class="sp-chip-label">{{ ds.register }}</span>
1618
+ <span class="sp-chip-count">{{ ds.count }}</span>
1619
+ </button>
1620
+ </div>
1621
+ </div>
1622
+
1623
+ <!-- Type legend -->
1624
+ <div v-if="legendItems.length" class="sp-section">
1625
+ <div class="sp-section-label">{{ t('sphere.relationTypes') }}</div>
1626
+ <div class="sp-legend-grid">
1627
+ <button v-for="item in legendItems" :key="item.type" class="sp-legend-item"
1628
+ :class="{ muted: isTypeMuted(item.type), 'is-partitive': item.type === '__partitive__' }"
1629
+ @click="toggleType(item.type)"
1630
+ :title="item.type">
1631
+ <span v-if="item.type === '__partitive__'" class="sp-swatch sp-swatch-rake"
1632
+ :style="{ '--rake-color': isTypeMuted(item.type) ? '#b8b9cc' : item.color }"></span>
1633
+ <span v-else class="sp-swatch" :style="{ background: isTypeMuted(item.type) ? '#b8b9cc' : item.color }"></span>
1634
+ <span>{{ item.label }}</span>
1635
+ <span class="sp-count">{{ item.count }}</span>
1636
+ </button>
1637
+ </div>
1638
+ </div>
1639
+
1640
+ <!-- Expand — continuous slider 0 (tight) to 10 (loose) -->
1641
+ <div class="sp-section">
1642
+ <div class="sp-section-label">
1643
+ <span>{{ t('sphere.expand') }}</span>
1644
+ <span class="sp-slider-val">{{ expandValue.toFixed(1) }}</span>
1645
+ </div>
1646
+ <input
1647
+ type="range" min="0" max="10" step="0.5"
1648
+ :value="expandValue"
1649
+ @input="changeExpand(parseFloat(($event.target as HTMLInputElement).value))"
1650
+ class="sp-slider"
1651
+ />
1652
+ <div class="sp-slider-labels"><span>{{ t('sphere.tight') }}</span><span>{{ t('sphere.loose') }}</span></div>
1653
+
1654
+ <button class="sp-redraw" @click="redraw" title="Reshuffle the layout">
1655
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1656
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6M4 10a8 8 0 0114-5M20 14a8 8 0 01-14 5"/>
1657
+ </svg>
1658
+ <span>{{ t('sphere.redraw') }}</span>
1659
+ </button>
1660
+ </div>
1661
+ </div>
1662
+ </div>
1663
+
1664
+ <Transition name="sp-preview">
1665
+ <div v-if="hoveredNode" class="sp-preview">
1666
+ <div class="sp-pv-ref">{{ previewRef }}</div>
1667
+ <div class="sp-pv-term">{{ previewTerm }}</div>
1668
+ <div v-if="previewDefinition" class="sp-pv-def">{{ previewDefinition }}</div>
1669
+ </div>
1670
+ </Transition>
1671
+ </div>
1672
+ </template>
1673
+
1674
+ <style scoped>
1675
+ .rel-sphere {
1676
+ position: relative; flex: 1; min-height: 0; width: 100%;
1677
+ background: #faf9f6; overflow: hidden;
1678
+ --ink: #1a1b2e; --ink-mute: #636588; --surface: #fff; --rule: rgba(26,27,46,0.08); --blue: #2563eb;
1679
+ /* Hover accent — warm amber. Deliberately distinct from the blue focus ring
1680
+ and from any dataset color (all are cool/blue-gray tones). */
1681
+ --hover-glow: #f59e0b;
1682
+ --hover-glow-soft: rgba(245, 158, 11, 0.18);
1683
+ }
1684
+ .rel-sphere.dark { background: #0f1020; --ink: #f0f0f4; --ink-mute: #8d8faa; --surface: #1c1e32; --rule: rgba(255,255,255,0.08); --blue: #60a5fa; --hover-glow: #fbbf24; --hover-glow-soft: rgba(251, 191, 36, 0.22); }
1685
+
1686
+ .sp-canvas { position: absolute; inset: 0; overflow: hidden; cursor: grab; }
1687
+ .sp-canvas:active { cursor: grabbing; }
1688
+ .sp-viewport { position: absolute; inset: 0; transform-origin: 0 0; will-change: transform; }
1689
+ .sp-edges { position: absolute; inset: 0; pointer-events: none; z-index: 2; }
1690
+ .sp-nodes { position: absolute; inset: 0; z-index: 4; }
1691
+
1692
+ :deep(.sp-node) {
1693
+ position: absolute; background: var(--surface); border: 1px solid var(--rule);
1694
+ border-radius: 6px; padding: 10px 13px; width: 220px;
1695
+ box-shadow: 0 1px 2px rgba(0,0,0,0.05), 0 2px 6px rgba(0,0,0,0.04);
1696
+ transition: box-shadow 0.15s, border-color 0.15s, transform 0.18s ease-out; will-change: transform;
1697
+ }
1698
+ :deep(.sp-node:hover), :deep(.sp-node.hovered) {
1699
+ /* Amber glow — multi-layer shadow for a "pulled forward" feel.
1700
+ Outer ring is the accent at low alpha; inner ring is tighter, stronger.
1701
+ Border picks up the same hue so the card outline reads as "active". */
1702
+ box-shadow:
1703
+ 0 0 0 3px var(--hover-glow-soft),
1704
+ 0 8px 28px rgba(245, 158, 11, 0.35),
1705
+ 0 2px 8px rgba(0,0,0,0.12);
1706
+ border-color: var(--hover-glow);
1707
+ }
1708
+ :deep(.sp-node.d-1) { /* left border set inline per dataset color */ }
1709
+ :deep(.sp-ref) { font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--ink-mute); margin-bottom: 4px; text-transform: uppercase; font-weight: 500; letter-spacing: 0.02em; }
1710
+ :deep(.sp-term) { font-family: 'Fraunces', Georgia, serif; font-size: 14px; line-height: 1.2; color: var(--ink); margin-bottom: 6px; font-weight: 500; }
1711
+ :deep(.sp-meta) { display: flex; gap: 5px; align-items: center; font-size: 9px; font-family: 'JetBrains Mono', monospace; color: var(--ink-mute); text-transform: uppercase; }
1712
+ :deep(.sp-badge) {
1713
+ padding: 2px 6px; border-radius: 2px; letter-spacing: 0.06em;
1714
+ /* Dataset color is passed via --ds-color inline. Mix it for readable
1715
+ text/background/border in light mode. */
1716
+ color: var(--ds-color, var(--ink));
1717
+ background: color-mix(in srgb, var(--ds-color, var(--ink)) 14%, transparent);
1718
+ border: 1px solid color-mix(in srgb, var(--ds-color, var(--ink)) 28%, transparent);
1719
+ }
1720
+ /* Dark mode — lighten the text (mix with white) and strengthen the
1721
+ background tint so the dataset hue is still visible against #1c1e32. */
1722
+ .rel-sphere.dark :deep(.sp-badge) {
1723
+ color: color-mix(in srgb, var(--ds-color, var(--ink)) 72%, white);
1724
+ background: color-mix(in srgb, var(--ds-color, var(--ink)) 26%, transparent);
1725
+ border: 1px solid color-mix(in srgb, var(--ds-color, var(--ink)) 50%, transparent);
1726
+ }
1727
+ :deep(.sp-rel) { margin-left: auto; }
1728
+ :deep(.sp-node.focus) {
1729
+ z-index: 20; cursor: default;
1730
+ outline: 2px solid var(--blue);
1731
+ outline-offset: 3px;
1732
+ box-shadow: 0 0 12px 4px rgba(37,99,235,0.25), 0 0 24px 8px rgba(37,99,235,0.12);
1733
+ }
1734
+ :deep(.sp-focus-badge) {
1735
+ position: absolute; top: -10px; right: 12px; font-size: 9px; color: white;
1736
+ background: var(--blue); padding: 3px 8px; font-weight: 700; letter-spacing: 0.18em;
1737
+ border-radius: 2px; font-family: 'DM Sans', sans-serif;
1738
+ }
1739
+
1740
+ /* ── Degree selector (inline in panel) ───────────────────── */
1741
+ .sp-degree-seg {
1742
+ display: inline-flex; gap: 2px; padding: 2px; background: var(--rule);
1743
+ border-radius: 4px; width: fit-content;
1744
+ }
1745
+ .sp-degree-btn {
1746
+ border: none; background: transparent; cursor: pointer;
1747
+ padding: 3px 9px; font-size: 11px; font-family: 'JetBrains Mono', monospace;
1748
+ color: var(--ink-mute); border-radius: 3px; transition: all 0.15s;
1749
+ font-weight: 600;
1750
+ }
1751
+ .sp-degree-btn:hover { color: var(--ink); }
1752
+ .sp-degree-btn.active {
1753
+ background: var(--surface); color: var(--blue);
1754
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
1755
+ }
1756
+
1757
+ /* Language selector — same shape as Degree, slightly narrower buttons */
1758
+ .sp-lang-seg {
1759
+ display: inline-flex; gap: 2px; padding: 2px; background: var(--rule);
1760
+ border-radius: 4px; width: fit-content; flex-wrap: wrap;
1761
+ }
1762
+ .sp-lang-btn {
1763
+ border: none; background: transparent; cursor: pointer;
1764
+ padding: 3px 8px; font-size: 10px; font-family: 'JetBrains Mono', monospace;
1765
+ color: var(--ink-mute); border-radius: 3px; transition: all 0.15s;
1766
+ font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
1767
+ }
1768
+ .sp-lang-btn:hover { color: var(--ink); }
1769
+ .sp-lang-btn.active {
1770
+ background: var(--surface); color: var(--blue);
1771
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
1772
+ }
1773
+
1774
+ /* ── Collapsible View options panel ──────────────────────── */
1775
+ .sp-panel {
1776
+ position: absolute; z-index: 30;
1777
+ background: var(--surface); border: 1px solid var(--rule); border-radius: 6px;
1778
+ width: 250px;
1779
+ box-shadow: 0 1px 2px rgba(0,0,0,0.05), 0 8px 24px rgba(0,0,0,0.08);
1780
+ overflow: hidden;
1781
+ }
1782
+ /* Top-right placement — clears the canvas drag area below */
1783
+ .sp-panel.sp-panel-top-right { top: 20px; right: 20px; }
1784
+ .sp-panel-head {
1785
+ display: flex; align-items: center; gap: 8px; width: 100%;
1786
+ padding: 10px 14px; border: none; background: transparent; cursor: pointer;
1787
+ font-family: inherit; text-align: left;
1788
+ border-bottom: 1px solid var(--rule);
1789
+ }
1790
+ .sp-panel.collapsed .sp-panel-head { border-bottom: none; }
1791
+ .sp-panel-title {
1792
+ font-family: 'DM Serif Display', serif; font-size: 13px; color: var(--ink);
1793
+ flex: 1;
1794
+ }
1795
+ .sp-panel-count {
1796
+ font-family: 'JetBrains Mono', monospace; font-size: 10px;
1797
+ background: var(--rule); color: var(--ink-mute);
1798
+ padding: 2px 7px; border-radius: 10px; font-weight: 600;
1799
+ }
1800
+ .sp-panel-chevron { color: var(--ink-mute); font-size: 11px; }
1801
+ .sp-panel-body { padding: 8px 12px 12px; }
1802
+ .sp-section { margin-top: 6px; }
1803
+ .sp-section-label {
1804
+ font-family: 'JetBrains Mono', monospace; font-size: 9px;
1805
+ color: var(--ink-mute); text-transform: uppercase; letter-spacing: 0.08em;
1806
+ margin-bottom: 6px; font-weight: 600;
1807
+ }
1808
+
1809
+ /* Expand slider — continuous 0–10 range */
1810
+ .sp-slider {
1811
+ width: 100%; height: 4px; appearance: none; -webkit-appearance: none;
1812
+ background: var(--rule); border-radius: 2px; outline: none; cursor: pointer;
1813
+ margin: 6px 0 2px;
1814
+ }
1815
+ .sp-slider::-webkit-slider-thumb {
1816
+ -webkit-appearance: none; appearance: none;
1817
+ width: 14px; height: 14px; border-radius: 50%;
1818
+ background: var(--blue); border: 2px solid var(--surface);
1819
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2); cursor: grab;
1820
+ }
1821
+ .sp-slider::-webkit-slider-thumb:active { cursor: grabbing; }
1822
+ .sp-slider::-moz-range-thumb {
1823
+ width: 14px; height: 14px; border-radius: 50%;
1824
+ background: var(--blue); border: 2px solid var(--surface);
1825
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2); cursor: grab;
1826
+ }
1827
+ .sp-slider-val {
1828
+ float: right; font-family: 'JetBrains Mono', monospace; font-size: 10px;
1829
+ color: var(--blue); font-weight: 600;
1830
+ }
1831
+ .sp-slider-labels {
1832
+ display: flex; justify-content: space-between;
1833
+ font-family: 'JetBrains Mono', monospace; font-size: 8px;
1834
+ color: var(--ink-mute); text-transform: uppercase; letter-spacing: 0.06em;
1835
+ margin-bottom: 6px;
1836
+ }
1837
+ .sp-redraw {
1838
+ display: flex; align-items: center; justify-content: center; gap: 6px;
1839
+ width: 100%; padding: 5px 8px;
1840
+ border: 1px solid var(--rule); background: transparent;
1841
+ color: var(--ink); font-family: inherit; font-size: 11px; font-weight: 500;
1842
+ border-radius: 4px; cursor: pointer; transition: all 0.15s;
1843
+ }
1844
+ .sp-redraw:hover {
1845
+ background: var(--rule); border-color: var(--blue); color: var(--blue);
1846
+ }
1847
+
1848
+ /* Dataset chips */
1849
+ .sp-chips { display: flex; flex-wrap: wrap; gap: 4px; }
1850
+ .sp-chip {
1851
+ display: inline-flex; align-items: center; gap: 5px;
1852
+ padding: 3px 8px 3px 6px; border-radius: 3px; cursor: pointer;
1853
+ border: 1px solid var(--rule); background: transparent;
1854
+ font-family: 'JetBrains Mono', monospace; font-size: 10px;
1855
+ color: var(--ink); font-weight: 500;
1856
+ transition: all 0.15s;
1857
+ }
1858
+ .sp-chip:hover { background: var(--rule); }
1859
+ .sp-chip.muted { opacity: 0.4; }
1860
+ .sp-chip-dot {
1861
+ width: 8px; height: 8px; border-radius: 50%;
1862
+ background: var(--chip-color, var(--blue));
1863
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--chip-color, var(--blue)) 20%, transparent);
1864
+ }
1865
+ .sp-chip.muted .sp-chip-dot { background: var(--ink-mute); box-shadow: none; }
1866
+ .sp-chip-label { letter-spacing: 0.02em; }
1867
+ .sp-chip-count {
1868
+ font-size: 9px; color: var(--ink-mute);
1869
+ background: var(--rule); padding: 1px 4px; border-radius: 2px;
1870
+ }
1871
+
1872
+ /* Type legend */
1873
+ .sp-legend-grid { display: flex; flex-direction: column; gap: 2px; }
1874
+ .sp-legend-item {
1875
+ display: flex; align-items: center; gap: 8px; padding: 3px 6px; border-radius: 3px;
1876
+ cursor: pointer; border: 1px solid transparent; background: transparent; text-align: left;
1877
+ width: 100%; font-family: inherit; font-size: 11px; color: var(--ink-mute);
1878
+ }
1879
+ .sp-legend-item:hover { background: var(--rule); }
1880
+ .sp-legend-item.muted { opacity: 0.35; }
1881
+ .sp-swatch { width: 18px; height: 2px; flex-shrink: 0; border-radius: 1px; }
1882
+ /* Rake swatch — small bracket icon signaling a PartitiveRelation hyperedge
1883
+ (not a single binary edge). Two parallel solid lines (close-set double)
1884
+ with a perpendicular stem, evoking the ISO 704 rake convention. */
1885
+ .sp-swatch-rake {
1886
+ width: 18px; height: 10px; position: relative; background: transparent !important;
1887
+ border-radius: 0;
1888
+ }
1889
+ .sp-swatch-rake::before,
1890
+ .sp-swatch-rake::after {
1891
+ content: ''; position: absolute; background: var(--rake-color, #0d9488);
1892
+ }
1893
+ .sp-swatch-rake::before {
1894
+ /* horizontal spine (close-set double line) */
1895
+ left: 4px; right: 0; top: 3px; height: 1.5px;
1896
+ box-shadow: 0 3px 0 var(--rake-color, #0d9488);
1897
+ }
1898
+ .sp-swatch-rake::after {
1899
+ /* vertical stem from top of swatch to spine */
1900
+ left: 4px; width: 1.5px; top: 0; height: 4px;
1901
+ }
1902
+ .sp-count { margin-left: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; }
1903
+
1904
+ .sp-preview {
1905
+ position: absolute; bottom: 24px; right: 24px; z-index: 40;
1906
+ background: var(--surface); border: 1px solid var(--rule); border-radius: 6px;
1907
+ padding: 12px 16px; width: 280px; pointer-events: none;
1908
+ box-shadow: 0 12px 32px rgba(0,0,0,0.1);
1909
+ }
1910
+ .sp-pv-ref { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--ink-mute); text-transform: uppercase; }
1911
+ .sp-pv-term { font-family: 'DM Serif Display', serif; font-size: 18px; color: var(--ink); margin-top: 4px; }
1912
+ .sp-pv-def {
1913
+ font-family: 'Source Sans 3', system-ui, sans-serif; font-size: 12px;
1914
+ line-height: 1.45; color: var(--ink-mute); margin-top: 8px;
1915
+ /* Clamp to 4 lines so very long definitions don't blow out the panel */
1916
+ display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical;
1917
+ overflow: hidden;
1918
+ }
1919
+ .sp-preview-enter-active, .sp-preview-leave-active { transition: all 0.25s; }
1920
+ .sp-preview-enter-from, .sp-preview-leave-to { opacity: 0; transform: translateY(8px); }
1921
+ </style>