pretzinger/ic-trait-network
0
1```html2<!--3IC MAP (Hugging Face Renderer) — CANON v14 5Purpose:6- Render a per-user 3D “IC Map” network (15 nodes) using data fetched from the app API.7- This is a renderer only. No interpretation logic lives here.8 9Hard 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 14Handshake (query params):15- assessment_id (required)16- viz_token (required) short-lived signed token17- 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 21Fetch:22GET `${api_base}/api/assessments/${assessment_id}/visualization?viz_token=${viz_token}`23 24Expected response:25{26 version: "ic_map_v1",27 topology_id: "ic_map_topology_v1",28 assessment_id: string,29 generated_at: ISO string,30 nodes: [{ id, group, label, weight_baseline, weight_pressure }]31}32 33Node IDs (fixed):34Move: pace, directness, influence, stability_pref, precision_pref35Protect: priority_1..priority_5 (labels dynamic from top values; empty string means hide)36Pressure: pursuit, withdrawal, control, appeasement, escalation37 38Failure behavior:39- If fetch fails or payload invalid, fall back to demo data and show “Data: Demo” in HUD.40-->41 42<!doctype html>43<html lang="en">44 <head>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 */57 --ink: #111827; /* charcoal */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;85 left: 16px;86 z-index: 10;87 font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;88 color: var(--ink);89 background: rgba(246, 244, 239, 0.78);90 backdrop-filter: blur(8px);91 border: 1px solid rgba(17, 24, 39, 0.08);92 border-radius: 12px;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;118 border-radius: 10px;119 border: 1px solid rgba(17, 24, 39, 0.14);120 background: #ffffff;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;130 border-radius: 999px;131 border: 1px solid rgba(17, 24, 39, 0.12);132 background: rgba(255, 255, 255, 0.7);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;160 padding: 8px 10px;161 background: rgba(255, 255, 255, 0.55);162 }163 .legendTitle {164 font-size: 11px;165 font-weight: 650;166 margin: 0 0 6px;167 color: rgba(17, 24, 39, 0.88);168 }169 .legendList {170 margin: 0;171 padding-left: 14px;172 font-size: 11px;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>214 </div>215 </div>216 </div>217 218 <div id="graph"></div>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|all229 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.com233 const API_BASE_DEFAULT = "https://intimacy-compass.com";234 235 const REQUIRED = {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 = [272 { id: "pace", label: "Pace", group: "move" },273 { id: "directness", label: "Directness", group: "move" },274 { id: "influence", label: "Influence", group: "move" },275 { id: "stability_pref", label: "Stability Preference", group: "move" },276 { id: "precision_pref", label: "Precision Preference", group: "move" },277 ];278 279 const protect = [280 { id: "priority_1", label: "Priority 1", group: "protect" },281 { id: "priority_2", label: "Priority 2", group: "protect" },282 { id: "priority_3", label: "Priority 3", group: "protect" },283 { id: "priority_4", label: "Priority 4", group: "protect" },284 { id: "priority_5", label: "Priority 5", group: "protect" },285 ];286 287 const pressure = [288 { id: "pursuit", label: "Pursuit", group: "pressure" },289 { id: "withdrawal", label: "Withdrawal", group: "pressure" },290 { id: "control", label: "Control", group: "pressure" },291 { id: "appeasement", label: "Appeasement", group: "pressure" },292 { id: "escalation", label: "Escalation", group: "pressure" },293 ];294 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 = [310 ...ringLinksByIds(REQUIRED.moveIds),311 ...ringLinksByIds(REQUIRED.protectIds),312 ...ringLinksByIds(REQUIRED.pressureIds),313 314 // Bridges: minimal, purposeful315 { source: "directness", target: "priority_2" },316 { source: "pace", target: "priority_4" },317 { source: "priority_1", target: "control" },318 { source: "priority_3", target: "withdrawal" },319 { source: "influence", target: "pursuit" },320 ];321 322 // ====== Graph ======323 const el = document.getElementById("graph");324 const hoverEl = document.getElementById("hover");325 const modeEl = document.getElementById("mode");326 const dataEl = document.getElementById("data");327 328 const legendMoveEl = document.getElementById("legend-move");329 const legendProtectEl = document.getElementById("legend-protect");330 const legendPressureEl = document.getElementById("legend-pressure");331 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 duplication340 }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 CDN347 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 433 // Toggle "Pressure Lift"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 handling446 window.addEventListener("resize", () => {447 Graph.width(window.innerWidth);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();464 const timeout = setTimeout(() => controller.abort(), 8000);465 466 const res = await fetch(url, {467 method: "GET",468 headers: { Accept: "application/json" },469 signal: controller.signal,470 });471 472 clearTimeout(timeout);473 474 if (!res.ok) throw new Error(`HTTP ${res.status}`);475 476 const payload = await res.json();477 if (!validatePayload(payload)) throw new Error("Invalid payload");478 479 applyPayload(payload);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 demo489 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 }503 return true;504 }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.520 n.weight_baseline = typeof src.weight_baseline === "number" ? src.weight_baseline : n.weight_baseline;521 n.weight_pressure = typeof src.weight_pressure === "number" ? src.weight_pressure : n.weight_pressure;522 });523 }524 525 function setDataStatus(kind) {526 dataEl.textContent = "Data: " + (kind === "Live" ? "Live" : "Demo");527 }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/whitespace544 const li = document.createElement("li");545 li.textContent = txt;546 el.appendChild(li);547 });548 }549 550 function findNodeLabel(id) {551 const n = nodes.find((x) => x.id === id);552 return n ? n.label : id;553 }554 555 // ====== Helpers ======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 565 function ringLinksByIds(ids) {566 const out = [];567 for (let i = 0; i < ids.length; i++) {568 out.push({ source: ids[i], target: ids[(i + 1) % ids.length] });569 out.push({ source: ids[i], target: ids[(i + 2) % ids.length] });570 }571 return out;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;581 }582 583 function nodeColor(n) {584 const base = GROUPS[n.group].color;585 if (!hoveredNodeId) return base;586 if (n.id === hoveredNodeId) return base;587 return "rgba(17,24,39,0.35)";588 }589 </script>590 </body>591</html>592```593 