elffuss-code / js /mind.js
KikoCis's picture
Sync with production: full i18n (EN/ES), English metadata, 15-min challenge banner
6b45f01 verified
Raw
History Blame Contribute Delete
32.7 kB
// 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 => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[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 = '<iframe id="mind-sc" title="música" width="100%" height="120" frameborder="no" allow="autoplay; encrypted-media" src="' + url + '"></iframe>' +
'<div class="mind-music-cap">▶ Dekel · Baoba Festival — modo trance</div>';
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 = `<span class="ml-dot" style="background:${prof.color}"></span><b style="color:${prof.color}">${escHtml(prof.name)}</b> <span class="ml-t">${escHtml(text)}</span>`;
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 = `<div class="mp-head">${escHtml(path)}<button id="mp-x">✕</button></div>` +
`<div class="mp-body">${renderMarkdown(md)}</div>` +
`<div class="mp-actions">` +
`<button id="mp-exec" class="mp-exec">▶ Ejecutar esta propuesta</button>` +
(node.userData.path ? `<button id="mp-open" class="mp-open">Abrir en el editor</button>` : '') +
`</div>`;
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 = `<div class="ml-item" data-id="ceo"><span class="ml-sw" style="background:${CEO_COLOR}"></span>CEO</div>` +
profs.map(p => `<div class="ml-item" data-id="${p.id}"><span class="ml-sw" style="background:${p.color}"></span>${escHtml(p.name)}</div>`).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 => `<div class="cfg-prof" data-id="${escHtml(p.id || '')}">
<input type="color" class="cp-color" value="${p.color || '#7c5cff'}">
<input type="text" class="cp-name" value="${escHtml(p.name || '')}" placeholder="Nombre">
<input type="text" class="cp-focus" value="${escHtml(p.focus || '')}" placeholder="En qué se centra">
<button class="cp-del" title="quitar">✕</button></div>`;
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 =
'<div class="cfg-head">⚙ Reprogramar el cerebro<button id="cfg-x">✕</button></div>' +
'<div class="cfg-notif">🔔 Notificaciones del navegador: ' + escHtml(ns.txt) + (ns.can ? ' <button id="cfg-notif-ask" class="cfg-notif-btn">Activar</button>' : '') + '</div>' +
'<label>Misión (qué quieres que haga por su cuenta)</label>' +
'<textarea id="cfg-mission" rows="3">' + escHtml(ceo.getMission()) + '</textarea>' +
'<label>Carpeta-alma (dentro del proyecto)</label>' +
'<input id="cfg-dir" value="' + escHtml(ceo.getSoulDir()) + '">' +
'<label>Perfiles — cada uno piensa en paralelo, con el color de su estrella</label>' +
'<div id="cfg-profiles">' + profs.map(profRowHtml).join('') + '</div>' +
'<button id="cfg-add-prof" class="cfg-add">+ nuevo perfil</button>' +
'<div class="cfg-actions"><button id="cfg-think-now" class="cfg-ghost">Pensar ahora</button>' +
'<button id="cfg-save" class="cfg-save">Reprogramar</button></div>' +
'<div class="cfg-note">La elfa trabajará según esto cuando estés ocioso y volcará todo en esa carpeta.</div>';
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 =
'<canvas id="mind-canvas"></canvas>' +
`<div id="mind-title">${t('mindTitle')}</div>` +
'<div id="mind-legend"></div>' +
'<button id="mind-playstop" title="pausar/reanudar el cerebro"></button>' +
'<button id="mind-config" title="reprogramar el cerebro">⚙ cerebro</button>' +
'<button id="mind-history" title="ver todo lo que ha llegado">≡ historial</button>' +
'<button id="mind-close" title="salir">✕ salir</button>' +
'<div id="mind-anchors"></div><div id="mind-panel"></div><div id="mind-cfg"></div>' +
'<div id="mind-log"><div class="ml-head">Historial completo<button id="ml-x">✕</button></div><div id="mind-log-body"></div></div>';
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); }