@glossarist/concept-browser 0.7.51 → 0.7.53

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