CoolFace
Apppublic

pretzinger/ic-trait-network

sourceHugging Faceupdated 8mo agoView on Hugging Face
0likes
index.html593 linesDownload Raw Back to root
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