ic-trait-network / index.html
pretzinger's picture
Update index.html
05c9a67 verified
raw
history blame
20.1 kB
```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.
-->
<!doctype html>
<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>
```