Spaces:
Running
Running
| ```html | |
| <!-- | |
| IC MAP (Hugging Face Renderer) — CANON v1 | |
| Purpose: | |
| - Render a per-user 3D “IC Map” network (15 nodes) using data fetched from the app API. | |
| - This is a renderer only. No interpretation logic lives here. | |
| Hard invariants: | |
| - Exactly 15 nodes. IDs are fixed. No extras. No missing. | |
| - NEVER display or mention the source assessment type, raw scores, or score breakdown. Source inputs must be translated server-side into IC node weights. | |
| - Topology is fixed client-side: ringLinks per cluster + fixed bridge links. | |
| Handshake (query params): | |
| - assessment_id (required) | |
| - viz_token (required) short-lived signed token | |
| - api_base (optional; defaults to REQUIRED_INPUT_PROD_API_BASE) | |
| - embed=1 (optional; compact/collapsible legend) | |
| - labels=hover|all (optional; default hover) | |
| Fetch: | |
| GET `${api_base}/api/assessments/${assessment_id}/visualization?viz_token=${viz_token}` | |
| Expected response: | |
| { | |
| version: "ic_map_v1", | |
| topology_id: "ic_map_topology_v1", | |
| assessment_id: string, | |
| generated_at: ISO string, | |
| nodes: [{ id, group, label, weight_baseline, weight_pressure }] | |
| } | |
| Node IDs (fixed): | |
| Move: pace, directness, influence, stability_pref, precision_pref | |
| Protect: priority_1..priority_5 (labels dynamic from top values; empty string means hide) | |
| Pressure: pursuit, withdrawal, control, appeasement, escalation | |
| Failure behavior: | |
| - If fetch fails or payload invalid, fall back to demo data and show “Data: Demo” in HUD. | |
| --> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width,initial-scale=1" /> | |
| <title>IC Trait Network</title> | |
| <!-- 3d-force-graph via CDN (official quick-start) --> | |
| <script src="https://cdn.jsdelivr.net/npm/3d-force-graph"></script> | |
| <!-- three-spritetext for always-on labels --> | |
| <script src="https://cdn.jsdelivr.net/npm/three-spritetext"></script> | |
| <style> | |
| :root { | |
| --bg: #f6f4ef; /* off-white */ | |
| --ink: #111827; /* charcoal */ | |
| --muted: rgba(17, 24, 39, 0.25); | |
| --accent: #0f766e; /* muted teal */ | |
| --accent2: #1f3a5f; /* desaturated blue */ | |
| /* IC Map cluster colors (distinct, readable) */ | |
| --cluster-move: #2563eb; /* blue */ | |
| --cluster-protect: #059669; /* green */ | |
| --cluster-pressure: #dc2626; /* red */ | |
| } | |
| html, | |
| body { | |
| height: 100%; | |
| margin: 0; | |
| background: var(--bg); | |
| } | |
| #wrap { | |
| position: fixed; | |
| inset: 0; | |
| } | |
| #graph { | |
| position: absolute; | |
| inset: 0; | |
| } | |
| #hud { | |
| position: absolute; | |
| top: 16px; | |
| left: 16px; | |
| z-index: 10; | |
| font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; | |
| color: var(--ink); | |
| background: rgba(246, 244, 239, 0.78); | |
| backdrop-filter: blur(8px); | |
| border: 1px solid rgba(17, 24, 39, 0.08); | |
| border-radius: 12px; | |
| padding: 12px 12px; | |
| max-width: 560px; | |
| } | |
| #hud h1 { | |
| margin: 0 0 6px; | |
| font-size: 14px; | |
| letter-spacing: 0.02em; | |
| font-weight: 650; | |
| } | |
| #hud p { | |
| margin: 0 0 10px; | |
| font-size: 12px; | |
| line-height: 1.35; | |
| color: rgba(17, 24, 39, 0.78); | |
| } | |
| #controls { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| align-items: center; | |
| margin-bottom: 10px; | |
| } | |
| button { | |
| font-size: 12px; | |
| padding: 8px 10px; | |
| border-radius: 10px; | |
| border: 1px solid rgba(17, 24, 39, 0.14); | |
| background: #ffffff; | |
| color: var(--ink); | |
| cursor: pointer; | |
| } | |
| button:hover { | |
| border-color: rgba(17, 24, 39, 0.28); | |
| } | |
| .pill { | |
| font-size: 11px; | |
| padding: 6px 8px; | |
| border-radius: 999px; | |
| border: 1px solid rgba(17, 24, 39, 0.12); | |
| background: rgba(255, 255, 255, 0.7); | |
| color: rgba(17, 24, 39, 0.78); | |
| white-space: nowrap; | |
| } | |
| #legend { | |
| display: grid; | |
| grid-template-columns: 1fr; | |
| gap: 8px; | |
| } | |
| #legend.compact { | |
| max-width: 360px; | |
| } | |
| #legend.collapsed .legendBlock { | |
| display: none; | |
| } | |
| #legendToggle { | |
| display: none; | |
| margin-bottom: 8px; | |
| width: 100%; | |
| } | |
| #legend.compact #legendToggle { | |
| display: block; | |
| } | |
| .legendBlock { | |
| border: 1px solid rgba(17, 24, 39, 0.08); | |
| border-radius: 10px; | |
| padding: 8px 10px; | |
| background: rgba(255, 255, 255, 0.55); | |
| } | |
| .legendTitle { | |
| font-size: 11px; | |
| font-weight: 650; | |
| margin: 0 0 6px; | |
| color: rgba(17, 24, 39, 0.88); | |
| } | |
| .legendList { | |
| margin: 0; | |
| padding-left: 14px; | |
| font-size: 11px; | |
| line-height: 1.35; | |
| color: rgba(17, 24, 39, 0.78); | |
| } | |
| .legendList li { | |
| margin: 2px 0; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="wrap"> | |
| <div id="hud"> | |
| <h1>IC Map</h1> | |
| <p> | |
| Three interacting forces with exactly 15 nodes: How You Move · What You Protect · Pressure Response. Toggle “Pressure” to see the network shift. | |
| </p> | |
| <div id="controls"> | |
| <button id="toggle">Toggle Pressure</button> | |
| <span class="pill" id="mode">Mode: Baseline</span> | |
| <span class="pill" id="data">Data: Demo</span> | |
| <span class="pill" id="hover">Hover: none</span> | |
| <button id="labelsToggle">Labels: Hover</button> | |
| </div> | |
| <div id="legend"> | |
| <button id="legendToggle">Show Legend</button> | |
| <div class="legendBlock"> | |
| <div class="legendTitle">How You Move</div> | |
| <ul class="legendList" id="legend-move"></ul> | |
| </div> | |
| <div class="legendBlock"> | |
| <div class="legendTitle">What You Protect</div> | |
| <ul class="legendList" id="legend-protect"></ul> | |
| </div> | |
| <div class="legendBlock"> | |
| <div class="legendTitle">Pressure Response</div> | |
| <ul class="legendList" id="legend-pressure"></ul> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="graph"></div> | |
| </div> | |
| <script> | |
| // ====== Query params (single source of truth) ====== | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const assessmentId = urlParams.get("assessment_id"); | |
| const vizToken = urlParams.get("viz_token"); | |
| const apiBase = urlParams.get("api_base") || "https://intimacy-compass.com"; | |
| const embedMode = urlParams.get("embed") === "1"; | |
| let labelsMode = urlParams.get("labels") || "hover"; // hover|all | |
| // ====== Config ====== | |
| // Default API base when api_base is not provided. | |
| // REQUIRED_INPUT_PROD_API_BASE should be your production base, e.g. https://intimacy-compass.com | |
| const API_BASE_DEFAULT = "https://intimacy-compass.com"; | |
| const REQUIRED = { | |
| version: "ic_map_v1", | |
| topology_id: "ic_map_topology_v1", | |
| nodeIds: [ | |
| "pace", | |
| "directness", | |
| "influence", | |
| "stability_pref", | |
| "precision_pref", | |
| "priority_1", | |
| "priority_2", | |
| "priority_3", | |
| "priority_4", | |
| "priority_5", | |
| "pursuit", | |
| "withdrawal", | |
| "control", | |
| "appeasement", | |
| "escalation", | |
| ], | |
| moveIds: ["pace", "directness", "influence", "stability_pref", "precision_pref"], | |
| protectIds: ["priority_1", "priority_2", "priority_3", "priority_4", "priority_5"], | |
| pressureIds: ["pursuit", "withdrawal", "control", "appeasement", "escalation"], | |
| }; | |
| function getCSS(varName) { | |
| return getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); | |
| } | |
| // ====== Fixed trait nodes (exactly 15) ====== | |
| const GROUPS = { | |
| move: { label: "How You Move", color: getCSS("--cluster-move") }, | |
| protect: { label: "What You Protect", color: getCSS("--cluster-protect") }, | |
| pressure: { label: "Pressure Response", color: getCSS("--cluster-pressure") }, | |
| }; | |
| const move = [ | |
| { id: "pace", label: "Pace", group: "move" }, | |
| { id: "directness", label: "Directness", group: "move" }, | |
| { id: "influence", label: "Influence", group: "move" }, | |
| { id: "stability_pref", label: "Stability Preference", group: "move" }, | |
| { id: "precision_pref", label: "Precision Preference", group: "move" }, | |
| ]; | |
| const protect = [ | |
| { id: "priority_1", label: "Priority 1", group: "protect" }, | |
| { id: "priority_2", label: "Priority 2", group: "protect" }, | |
| { id: "priority_3", label: "Priority 3", group: "protect" }, | |
| { id: "priority_4", label: "Priority 4", group: "protect" }, | |
| { id: "priority_5", label: "Priority 5", group: "protect" }, | |
| ]; | |
| const pressure = [ | |
| { id: "pursuit", label: "Pursuit", group: "pressure" }, | |
| { id: "withdrawal", label: "Withdrawal", group: "pressure" }, | |
| { id: "control", label: "Control", group: "pressure" }, | |
| { id: "appeasement", label: "Appeasement", group: "pressure" }, | |
| { id: "escalation", label: "Escalation", group: "pressure" }, | |
| ]; | |
| const nodes = [...move, ...protect, ...pressure]; | |
| // Demo/default weights so renderer works without API. | |
| nodes.forEach((n) => { | |
| n.weight_baseline = 1.8; | |
| n.weight_pressure = 2.0; | |
| }); | |
| // Seed cluster positions (3 clusters in 3D space) | |
| seedCluster(move, { x: -80, y: 10, z: 40 }); | |
| seedCluster(protect, { x: 70, y: -10, z: -30 }); | |
| seedCluster(pressure, { x: 10, y: 60, z: 70 }); | |
| // Links: dense within cluster + a few bridges between clusters (no new nodes) | |
| const links = [ | |
| ...ringLinksByIds(REQUIRED.moveIds), | |
| ...ringLinksByIds(REQUIRED.protectIds), | |
| ...ringLinksByIds(REQUIRED.pressureIds), | |
| // Bridges: minimal, purposeful | |
| { source: "directness", target: "priority_2" }, | |
| { source: "pace", target: "priority_4" }, | |
| { source: "priority_1", target: "control" }, | |
| { source: "priority_3", target: "withdrawal" }, | |
| { source: "influence", target: "pursuit" }, | |
| ]; | |
| // ====== Graph ====== | |
| const el = document.getElementById("graph"); | |
| const hoverEl = document.getElementById("hover"); | |
| const modeEl = document.getElementById("mode"); | |
| const dataEl = document.getElementById("data"); | |
| const legendMoveEl = document.getElementById("legend-move"); | |
| const legendProtectEl = document.getElementById("legend-protect"); | |
| const legendPressureEl = document.getElementById("legend-pressure"); | |
| let isPressure = false; | |
| let hoveredNodeId = null; | |
| function getNodeTooltip(node) { | |
| if (labelsMode === "hover") { | |
| return node && node.label && node.label.trim() !== "" ? node.label : ""; | |
| } | |
| return ""; // no tooltip in "all" mode to avoid duplication | |
| } | |
| function createNodeSprite(node) { | |
| if (labelsMode !== "all") return null; | |
| if (!node || !node.label || node.label.trim() === "") return null; | |
| const SpriteTextCtor = window.SpriteText; // from three-spritetext CDN | |
| if (!SpriteTextCtor) return null; | |
| const sprite = new SpriteTextCtor(node.label); | |
| sprite.color = GROUPS[node.group].color; | |
| sprite.textHeight = 6; | |
| sprite.backgroundColor = "rgba(246, 244, 239, 0.85)"; | |
| sprite.padding = 2; | |
| sprite.borderRadius = 3; | |
| return sprite; | |
| } | |
| const Graph = ForceGraph3D()(el) | |
| .backgroundColor(getCSS("--bg")) | |
| .showNavInfo(false) | |
| .width(window.innerWidth) | |
| .height(window.innerHeight) | |
| .graphData({ nodes, links }) | |
| .nodeLabel((n) => getNodeTooltip(n)) | |
| .nodeThreeObject((n) => createNodeSprite(n)) | |
| .nodeVal((n) => nodeVal(n)) | |
| .nodeOpacity(0.92) | |
| .nodeColor((n) => nodeColor(n)) | |
| .linkColor(() => getCSS("--muted")) | |
| .linkOpacity(0.35) | |
| .linkCurvature(0.18) | |
| .linkDirectionalParticles(() => (isPressure ? 4 : 2)) | |
| .linkDirectionalParticleWidth(() => (isPressure ? 1.2 : 0.8)) | |
| .linkDirectionalParticleSpeed(() => (isPressure ? 0.014 : 0.005)) | |
| .onNodeHover((n) => { | |
| hoveredNodeId = n ? n.id : null; | |
| hoverEl.textContent = "Hover: " + (n ? (n.label || "none") : "none"); | |
| }); | |
| function updateNodeLabels() { | |
| Graph.nodeLabel((n) => getNodeTooltip(n)).nodeThreeObject((n) => createNodeSprite(n)); | |
| if (typeof Graph.refresh === "function") { | |
| Graph.refresh(); | |
| } else { | |
| Graph.graphData(Graph.graphData()); | |
| } | |
| } | |
| // ====== Embed/controls setup ====== | |
| (function initEmbedAndControls() { | |
| const legendEl = document.getElementById("legend"); | |
| const legendToggleBtn = document.getElementById("legendToggle"); | |
| const labelsToggleBtn = document.getElementById("labelsToggle"); | |
| if (legendEl && embedMode) { | |
| legendEl.classList.add("compact", "collapsed"); | |
| if (legendToggleBtn) { | |
| legendToggleBtn.textContent = "Show Legend"; | |
| legendToggleBtn.addEventListener("click", function () { | |
| const isCollapsedNow = legendEl.classList.contains("collapsed"); | |
| if (isCollapsedNow) { | |
| legendEl.classList.remove("collapsed"); | |
| this.textContent = "Hide Legend"; | |
| } else { | |
| legendEl.classList.add("collapsed"); | |
| this.textContent = "Show Legend"; | |
| } | |
| }); | |
| } | |
| } | |
| if (labelsToggleBtn) { | |
| labelsToggleBtn.textContent = "Labels: " + (labelsMode === "hover" ? "Hover" : "All"); | |
| labelsToggleBtn.addEventListener("click", function () { | |
| labelsMode = labelsMode === "hover" ? "all" : "hover"; | |
| this.textContent = "Labels: " + (labelsMode === "hover" ? "Hover" : "All"); | |
| updateNodeLabels(); | |
| }); | |
| } | |
| })(); | |
| // Gentle camera orbit (no heavy assets) | |
| let t = 0; | |
| const camDist = 240; | |
| (function animate() { | |
| t += isPressure ? 0.0032 : 0.0016; | |
| 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 }); | |
| requestAnimationFrame(animate); | |
| })(); | |
| // Toggle "Pressure Lift" | |
| document.getElementById("toggle").addEventListener("click", () => { | |
| isPressure = !isPressure; | |
| modeEl.textContent = "Mode: " + (isPressure ? "Pressure" : "Baseline"); | |
| Graph.nodeVal((n) => nodeVal(n)) | |
| .nodeColor((n) => nodeColor(n)) | |
| .linkDirectionalParticles(() => (isPressure ? 4 : 2)) | |
| .linkDirectionalParticleSpeed(() => (isPressure ? 0.014 : 0.005)); | |
| updateNodeLabels(); | |
| }); | |
| // Resize handling | |
| window.addEventListener("resize", () => { | |
| Graph.width(window.innerWidth); | |
| Graph.height(window.innerHeight); | |
| }); | |
| // ====== Live data wiring ====== | |
| (async function initLiveData() { | |
| // Always render demo immediately, then try to upgrade to live. | |
| renderLegends(); | |
| setDataStatus("Demo"); | |
| if (!assessmentId || !vizToken) return; | |
| const resolvedApiBase = apiBase || API_BASE_DEFAULT; | |
| const url = `${resolvedApiBase}/api/assessments/${encodeURIComponent(assessmentId)}/visualization?viz_token=${encodeURIComponent(vizToken)}`; | |
| try { | |
| const controller = new AbortController(); | |
| const timeout = setTimeout(() => controller.abort(), 8000); | |
| const res = await fetch(url, { | |
| method: "GET", | |
| headers: { Accept: "application/json" }, | |
| signal: controller.signal, | |
| }); | |
| clearTimeout(timeout); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| const payload = await res.json(); | |
| if (!validatePayload(payload)) throw new Error("Invalid payload"); | |
| applyPayload(payload); | |
| setDataStatus("Live"); | |
| // Re-bind the graph to ensure it re-renders with patched node fields. | |
| Graph.graphData({ nodes, links }).nodeVal((n) => nodeVal(n)).nodeColor((n) => nodeColor(n)); | |
| updateNodeLabels(); | |
| renderLegends(); | |
| } catch (e) { | |
| // Fail open to demo | |
| setDataStatus("Demo"); | |
| } | |
| })(); | |
| function validatePayload(p) { | |
| if (!p || typeof p !== "object") return false; | |
| if (p.version !== REQUIRED.version) return false; | |
| if (p.topology_id !== REQUIRED.topology_id) return false; | |
| if (!Array.isArray(p.nodes) || p.nodes.length !== 15) return false; | |
| const ids = new Set(p.nodes.map((n) => n && n.id)); | |
| for (const reqId of REQUIRED.nodeIds) { | |
| if (!ids.has(reqId)) return false; | |
| } | |
| return true; | |
| } | |
| function applyPayload(p) { | |
| const map = new Map(); | |
| p.nodes.forEach((n) => map.set(n.id, n)); | |
| nodes.forEach((n) => { | |
| const src = map.get(n.id); | |
| if (!src) return; | |
| // Labels: protect nodes dynamic; empty string means hide (no placeholders) | |
| if (n.group === "protect" && typeof src.label === "string") { | |
| n.label = src.label.trim(); // may be "" | |
| } | |
| // Weights: always take from payload; HF must not invent weights. | |
| n.weight_baseline = typeof src.weight_baseline === "number" ? src.weight_baseline : n.weight_baseline; | |
| n.weight_pressure = typeof src.weight_pressure === "number" ? src.weight_pressure : n.weight_pressure; | |
| }); | |
| } | |
| function setDataStatus(kind) { | |
| dataEl.textContent = "Data: " + (kind === "Live" ? "Live" : "Demo"); | |
| } | |
| function renderLegends() { | |
| // How You Move (fixed) | |
| setLegendList(legendMoveEl, REQUIRED.moveIds.map((id) => findNodeLabel(id))); | |
| // What You Protect (dynamic; empty labels skipped) | |
| setLegendList(legendProtectEl, REQUIRED.protectIds.map((id) => findNodeLabel(id))); | |
| // Pressure Response (fixed) | |
| setLegendList(legendPressureEl, REQUIRED.pressureIds.map((id) => findNodeLabel(id))); | |
| } | |
| function setLegendList(el, labels) { | |
| el.innerHTML = ""; | |
| labels.forEach((txt) => { | |
| if (!txt || txt.trim() === "") return; // skip empty/whitespace | |
| const li = document.createElement("li"); | |
| li.textContent = txt; | |
| el.appendChild(li); | |
| }); | |
| } | |
| function findNodeLabel(id) { | |
| const n = nodes.find((x) => x.id === id); | |
| return n ? n.label : id; | |
| } | |
| // ====== Helpers ====== | |
| function seedCluster(arr, center) { | |
| arr.forEach((n, i) => { | |
| const jitter = 18; | |
| n.x = center.x + Math.sin(i * 2.1) * jitter; | |
| n.y = center.y + Math.cos(i * 1.7) * jitter; | |
| n.z = center.z + Math.sin(i * 1.3) * jitter; | |
| }); | |
| } | |
| function ringLinksByIds(ids) { | |
| const out = []; | |
| for (let i = 0; i < ids.length; i++) { | |
| out.push({ source: ids[i], target: ids[(i + 1) % ids.length] }); | |
| out.push({ source: ids[i], target: ids[(i + 2) % ids.length] }); | |
| } | |
| return out; | |
| } | |
| function nodeVal(n) { | |
| const hoverBoost = hoveredNodeId && n.id === hoveredNodeId ? 2.2 : 1.0; | |
| const base = typeof n.weight_baseline === "number" ? n.weight_baseline : 1.8; | |
| const press = typeof n.weight_pressure === "number" ? n.weight_pressure : 2.0; | |
| if (!isPressure) return base; | |
| return press * hoverBoost; | |
| } | |
| function nodeColor(n) { | |
| const base = GROUPS[n.group].color; | |
| if (!hoveredNodeId) return base; | |
| if (n.id === hoveredNodeId) return base; | |
| return "rgba(17,24,39,0.35)"; | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| ``` | |