pretzinger commited on
Commit
05c9a67
·
verified ·
1 Parent(s): 1754aa5

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +223 -80
index.html CHANGED
@@ -1,3 +1,4 @@
 
1
  <!--
2
  IC MAP (Hugging Face Renderer) — CANON v1
3
 
@@ -7,13 +8,15 @@ Purpose:
7
 
8
  Hard invariants:
9
  - Exactly 15 nodes. IDs are fixed. No extras. No missing.
10
- - NEVER display or mention DISC (letters, acronym, or score breakdown). DISC is input-only and must be translated server-side into IC node weights.
11
  - Topology is fixed client-side: ringLinks per cluster + fixed bridge links.
12
 
13
  Handshake (query params):
14
  - assessment_id (required)
15
  - viz_token (required) short-lived signed token
16
  - api_base (optional; defaults to REQUIRED_INPUT_PROD_API_BASE)
 
 
17
 
18
  Fetch:
19
  GET `${api_base}/api/assessments/${assessment_id}/visualization?viz_token=${viz_token}`
@@ -29,7 +32,7 @@ Expected response:
29
 
30
  Node IDs (fixed):
31
  Move: pace, directness, influence, stability_pref, precision_pref
32
- Protect: priority_1..priority_5 (labels dynamic from top values)
33
  Pressure: pursuit, withdrawal, control, appeasement, escalation
34
 
35
  Failure behavior:
@@ -42,8 +45,12 @@ Failure behavior:
42
  <meta charset="UTF-8" />
43
  <meta name="viewport" content="width=device-width,initial-scale=1" />
44
  <title>IC Trait Network</title>
 
45
  <!-- 3d-force-graph via CDN (official quick-start) -->
46
  <script src="https://cdn.jsdelivr.net/npm/3d-force-graph"></script>
 
 
 
47
  <style>
48
  :root {
49
  --bg: #f6f4ef; /* off-white */
@@ -51,10 +58,27 @@ Failure behavior:
51
  --muted: rgba(17, 24, 39, 0.25);
52
  --accent: #0f766e; /* muted teal */
53
  --accent2: #1f3a5f; /* desaturated blue */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  }
55
- html, body { height: 100%; margin: 0; background: var(--bg); }
56
- #wrap { position: fixed; inset: 0; }
57
- #graph { position: absolute; inset: 0; }
58
  #hud {
59
  position: absolute;
60
  top: 16px;
@@ -69,9 +93,25 @@ Failure behavior:
69
  padding: 12px 12px;
70
  max-width: 560px;
71
  }
72
- #hud h1 { margin: 0 0 6px; font-size: 14px; letter-spacing: 0.02em; font-weight: 650; }
73
- #hud p { margin: 0 0 10px; font-size: 12px; line-height: 1.35; color: rgba(17, 24, 39, 0.78); }
74
- #controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 10px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  button {
76
  font-size: 12px;
77
  padding: 8px 10px;
@@ -81,7 +121,9 @@ Failure behavior:
81
  color: var(--ink);
82
  cursor: pointer;
83
  }
84
- button:hover { border-color: rgba(17, 24, 39, 0.28); }
 
 
85
  .pill {
86
  font-size: 11px;
87
  padding: 6px 8px;
@@ -91,11 +133,27 @@ Failure behavior:
91
  color: rgba(17, 24, 39, 0.78);
92
  white-space: nowrap;
93
  }
 
94
  #legend {
95
  display: grid;
96
  grid-template-columns: 1fr;
97
  gap: 8px;
98
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  .legendBlock {
100
  border: 1px solid rgba(17, 24, 39, 0.08);
101
  border-radius: 10px;
@@ -115,31 +173,41 @@ Failure behavior:
115
  line-height: 1.35;
116
  color: rgba(17, 24, 39, 0.78);
117
  }
118
- .legendList li { margin: 2px 0; }
 
 
119
  </style>
120
  </head>
 
121
  <body>
122
  <div id="wrap">
123
  <div id="hud">
124
  <h1>IC Map</h1>
125
- <p>Three interacting forces with exactly 15 nodes: How You Move · What You Protect · Pressure Response. Toggle “Pressure” to see the network shift.</p>
 
 
126
 
127
  <div id="controls">
128
  <button id="toggle">Toggle Pressure</button>
129
  <span class="pill" id="mode">Mode: Baseline</span>
130
  <span class="pill" id="data">Data: Demo</span>
131
  <span class="pill" id="hover">Hover: none</span>
 
132
  </div>
133
 
134
  <div id="legend">
 
 
135
  <div class="legendBlock">
136
  <div class="legendTitle">How You Move</div>
137
  <ul class="legendList" id="legend-move"></ul>
138
  </div>
 
139
  <div class="legendBlock">
140
  <div class="legendTitle">What You Protect</div>
141
  <ul class="legendList" id="legend-protect"></ul>
142
  </div>
 
143
  <div class="legendBlock">
144
  <div class="legendTitle">Pressure Response</div>
145
  <ul class="legendList" id="legend-pressure"></ul>
@@ -151,6 +219,14 @@ Failure behavior:
151
  </div>
152
 
153
  <script>
 
 
 
 
 
 
 
 
154
  // ====== Config ======
155
  // Default API base when api_base is not provided.
156
  // REQUIRED_INPUT_PROD_API_BASE should be your production base, e.g. https://intimacy-compass.com
@@ -160,20 +236,36 @@ Failure behavior:
160
  version: "ic_map_v1",
161
  topology_id: "ic_map_topology_v1",
162
  nodeIds: [
163
- "pace","directness","influence","stability_pref","precision_pref",
164
- "priority_1","priority_2","priority_3","priority_4","priority_5",
165
- "pursuit","withdrawal","control","appeasement","escalation"
 
 
 
 
 
 
 
 
 
 
 
 
166
  ],
167
- moveIds: ["pace","directness","influence","stability_pref","precision_pref"],
168
- protectIds: ["priority_1","priority_2","priority_3","priority_4","priority_5"],
169
- pressureIds: ["pursuit","withdrawal","control","appeasement","escalation"]
170
  };
171
 
 
 
 
 
172
  // ====== Fixed trait nodes (exactly 15) ======
173
  const GROUPS = {
174
- move: { label: "How You Move", color: getCSS("--accent2") },
175
- protect: { label: "What You Protect", color: getCSS("--ink") },
176
- pressure: { label: "Pressure Response", color: getCSS("--accent") }
177
  };
178
 
179
  const move = [
@@ -203,15 +295,15 @@ Failure behavior:
203
  const nodes = [...move, ...protect, ...pressure];
204
 
205
  // Demo/default weights so renderer works without API.
206
- nodes.forEach(n => {
207
  n.weight_baseline = 1.8;
208
  n.weight_pressure = 2.0;
209
  });
210
 
211
  // Seed cluster positions (3 clusters in 3D space)
212
- seedCluster(move, { x: -80, y: 10, z: 40 });
213
- seedCluster(protect,{ x: 70, y: -10, z: -30 });
214
- seedCluster(pressure,{x: 10, y: 60, z: 70 });
215
 
216
  // Links: dense within cluster + a few bridges between clusters (no new nodes)
217
  const links = [
@@ -240,36 +332,101 @@ Failure behavior:
240
  let isPressure = false;
241
  let hoveredNodeId = null;
242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  const Graph = ForceGraph3D()(el)
244
  .backgroundColor(getCSS("--bg"))
245
  .showNavInfo(false)
246
  .width(window.innerWidth)
247
  .height(window.innerHeight)
248
  .graphData({ nodes, links })
249
- .nodeLabel(n => n.label)
250
- .nodeVal(n => nodeVal(n))
 
251
  .nodeOpacity(0.92)
252
- .nodeColor(n => nodeColor(n))
253
  .linkColor(() => getCSS("--muted"))
254
  .linkOpacity(0.35)
255
  .linkCurvature(0.18)
256
  .linkDirectionalParticles(() => (isPressure ? 4 : 2))
257
  .linkDirectionalParticleWidth(() => (isPressure ? 1.2 : 0.8))
258
  .linkDirectionalParticleSpeed(() => (isPressure ? 0.014 : 0.005))
259
- .onNodeHover(n => {
260
  hoveredNodeId = n ? n.id : null;
261
- hoverEl.textContent = "Hover: " + (n ? n.label : "none");
262
  });
263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  // Gentle camera orbit (no heavy assets)
265
  let t = 0;
266
  const camDist = 240;
267
  (function animate() {
268
  t += isPressure ? 0.0032 : 0.0016;
269
- Graph.cameraPosition(
270
- { x: camDist * Math.cos(t), y: 90 + 10 * Math.sin(t * 0.7), z: camDist * Math.sin(t) },
271
- { x: 0, y: 10, z: 0 }
272
- );
273
  requestAnimationFrame(animate);
274
  })();
275
 
@@ -277,11 +434,12 @@ Failure behavior:
277
  document.getElementById("toggle").addEventListener("click", () => {
278
  isPressure = !isPressure;
279
  modeEl.textContent = "Mode: " + (isPressure ? "Pressure" : "Baseline");
280
- Graph
281
- .nodeVal(n => nodeVal(n))
282
- .nodeColor(n => nodeColor(n))
283
  .linkDirectionalParticles(() => (isPressure ? 4 : 2))
284
  .linkDirectionalParticleSpeed(() => (isPressure ? 0.014 : 0.005));
 
 
285
  });
286
 
287
  // Resize handling
@@ -290,17 +448,16 @@ Failure behavior:
290
  Graph.height(window.innerHeight);
291
  });
292
 
293
- // ====== Live data wiring (Step #4) ======
294
  (async function initLiveData() {
295
  // Always render demo immediately, then try to upgrade to live.
296
  renderLegends();
297
  setDataStatus("Demo");
298
 
299
- const params = getQueryParams();
300
- if (!params.assessment_id || !params.viz_token) return;
301
 
302
- const apiBase = params.api_base || API_BASE_DEFAULT;
303
- const url = `${apiBase}/api/assessments/${encodeURIComponent(params.assessment_id)}/visualization?viz_token=${encodeURIComponent(params.viz_token)}`;
304
 
305
  try {
306
  const controller = new AbortController();
@@ -308,8 +465,8 @@ Failure behavior:
308
 
309
  const res = await fetch(url, {
310
  method: "GET",
311
- headers: { "Accept": "application/json" },
312
- signal: controller.signal
313
  });
314
 
315
  clearTimeout(timeout);
@@ -323,35 +480,23 @@ Failure behavior:
323
  setDataStatus("Live");
324
 
325
  // Re-bind the graph to ensure it re-renders with patched node fields.
326
- Graph.graphData({ nodes, links })
327
- .nodeLabel(n => n.label)
328
- .nodeVal(n => nodeVal(n))
329
- .nodeColor(n => nodeColor(n));
330
 
 
331
  renderLegends();
332
  } catch (e) {
333
  // Fail open to demo
334
  setDataStatus("Demo");
335
- // no throw
336
  }
337
  })();
338
 
339
- function getQueryParams() {
340
- const sp = new URLSearchParams(window.location.search);
341
- return {
342
- assessment_id: sp.get("assessment_id"),
343
- viz_token: sp.get("viz_token"),
344
- api_base: sp.get("api_base")
345
- };
346
- }
347
-
348
  function validatePayload(p) {
349
  if (!p || typeof p !== "object") return false;
350
  if (p.version !== REQUIRED.version) return false;
351
  if (p.topology_id !== REQUIRED.topology_id) return false;
352
  if (!Array.isArray(p.nodes) || p.nodes.length !== 15) return false;
353
 
354
- const ids = new Set(p.nodes.map(n => n && n.id));
355
  for (const reqId of REQUIRED.nodeIds) {
356
  if (!ids.has(reqId)) return false;
357
  }
@@ -360,15 +505,15 @@ Failure behavior:
360
 
361
  function applyPayload(p) {
362
  const map = new Map();
363
- p.nodes.forEach(n => map.set(n.id, n));
364
 
365
- nodes.forEach(n => {
366
  const src = map.get(n.id);
367
  if (!src) return;
368
 
369
- // Labels: protect nodes dynamic, move+pressure fixed.
370
- if (n.group === "protect" && typeof src.label === "string" && src.label.trim()) {
371
- n.label = src.label.trim();
372
  }
373
 
374
  // Weights: always take from payload; HF must not invent weights.
@@ -383,18 +528,19 @@ Failure behavior:
383
 
384
  function renderLegends() {
385
  // How You Move (fixed)
386
- setLegendList(legendMoveEl, REQUIRED.moveIds.map(id => findNodeLabel(id)));
387
 
388
- // What You Protect (dynamic)
389
- setLegendList(legendProtectEl, REQUIRED.protectIds.map(id => findNodeLabel(id)));
390
 
391
  // Pressure Response (fixed)
392
- setLegendList(legendPressureEl, REQUIRED.pressureIds.map(id => findNodeLabel(id)));
393
  }
394
 
395
  function setLegendList(el, labels) {
396
  el.innerHTML = "";
397
- labels.forEach(txt => {
 
398
  const li = document.createElement("li");
399
  li.textContent = txt;
400
  el.appendChild(li);
@@ -402,7 +548,7 @@ Failure behavior:
402
  }
403
 
404
  function findNodeLabel(id) {
405
- const n = nodes.find(x => x.id === id);
406
  return n ? n.label : id;
407
  }
408
 
@@ -410,9 +556,9 @@ Failure behavior:
410
  function seedCluster(arr, center) {
411
  arr.forEach((n, i) => {
412
  const jitter = 18;
413
- n.x = center.x + (Math.sin(i * 2.1) * jitter);
414
- n.y = center.y + (Math.cos(i * 1.7) * jitter);
415
- n.z = center.z + (Math.sin(i * 1.3) * jitter);
416
  });
417
  }
418
 
@@ -426,9 +572,9 @@ Failure behavior:
426
  }
427
 
428
  function nodeVal(n) {
429
- const hoverBoost = (hoveredNodeId && n.id === hoveredNodeId) ? 2.2 : 1.0;
430
- const base = (typeof n.weight_baseline === "number") ? n.weight_baseline : 1.8;
431
- const press = (typeof n.weight_pressure === "number") ? n.weight_pressure : 2.0;
432
 
433
  if (!isPressure) return base;
434
  return press * hoverBoost;
@@ -440,10 +586,7 @@ Failure behavior:
440
  if (n.id === hoveredNodeId) return base;
441
  return "rgba(17,24,39,0.35)";
442
  }
443
-
444
- function getCSS(varName) {
445
- return getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
446
- }
447
  </script>
448
  </body>
449
- </html>
 
 
1
+ ```html
2
  <!--
3
  IC MAP (Hugging Face Renderer) — CANON v1
4
 
 
8
 
9
  Hard invariants:
10
  - Exactly 15 nodes. IDs are fixed. No extras. No missing.
11
+ - NEVER display or mention the source assessment type, raw scores, or score breakdown. Source inputs must be translated server-side into IC node weights.
12
  - Topology is fixed client-side: ringLinks per cluster + fixed bridge links.
13
 
14
  Handshake (query params):
15
  - assessment_id (required)
16
  - viz_token (required) short-lived signed token
17
  - api_base (optional; defaults to REQUIRED_INPUT_PROD_API_BASE)
18
+ - embed=1 (optional; compact/collapsible legend)
19
+ - labels=hover|all (optional; default hover)
20
 
21
  Fetch:
22
  GET `${api_base}/api/assessments/${assessment_id}/visualization?viz_token=${viz_token}`
 
32
 
33
  Node IDs (fixed):
34
  Move: pace, directness, influence, stability_pref, precision_pref
35
+ Protect: priority_1..priority_5 (labels dynamic from top values; empty string means hide)
36
  Pressure: pursuit, withdrawal, control, appeasement, escalation
37
 
38
  Failure behavior:
 
45
  <meta charset="UTF-8" />
46
  <meta name="viewport" content="width=device-width,initial-scale=1" />
47
  <title>IC Trait Network</title>
48
+
49
  <!-- 3d-force-graph via CDN (official quick-start) -->
50
  <script src="https://cdn.jsdelivr.net/npm/3d-force-graph"></script>
51
+ <!-- three-spritetext for always-on labels -->
52
+ <script src="https://cdn.jsdelivr.net/npm/three-spritetext"></script>
53
+
54
  <style>
55
  :root {
56
  --bg: #f6f4ef; /* off-white */
 
58
  --muted: rgba(17, 24, 39, 0.25);
59
  --accent: #0f766e; /* muted teal */
60
  --accent2: #1f3a5f; /* desaturated blue */
61
+
62
+ /* IC Map cluster colors (distinct, readable) */
63
+ --cluster-move: #2563eb; /* blue */
64
+ --cluster-protect: #059669; /* green */
65
+ --cluster-pressure: #dc2626; /* red */
66
+ }
67
+
68
+ html,
69
+ body {
70
+ height: 100%;
71
+ margin: 0;
72
+ background: var(--bg);
73
+ }
74
+ #wrap {
75
+ position: fixed;
76
+ inset: 0;
77
+ }
78
+ #graph {
79
+ position: absolute;
80
+ inset: 0;
81
  }
 
 
 
82
  #hud {
83
  position: absolute;
84
  top: 16px;
 
93
  padding: 12px 12px;
94
  max-width: 560px;
95
  }
96
+ #hud h1 {
97
+ margin: 0 0 6px;
98
+ font-size: 14px;
99
+ letter-spacing: 0.02em;
100
+ font-weight: 650;
101
+ }
102
+ #hud p {
103
+ margin: 0 0 10px;
104
+ font-size: 12px;
105
+ line-height: 1.35;
106
+ color: rgba(17, 24, 39, 0.78);
107
+ }
108
+ #controls {
109
+ display: flex;
110
+ flex-wrap: wrap;
111
+ gap: 8px;
112
+ align-items: center;
113
+ margin-bottom: 10px;
114
+ }
115
  button {
116
  font-size: 12px;
117
  padding: 8px 10px;
 
121
  color: var(--ink);
122
  cursor: pointer;
123
  }
124
+ button:hover {
125
+ border-color: rgba(17, 24, 39, 0.28);
126
+ }
127
  .pill {
128
  font-size: 11px;
129
  padding: 6px 8px;
 
133
  color: rgba(17, 24, 39, 0.78);
134
  white-space: nowrap;
135
  }
136
+
137
  #legend {
138
  display: grid;
139
  grid-template-columns: 1fr;
140
  gap: 8px;
141
  }
142
+ #legend.compact {
143
+ max-width: 360px;
144
+ }
145
+ #legend.collapsed .legendBlock {
146
+ display: none;
147
+ }
148
+ #legendToggle {
149
+ display: none;
150
+ margin-bottom: 8px;
151
+ width: 100%;
152
+ }
153
+ #legend.compact #legendToggle {
154
+ display: block;
155
+ }
156
+
157
  .legendBlock {
158
  border: 1px solid rgba(17, 24, 39, 0.08);
159
  border-radius: 10px;
 
173
  line-height: 1.35;
174
  color: rgba(17, 24, 39, 0.78);
175
  }
176
+ .legendList li {
177
+ margin: 2px 0;
178
+ }
179
  </style>
180
  </head>
181
+
182
  <body>
183
  <div id="wrap">
184
  <div id="hud">
185
  <h1>IC Map</h1>
186
+ <p>
187
+ Three interacting forces with exactly 15 nodes: How You Move · What You Protect · Pressure Response. Toggle “Pressure” to see the network shift.
188
+ </p>
189
 
190
  <div id="controls">
191
  <button id="toggle">Toggle Pressure</button>
192
  <span class="pill" id="mode">Mode: Baseline</span>
193
  <span class="pill" id="data">Data: Demo</span>
194
  <span class="pill" id="hover">Hover: none</span>
195
+ <button id="labelsToggle">Labels: Hover</button>
196
  </div>
197
 
198
  <div id="legend">
199
+ <button id="legendToggle">Show Legend</button>
200
+
201
  <div class="legendBlock">
202
  <div class="legendTitle">How You Move</div>
203
  <ul class="legendList" id="legend-move"></ul>
204
  </div>
205
+
206
  <div class="legendBlock">
207
  <div class="legendTitle">What You Protect</div>
208
  <ul class="legendList" id="legend-protect"></ul>
209
  </div>
210
+
211
  <div class="legendBlock">
212
  <div class="legendTitle">Pressure Response</div>
213
  <ul class="legendList" id="legend-pressure"></ul>
 
219
  </div>
220
 
221
  <script>
222
+ // ====== Query params (single source of truth) ======
223
+ const urlParams = new URLSearchParams(window.location.search);
224
+ const assessmentId = urlParams.get("assessment_id");
225
+ const vizToken = urlParams.get("viz_token");
226
+ const apiBase = urlParams.get("api_base") || "https://intimacy-compass.com";
227
+ const embedMode = urlParams.get("embed") === "1";
228
+ let labelsMode = urlParams.get("labels") || "hover"; // hover|all
229
+
230
  // ====== Config ======
231
  // Default API base when api_base is not provided.
232
  // REQUIRED_INPUT_PROD_API_BASE should be your production base, e.g. https://intimacy-compass.com
 
236
  version: "ic_map_v1",
237
  topology_id: "ic_map_topology_v1",
238
  nodeIds: [
239
+ "pace",
240
+ "directness",
241
+ "influence",
242
+ "stability_pref",
243
+ "precision_pref",
244
+ "priority_1",
245
+ "priority_2",
246
+ "priority_3",
247
+ "priority_4",
248
+ "priority_5",
249
+ "pursuit",
250
+ "withdrawal",
251
+ "control",
252
+ "appeasement",
253
+ "escalation",
254
  ],
255
+ moveIds: ["pace", "directness", "influence", "stability_pref", "precision_pref"],
256
+ protectIds: ["priority_1", "priority_2", "priority_3", "priority_4", "priority_5"],
257
+ pressureIds: ["pursuit", "withdrawal", "control", "appeasement", "escalation"],
258
  };
259
 
260
+ function getCSS(varName) {
261
+ return getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
262
+ }
263
+
264
  // ====== Fixed trait nodes (exactly 15) ======
265
  const GROUPS = {
266
+ move: { label: "How You Move", color: getCSS("--cluster-move") },
267
+ protect: { label: "What You Protect", color: getCSS("--cluster-protect") },
268
+ pressure: { label: "Pressure Response", color: getCSS("--cluster-pressure") },
269
  };
270
 
271
  const move = [
 
295
  const nodes = [...move, ...protect, ...pressure];
296
 
297
  // Demo/default weights so renderer works without API.
298
+ nodes.forEach((n) => {
299
  n.weight_baseline = 1.8;
300
  n.weight_pressure = 2.0;
301
  });
302
 
303
  // Seed cluster positions (3 clusters in 3D space)
304
+ seedCluster(move, { x: -80, y: 10, z: 40 });
305
+ seedCluster(protect, { x: 70, y: -10, z: -30 });
306
+ seedCluster(pressure, { x: 10, y: 60, z: 70 });
307
 
308
  // Links: dense within cluster + a few bridges between clusters (no new nodes)
309
  const links = [
 
332
  let isPressure = false;
333
  let hoveredNodeId = null;
334
 
335
+ function getNodeTooltip(node) {
336
+ if (labelsMode === "hover") {
337
+ return node && node.label && node.label.trim() !== "" ? node.label : "";
338
+ }
339
+ return ""; // no tooltip in "all" mode to avoid duplication
340
+ }
341
+
342
+ function createNodeSprite(node) {
343
+ if (labelsMode !== "all") return null;
344
+ if (!node || !node.label || node.label.trim() === "") return null;
345
+
346
+ const SpriteTextCtor = window.SpriteText; // from three-spritetext CDN
347
+ if (!SpriteTextCtor) return null;
348
+
349
+ const sprite = new SpriteTextCtor(node.label);
350
+ sprite.color = GROUPS[node.group].color;
351
+ sprite.textHeight = 6;
352
+ sprite.backgroundColor = "rgba(246, 244, 239, 0.85)";
353
+ sprite.padding = 2;
354
+ sprite.borderRadius = 3;
355
+ return sprite;
356
+ }
357
+
358
  const Graph = ForceGraph3D()(el)
359
  .backgroundColor(getCSS("--bg"))
360
  .showNavInfo(false)
361
  .width(window.innerWidth)
362
  .height(window.innerHeight)
363
  .graphData({ nodes, links })
364
+ .nodeLabel((n) => getNodeTooltip(n))
365
+ .nodeThreeObject((n) => createNodeSprite(n))
366
+ .nodeVal((n) => nodeVal(n))
367
  .nodeOpacity(0.92)
368
+ .nodeColor((n) => nodeColor(n))
369
  .linkColor(() => getCSS("--muted"))
370
  .linkOpacity(0.35)
371
  .linkCurvature(0.18)
372
  .linkDirectionalParticles(() => (isPressure ? 4 : 2))
373
  .linkDirectionalParticleWidth(() => (isPressure ? 1.2 : 0.8))
374
  .linkDirectionalParticleSpeed(() => (isPressure ? 0.014 : 0.005))
375
+ .onNodeHover((n) => {
376
  hoveredNodeId = n ? n.id : null;
377
+ hoverEl.textContent = "Hover: " + (n ? (n.label || "none") : "none");
378
  });
379
 
380
+ function updateNodeLabels() {
381
+ Graph.nodeLabel((n) => getNodeTooltip(n)).nodeThreeObject((n) => createNodeSprite(n));
382
+
383
+ if (typeof Graph.refresh === "function") {
384
+ Graph.refresh();
385
+ } else {
386
+ Graph.graphData(Graph.graphData());
387
+ }
388
+ }
389
+
390
+ // ====== Embed/controls setup ======
391
+ (function initEmbedAndControls() {
392
+ const legendEl = document.getElementById("legend");
393
+ const legendToggleBtn = document.getElementById("legendToggle");
394
+ const labelsToggleBtn = document.getElementById("labelsToggle");
395
+
396
+ if (legendEl && embedMode) {
397
+ legendEl.classList.add("compact", "collapsed");
398
+
399
+ if (legendToggleBtn) {
400
+ legendToggleBtn.textContent = "Show Legend";
401
+ legendToggleBtn.addEventListener("click", function () {
402
+ const isCollapsedNow = legendEl.classList.contains("collapsed");
403
+ if (isCollapsedNow) {
404
+ legendEl.classList.remove("collapsed");
405
+ this.textContent = "Hide Legend";
406
+ } else {
407
+ legendEl.classList.add("collapsed");
408
+ this.textContent = "Show Legend";
409
+ }
410
+ });
411
+ }
412
+ }
413
+
414
+ if (labelsToggleBtn) {
415
+ labelsToggleBtn.textContent = "Labels: " + (labelsMode === "hover" ? "Hover" : "All");
416
+ labelsToggleBtn.addEventListener("click", function () {
417
+ labelsMode = labelsMode === "hover" ? "all" : "hover";
418
+ this.textContent = "Labels: " + (labelsMode === "hover" ? "Hover" : "All");
419
+ updateNodeLabels();
420
+ });
421
+ }
422
+ })();
423
+
424
  // Gentle camera orbit (no heavy assets)
425
  let t = 0;
426
  const camDist = 240;
427
  (function animate() {
428
  t += isPressure ? 0.0032 : 0.0016;
429
+ Graph.cameraPosition({ x: camDist * Math.cos(t), y: 90 + 10 * Math.sin(t * 0.7), z: camDist * Math.sin(t) }, { x: 0, y: 10, z: 0 });
 
 
 
430
  requestAnimationFrame(animate);
431
  })();
432
 
 
434
  document.getElementById("toggle").addEventListener("click", () => {
435
  isPressure = !isPressure;
436
  modeEl.textContent = "Mode: " + (isPressure ? "Pressure" : "Baseline");
437
+ Graph.nodeVal((n) => nodeVal(n))
438
+ .nodeColor((n) => nodeColor(n))
 
439
  .linkDirectionalParticles(() => (isPressure ? 4 : 2))
440
  .linkDirectionalParticleSpeed(() => (isPressure ? 0.014 : 0.005));
441
+
442
+ updateNodeLabels();
443
  });
444
 
445
  // Resize handling
 
448
  Graph.height(window.innerHeight);
449
  });
450
 
451
+ // ====== Live data wiring ======
452
  (async function initLiveData() {
453
  // Always render demo immediately, then try to upgrade to live.
454
  renderLegends();
455
  setDataStatus("Demo");
456
 
457
+ if (!assessmentId || !vizToken) return;
 
458
 
459
+ const resolvedApiBase = apiBase || API_BASE_DEFAULT;
460
+ const url = `${resolvedApiBase}/api/assessments/${encodeURIComponent(assessmentId)}/visualization?viz_token=${encodeURIComponent(vizToken)}`;
461
 
462
  try {
463
  const controller = new AbortController();
 
465
 
466
  const res = await fetch(url, {
467
  method: "GET",
468
+ headers: { Accept: "application/json" },
469
+ signal: controller.signal,
470
  });
471
 
472
  clearTimeout(timeout);
 
480
  setDataStatus("Live");
481
 
482
  // Re-bind the graph to ensure it re-renders with patched node fields.
483
+ Graph.graphData({ nodes, links }).nodeVal((n) => nodeVal(n)).nodeColor((n) => nodeColor(n));
 
 
 
484
 
485
+ updateNodeLabels();
486
  renderLegends();
487
  } catch (e) {
488
  // Fail open to demo
489
  setDataStatus("Demo");
 
490
  }
491
  })();
492
 
 
 
 
 
 
 
 
 
 
493
  function validatePayload(p) {
494
  if (!p || typeof p !== "object") return false;
495
  if (p.version !== REQUIRED.version) return false;
496
  if (p.topology_id !== REQUIRED.topology_id) return false;
497
  if (!Array.isArray(p.nodes) || p.nodes.length !== 15) return false;
498
 
499
+ const ids = new Set(p.nodes.map((n) => n && n.id));
500
  for (const reqId of REQUIRED.nodeIds) {
501
  if (!ids.has(reqId)) return false;
502
  }
 
505
 
506
  function applyPayload(p) {
507
  const map = new Map();
508
+ p.nodes.forEach((n) => map.set(n.id, n));
509
 
510
+ nodes.forEach((n) => {
511
  const src = map.get(n.id);
512
  if (!src) return;
513
 
514
+ // Labels: protect nodes dynamic; empty string means hide (no placeholders)
515
+ if (n.group === "protect" && typeof src.label === "string") {
516
+ n.label = src.label.trim(); // may be ""
517
  }
518
 
519
  // Weights: always take from payload; HF must not invent weights.
 
528
 
529
  function renderLegends() {
530
  // How You Move (fixed)
531
+ setLegendList(legendMoveEl, REQUIRED.moveIds.map((id) => findNodeLabel(id)));
532
 
533
+ // What You Protect (dynamic; empty labels skipped)
534
+ setLegendList(legendProtectEl, REQUIRED.protectIds.map((id) => findNodeLabel(id)));
535
 
536
  // Pressure Response (fixed)
537
+ setLegendList(legendPressureEl, REQUIRED.pressureIds.map((id) => findNodeLabel(id)));
538
  }
539
 
540
  function setLegendList(el, labels) {
541
  el.innerHTML = "";
542
+ labels.forEach((txt) => {
543
+ if (!txt || txt.trim() === "") return; // skip empty/whitespace
544
  const li = document.createElement("li");
545
  li.textContent = txt;
546
  el.appendChild(li);
 
548
  }
549
 
550
  function findNodeLabel(id) {
551
+ const n = nodes.find((x) => x.id === id);
552
  return n ? n.label : id;
553
  }
554
 
 
556
  function seedCluster(arr, center) {
557
  arr.forEach((n, i) => {
558
  const jitter = 18;
559
+ n.x = center.x + Math.sin(i * 2.1) * jitter;
560
+ n.y = center.y + Math.cos(i * 1.7) * jitter;
561
+ n.z = center.z + Math.sin(i * 1.3) * jitter;
562
  });
563
  }
564
 
 
572
  }
573
 
574
  function nodeVal(n) {
575
+ const hoverBoost = hoveredNodeId && n.id === hoveredNodeId ? 2.2 : 1.0;
576
+ const base = typeof n.weight_baseline === "number" ? n.weight_baseline : 1.8;
577
+ const press = typeof n.weight_pressure === "number" ? n.weight_pressure : 2.0;
578
 
579
  if (!isPressure) return base;
580
  return press * hoverBoost;
 
586
  if (n.id === hoveredNodeId) return base;
587
  return "rgba(17,24,39,0.35)";
588
  }
 
 
 
 
589
  </script>
590
  </body>
591
+ </html>
592
+ ```