Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Holographic Estate — Holo Lab</title> | |
| <style> | |
| :root { --cyan:#39e6d4; --cyan-dim:#1c8f86; --ink:#04070b; } | |
| * { box-sizing: border-box; } | |
| html, body { margin:0; height:100%; } | |
| body { | |
| background: | |
| radial-gradient(1200px 700px at 68% 18%, rgba(57,230,212,.10), transparent 60%), | |
| radial-gradient(900px 900px at 20% 90%, rgba(40,120,200,.08), transparent 60%), | |
| linear-gradient(160deg, #04070b 0%, #060c14 55%, #030507 100%); | |
| color:#cfe9e6; | |
| font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; | |
| overflow:hidden; | |
| } | |
| #stage { position:fixed; inset:0; } | |
| canvas { display:block; width:100%; height:100%; } | |
| #gl, #fb { cursor:crosshair; } | |
| /* Site injects a top-left signed-corpus badge + bottom-right widget; keep clear of them. */ | |
| .hud { position:fixed; left:152px; top:18px; z-index:5; pointer-events:none; max-width:min(560px,60vw); } | |
| .title { margin:0; font-size:20px; letter-spacing:.28em; font-weight:600; color:#eafffb; } | |
| .sub { margin:6px 0 12px; font-size:12px; letter-spacing:.14em; color:#7fb7b0; } | |
| .badges { display:flex; gap:8px; flex-wrap:wrap; } | |
| .badge { | |
| font-size:10.5px; letter-spacing:.16em; padding:4px 9px; border-radius:999px; | |
| border:1px solid rgba(57,230,212,.35); color:#bfeee7; background:rgba(57,230,212,.06); | |
| } | |
| .badge.warn { border-color:rgba(240,200,90,.45); color:#f0d98a; background:rgba(240,200,90,.06); } | |
| .badge.live { border-color:rgba(90,240,170,.5); color:#8ff0c4; background:rgba(90,240,170,.07); } | |
| .status { | |
| position:fixed; left:152px; bottom:20px; z-index:5; font-size:11px; letter-spacing:.10em; | |
| color:#6fa6a0; pointer-events:none; | |
| } | |
| .status b { color:#9fe6dd; font-weight:600; } | |
| .legend { | |
| position:fixed; right:22px; bottom:18px; z-index:5; font-size:10.5px; letter-spacing:.06em; | |
| color:#6fa6a0; text-align:right; pointer-events:none; line-height:1.7; | |
| } | |
| .dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-left:8px; vertical-align:middle; } | |
| /* ---- Estate-brain query + exploration console ---- */ | |
| #brain { | |
| position:fixed; right:22px; top:78px; z-index:6; width:min(348px,88vw); | |
| background:rgba(6,12,18,.84); border:1px solid rgba(57,230,212,.28); border-radius:14px; | |
| backdrop-filter:blur(7px); padding:14px 14px 12px; pointer-events:auto; | |
| box-shadow:0 18px 50px rgba(0,0,0,.45); | |
| } | |
| #brain h2 { margin:0 0 2px; font-size:12px; letter-spacing:.20em; color:#eafffb; font-weight:600; } | |
| #brain .hint { margin:0 0 10px; font-size:10px; letter-spacing:.05em; color:#6fa6a0; line-height:1.55; } | |
| #brain .hint b { color:#9fe6dd; } | |
| #brain .row { display:flex; gap:7px; } | |
| #q { | |
| flex:1; min-width:0; background:rgba(3,7,11,.9); border:1px solid rgba(57,230,212,.30); | |
| color:#dffaf5; border-radius:9px; padding:9px 10px; font:inherit; font-size:12px; outline:none; | |
| } | |
| #q:focus { border-color:rgba(57,230,212,.7); } | |
| #ask { | |
| border:1px solid rgba(57,230,212,.45); background:rgba(57,230,212,.12); color:#bff5ec; | |
| border-radius:9px; padding:0 13px; font:inherit; font-size:11px; letter-spacing:.08em; | |
| cursor:pointer; white-space:nowrap; | |
| } | |
| #ask:hover { background:rgba(57,230,212,.22); } | |
| #ask:disabled { opacity:.5; cursor:default; } | |
| #ans { margin-top:11px; font-size:11px; line-height:1.6; max-height:52vh; overflow:auto; } | |
| #ans .meta { color:#6fa6a0; letter-spacing:.04em; font-size:10px; margin-bottom:8px; } | |
| #ans .meta b { color:#9fe6dd; } | |
| #ans .prose { color:#dbeee9; margin:0 0 10px; padding:9px 10px; border-radius:8px; | |
| background:rgba(57,230,212,.05); border:1px solid rgba(57,230,212,.16); } | |
| #ans .prose.na { color:#f0d98a; border-color:rgba(240,200,90,.30); background:rgba(240,200,90,.05); } | |
| #ans .prose b { color:#eafffb; font-size:12px; } | |
| #ans .grp { font-size:9.5px; letter-spacing:.14em; color:#5f9089; margin:9px 0 3px; } | |
| #ans .seed { display:flex; gap:8px; align-items:baseline; padding:5px 2px; border-top:1px solid rgba(57,230,212,.10); | |
| cursor:pointer; border-radius:6px; } | |
| #ans .seed:hover { background:rgba(57,230,212,.08); } | |
| #ans .seed .k { font-size:9px; letter-spacing:.08em; color:#04070b; border-radius:4px; padding:1px 5px; flex:none; } | |
| #ans .seed .t { color:#cfe9e6; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } | |
| #ans .seed .sc { margin-left:auto; color:#5f9089; font-size:10px; flex:none; } | |
| #labels { position:fixed; inset:0; z-index:4; pointer-events:none; } | |
| #labels .lab { | |
| position:absolute; transform:translate(-50%,-140%); font-size:10px; letter-spacing:.03em; | |
| color:#eafffb; text-shadow:0 1px 6px #000; white-space:nowrap; max-width:220px; overflow:hidden; text-overflow:ellipsis; | |
| } | |
| #labels .lab.focus { color:#8ff0c4; font-size:11px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="stage"> | |
| <canvas id="gl"></canvas> | |
| <canvas id="fb" hidden></canvas> | |
| </div> | |
| <div id="labels"></div> | |
| <div class="hud"> | |
| <h1 class="title">HOLOGRAPHIC ESTATE</h1> | |
| <p class="sub">Point-field estate · explorable knowledge brain</p> | |
| <div class="badges"> | |
| <span class="badge warn">SAMPLE — procedural estate model</span> | |
| <span class="badge live" id="brainBadge">BRAIN — connecting…</span> | |
| </div> | |
| </div> | |
| <div id="brain"> | |
| <h2>ASK · EXPLORE THE ESTATE BRAIN</h2> | |
| <p class="hint">Search the live a11oy knowledge graph, or <b>click any glowing node</b> | |
| to expand its real connections and community. Nodes are <b>MODELED</b>; written prose | |
| is shown only when a sovereign model is reachable — never fabricated.</p> | |
| <div class="row"> | |
| <input id="q" type="text" placeholder="search e.g. energy, formulas, orchestration…" autocomplete="off" /> | |
| <button id="ask">ASK</button> | |
| </div> | |
| <div id="ans"></div> | |
| </div> | |
| <div class="legend"> | |
| estate structure<span class="dot" style="background:#eafffb"></span><br/> | |
| roof line<span class="dot" style="background:#39e6d4"></span><br/> | |
| grounds<span class="dot" style="background:#2f7fb0"></span><br/> | |
| knowledge nodes<span class="dot" style="background:#f0c85a"></span><br/> | |
| <span style="opacity:.7">node layout is deterministic, not measured coordinates</span> | |
| </div> | |
| <div class="status">renderer: <b id="rstat">initialising…</b> · <span id="rnote">building point field</span></div> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "/static/3d/vendor/three/three.module.min.js", | |
| "three/addons/": "/static/3d/vendor/three/addons/" | |
| } | |
| } | |
| </script> | |
| <script> | |
| // ============================================================================ | |
| // Holographic Estate — procedural estate point field + LIVE, explorable brain. | |
| // Honest by construction: | |
| // • the house/grounds are a SAMPLE procedural model, not a real capture; | |
| // • constellation nodes + edges come from the live a11oy brain (MODELED); | |
| // • node positions are a deterministic layout, NOT measured coordinates; | |
| // • generated prose is shown ONLY if the brain reports a reachable model, | |
| // otherwise we display the real grounded subgraph and say prose is UNAVAILABLE. | |
| // All brain calls are same-origin, read-only GET — zero ongoing cost. | |
| // ============================================================================ | |
| const BRAIN = '/api/a11oy/v1/brain'; | |
| // ---- Shared procedural estate point field (WebGL + 2D fallback both use it) ---- | |
| function buildEstatePoints() { | |
| const pts = []; | |
| const push = (x,y,z,c) => pts.push({x,y,z,c}); | |
| const CYAN = [0.22,0.90,0.83], WHITE = [0.92,1.0,0.98], BLUE = [0.18,0.50,0.69]; | |
| const jit = (n) => (Math.random()-0.5)*n; | |
| const W=6, H=3, D=4; | |
| for (let i=0;i<2600;i++){ | |
| const face = Math.floor(Math.random()*4); let x,y,z; y = Math.random()*H; | |
| if (face===0){ x=-W/2; z=(Math.random()-0.5)*D; } | |
| else if (face===1){ x=W/2; z=(Math.random()-0.5)*D; } | |
| else if (face===2){ z=-D/2; x=(Math.random()-0.5)*W; } | |
| else { z=D/2; x=(Math.random()-0.5)*W; } | |
| push(x+jit(0.03), y+jit(0.03), z+jit(0.03), WHITE); | |
| } | |
| const RH=1.8; | |
| for (let i=0;i<1700;i++){ | |
| const t = Math.random(); const along = (Math.random()-0.5)*D; const side = Math.random()<0.5?-1:1; | |
| push(side*(W/2)*(1-t)+jit(0.02), H+RH*t+jit(0.02), along+jit(0.03), CYAN); | |
| } | |
| for (let i=0;i<260;i++){ push(jit(0.05), H+RH+jit(0.02), (Math.random()-0.5)*D, CYAN); } | |
| for (const [wx,wy] of [[-1.6,1.3],[1.6,1.3],[0,0.9]]){ | |
| for (let i=0;i<230;i++){ push(wx+jit(0.5), wy+jit(0.6), D/2+0.02, WHITE); } | |
| } | |
| const groundR = 11; | |
| for (let i=0;i<3200;i++){ | |
| const r = Math.pow(Math.random(),0.6)*groundR; const a = Math.random()*Math.PI*2; | |
| const x = Math.cos(a)*r, z = Math.sin(a)*r; | |
| if (Math.abs(x)<W/2+0.3 && Math.abs(z)<D/2+0.3) continue; | |
| push(x, jit(0.04), z, BLUE); | |
| } | |
| for (const [tx,tz] of [[-7,-6],[7,-6.5],[-6.5,6],[7.5,6]]){ | |
| for (let i=0;i<420;i++){ const h=Math.random(); const s=(1-h)*1.1; | |
| push(tx+jit(s), 0.2+h*3.4, tz+jit(s), [0.16,0.6,0.5]); } | |
| } | |
| return pts; | |
| } | |
| const ESTATE = buildEstatePoints(); | |
| const rstat = document.getElementById('rstat'); | |
| const rnote = document.getElementById('rnote'); | |
| const labelsBox = document.getElementById('labels'); | |
| // ---- Knowledge brain state (populated only from live API; never fabricated) ---- | |
| const KIND_COLORS = { | |
| axis:[0.24,0.92,0.84], surface:[0.30,0.62,0.86], estate:[0.95,1.00,0.98], | |
| repo:[0.96,0.78,0.35], person:[0.72,0.55,0.96], formula:[0.98,0.55,0.75], energy:[0.99,0.72,0.30], | |
| }; | |
| const kindColor = (k) => KIND_COLORS[k] || [0.55,0.78,0.74]; | |
| const brainNodes = []; // {id,title,kind,degree,community,pos:{x,y,z}} | |
| const nodeById = new Map(); | |
| const edgeKeys = new Set(); | |
| const edges = []; // [aid, bid] | |
| let highlightSet = new Set(); | |
| let focusId = null; | |
| function hashStr(s){ let h=2166136261>>>0; for(let i=0;i<s.length;i++){ h^=s.charCodeAt(i); h=Math.imul(h,16777619);} return h>>>0; } | |
| function layoutPos(id){ | |
| const h = hashStr(id); | |
| const a = (h % 4096)/4096 * Math.PI*2; | |
| const r = 7.5 + ((h>>>12) % 1000)/1000 * 5.5; | |
| const y = 3.8 + ((h>>>22) % 1000)/1000 * 5.0; | |
| return { x: Math.cos(a)*r, y, z: Math.sin(a)*r }; | |
| } | |
| function addNode(n){ | |
| if (!n || !n.id) return null; | |
| if (nodeById.has(n.id)){ | |
| const ex = nodeById.get(n.id); | |
| if (n.title && ex.title===ex.id) ex.title = n.title; // hydrate placeholder titles | |
| if (n.kind && !ex.kind) ex.kind = n.kind; | |
| if (n.community!=null) ex.community = n.community; | |
| if (n.degree!=null) ex.degree = n.degree; | |
| return ex; | |
| } | |
| const node = { id:n.id, title:n.title||n.id, kind:n.kind||'node', | |
| degree:(n.degree!=null?n.degree:null), community:(n.community!=null?n.community:null), | |
| pos:layoutPos(n.id) }; | |
| nodeById.set(node.id, node); brainNodes.push(node); return node; | |
| } | |
| function addEdge(a,b){ | |
| if (!a||!b||a===b) return; | |
| const key = a<b ? a+'|'+b : b+'|'+a; | |
| if (edgeKeys.has(key)) return; | |
| edgeKeys.add(key); edges.push([a,b]); | |
| } | |
| const viz = { rebuildNodes(){}, rebuildEdges(){}, project:null, pick:null }; | |
| async function jget(url){ | |
| const r = await fetch(url, { headers:{ 'accept':'application/json' } }); | |
| if (!r.ok) throw new Error('HTTP '+r.status); | |
| return r.json(); | |
| } | |
| function esc(s){ return String(s).replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); } | |
| function escAttr(s){ | |
| return String(s) | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| } | |
| function kchip(kind){ const c=kindColor(kind).map(v=>Math.round(v*255)); | |
| return `<span class="k" style="background:rgb(${c[0]},${c[1]},${c[2]})">${esc(kind||'node')}</span>`; } | |
| function seedRow(n){ | |
| const sc = (typeof n.score==='number') ? n.score.toFixed(3) | |
| : (typeof n.salience==='number' ? n.salience.toExponential(1) : ''); | |
| return `<div class="seed" data-id="${escAttr(n.id)}">${kchip(n.kind)}` | |
| + `<span class="t">${esc(n.title||n.id)}</span><span class="sc">${sc}</span></div>`; | |
| } | |
| async function initBrain(){ | |
| const badge = document.getElementById('brainBadge'); | |
| try { | |
| const stats = await jget(`${BRAIN}/stats`); | |
| badge.textContent = `BRAIN LIVE · ${(stats.node_count||0).toLocaleString()} nodes · ${(stats.link_count||0).toLocaleString()} links`; | |
| badge.classList.add('live'); | |
| } catch (e) { badge.textContent='BRAIN OFFLINE'; badge.classList.remove('live'); badge.classList.add('warn'); } | |
| try { | |
| const sal = await jget(`${BRAIN}/salience?top=60`); | |
| for (const r of (sal.ranking||[])) addNode(r); | |
| viz.rebuildNodes(); | |
| } catch (e) { /* estate still renders */ } | |
| } | |
| // ---- Search / ask ---- | |
| async function ask(q){ | |
| const ansEl = document.getElementById('ans'); const btn = document.getElementById('ask'); | |
| q = (q||'').trim(); if (!q) return; | |
| btn.disabled = true; ansEl.innerHTML = '<div class="meta">querying the estate brain…</div>'; | |
| try { | |
| const d = await jget(`${BRAIN}/ask?q=${encodeURIComponent(q)}`); | |
| const seeds = (d.seeds||[]).slice(0, 10); | |
| highlightSet = new Set(); focusId = null; | |
| for (const s of seeds){ const n = addNode(s); if (n) highlightSet.add(n.id); } | |
| const gs = d.grounding_subgraph || {}; | |
| for (const l of (gs.links||[])) addEdge(l.source, l.target); | |
| for (const nd of (gs.nodes||[])) addNode(nd); | |
| viz.rebuildNodes(); viz.rebuildEdges(); | |
| const model = d.answer_model || null; const prose = (d.answer||'').trim(); | |
| let html = `<div class="meta">retrieval · ${esc(d.retrieval || 'graph grounding')}</div>`; | |
| html += (model && prose) | |
| ? `<p class="prose">${esc(prose)}<br><span style="opacity:.6;font-size:9px">MODELED · ${esc(model)}</span></p>` | |
| : `<p class="prose na">PROSE UNAVAILABLE — no sovereign model reachable. Showing the real grounded subgraph below; click any row to expand it in 3D.</p>`; | |
| if (seeds.length){ html += `<div class="grp">GROUNDING NODES — click to expand</div>`; for (const s of seeds) html += seedRow(s); } | |
| else html += `<div class="meta">no grounding nodes returned.</div>`; | |
| ansEl.innerHTML = html; bindRows(); | |
| } catch (e) { | |
| ansEl.innerHTML = `<div class="meta" style="color:#f0d98a">brain unreachable (${esc(e.message)}). The estate model is still live above.</div>`; | |
| } finally { btn.disabled = false; } | |
| } | |
| // ---- Click a node -> expand its real neighbourhood + community ---- | |
| async function focusNode(id){ | |
| const ansEl = document.getElementById('ans'); | |
| const base = nodeById.get(id) || { id, title:id, kind:'node' }; | |
| ansEl.innerHTML = `<div class="meta">expanding <b>${esc(base.title||id)}</b>…</div>`; | |
| let nb={}, com={}; | |
| try { nb = await jget(`${BRAIN}/neighbors?id=${encodeURIComponent(id)}&hops=1`); } catch(e){} | |
| try { com = await jget(`${BRAIN}/community?id=${encodeURIComponent(id)}`); } catch(e){} | |
| const nbNodes = (nb.nodes||[]); const nbLinks = (nb.links||[]); | |
| for (const nd of nbNodes) addNode(nd); | |
| for (const l of nbLinks) addEdge(l.source, l.target); | |
| focusId = id; highlightSet = new Set([id]); | |
| for (const nd of nbNodes) highlightSet.add(nd.id); | |
| viz.rebuildNodes(); viz.rebuildEdges(); | |
| const self = nodeById.get(id) || base; | |
| const neigh = nbNodes.filter(n => n.id!==id); | |
| const deg = (self.degree!=null?self.degree:neigh.length); | |
| const cid = (com.id!=null?com.id:self.community); | |
| const csize = (com.size!=null?com.size:(com.members?com.members.length:null)); | |
| const csumm = com.summary || (csize!=null ? `community ${cid==null?'?':cid}: ${csize} nodes` : null); | |
| let html = `<div class="meta">focused node${cid!=null?` · community <b>${esc(cid)}</b>`:''}</div>`; | |
| html += `<p class="prose"><b>${esc(self.title||id)}</b><br>` | |
| + `${kchip(self.kind)} <span style="opacity:.75">degree ${esc(deg)}</span>` | |
| + (csumm?`<br><span style="opacity:.85;font-size:10px">${esc(csumm)}</span>`:'') | |
| + `</p>`; | |
| if (neigh.length){ html += `<div class="grp">CONNECTED NODES (${neigh.length}) — click to walk the graph</div>`; | |
| for (const n of neigh.slice(0,24)) html += seedRow(n); } | |
| else html += `<div class="meta">no further connections recorded for this node.</div>`; | |
| ansEl.innerHTML = html; bindRows(); | |
| } | |
| function bindRows(){ | |
| document.querySelectorAll('#ans .seed').forEach(el => | |
| el.addEventListener('click', () => focusNode(el.getAttribute('data-id')))); | |
| } | |
| document.getElementById('ask').addEventListener('click', ()=> ask(document.getElementById('q').value)); | |
| document.getElementById('q').addEventListener('keydown', (e)=>{ if (e.key==='Enter') ask(e.target.value); }); | |
| // ---- floating labels for highlighted nodes ---- | |
| const labelDivs = new Map(); | |
| function syncLabels(){ | |
| for (const [id,div] of labelDivs){ if (!highlightSet.has(id)){ div.remove(); labelDivs.delete(id); } } | |
| for (const id of highlightSet){ | |
| const n = nodeById.get(id); if (!n) continue; | |
| let div = labelDivs.get(id); | |
| if (!div){ div = document.createElement('div'); div.className='lab'; labelsBox.appendChild(div); labelDivs.set(id,div); } | |
| div.textContent = n.title.length>34 ? n.title.slice(0,33)+'…' : n.title; | |
| div.classList.toggle('focus', id===focusId); | |
| } | |
| } | |
| function positionLabels(){ | |
| if (!viz.project) return; | |
| for (const [id,div] of labelDivs){ | |
| const n = nodeById.get(id); if (!n) continue; | |
| const p = viz.project(n.pos.x, n.pos.y, n.pos.z); | |
| if (p && p.vis){ div.style.display='block'; div.style.left=p.sx+'px'; div.style.top=p.sy+'px'; } | |
| else div.style.display='none'; | |
| } | |
| } | |
| // ---- 2D never-blank fallback (estate + explorable constellation) ---- | |
| function run2DFallback(reason){ | |
| document.getElementById('gl').hidden = true; | |
| const c = document.getElementById('fb'); c.hidden = false; | |
| const ctx = c.getContext('2d'); | |
| rstat.textContent='2D fallback'; rstat.style.color='#f0d98a'; | |
| rnote.textContent = reason || 'WebGL2 unavailable — projecting same model'; | |
| let W,H,DPR; | |
| function resize(){ DPR=Math.min(window.devicePixelRatio||1,2); W=c.clientWidth=window.innerWidth; H=c.clientHeight=window.innerHeight; | |
| c.width=W*DPR; c.height=H*DPR; ctx.setTransform(DPR,0,0,DPR,0,0); } | |
| window.addEventListener('resize', resize); resize(); | |
| let ang=0; const focal=620, camY=2.6, camDist=18; const cx=()=>W/2, cy=()=>H/2+40; | |
| function proj3(x,y,z){ const ca=Math.cos(ang), sa=Math.sin(ang); | |
| const rx=x*ca - z*sa; const rz=x*sa + z*ca + camDist; const ry=y-camY; | |
| if (rz<=0.1) return null; const s=focal/rz; return { sx:cx()+rx*s, sy:cy()-ry*s, z:rz, s }; } | |
| viz.project = (x,y,z)=>{ const p=proj3(x,y,z); return p?{sx:p.sx,sy:p.sy,vis:true}:{vis:false}; }; | |
| viz.rebuildNodes = ()=>{ syncLabels(); }; viz.rebuildEdges = ()=>{}; | |
| c.addEventListener('click', (ev)=>{ | |
| const rect=c.getBoundingClientRect(); const mx=ev.clientX-rect.left, my=ev.clientY-rect.top; | |
| let best=null, bd=16; | |
| for (const n of brainNodes){ const p=proj3(n.pos.x,n.pos.y,n.pos.z); if(!p) continue; | |
| const d=Math.hypot(p.sx-mx,p.sy-my); if(d<bd){ bd=d; best=n; } } | |
| if (best) focusNode(best.id); | |
| }); | |
| function frame(){ | |
| ang+=0.0030; ctx.clearRect(0,0,W,H); | |
| // real graph edges | |
| if (edges.length){ ctx.strokeStyle='rgba(57,230,212,.16)'; ctx.lineWidth=1; | |
| for (const [a,b] of edges){ const na=nodeById.get(a), nb=nodeById.get(b); if(!na||!nb) continue; | |
| const pa=proj3(na.pos.x,na.pos.y,na.pos.z), pb=proj3(nb.pos.x,nb.pos.y,nb.pos.z); if(!pa||!pb) continue; | |
| ctx.beginPath(); ctx.moveTo(pa.sx,pa.sy); ctx.lineTo(pb.sx,pb.sy); ctx.stroke(); } } | |
| const proj=[]; | |
| for (const p of ESTATE){ const q=proj3(p.x,p.y,p.z); if(q){ q.c=p.c; proj.push(q);} } | |
| for (const n of brainNodes){ const q=proj3(n.pos.x,n.pos.y,n.pos.z); if(q){ q.c=kindColor(n.kind); q.node=n; proj.push(q);} } | |
| proj.sort((a,b)=>b.z-a.z); | |
| for (const q of proj){ | |
| const hot = q.node && highlightSet.has(q.node.id); const foc = q.node && q.node.id===focusId; | |
| const r = q.node ? (foc?Math.max(3.4,q.s*0.13):hot?Math.max(2.6,q.s*0.10):Math.max(1.2,q.s*0.05)) : Math.max(0.35,q.s*0.05); | |
| const a = q.node ? (foc?1:hot?0.9:0.6) : Math.min(0.85,34/q.z); | |
| ctx.beginPath(); ctx.fillStyle=`rgba(${(q.c[0]*255)|0},${(q.c[1]*255)|0},${(q.c[2]*255)|0},${a})`; | |
| ctx.arc(q.sx,q.sy,r,0,Math.PI*2); ctx.fill(); | |
| } | |
| positionLabels(); requestAnimationFrame(frame); | |
| } | |
| frame(); | |
| } | |
| // ---- WebGL2 path (soft gaussian estate + explorable constellation + bloom) ---- | |
| async function runWebGL(){ | |
| const canvas = document.getElementById('gl'); | |
| if (!canvas.getContext('webgl2')) throw new Error('no-webgl2'); | |
| const THREE = await import('three'); | |
| const { OrbitControls } = await import('three/addons/controls/OrbitControls.js'); | |
| const { EffectComposer } = await import('three/addons/postprocessing/EffectComposer.js'); | |
| const { RenderPass } = await import('three/addons/postprocessing/RenderPass.js'); | |
| const { UnrealBloomPass } = await import('three/addons/postprocessing/UnrealBloomPass.js'); | |
| const renderer = new THREE.WebGLRenderer({ canvas, antialias:true, alpha:true }); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio||1, 2)); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| const scene = new THREE.Scene(); | |
| const camera = new THREE.PerspectiveCamera(52, window.innerWidth/window.innerHeight, 0.1, 200); | |
| camera.position.set(13, 8, 15); | |
| const controls = new OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping=true; controls.autoRotate=true; controls.autoRotateSpeed=0.5; | |
| controls.target.set(0,2.4,0); controls.minDistance=8; controls.maxDistance=46; | |
| const n = ESTATE.length; | |
| const positions=new Float32Array(n*3), colors=new Float32Array(n*3); | |
| for (let i=0;i<n;i++){ const p=ESTATE[i]; | |
| positions[i*3]=p.x; positions[i*3+1]=p.y; positions[i*3+2]=p.z; | |
| colors[i*3]=p.c[0]; colors[i*3+1]=p.c[1]; colors[i*3+2]=p.c[2]; } | |
| const geo=new THREE.BufferGeometry(); | |
| geo.setAttribute('position', new THREE.BufferAttribute(positions,3)); | |
| geo.setAttribute('color', new THREE.BufferAttribute(colors,3)); | |
| const estScale=new Float32Array(n).fill(1.0); | |
| geo.setAttribute('aScale', new THREE.BufferAttribute(estScale,1)); | |
| const pointMat = (size, alpha) => new THREE.ShaderMaterial({ | |
| uniforms:{ uSize:{value:size}, uPixelRatio:{value:renderer.getPixelRatio()}, uAlpha:{value:alpha} }, | |
| transparent:true, depthWrite:false, blending:THREE.AdditiveBlending, vertexColors:true, | |
| vertexShader:`uniform float uSize; uniform float uPixelRatio; attribute float aScale; varying vec3 vColor; varying float vScale; | |
| void main(){ vColor=color; vScale=aScale; vec4 mv=modelViewMatrix*vec4(position,1.0); | |
| gl_PointSize=uSize*uPixelRatio*(1.0/-mv.z)*4.0*aScale; gl_Position=projectionMatrix*mv; }`, | |
| fragmentShader:`varying vec3 vColor; varying float vScale; uniform float uAlpha; | |
| void main(){ vec2 d=gl_PointCoord-vec2(0.5); float r=dot(d,d); | |
| float a=exp(-r*7.0)*uAlpha*(0.6+0.4*vScale); if(a<0.01) discard; gl_FragColor=vec4(vColor,a); }` | |
| }); | |
| scene.add(new THREE.Points(geo, pointMat(16.0, 0.30))); | |
| let cGeo=null, cPoints=null, edgeObj=null; | |
| const cMat = pointMat(30.0, 0.95); | |
| function rebuildNodes(){ | |
| if (cPoints){ scene.remove(cPoints); cGeo.dispose(); } | |
| const m=brainNodes.length; if(!m){ cPoints=null; syncLabels(); return; } | |
| const cp=new Float32Array(m*3), cc=new Float32Array(m*3), cs=new Float32Array(m); | |
| for (let i=0;i<m;i++){ const nd=brainNodes[i]; const col=kindColor(nd.kind); | |
| const foc=nd.id===focusId; const hot=highlightSet.has(nd.id); | |
| cp[i*3]=nd.pos.x; cp[i*3+1]=nd.pos.y; cp[i*3+2]=nd.pos.z; | |
| const boost=(foc||hot)?1.7:1.0; | |
| cc[i*3]=Math.min(1,col[0]*boost); cc[i*3+1]=Math.min(1,col[1]*boost); cc[i*3+2]=Math.min(1,col[2]*boost); | |
| cs[i]= foc?3.2 : hot?2.2 : 0.9; } | |
| cGeo=new THREE.BufferGeometry(); | |
| cGeo.setAttribute('position', new THREE.BufferAttribute(cp,3)); | |
| cGeo.setAttribute('color', new THREE.BufferAttribute(cc,3)); | |
| cGeo.setAttribute('aScale', new THREE.BufferAttribute(cs,1)); | |
| cPoints=new THREE.Points(cGeo, cMat); scene.add(cPoints); syncLabels(); | |
| } | |
| function rebuildEdges(){ | |
| if (edgeObj){ scene.remove(edgeObj); edgeObj.geometry.dispose(); edgeObj=null; } | |
| const segs=[]; | |
| for (const [a,b] of edges){ const na=nodeById.get(a), nb=nodeById.get(b); if(!na||!nb) continue; | |
| segs.push(na.pos.x,na.pos.y,na.pos.z, nb.pos.x,nb.pos.y,nb.pos.z); } | |
| if (!segs.length) return; | |
| const lg=new THREE.BufferGeometry(); lg.setAttribute('position', new THREE.BufferAttribute(new Float32Array(segs),3)); | |
| edgeObj=new THREE.LineSegments(lg, new THREE.LineBasicMaterial({ color:0x39e6d4, transparent:true, opacity:0.20 })); | |
| scene.add(edgeObj); | |
| } | |
| viz.rebuildNodes=rebuildNodes; viz.rebuildEdges=rebuildEdges; | |
| viz.project=(x,y,z)=>{ const v=new THREE.Vector3(x,y,z).project(camera); | |
| return { sx:(v.x*0.5+0.5)*window.innerWidth, sy:(-v.y*0.5+0.5)*window.innerHeight, vis:(v.z<1&&v.z>-1) }; }; | |
| // raycast click -> nearest constellation node | |
| const raycaster=new THREE.Raycaster(); raycaster.params.Points.threshold=0.55; | |
| const ndc=new THREE.Vector2(); | |
| let downXY=null; | |
| renderer.domElement.addEventListener('pointerdown', e=>{ downXY=[e.clientX,e.clientY]; }); | |
| renderer.domElement.addEventListener('pointerup', e=>{ | |
| if (!downXY) return; const moved=Math.hypot(e.clientX-downXY[0], e.clientY-downXY[1]); downXY=null; | |
| if (moved>6 || !cPoints) return; // ignore drags (orbit) | |
| const rect=renderer.domElement.getBoundingClientRect(); | |
| ndc.x=((e.clientX-rect.left)/rect.width)*2-1; ndc.y=-((e.clientY-rect.top)/rect.height)*2+1; | |
| raycaster.setFromCamera(ndc, camera); | |
| const hits=raycaster.intersectObject(cPoints); | |
| if (hits.length){ const idx=hits[0].index; const nd=brainNodes[idx]; if (nd) focusNode(nd.id); } | |
| }); | |
| const composer=new EffectComposer(renderer); | |
| composer.addPass(new RenderPass(scene,camera)); | |
| composer.addPass(new UnrealBloomPass(new THREE.Vector2(window.innerWidth,window.innerHeight), 0.7, 0.6, 0.25)); | |
| window.addEventListener('resize', ()=>{ | |
| camera.aspect=window.innerWidth/window.innerHeight; camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth,window.innerHeight); composer.setSize(window.innerWidth,window.innerHeight); | |
| }); | |
| rstat.textContent='WebGL2'; rstat.style.color='#9fe6dd'; | |
| rnote.textContent=`three r${THREE.REVISION} · estate + explorable brain graph`; | |
| rebuildNodes(); rebuildEdges(); | |
| (function loop(){ controls.update(); composer.render(); positionLabels(); requestAnimationFrame(loop); })(); | |
| } | |
| runWebGL().then(initBrain).catch(err => { | |
| run2DFallback(err && err.message==='no-webgl2' | |
| ? 'WebGL2 unavailable — projecting same model' | |
| : 'renderer offline — projecting same model'); | |
| initBrain(); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |