// Vista «Mente de Elffuss» — el pensamiento subyacente del cerebro CEO en un // mundo 3D de fantasía psicodélica trance, con la CIUDAD real del proyecto // (motor VibeCodeViewer) flotando por debajo. // · cada línea/tool-call que llega nace como una ESTRELLA de texto (color por // PERFIL, tamaño/tipografía según lo que ocurre) que se desvanece sola. // · los perfiles (nombre, foco, color) se editan desde ⚙ — el usuario los crea // a su gusto. // · «≡ historial»: panel con TODO lo que ha llegado, sin recortar. // · cuando lee/escribe un fichero real, un haz baja hasta él en la ciudad. // · overlay PERSISTENTE (música/animación no reinician al salir y volver). import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'; import { OutputPass } from 'three/addons/postprocessing/OutputPass.js'; import * as ceo from './ceo.js'; import * as db from './db.js'; import { renderMarkdown } from './md.js'; import { t } from './i18n.js'; const SOUNDCLOUD = 'https://soundcloud.com/dekel-official/dekel-baoba-festival-2023'; const CEO_COLOR = '#ff4d8d'; let open = false, built = false, raf = null; let overlay, S, widget, onOpenFile = () => {}, onExecute = () => {}; // Ciudad de fondo (vista real del workspace) y semilla de pensamientos previos: // AMBOS opcionales e inyectados por la app anfitriona — Code tiene un motor // VibeCodeViewer sobre su proyecto único; Claw (v1) no lo ofrece y la Mente // funciona igual de bien sin ciudad de fondo. let loadThoughts = async () => []; // () => [{path, md}] let buildCityFn = null; // (scene) => {cityWrap, cityCity, cityModel} | null export function init({ loadThoughts: lt, buildCity: bc } = {}) { if (lt) loadThoughts = lt; if (bc) buildCityFn = bc; } let anchorMap = new Map(); // id de perfil (+ 'ceo') → Vector3 (anclas del mundo) const anchors = []; // { el, pos } — solo etiquetas de propuestas forjadas let nodesGroup, thoughtNodes = []; let starGroup, stars = []; // estrellas de pensamiento efímeras const lineBuf = new Map(); // canal → texto acumulado hasta la línea completa const streamed = new Map(); // canal → ¿llegaron tokens? (evita duplicar el texto final) let cityWrap = null, cityCity = null, cityModel = null; let beams = [], fileActivity = []; export function isOpen() { return open; } export function setOpenFile(fn) { onOpenFile = fn; } export function setExecuteProposal(fn) { onExecute = fn; } const escHtml = s => String(s).replace(/[<>&]/g, c => ({ '<': '<', '>': '>', '&': '&' }[c])); const profileOf = channel => channel === 'ceo' ? { id: 'ceo', name: 'CEO', color: CEO_COLOR } : ceo.getProfiles().find(p => p.id === channel) || { id: channel, name: channel, color: '#7c5cff' }; // ── música con Widget API (persistente: pausa al salir, reanuda al volver) ── function mountMusic(root) { const wrap = document.createElement('div'); wrap.className = 'mind-music'; const url = 'https://w.soundcloud.com/player/?url=' + encodeURIComponent(SOUNDCLOUD) + '&auto_play=true&hide_related=true&show_comments=false&show_user=false&show_reposts=false&visual=false&color=%23ff4d8d'; wrap.innerHTML = '' + '
▶ Dekel · Baoba Festival — modo trance
'; root.appendChild(wrap); const iframe = wrap.querySelector('#mind-sc'); const initWidget = () => { try { widget = window.SC.Widget(iframe); } catch { /* aún no */ } }; if (window.SC && window.SC.Widget) initWidget(); else { const s = document.createElement('script'); s.src = 'https://w.soundcloud.com/player/api.js'; s.onload = initWidget; document.head.appendChild(s); } } // ══ ESTRELLAS de pensamiento: cada línea/tool-call nace, brilla y se apaga ══ function makeTextSprite(text, color, { fontSize = 16, bold = false, glow = 10 } = {}) { const pad = 8; const probe = document.createElement('canvas').getContext('2d'); probe.font = `${bold ? 700 : 500} ${fontSize}px ui-monospace, Menlo, monospace`; const w = Math.ceil(probe.measureText(text).width) + pad * 2; const h = fontSize + pad * 2; const cv = document.createElement('canvas'); cv.width = w; cv.height = h; const g = cv.getContext('2d'); g.font = `${bold ? 700 : 500} ${fontSize}px ui-monospace, Menlo, monospace`; g.textBaseline = 'middle'; g.shadowColor = color; g.shadowBlur = glow; g.fillStyle = color; g.fillText(text, pad, h / 2 + 1); const tex = new THREE.CanvasTexture(cv); const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending }); const sprite = new THREE.Sprite(mat); const scale = 0.09; sprite.scale.set(w * scale, h * scale, 1); return sprite; } // kind: 'line' (charla normal) · 'tool' (tool-call) · 'event' (ciclo/estado) · 'built' (propuesta forjada) const KIND_STYLE = { line: { fontSize: 13, bold: false, glow: 8, life: 6, jitter: 15 }, tool: { fontSize: 17, bold: true, glow: 16, life: 9, jitter: 9 }, event: { fontSize: 20, bold: true, glow: 14, life: 7, jitter: 6 }, built: { fontSize: 26, bold: true, glow: 22, life: 13, jitter: 4 }, }; function spawnStar(channel, text, kind = 'line') { if (!starGroup || !text) return; const prof = profileOf(channel); const st = KIND_STYLE[kind] || KIND_STYLE.line; const anchor = anchorMap.get(prof.id) || new THREE.Vector3(0, 20, 0); const j = st.jitter; const label = kind === 'tool' ? '⟐ ' + text : kind === 'event' ? '★ ' + text : kind === 'built' ? '✦ ' + text : text; const sprite = makeTextSprite(label.slice(0, kind === 'line' ? 52 : 72), prof.color, st); sprite.position.copy(anchor).add(new THREE.Vector3((Math.random() - 0.5) * j, (Math.random() - 0.5) * j * 0.6, (Math.random() - 0.5) * j)); sprite.material.opacity = 0; starGroup.add(sprite); stars.push({ obj: sprite, born: elapsed, life: st.life, seed: Math.random() * 1000, up: kind === 'built' ? 0.5 : 0.22 }); if (stars.length > 160) { const old = stars.shift(); starGroup.remove(old.obj); old.obj.material.map.dispose(); old.obj.material.dispose(); } } // ── historial COMPLETO (todo lo que llega, sin recortar) — PERSISTENTE: antes // vivía solo en el DOM/memoria y una recarga de página lo borraba entero. const LOG_CAP = 500; let logHistory = []; // [{channel, text}] — se guarda en IndexedDB let logSaveTimer = null; function saveLogSoon() { clearTimeout(logSaveTimer); logSaveTimer = setTimeout(() => db.set('kv', 'mindLog', logHistory).catch(() => {}), 400); } function renderLogRow(channel, text) { const body = document.getElementById('mind-log-body'); if (!body) return; const prof = profileOf(channel); const row = document.createElement('div'); row.className = 'ml-row'; row.innerHTML = `${escHtml(prof.name)} ${escHtml(text)}`; body.appendChild(row); while (body.children.length > LOG_CAP) body.removeChild(body.firstChild); body.scrollTop = body.scrollHeight; } function logLine(channel, text) { logHistory.push({ channel, text }); if (logHistory.length > LOG_CAP) logHistory = logHistory.slice(-LOG_CAP); saveLogSoon(); renderLogRow(channel, text); } async function loadPersistedLog() { try { const saved = await db.get('kv', 'mindLog'); if (!Array.isArray(saved) || !saved.length) return; logHistory = saved; for (const { channel, text } of saved) renderLogRow(channel, text); } catch { /* aún no hay historial guardado */ } } function flushLine(channel, force = false) { const buf = (lineBuf.get(channel) || '').trim(); if (!buf && !force) return; if (buf) { spawnStar(channel, buf, 'line'); logLine(channel, buf); } lineBuf.set(channel, ''); } // alimenta estrellas + historial + haces sobre la ciudad (ceo.js → aquí) export function pushThought(channel, ev) { if (!open) return; if (channel === 'sys') { logLine('ceo', ev.text); return; } if (channel === 'ceo') { if (ev.type === 'cycle') { logLine('ceo', '● ' + ev.text); spawnStar('ceo', ev.text, 'event'); } else if (ev.type === 'survey') { logLine('ceo', ev.text); } else if (ev.type === 'reprogram') { logLine('ceo', '⚙ ' + ev.text); spawnStar('ceo', ev.text, 'event'); if (ev.profiles) rebuildWorld(); } else if (ev.type === 'paused') { logLine('ceo', ev.text); spawnStar('ceo', ev.text, 'event'); } else if (ev.type === 'built') { logLine('ceo', '✦ ' + ev.text); spawnStar('ceo', ev.text, 'built'); addThoughtNode(ev); } else logLine('ceo', ev.text || ''); return; } if (ev.type === 'open') { lineBuf.set(channel, ''); streamed.set(channel, false); } else if (ev.type === 'token') { streamed.set(channel, true); lineBuf.set(channel, (lineBuf.get(channel) || '') + ev.text); const buf = lineBuf.get(channel); if (/\n/.test(ev.text) || buf.length > 90) flushLine(channel); } else if (ev.type === 'tool') { flushLine(channel); spawnStar(channel, ev.text, 'tool'); logLine(channel, '⟐ ' + ev.text); if (ev.path) fileBeam(ev.path, /escrib/i.test(ev.text) ? 'write' : 'read'); } else if (ev.type === 'tool_result') { // el RESULTADO real de la tool (lo que de verdad se leyó/escribió/ejecutó), // enlazado justo debajo de su tool-call — no solo el nombre de la acción. const t = '→ ' + (ev.text || '(sin salida)'); spawnStar(channel, t, 'line'); logLine(channel, t); } else if (ev.type === 'done') { flushLine(channel, true); // el proveedor puede devolver el texto final SIN pasar por tokens (sin // streaming) → si no vimos ningún token, esto es lo único que lo muestra. if (ev.text && !streamed.get(channel)) { spawnStar(channel, ev.text, 'line'); logLine(channel, ev.text); } } } // ── nodos de pensamiento clicables (cada .md forjado = un punto brillante) ── function addThoughtNode({ path, md, text }) { if (!nodesGroup) return; const i = thoughtNodes.length; const a = i * 2.399963; // ángulo áureo const r = 30 + i * 5; const mesh = new THREE.Mesh( new THREE.IcosahedronGeometry(3.2, 0), new THREE.MeshBasicMaterial({ color: 0xff4d8d, transparent: true, opacity: 0.95, blending: THREE.AdditiveBlending, depthWrite: false })); mesh.position.set(Math.cos(a) * r, 6 + Math.sin(i * 0.7) * 10, Math.sin(a) * r); mesh.userData = { path, md, text }; nodesGroup.add(mesh); thoughtNodes.push(mesh); const lab = document.createElement('div'); lab.className = 'mind-node-label'; lab.textContent = (path || 'pensamiento').split('/').pop(); // clicable ella misma — antes SOLO el raycast contra la diminuta malla 3D // abría el panel, fácil de fallar; la etiqueta es el blanco visible real. lab.addEventListener('click', () => selectThoughtNode(mesh)); document.getElementById('mind-anchors').appendChild(lab); anchors.push({ el: lab, pos: mesh.position }); mesh.userData.label = lab; } async function loadExistingThoughts() { try { for (const it of await loadThoughts()) addThoughtNode(it); } catch { /* aún no hay */ } } // selección de un nodo de propuesta — desde la malla 3D (raycast) o su etiqueta function selectThoughtNode(mesh) { showThoughtPanel(mesh); focusOn(mesh.position, 26); } function showThoughtPanel(node) { const root = document.getElementById('mind-panel'); const md = node.userData.md || node.userData.text || '(sin contenido)'; const path = node.userData.path || 'pensamiento'; root.innerHTML = `
${escHtml(path)}
` + `
${renderMarkdown(md)}
` + `
` + `` + (node.userData.path ? `` : '') + `
`; root.classList.add('show'); root.querySelector('#mp-x').onclick = () => root.classList.remove('show'); root.querySelector('#mp-exec').onclick = () => { onExecute(md); root.classList.remove('show'); }; const openBtn = root.querySelector('#mp-open'); if (openBtn) openBtn.onclick = () => { onOpenFile(node.userData.path); root.classList.remove('show'); }; } // ══ perfiles: anclas 3D + leyenda (recalculadas al abrir o al reprogramar) ══ function computeAnchors() { const map = new Map(); map.set('ceo', new THREE.Vector3(0, 46, 0)); const profs = ceo.getProfiles(); const n = Math.max(profs.length, 1); profs.forEach((p, i) => { const a = (i / n) * Math.PI * 2; map.set(p.id, new THREE.Vector3(Math.cos(a) * 70, 8 + (i % 2) * 20, Math.sin(a) * 70)); }); return map; } function renderLegend() { const el = document.getElementById('mind-legend'); if (!el) return; const profs = ceo.getProfiles(); el.innerHTML = `
CEO
` + profs.map(p => `
${escHtml(p.name)}
`).join(''); el.querySelectorAll('.ml-item').forEach(row => { row.onclick = () => { const pos = anchorMap.get(row.dataset.id); if (pos) focusOn(pos, 46); }; }); } // vuelo suave de la cámara hacia un punto — así «clic en algo → la cámara se centra» let flyTo = null; function focusOn(pos, distance = 40) { if (!S) return; const dir = new THREE.Vector3().subVectors(S.camera.position, S.controls.target); if (dir.lengthSq() < 0.001) dir.set(0, 0.3, 1); dir.normalize().multiplyScalar(distance); flyTo = { fromPos: S.camera.position.clone(), toPos: pos.clone().add(dir), fromTarget: S.controls.target.clone(), toTarget: pos.clone(), t0: elapsed, dur: 1.1 }; } const easeInOut = k => k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2; function rebuildWorld() { anchorMap = computeAnchors(); renderLegend(); } // ── panel ⚙: reprograma misión, carpeta-alma y PERFILES (editables) ────── function wireConfig(root) { const btn = root.querySelector('#mind-config'); const cfg = root.querySelector('#mind-cfg'); const profRowHtml = p => `
`; const notifState = () => (!('Notification' in window)) ? { txt: 'no soportadas por este navegador', can: false } : Notification.permission === 'granted' ? { txt: '✓ concedidas — avisaré cuando encuentre algo bueno', can: false } : Notification.permission === 'denied' ? { txt: '✕ bloqueadas — actívalas en los ajustes del sitio del navegador', can: false } : { txt: 'aún no pedidas', can: true }; const draw = () => { const profs = ceo.getProfiles(); const ns = notifState(); cfg.innerHTML = '
⚙ Reprogramar el cerebro
' + '
🔔 Notificaciones del navegador: ' + escHtml(ns.txt) + (ns.can ? ' ' : '') + '
' + '' + '' + '' + '' + '' + '
' + profs.map(profRowHtml).join('') + '
' + '' + '
' + '
' + '
La elfa trabajará según esto cuando estés ocioso y volcará todo en esa carpeta.
'; cfg.querySelector('#cfg-x').onclick = () => cfg.classList.remove('show'); cfg.querySelector('#cfg-notif-ask')?.addEventListener('click', () => { Notification.requestPermission().then(draw); }); cfg.querySelector('#cfg-add-prof').onclick = () => { cfg.querySelector('#cfg-profiles').insertAdjacentHTML('beforeend', profRowHtml({ color: '#ff9f45', name: '', focus: '' })); wireRows(); }; cfg.querySelector('#cfg-think-now').onclick = () => { ceo.forceCycle(); cfg.classList.remove('show'); }; cfg.querySelector('#cfg-save').onclick = () => { // preserva el id original de cada perfil (si no, cada guardado los // regeneraría desde el nombre y los ciclos en curso perderían el hilo) const rows = [...cfg.querySelectorAll('.cfg-prof')].map(r => ({ id: r.dataset.id || undefined, color: r.querySelector('.cp-color').value, name: r.querySelector('.cp-name').value.trim(), focus: r.querySelector('.cp-focus').value.trim(), })); ceo.setMission(cfg.querySelector('#cfg-mission').value); ceo.setSoulDir(cfg.querySelector('#cfg-dir').value); ceo.setProfiles(rows); if (!ceo.isEnabled()) ceo.enable(); cfg.classList.remove('show'); }; wireRows(); function wireRows() { cfg.querySelectorAll('.cp-del').forEach(b => { b.onclick = () => b.closest('.cfg-prof').remove(); }); } }; btn.onclick = () => { if (cfg.classList.contains('show')) cfg.classList.remove('show'); else { draw(); cfg.classList.add('show'); } }; } function wireHistory(root) { const btn = root.querySelector('#mind-history'), panel = root.querySelector('#mind-log'); btn.onclick = () => panel.classList.toggle('show'); panel.querySelector('#ml-x').onclick = () => panel.classList.remove('show'); } // play/stop del cerebro, visible dentro de la Mente. La elección la persiste // ceo.js (misma clave que usa el clic en la elfa) → sobrevive a recargar. function wirePlayStop(root) { const btn = root.querySelector('#mind-playstop'); const paint = () => { const on = ceo.isEnabled(); btn.textContent = on ? '⏸ pausar cerebro' : '▶ reanudar cerebro'; btn.classList.toggle('paused', !on); }; btn.onclick = () => { if (ceo.isEnabled()) ceo.disable(); else ceo.enable(); paint(); }; paint(); } // ══ ciudad de fondo (motor de la app anfitriona, por debajo del mundo) ══════ async function buildBackgroundCity(scene) { if (!buildCityFn) return; try { const city = await buildCityFn(scene); if (!city) return; cityWrap = city.cityWrap; cityCity = city.cityCity; cityModel = city.cityModel; } catch { /* sigue sin ciudad de fondo */ } } // haz de luz que baja hasta el fichero real en la ciudad + resalta su celda function fileBeam(path, kind) { if (!cityModel || !cityCity || !S) return; const rel = String(path).replace(/^\.?\//, ''); const f = cityModel.fileByRel.get(rel); if (!f) return; const local = new THREE.Vector3(f.box.cx, f.box.cy, f.box.cz); const world = cityWrap.localToWorld(local.clone()); const top = world.clone(); top.y += 50; const geo = new THREE.BufferGeometry().setFromPoints([top, world]); const mat = new THREE.LineBasicMaterial({ color: kind === 'write' ? 0xff4d8d : 0x49e8ff, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false }); const line = new THREE.Line(geo, mat); S.scene.add(line); beams.push({ obj: line, born: elapsed, life: 1.4 }); fileActivity.push({ id: f.id, born: elapsed, life: 2.2 }); } // ── mundo psicodélico ─────────────────────────────────────────────────────── function buildScene(canvas, W, H) { const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.setSize(W, H, false); renderer.toneMapping = THREE.ACESFilmicToneMapping; const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(60, W / H, 0.1, 2000); camera.position.set(0, 40, 150); const controls = new OrbitControls(camera, canvas); controls.enableDamping = true; controls.dampingFactor = 0.06; controls.autoRotate = false; controls.enablePan = false; controls.minDistance = 60; controls.maxDistance = 420; const N = 14000; const pos = new Float32Array(N * 3), rad = new Float32Array(N); let s = 987654321; const rnd = () => (s = (s * 16807) % 2147483647) / 2147483647; for (let i = 0; i < N; i++) { const arm = Math.floor(rnd() * 4); const r = Math.pow(rnd(), 0.6) * 120 + 6; const a = arm * (Math.PI / 2) + r * 0.03 + (rnd() - 0.5) * 0.5; const y = (rnd() - 0.5) * (14 - r * 0.06); pos[i * 3] = Math.cos(a) * r; pos[i * 3 + 1] = y; pos[i * 3 + 2] = Math.sin(a) * r; rad[i] = r; } const geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); geo.setAttribute('aRad', new THREE.BufferAttribute(rad, 1)); const mat = new THREE.ShaderMaterial({ uniforms: { uTime: { value: 0 }, uBass: { value: 0 }, uMid: { value: 0 } }, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, vertexShader: `uniform float uTime, uBass, uMid; attribute float aRad; varying vec3 vC; vec3 hsv(float h){ vec3 k=vec3(1.,2./3.,1./3.); vec3 p=abs(fract(h+k)*6.-3.); return clamp(p-1.,0.,1.);} float hash(vec3 p){ p=fract(p*0.3183099+0.1); p*=17.0; return fract(p.x*p.y*p.z*(p.x+p.y+p.z)); } float vnoise(vec3 x){ vec3 i=floor(x),f=fract(x); f=f*f*(3.0-2.0*f); return mix(mix(mix(hash(i+vec3(0,0,0)),hash(i+vec3(1,0,0)),f.x),mix(hash(i+vec3(0,1,0)),hash(i+vec3(1,1,0)),f.x),f.y), mix(mix(hash(i+vec3(0,0,1)),hash(i+vec3(1,0,1)),f.x),mix(hash(i+vec3(0,1,1)),hash(i+vec3(1,1,1)),f.x),f.y),f.z); } float fbm(vec3 p){ float a=0.5,s=0.0; for(int i=0;i<4;i++){ s+=a*vnoise(p); p*=2.02; a*=0.5;} return s; } void main(){ vec3 pos=position; float n=fbm(pos*0.03 + vec3(0.0,uTime*0.18,0.0)); pos += normalize(pos+0.001) * (n-0.5) * (5.0 + uMid*4.0 + uBass*7.0); float sw = uTime*0.14 + aRad*0.015 + uBass*0.18; float cs=cos(sw), sn=sin(sw); pos.xz = mat2(cs,-sn,sn,cs) * pos.xz; pos *= (1.0 + uBass*0.05); float h=fract(aRad*0.004+uTime*0.03+n*0.12); vC=hsv(h)*(1.3-aRad*0.004)*(0.95+uMid*0.18+uBass*0.12); vec4 mv=modelViewMatrix*vec4(pos,1.0); gl_PointSize=(2.2+60.0/-mv.z)*(1.0+uBass*0.18); gl_Position=projectionMatrix*mv; }`, fragmentShader: `varying vec3 vC; void main(){ vec2 d=gl_PointCoord-0.5; float m=smoothstep(0.5,0.0,length(d)); gl_FragColor=vec4(vC*2.0,m); }`, }); const galaxy = new THREE.Points(geo, mat); scene.add(galaxy); const core = new THREE.LineSegments(new THREE.WireframeGeometry(new THREE.IcosahedronGeometry(18, 1)), new THREE.LineBasicMaterial({ color: 0xff4d8d, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false })); scene.add(core); const rings = []; for (let i = 0; i < 24; i++) { const ring = new THREE.Mesh(new THREE.TorusGeometry(22, 0.5, 8, 80), new THREE.MeshBasicMaterial({ color: 0x7c5cff, transparent: true, opacity: 0.45, blending: THREE.AdditiveBlending, depthWrite: false })); ring.rotation.x = Math.PI / 2; ring.position.y = -70 + i * 6; ring.userData.i = i; scene.add(ring); rings.push(ring); } nodesGroup = new THREE.Group(); scene.add(nodesGroup); starGroup = new THREE.Group(); scene.add(starGroup); const composer = new EffectComposer(renderer); composer.setSize(W, H); composer.addPass(new RenderPass(scene, camera)); const bloom = new UnrealBloomPass(new THREE.Vector2(W, H), 1.1, 0.5, 0.2); composer.addPass(bloom); composer.addPass(new OutputPass()); return { renderer, scene, camera, controls, composer, bloom, mat, galaxy, core, rings }; } export async function openMind() { if (open) return; open = true; if (built) { overlay.style.display = ''; try { widget?.play(); } catch {} startLoop(); return; } overlay = document.createElement('div'); overlay.id = 'mind-overlay'; overlay.innerHTML = '' + `
${t('mindTitle')}
` + '
' + '' + '' + '' + '' + '
' + '
Historial completo
'; document.body.appendChild(overlay); wireConfig(overlay); wireHistory(overlay); wirePlayStop(overlay); mountMusic(overlay); anchors.length = 0; thoughtNodes = []; stars = []; lineBuf.clear(); rebuildWorld(); const canvas = overlay.querySelector('#mind-canvas'); S = buildScene(canvas, window.innerWidth, window.innerHeight); buildBackgroundCity(S.scene); await loadExistingThoughts(); await loadPersistedLog(); // el historial de «≡ historial» sobrevive a recargas const ray = new THREE.Raycaster(), ptr = new THREE.Vector2(); let downXY = null; canvas.addEventListener('pointerdown', e => { downXY = [e.clientX, e.clientY]; }); canvas.addEventListener('pointerup', e => { if (!downXY) return; const moved = Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]); downXY = null; if (moved > 5 || !thoughtNodes.length) return; ptr.set((e.clientX / window.innerWidth) * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1); ray.setFromCamera(ptr, S.camera); const hit = ray.intersectObjects(thoughtNodes)[0]; if (hit) selectThoughtNode(hit.object); }); const onResize = () => { const w = window.innerWidth, h = window.innerHeight; S.renderer.setSize(w, h, false); S.composer.setSize(w, h); S.bloom.resolution.set(w, h); S.camera.aspect = w / h; S.camera.updateProjectionMatrix(); }; window.addEventListener('resize', onResize); S.onResize = onResize; overlay.querySelector('#mind-close').addEventListener('click', closeMind); S.onKey = e => { if (e.key === 'Escape') closeMind(); }; document.addEventListener('keydown', S.onKey); built = true; startLoop(); } // Etiquetas de propuestas: se proyectan del mundo 3D a la pantalla, así que // con muchas acumuladas acaban solapándose ilegibles según el ángulo de // cámara. Aquí: cap a las más cercanas y reparto anti-solape (empuja hacia // abajo la que choca con otra ya colocada), para que se apilen separadas. const MAX_LABELS = 14, LABEL_W = 90, LABEL_H = 20; function projectAnchors() { const w = window.innerWidth, h = window.innerHeight, v = new THREE.Vector3(); const projected = []; for (const a of anchors) { v.copy(a.pos).project(S.camera); if (v.z > 1) { a.el.style.display = 'none'; continue; } // detrás de la cámara projected.push({ a, x: (v.x * 0.5 + 0.5) * w, y: (-v.y * 0.5 + 0.5) * h, depth: v.z }); } projected.sort((p1, p2) => p1.depth - p2.depth); // las más cercanas a la cámara, prioridad projected.forEach((p, i) => { p.a.el.style.display = i < MAX_LABELS ? '' : 'none'; }); const visible = projected.slice(0, MAX_LABELS).sort((p1, p2) => p1.x - p2.x || p1.y - p2.y); const placed = []; for (const p of visible) { let y = p.y; for (const q of placed) if (Math.abs(q.x - p.x) < LABEL_W && Math.abs(q.y - y) < LABEL_H) y = q.y + LABEL_H; placed.push({ x: p.x, y }); p.a.el.style.left = p.x + 'px'; p.a.el.style.top = y + 'px'; p.a.el.style.transform = 'translate(-50%,-50%)'; } } const wob = (t, seed, freq = 0.1) => Math.sin(t * freq + seed) * 0.6 + Math.sin(t * freq * 0.43 + seed * 2.1) * 0.3 + Math.sin(t * freq * 0.19 + seed * 4.7) * 0.1; let elapsed = 0, lastMs = 0, smBass = 0, smMid = 0; function startLoop() { if (raf) return; const loop = (ms) => { raf = requestAnimationFrame(loop); const dt = lastMs ? (ms - lastMs) / 1000 : 0; elapsed += dt; lastMs = ms; const t = elapsed; const beat = 60 / 138; const bp = (t % beat) / beat; const bassRaw = Math.pow(1 - bp, 3); const midRaw = 0.5 + 0.5 * Math.sin(t * 6.0); smBass += (bassRaw - smBass) * 0.05; smMid += (midRaw - smMid) * 0.06; S.mat.uniforms.uTime.value = t; S.mat.uniforms.uBass.value = smBass; S.mat.uniforms.uMid.value = smMid; S.galaxy.rotation.y = t * 0.05; S.galaxy.rotation.x = wob(t, 1.7, 0.05) * 0.15; S.galaxy.rotation.z = wob(t, 5.2, 0.04) * 0.1; S.galaxy.scale.setScalar(1 + smBass * 0.02); const pulse = 1 + smBass * 0.05 + Math.sin(t * 0.9) * 0.03; S.core.scale.setScalar(pulse); S.core.rotation.y = t * 0.16; S.core.rotation.x = t * 0.09; S.core.position.x = wob(t, 3.1, 0.11) * 6; S.core.position.z = wob(t, 9.4, 0.09) * 6; S.core.material.color.setHSL((t * 0.04) % 1, 0.9, 0.6); for (const r of S.rings) { r.position.y += 0.14 + smBass * 0.12; if (r.position.y > 74) r.position.y -= 144; r.position.x = wob(t, r.userData.i * 1.3 + 1, 0.08) * 3; r.scale.setScalar(1 + smBass * 0.12 + Math.sin(t * 0.8 + r.userData.i) * 0.04); r.material.opacity = 0.2 + smBass * 0.16; r.material.color.setHSL((t * 0.05 + r.userData.i * 0.03) % 1, 0.85, 0.6); } for (let i = 0; i < thoughtNodes.length; i++) { const n = thoughtNodes[i]; n.rotation.y = t + i; n.scale.setScalar(1 + Math.sin(t * 2 + i) * 0.14); } // estrellas: nacen (fade-in), viven, se apagan solas y se reciclan for (let i = stars.length - 1; i >= 0; i--) { const st = stars[i], age = t - st.born; if (age > st.life) { starGroup.remove(st.obj); st.obj.material.map.dispose(); st.obj.material.dispose(); stars.splice(i, 1); continue; } const fadeIn = Math.min(1, age / 0.5), fadeOut = Math.min(1, (st.life - age) / (st.life * 0.35)); st.obj.material.opacity = Math.min(fadeIn, fadeOut); st.obj.position.y += st.up * dt; st.obj.position.x += Math.sin(t * 0.6 + st.seed) * 0.3 * dt; } // haces sobre la ciudad + celdas de fichero resaltadas por actividad for (let i = beams.length - 1; i >= 0; i--) { const b = beams[i], age = t - b.born; if (age > b.life) { S.scene.remove(b.obj); b.obj.geometry.dispose(); b.obj.material.dispose(); beams.splice(i, 1); continue; } b.obj.material.opacity = 0.9 * Math.max(0, 1 - age / b.life); } if (cityCity) for (let i = fileActivity.length - 1; i >= 0; i--) { const a = fileActivity[i], age = t - a.born; if (age > a.life) { cityCity.paintFile(a.id, 0); fileActivity.splice(i, 1); continue; } cityCity.paintFile(a.id, Math.max(0, 1 - age / a.life)); } if (flyTo) { const k = Math.min(1, (t - flyTo.t0) / flyTo.dur), e = easeInOut(k); S.camera.position.lerpVectors(flyTo.fromPos, flyTo.toPos, e); S.controls.target.lerpVectors(flyTo.fromTarget, flyTo.toTarget, e); if (k >= 1) flyTo = null; } S.controls.update(); projectAnchors(); S.composer.render(); }; loop(performance.now()); } export function closeMind() { open = false; if (raf) { cancelAnimationFrame(raf); raf = null; } lastMs = 0; try { widget?.pause(); } catch {} if (overlay) overlay.style.display = 'none'; } // para tests/depuración export function _debug() { return { starCount: stars.length, hasCity: !!cityCity, profileCount: ceo.getProfiles().length, beamCount: beams.length, fileActivityCount: fileActivity.length, cameraPos: S ? [S.camera.position.x, S.camera.position.y, S.camera.position.z] : null, flying: !!flyTo, }; } export function _debugFocusOn(id) { const p = anchorMap.get(id); if (p) focusOn(p, 46); }