File size: 11,932 Bytes
956241a 9a42152 956241a 703b5b1 9a42152 956241a 703b5b1 956241a 40b1944 956241a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 | """
space/_three.py
--------------
Generates a self-contained HTML+Three.js snippet for the 3D soul space.
Called both at startup (base model points) and on each probe (adds probe point).
The snippet is injected into a gr.HTML component. Camera position persists
across rebuilds via localStorage so the view doesn't reset on each probe.
"""
from __future__ import annotations
import html as _html
import json
import numpy as np
MODEL_COLORS = ["#e6edf3", "#7c3aed", "#06b6d4", "#f59e0b", "#34d399", "#f472b6"]
_EMPTY = """
<div style="width:100%;height:520px;background:#0d1117;border-radius:8px;
display:flex;align-items:center;justify-content:center;
color:#8b949e;font-family:monospace;font-size:13px;">
no embedding data — run export_viz to populate
</div>
"""
def build_canvas_page(viz: dict, coords3d: "np.ndarray | None",
probe_points: list[dict]) -> str:
"""Full standalone HTML page served by the /ofa-canvas FastAPI route."""
if coords3d is None or len(coords3d) == 0 or not viz.get("model_names"):
return (
"<!DOCTYPE html><html><head><meta charset='utf-8'></head>"
f"<body style='margin:0;background:#0d1117'>{_EMPTY}</body></html>"
)
fragment = build_umap_html(viz, coords3d, probe_points)
return (
"<!DOCTYPE html><html><head><meta charset='utf-8'>"
"<style>*{margin:0;padding:0;box-sizing:border-box}"
"html,body{width:100%;height:100%;background:#0d1117}</style>"
"</head><body>" + fragment + "</body></html>"
)
def build_iframe_tag(ts: str = "0") -> str:
"""Iframe pointing to /ofa-canvas with cache-busting timestamp."""
return (
f'<iframe src="/ofa-canvas?v={ts}" '
f'style="width:100%;height:520px;border:none;border-radius:8px;display:block;">'
f'</iframe>'
)
def build_iframe_srcdoc(viz: dict, coords3d: "np.ndarray | None",
probe_points: list[dict]) -> str:
"""Embed the full standalone page in an iframe via srcdoc.
Scripts injected straight into gr.HTML never run: when Gradio sets
innerHTML, the HTML5 spec says <script> tags are parsed but NOT executed.
An iframe's srcdoc spins up a fresh document that DOES execute its scripts,
so the Three.js renderer actually runs. The page string is HTML-escaped
because srcdoc is an attribute value the browser decodes before parsing.
"""
page = build_canvas_page(viz, coords3d, probe_points)
escaped = _html.escape(page, quote=True)
return (
f'<iframe srcdoc="{escaped}" '
f'style="width:100%;height:520px;border:none;border-radius:8px;display:block;">'
f'</iframe>'
)
def build_umap_html(viz: dict, coords3d: "np.ndarray | None",
probe_points: list[dict]) -> str:
"""Return a self-contained HTML+Three.js soul space for the given data."""
if coords3d is None or len(coords3d) == 0 or not viz.get("model_names"):
return _EMPTY
model_names = viz["model_names"]
labels_arr = np.array(viz["labels"])
models_json: list[dict] = []
for i, name in enumerate(model_names):
mask = labels_arr == name
if not mask.any():
continue
pts = coords3d[mask].tolist()
models_json.append({
"name": name,
"color": MODEL_COLORS[i % len(MODEL_COLORS)],
"points": pts,
"isStudent": name == "student",
})
payload = json.dumps({"models": models_json, "probes": probe_points})
return _TEMPLATE.replace("__OFA_DATA__", payload)
# ── Three.js HTML template ─────────────────────────────────────────────────
_TEMPLATE = r"""
<style>
#ofa-root{position:relative;width:100%;height:520px;background:#0d1117;border-radius:8px;overflow:hidden;user-select:none}
#ofa-canvas{display:block;width:100%;height:100%}
#ofa-sub{position:absolute;top:12px;left:16px;font-size:11px;color:#8b949e;font-family:monospace;pointer-events:none}
#ofa-legend{position:absolute;bottom:12px;left:16px;display:flex;flex-wrap:wrap;gap:8px;pointer-events:none}
.ofa-li{display:flex;align-items:center;gap:5px;font-size:11px;color:#8b949e;font-family:monospace}
.ofa-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.ofa-lbl{position:absolute;font-size:11px;font-family:monospace;color:#e6edf3;
background:rgba(13,17,23,0.88);border:1px solid #30363d;border-radius:4px;
padding:2px 8px;pointer-events:none;transform:translate(-50%,-130%);white-space:nowrap}
.ofa-lbl.student{color:#fff;border-color:#7c3aed;background:rgba(124,58,237,0.18)}
</style>
<div id="ofa-root">
<canvas id="ofa-canvas"></canvas>
<div id="ofa-sub">soul space — UMAP 3D · drag to rotate · scroll to zoom</div>
<div id="ofa-legend"></div>
</div>
<script type="module">
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.min.js';
import { OrbitControls } from 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js';
const DATA = __OFA_DATA__;
const root = document.getElementById('ofa-root');
const canvas = document.getElementById('ofa-canvas');
const legend = document.getElementById('ofa-legend');
// ── renderer ──────────────────────────────────────────────────────
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x0d1117, 1);
// ── camera + controls ─────────────────────────────────────────────
const camera = new THREE.PerspectiveCamera(55, 1, 0.001, 500);
camera.position.set(3, 2, 7);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.07;
controls.rotateSpeed = 0.65;
controls.zoomSpeed = 1.2;
controls.enablePan = true;
controls.minDistance = 0.5;
controls.maxDistance = 40;
// persist camera across HTML rebuilds (probe updates)
const CAM_KEY = 'ofa-soul-cam-v1';
try {
const s = JSON.parse(localStorage.getItem(CAM_KEY));
if (s) {
camera.position.set(s.px, s.py, s.pz);
controls.target.set(s.tx, s.ty, s.tz);
controls.update();
}
} catch(_) {}
controls.addEventListener('change', () => {
try {
localStorage.setItem(CAM_KEY, JSON.stringify({
px: camera.position.x, py: camera.position.y, pz: camera.position.z,
tx: controls.target.x, ty: controls.target.y, tz: controls.target.z,
}));
} catch(_) {}
});
// ── scene ─────────────────────────────────────────────────────────
const scene = new THREE.Scene();
// normalise all model points to [-2, 2] so the cloud fits any UMAP scale
const allPts = DATA.models.flatMap(m => m.points);
let normFn = p => p;
if (allPts.length > 0) {
const xs = allPts.map(p => p[0]), ys = allPts.map(p => p[1]), zs = allPts.map(p => p[2]);
const [x0, x1] = [Math.min(...xs), Math.max(...xs)];
const [y0, y1] = [Math.min(...ys), Math.max(...ys)];
const [z0, z1] = [Math.min(...zs), Math.max(...zs)];
const cx = (x0+x1)/2, cy = (y0+y1)/2, cz = (z0+z1)/2;
const span = Math.max(x1-x0, y1-y0, z1-z0, 1e-6);
const k = 4 / span;
normFn = ([x, y, z]) => [(x-cx)*k, (y-cy)*k, (z-cz)*k];
// also normalise probe points that were stored in UMAP space
if (DATA.probes) {
DATA.probes = DATA.probes.map(p => {
const [nx, ny, nz] = normFn([p.x, p.y, p.z]);
return { ...p, x: nx, y: ny, z: nz };
});
}
}
// label DOM elements, updated each frame via project()
const labelEls = [];
// ── model point clouds ────────────────────────────────────────────
DATA.models.forEach((m, idx) => {
const pts = m.points.map(normFn);
if (!pts.length) return;
const pos = new Float32Array(pts.length * 3);
pts.forEach(([x,y,z], i) => { pos[i*3]=x; pos[i*3+1]=y; pos[i*3+2]=z; });
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
const mat = new THREE.PointsMaterial({
color: new THREE.Color(m.color),
size: m.isStudent ? 0.20 : 0.09,
sizeAttenuation: true,
transparent: true,
opacity: m.isStudent ? 1.0 : 0.80,
depthWrite: m.isStudent,
});
scene.add(new THREE.Points(geo, mat));
// centroid for label anchor
const cx = pts.reduce((s,p) => s+p[0], 0) / pts.length;
const cy = pts.reduce((s,p) => s+p[1], 0) / pts.length;
const cz = pts.reduce((s,p) => s+p[2], 0) / pts.length;
const el = document.createElement('div');
el.className = 'ofa-lbl' + (m.isStudent ? ' student' : '');
el.textContent = m.isStudent ? 'student · Qwen2.5-0.5B' : m.name;
root.appendChild(el);
labelEls.push({ el, world: new THREE.Vector3(cx, cy, cz) });
// legend
const li = document.createElement('div'); li.className = 'ofa-li';
const dot = document.createElement('div'); dot.className = 'ofa-dot';
dot.style.background = m.color;
if (m.isStudent) { dot.style.width = dot.style.height = '10px'; }
li.appendChild(dot);
li.appendChild(document.createTextNode(m.isStudent
? 'student — Qwen2.5-0.5B'
: `${m.name} — teacher`));
legend.appendChild(li);
});
// ── live probe points ─────────────────────────────────────────────
if (DATA.probes && DATA.probes.length) {
const n = DATA.probes.length;
const pos = new Float32Array(n * 3);
DATA.probes.forEach((p, i) => { pos[i*3]=p.x; pos[i*3+1]=p.y; pos[i*3+2]=p.z; });
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
// inner bright point
scene.add(new THREE.Points(geo, new THREE.PointsMaterial({
color: 0xffffff, size: 0.26, sizeAttenuation: true,
transparent: true, opacity: 1.0, depthWrite: true,
})));
// outer glow (additive blending)
scene.add(new THREE.Points(geo, new THREE.PointsMaterial({
color: 0xaaaaff, size: 0.55, sizeAttenuation: true,
transparent: true, opacity: 0.35,
blending: THREE.AdditiveBlending, depthWrite: false,
})));
}
// ── resize ────────────────────────────────────────────────────────
function onResize() {
const w = root.clientWidth, h = root.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
onResize();
new ResizeObserver(onResize).observe(root);
// ── render loop ───────────────────────────────────────────────────
let raf;
function tick() {
raf = requestAnimationFrame(tick);
controls.update();
renderer.render(scene, camera);
// reproject HTML labels each frame
const W = root.clientWidth, H = root.clientHeight;
for (const { el, world } of labelEls) {
const p = world.clone().project(camera);
const behind = p.z > 1;
el.style.display = behind ? 'none' : 'block';
if (!behind) {
el.style.left = ((p.x * 0.5 + 0.5) * W) + 'px';
el.style.top = ((p.y * -0.5 + 0.5) * H) + 'px';
}
}
}
tick();
// cleanup on removal
new MutationObserver(() => {
if (!root.isConnected) { cancelAnimationFrame(raf); renderer.dispose(); }
}).observe(document.body, { childList: true, subtree: true });
</script>
"""
|