527 lines
97 KiB
JavaScript
527 lines
97 KiB
JavaScript
'use strict';
|
||
|
||
const app = document.getElementById('app');
|
||
const $ = id => document.getElementById(id);
|
||
const esc = (s='') => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
const clamp = (v,a,b) => Math.max(a, Math.min(b, v));
|
||
const lerp = (a,b,t) => a + (b-a)*t;
|
||
const smooth = t => { t=clamp(t,0,1); return t*t*(3-2*t); };
|
||
|
||
const tokenKey = 'neuralhunt.token';
|
||
const adminTokenKey = 'neuralhunt.adminToken';
|
||
const mobileModeKey = 'neuralhunt.mobileMode';
|
||
const getToken = () => localStorage.getItem(tokenKey) || '';
|
||
function autoMobileMode(){return matchMedia('(max-width: 850px)').matches || matchMedia('(pointer: coarse)').matches}
|
||
function mobileModeEnabled(){const v=localStorage.getItem(mobileModeKey);return v===null?autoMobileMode():v==='1'}
|
||
function applyMobileMode(on,persist=false){document.documentElement.classList.toggle('mobile-mode',!!on);if(persist)localStorage.setItem(mobileModeKey,on?'1':'0');window.dispatchEvent(new CustomEvent('neuralhunt-mobile-mode',{detail:{enabled:!!on}}))}
|
||
function toggleMobileMode(){applyMobileMode(!document.documentElement.classList.contains('mobile-mode'),true)}
|
||
function mobileButtonLabel(){return document.documentElement.classList.contains('mobile-mode')?'MOBILE ON':'MOBILE OFF'}
|
||
const setToken = t => localStorage.setItem(tokenKey, t);
|
||
const clearToken = () => localStorage.removeItem(tokenKey);
|
||
|
||
async function api(path, init={}, admin=false) {
|
||
const token = admin ? localStorage.getItem(adminTokenKey) : getToken();
|
||
const headers = new Headers(init.headers || {});
|
||
if (!(init.body instanceof FormData)) headers.set('Content-Type','application/json');
|
||
if (token) headers.set('Authorization','Bearer '+token);
|
||
const r = await fetch(path,{...init,headers});
|
||
if (!r.ok) {
|
||
let msg = `HTTP ${r.status}`, payload = null;
|
||
try { payload=await r.json(); msg=payload?.error || msg; } catch {}
|
||
const err = new Error(msg); err.status = r.status; err.code = payload?.code || ''; err.data = payload || null; throw err;
|
||
}
|
||
return r.json();
|
||
}
|
||
|
||
async function loadProtectedImage(path,img,admin=false){
|
||
if(!img)return;const token=admin?localStorage.getItem(adminTokenKey):getToken();const headers={};if(token)headers.Authorization='Bearer '+token;
|
||
const r=await fetch(path,{headers});if(!r.ok)throw new Error(`Bild HTTP ${r.status}`);const blob=await r.blob();const old=img.dataset.objectUrl;if(old)URL.revokeObjectURL(old);const u=URL.createObjectURL(blob);img.dataset.objectUrl=u;img.src=u;
|
||
}
|
||
|
||
// Browser-persistent cryptographic identity. The private key never leaves the
|
||
// browser unencrypted. Export/import is password-protected AES-GCM.
|
||
const identityKey='neuralhunt.identity.v1';
|
||
const b64u=b=>{const a=b instanceof Uint8Array?b:new Uint8Array(b);let s='';a.forEach(x=>s+=String.fromCharCode(x));return btoa(s).replaceAll('+','-').replaceAll('/','_').replaceAll('=','')};
|
||
const ub64=s=>{s=s.replaceAll('-','+').replaceAll('_','/');while(s.length%4)s+='=';const x=atob(s);return Uint8Array.from(x,c=>c.charCodeAt(0))};
|
||
async function ensureIdentity(){const raw=localStorage.getItem(identityKey);if(raw)return JSON.parse(raw);const kp=await crypto.subtle.generateKey({name:'ECDSA',namedCurve:'P-256'},true,['sign','verify']);const b={version:1,publicJwk:await crypto.subtle.exportKey('jwk',kp.publicKey),privateJwk:await crypto.subtle.exportKey('jwk',kp.privateKey)};localStorage.setItem(identityKey,JSON.stringify(b));return b}
|
||
async function clientId(pub){const s=`${pub.kty}|${pub.crv}|${pub.x}|${pub.y}`;return b64u(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(s)))}
|
||
async function sign(message){const b=await ensureIdentity();const k=await crypto.subtle.importKey('jwk',b.privateJwk,{name:'ECDSA',namedCurve:'P-256'},false,['sign']);return b64u(await crypto.subtle.sign({name:'ECDSA',hash:'SHA-256'},k,new TextEncoder().encode(message)))}
|
||
async function responseError(r,fallback){try{const b=await r.json();return b?.error?`${fallback}: ${b.error}`:`${fallback} (HTTP ${r.status})`}catch{return `${fallback} (HTTP ${r.status})`}}
|
||
async function loginIdentity(){const b=await ensureIdentity();const cr=await fetch('/api/auth/challenge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({public_jwk:b.publicJwk})});if(!cr.ok)throw new Error(await responseError(cr,'Challenge fehlgeschlagen'));const c=await cr.json();const signature=await sign(`login|${c.challenge}|${c.client_id}`);const r=await fetch('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({public_jwk:b.publicJwk,challenge:c.challenge,signature})});if(!r.ok)throw new Error(await responseError(r,'Login fehlgeschlagen'));return r.json()}
|
||
async function deterministicGuess(taskID,seed,cid,seq,bits){const h=new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(`${taskID}|${seed}|${cid}|${seq}`)));let n=0n;for(const x of h)n=(n<<8n)|BigInt(x);return (n%(1n<<BigInt(bits))).toString()}
|
||
async function exportIdentity(passphrase){const b=await ensureIdentity();const salt=crypto.getRandomValues(new Uint8Array(16));const iv=crypto.getRandomValues(new Uint8Array(12));const base=await crypto.subtle.importKey('raw',new TextEncoder().encode(passphrase),'PBKDF2',false,['deriveKey']);const aes=await crypto.subtle.deriveKey({name:'PBKDF2',salt,iterations:250000,hash:'SHA-256'},base,{name:'AES-GCM',length:256},false,['encrypt']);const ct=await crypto.subtle.encrypt({name:'AES-GCM',iv},aes,new TextEncoder().encode(JSON.stringify(b)));return JSON.stringify({version:1,salt:b64u(salt),iv:b64u(iv),ciphertext:b64u(ct)},null,2)}
|
||
async function importIdentity(raw,passphrase){const x=JSON.parse(raw);const base=await crypto.subtle.importKey('raw',new TextEncoder().encode(passphrase),'PBKDF2',false,['deriveKey']);const aes=await crypto.subtle.deriveKey({name:'PBKDF2',salt:ub64(x.salt),iterations:250000,hash:'SHA-256'},base,{name:'AES-GCM',length:256},false,['decrypt']);const pt=await crypto.subtle.decrypt({name:'AES-GCM',iv:ub64(x.iv)},aes,ub64(x.ciphertext));const b=JSON.parse(new TextDecoder().decode(pt));localStorage.setItem(identityKey,JSON.stringify(b));return b}
|
||
|
||
function hashInt(s){let h=2166136261>>>0;s=String(s||'');for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619)}return h>>>0}
|
||
function pseudo(s,o=0){return ((Math.sin((hashInt(`${s}:${o}`)+1)*0.00000137+o*12.345)*43758.5453123)%1+1)%1}
|
||
function scoreRGB(score){
|
||
const t=clamp(Number(score||0)/100,0,1);
|
||
// low = deep blue, middle = cyan, high = mint/amber, near-perfect = magenta-white
|
||
const stops=[[0,[75,123,255]],[.45,[82,231,255]],[.78,[93,255,189]],[.94,[255,180,82]],[1,[255,111,188]]];
|
||
for(let i=1;i<stops.length;i++) if(t<=stops[i][0]){const [a,ca]=stops[i-1],[b,cb]=stops[i];const q=(t-a)/(b-a);return ca.map((v,j)=>Math.round(lerp(v,cb[j],q)))}
|
||
return stops.at(-1)[1];
|
||
}
|
||
function rgba(rgb,a){return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`}
|
||
|
||
function makeLOD(points,maxNodes,selfId,enabled=true){
|
||
const source=(points||[]).map(p=>({...p,_count:1,_group:false,_bestScore:Number(p.score||0)}));
|
||
if(source.length<=maxNodes) return source;
|
||
const self=source.find(p=>p.client_id===selfId);
|
||
if(!enabled){
|
||
const rest=self?source.filter(p=>p!==self):source;
|
||
rest.sort((a,b)=>(Number(b.score||0)-Number(a.score||0))||(Number(a.rank||1e12)-Number(b.rank||1e12))||a.client_id.localeCompare(b.client_id));
|
||
const keep=rest.slice(0,Math.max(1,maxNodes-(self?1:0)));
|
||
return self?[...keep,self]:keep;
|
||
}
|
||
const others=self?source.filter(p=>p!==self):source;
|
||
const target=Math.max(8,maxNodes-(self?1:0));
|
||
if(others.length<=target) return self?[...others,self]:others;
|
||
let minX=Infinity,maxX=-Infinity,minY=Infinity,maxY=-Infinity,minZ=Infinity,maxZ=-Infinity;
|
||
for(const p of others){minX=Math.min(minX,p.x);maxX=Math.max(maxX,p.x);minY=Math.min(minY,p.y);maxY=Math.max(maxY,p.y);minZ=Math.min(minZ,p.z);maxZ=Math.max(maxZ,p.z)}
|
||
const span=Math.max(1,maxX-minX,maxY-minY,maxZ-minZ);
|
||
let cell=span/Math.max(2,Math.cbrt(target)*.82), grouped=[];
|
||
for(let attempt=0;attempt<12;attempt++){
|
||
const buckets=new Map();
|
||
for(const p of others){
|
||
const ix=Math.floor((p.x-minX)/cell),iy=Math.floor((p.y-minY)/cell),iz=Math.floor((p.z-minZ)/cell),key=`${ix}:${iy}:${iz}`;
|
||
let b=buckets.get(key);
|
||
if(!b){b={client_id:`cluster:${key}`,x:0,y:0,z:0,score:0,rank:Number.MAX_SAFE_INTEGER,guess_count:0,_count:0,_group:true,_bestScore:0,_scoreSum:0};buckets.set(key,b)}
|
||
const weight=1+Math.sqrt(Math.max(0,Number(p.score||0)))/12;
|
||
b.x+=Number(p.x)*weight;b.y+=Number(p.y)*weight;b.z+=Number(p.z)*weight;b._mass=(b._mass||0)+weight;
|
||
b._count++;b._scoreSum+=Number(p.score||0);b._bestScore=Math.max(b._bestScore,Number(p.score||0));b.rank=Math.min(b.rank,Number(p.rank||Number.MAX_SAFE_INTEGER));b.guess_count+=Number(p.guess_count||0);
|
||
}
|
||
grouped=[...buckets.values()].map(b=>({...b,x:b.x/b._mass,y:b.y/b._mass,z:b.z/b._mass,score:b._scoreSum/b._count}));
|
||
if(grouped.length<=target) break;
|
||
cell*=1.24;
|
||
}
|
||
if(grouped.length>target){
|
||
grouped.sort((a,b)=>(b._bestScore-a._bestScore)||(b._count-a._count)||a.client_id.localeCompare(b.client_id));
|
||
grouped=grouped.slice(0,target);
|
||
}
|
||
return self?[...grouped,self]:grouped;
|
||
}
|
||
|
||
class NeuralMap {
|
||
constructor(host, opts={}) {
|
||
this.host=host;
|
||
this.opts={panelOffset:0,admin:false,...opts};
|
||
this.canvas=document.createElement('canvas'); this.canvas.className='brain-canvas'; host.appendChild(this.canvas);
|
||
this.ctx=this.canvas.getContext('2d',{alpha:false});
|
||
this.tooltip=document.createElement('div'); this.tooltip.className='tooltip hidden'; host.appendChild(this.tooltip);
|
||
this.points=[]; this.rawPoints=[]; this.selfId=''; this.maxNodes=2000; this.lodEnabled=true;
|
||
// TARGET FIELD is the default: score controls orbital radius exactly. RAW 3D
|
||
// remains available for inspecting the server-provided x/y/z coordinates.
|
||
this.yaw=.18; this.pitch=-.58; this.zoom=1.02; this.autoRotate=true; this.labels=true; this.edges=true; this.shells=true; this.eco=false;
|
||
this.proximityFocus=true;
|
||
this.drag=false; this.moved=false; this.lastX=0; this.lastY=0; this.mouseX=0; this.mouseY=0; this.hover=null;
|
||
this.width=1; this.height=1; this.dpr=1; this.last=performance.now(); this.lastPaint=0; this.fps=0; this.fpsFrames=0; this.fpsStart=performance.now();
|
||
this.renderCount=0; this.background=document.createElement('canvas'); this.backgroundKey='';
|
||
this.motion=new Map(); this.scoreMotion=new Map(); this.particles=[]; this.projected=[]; this.statsAt=0;
|
||
this.ro=new ResizeObserver(()=>this.resize()); this.ro.observe(host); this.resize(); this.bind();
|
||
this.frame=requestAnimationFrame(t=>this.draw(t));
|
||
}
|
||
bind(){
|
||
this.canvas.addEventListener('pointerdown',e=>{this.drag=true;this.moved=false;this.lastX=e.clientX;this.lastY=e.clientY;this.canvas.setPointerCapture(e.pointerId)});
|
||
this.canvas.addEventListener('pointermove',e=>{const r=this.canvas.getBoundingClientRect();this.mouseX=e.clientX-r.left;this.mouseY=e.clientY-r.top;if(!this.drag){this.pick();return}const dx=e.clientX-this.lastX,dy=e.clientY-this.lastY;if(Math.abs(dx)+Math.abs(dy)>2)this.moved=true;this.yaw+=dx*.006;this.pitch=clamp(this.pitch+dy*.004,-1.02,-.12);this.lastX=e.clientX;this.lastY=e.clientY});
|
||
this.canvas.addEventListener('pointerup',()=>{this.drag=false;this.pick()});
|
||
this.canvas.addEventListener('pointercancel',()=>this.drag=false);
|
||
this.canvas.addEventListener('pointerleave',()=>{if(!this.drag){this.hover=null;this.tooltip.classList.add('hidden')}});
|
||
this.canvas.addEventListener('wheel',e=>{e.preventDefault();this.zoom=clamp(this.zoom*Math.exp(-e.deltaY*.001),.55,2.4)},{passive:false});
|
||
this.canvas.addEventListener('dblclick',()=>this.resetView());
|
||
}
|
||
resize(){const r=this.host.getBoundingClientRect();this.width=Math.max(1,r.width);this.height=Math.max(1,r.height);this.dpr=Math.min(devicePixelRatio||1,this.eco?1.15:2);this.canvas.width=Math.max(1,Math.floor(this.width*this.dpr));this.canvas.height=Math.max(1,Math.floor(this.height*this.dpr));this.canvas.style.width=this.width+'px';this.canvas.style.height=this.height+'px';this.backgroundKey=''}
|
||
setOptions(opts={}){Object.assign(this,opts);if('eco' in opts)this.resize();if('lodEnabled' in opts||'maxNodes' in opts)this.rebuild()}
|
||
resetView(){this.yaw=.18;this.pitch=-.58;this.zoom=1.02}
|
||
fieldRadius(score){
|
||
// Score itself is logarithmic. This perceptual expansion deliberately gives
|
||
// the 90..100 region much more room so 95, 98 and 99+ are visibly distinct.
|
||
const miss=clamp(1-Number(score||0)/100,0,1);
|
||
return .045+.955*Math.pow(miss,.38);
|
||
}
|
||
fieldGeometry(){
|
||
const panel=this.width>1050?this.opts.panelOffset:0;
|
||
const scale=Math.min(this.width*(this.opts.admin?.36:.34),this.height*.42)*this.zoom;
|
||
// TARGET FIELD stays face-on so screen distance is a mathematically exact
|
||
// proximity cue. Pitch changes only depth/perspective intensity.
|
||
const depth=clamp(.07+(-this.pitch-.12)*.10,.07,.16);
|
||
return {cx:this.width/2+panel,cy:this.height/2+8,scale,depth};
|
||
}
|
||
currentMotion(id,now=performance.now()){const m=this.motion.get(id);if(!m)return null;const q=smooth((now-m.start)/m.duration);return {x:lerp(m.fx,m.tx,q),y:lerp(m.fy,m.ty,q),z:lerp(m.fz,m.tz,q)}}
|
||
currentScore(p,now=performance.now()){
|
||
const m=this.scoreMotion.get(p.client_id); if(!m)return Number(p._group?(p._bestScore||p.score):p.score||0);
|
||
const q=clamp((now-m.start)/m.duration,0,1); if(q>=1){this.scoreMotion.delete(p.client_id);return Number(m.to)}
|
||
return lerp(m.from,m.to,smooth(q));
|
||
}
|
||
update(points,selfId,maxNodes){
|
||
const oldRaw=new Map(this.rawPoints.map(p=>[p.client_id,p])),now=performance.now();
|
||
this.rawPoints=Array.isArray(points)?points:[]; this.selfId=selfId||''; this.maxNodes=Math.max(10,Number(maxNodes||this.maxNodes));
|
||
for(const p of this.rawPoints){const prev=oldRaw.get(p.client_id);if(prev&&Number(p.score||0)>Number(prev.score||0)+.0001){this.scoreMotion.set(p.client_id,{from:Number(prev.score||0),to:Number(p.score||0),start:now,duration:900});this.emitSignal(p)}}
|
||
this.rebuild();
|
||
}
|
||
rebuild(){
|
||
const now=performance.now(),next=makeLOD(this.rawPoints,this.maxNodes,this.selfId,this.lodEnabled),oldBy=new Map(this.points.map(p=>[p.client_id,p]));
|
||
for(const p of next){const prior=this.currentMotion(p.client_id,now)||oldBy.get(p.client_id)||p;this.motion.set(p.client_id,{fx:Number(prior.x||0),fy:Number(prior.y||0),fz:Number(prior.z||0),tx:Number(p.x||0),ty:Number(p.y||0),tz:Number(p.z||0),start:now,duration:650})}
|
||
this.points=next;
|
||
}
|
||
emitSignal(p){
|
||
if(this.particles.length>180)this.particles.splice(0,this.particles.length-120);
|
||
const now=performance.now(),col=scoreRGB(p.score);
|
||
for(let i=0;i<3;i++)this.particles.push({id:p.client_id,start:now+i*95,duration:720+i*90,color:col,size:1+clamp(Number(p.score||0)/100,0,1)*.55});
|
||
}
|
||
rawCamera(){const panel=this.width>1050?this.opts.panelOffset:0;return {cy:Math.cos(this.yaw),sy:Math.sin(this.yaw),cp:Math.cos(this.pitch),sp:Math.sin(this.pitch),cx:this.width/2+panel,cyy:this.height/2,scale:Math.min(this.width*(this.opts.admin?.44:.40),this.height*.48)*this.zoom}}
|
||
projectRawXYZ(x,y,z){const cam=this.rawCamera();const x1=x*cam.cy-z*cam.sy,z1=x*cam.sy+z*cam.cy,y1=y*cam.cp-z1*cam.sp,z2=y*cam.sp+z1*cam.cp;const perspective=2.9/(3.3-z2*.042);return {x:cam.cx+x1*cam.scale*perspective/13.5,y:cam.cyy-y1*cam.scale*perspective/13.5,z:z2,p:perspective}}
|
||
projectXYZ(x,y,z){return this.projectRawXYZ(x,y,z)}
|
||
projectFieldPoint(p,now){
|
||
const g=this.fieldGeometry(),score=this.currentScore(p,now),r=this.fieldRadius(score);
|
||
const id=p.client_id||'node',a=pseudo(id,41)*Math.PI*2+this.yaw;
|
||
const side=Math.cos(a)*r,vertical=Math.sin(a)*r;
|
||
// Screen radius is exactly r*scale. The third dimension is encoded only as
|
||
// perspective/brightness, never as a positional offset that could invert
|
||
// who appears closer to the task.
|
||
const z=(Math.sin(a)*.78+(pseudo(id,42)-.5)*.22)*r;
|
||
const x=g.cx+side*g.scale;
|
||
const y=g.cy+vertical*g.scale;
|
||
const perspective=clamp(1+z*g.depth,.90,1.10);
|
||
return {x,y,z,p:perspective,score,fieldR:r,angle:a};
|
||
}
|
||
projectPoint(p,now){
|
||
if(this.proximityFocus)return this.projectFieldPoint(p,now);
|
||
const m=this.currentMotion(p.client_id,now)||p;return this.projectRawXYZ(Number(m.x||0),Number(m.y||0),Number(m.z||0));
|
||
}
|
||
buildBackground(){const key=`${Math.floor(this.width)}:${Math.floor(this.height)}:${this.eco?1:0}`;if(key===this.backgroundKey)return;this.backgroundKey=key;this.background.width=Math.ceil(this.width);this.background.height=Math.ceil(this.height);const c=this.background.getContext('2d',{alpha:false}),g=c.createRadialGradient(this.width*.5,this.height*.48,30,this.width*.5,this.height*.48,Math.max(this.width,this.height)*.78);g.addColorStop(0,'#071827');g.addColorStop(.5,'#020711');g.addColorStop(1,'#010207');c.fillStyle=g;c.fillRect(0,0,this.width,this.height);c.globalAlpha=this.eco?.08:.14;const stars=this.eco?28:90;for(let i=0;i<stars;i++){const x=pseudo('star',i*2)*this.width,y=pseudo('star',i*2+1)*this.height;c.fillStyle=i%11===0?'#52e7ff':'#5d7890';const s=i%13===0?1.4:.7;c.fillRect(x,y,s,s)}c.globalAlpha=1}
|
||
drawBackground(now){this.buildBackground();const c=this.ctx;c.drawImage(this.background,0,0,this.width,this.height);c.save();c.globalAlpha=this.eco?.04:.08;for(let i=0;i<(this.eco?6:16);i++){const x=(pseudo(`moving:${i}`,1)*this.width+now*.002*(i%3+1))%this.width,y=pseudo(`moving:${i}`,2)*this.height;c.fillStyle=i%7===0?'#52e7ff':'#6a8194';c.fillRect(x,y,i%9===0?1.2:.65,i%9===0?1.2:.65)}c.restore()}
|
||
drawRawRing3D(radius,plane,col,alpha,width,dash){const c=this.ctx;c.strokeStyle=rgba(col,alpha);c.lineWidth=width;c.setLineDash(dash||[]);c.beginPath();const steps=this.eco?32:64;for(let i=0;i<=steps;i++){const a=i/steps*Math.PI*2,co=Math.cos(a)*radius,si=Math.sin(a)*radius;let q;if(plane==='xy')q=this.projectRawXYZ(co,si,0);else if(plane==='xz')q=this.projectRawXYZ(co,0,si);else q=this.projectRawXYZ(0,co,si);if(i===0)c.moveTo(q.x,q.y);else c.lineTo(q.x,q.y)}c.stroke();c.setLineDash([])}
|
||
drawTargetField(){
|
||
if(!this.shells||!this.proximityFocus)return;
|
||
const c=this.ctx,g=this.fieldGeometry(),bands=[0,25,50,75,90,95,99];
|
||
c.save();c.globalCompositeOperation='screen';
|
||
// radial guide spokes: orientation only, never data relationships
|
||
c.strokeStyle='rgba(87,135,160,.065)';c.lineWidth=.5;
|
||
for(let i=0;i<12;i++){const a=i/12*Math.PI*2+this.yaw,rx=Math.cos(a)*g.scale,ry=Math.sin(a)*g.scale;c.beginPath();c.moveTo(g.cx,g.cy);c.lineTo(g.cx+rx,g.cy+ry);c.stroke()}
|
||
for(const score of bands){
|
||
const r=this.fieldRadius(score),rx=g.scale*r,col=scoreRGB(score),major=score>=90;
|
||
// Back half is dim/dashed; front half is brighter. This depth cue makes
|
||
// the target plane read as 3D while ring radius stays a precise score cue.
|
||
c.setLineDash(major?[3,6]:[2,9]);c.strokeStyle=rgba(col,major?.14:.06);c.lineWidth=major?.9:.55;c.beginPath();c.arc(g.cx,g.cy,rx,Math.PI,Math.PI*2);c.stroke();
|
||
c.setLineDash([]);c.strokeStyle=rgba(col,major?.38:.14);c.lineWidth=major?1.05:.65;c.beginPath();c.arc(g.cx,g.cy,rx,0,Math.PI);c.stroke();
|
||
if(this.labels&&this.width>650){const lx=g.cx+rx+5,ly=g.cy;c.font=major?'800 9px Inter,system-ui':'700 8px Inter,system-ui';c.fillStyle=rgba(col,major?.82:.47);c.fillText(score===99?'99+':String(score),lx,ly+3)}
|
||
}
|
||
if(this.labels&&this.width>720){c.font='700 8px Inter,system-ui';c.fillStyle='rgba(134,171,190,.58)';c.fillText('AUSSEN = WEIT',g.cx-g.scale-2,g.cy+g.scale+19);c.fillText('INNEN = NAH AM TASK',g.cx+14,g.cy-18)}
|
||
c.restore();
|
||
}
|
||
drawRawShells(){if(!this.shells||this.proximityFocus)return;const bands=[0,25,50,75,90,95,99];const c=this.ctx;c.save();c.globalCompositeOperation='screen';for(const s of bands){const r=.34+13*Math.sqrt(clamp(1-s/100,0,1)),col=scoreRGB(s);this.drawRawRing3D(r,'xy',col,s>=95?.19:.06,s>=95?1:.6,[3,7])}c.restore()}
|
||
drawAura(now){const c=this.ctx,g=this.proximityFocus?this.fieldGeometry():null,core=g?{x:g.cx,y:g.cy}:this.projectRawXYZ(0,0,0),pulse=.5+.5*Math.sin(now*.0032),radius=this.eco?52:74+pulse*9;c.save();c.globalCompositeOperation='screen';const grad=c.createRadialGradient(core.x,core.y,0,core.x,core.y,radius);grad.addColorStop(0,'rgba(82,231,255,.17)');grad.addColorStop(.25,'rgba(82,231,255,.045)');grad.addColorStop(1,'rgba(82,231,255,0)');c.fillStyle=grad;c.beginPath();c.arc(core.x,core.y,radius,0,Math.PI*2);c.fill();c.restore()}
|
||
drawClouds(now){
|
||
if(this.eco||this.points.length<6||!this.proximityFocus)return;
|
||
const c=this.ctx,g=this.fieldGeometry(),groups=[{lo:0,hi:50},{lo:50,hi:75},{lo:75,hi:90},{lo:90,hi:95},{lo:95,hi:101}];
|
||
c.save();c.globalCompositeOperation='screen';
|
||
for(const band of groups){const members=this.points.filter(p=>{const s=Number(p._group?(p._bestScore||p.score):p.score||0);return s>=band.lo&&s<band.hi});if(!members.length)continue;const mid=(band.lo+band.hi)/2,r=this.fieldRadius(mid),col=scoreRGB(mid),alpha=band.lo>=90?.018:.008;c.strokeStyle=rgba(col,alpha*Math.min(4,1+Math.log10(members.length+1)));c.lineWidth=Math.max(8,g.scale*(band.hi-band.lo)/900);c.beginPath();c.arc(g.cx,g.cy,g.scale*r,0,Math.PI*2);c.stroke()}c.restore()
|
||
}
|
||
selectedPaths(projected){
|
||
const singles=projected.filter(x=>!x.p._group).slice().sort((a,b)=>Number((b.q.score??b.p.score)||0)-Number((a.q.score??a.p.score)||0));
|
||
const chosen=singles.filter(x=>Number((x.q.score??x.p.score)||0)>=75).slice(0,this.eco?4:9);
|
||
const self=singles.find(x=>x.p.client_id===this.selfId);if(self&&!chosen.includes(self))chosen.push(self);
|
||
return chosen;
|
||
}
|
||
pathControl(q,core,id){const dx=core.x-q.x,dy=core.y-q.y,len=Math.max(1,Math.hypot(dx,dy)),nx=-dy/len,ny=dx/len,bend=(pseudo(id,73)-.5)*Math.min(46,len*.18);return {x:(q.x+core.x)/2+nx*bend,y:(q.y+core.y)/2+ny*bend}}
|
||
drawEdges(projected){
|
||
// "Signalwege" are intentionally NOT client-to-client graph edges. There
|
||
// is no semantic client relation in Neural Hunt. Only contenders -> task
|
||
// paths are drawn, so every visible line has a real meaning.
|
||
if(!this.edges||!projected.length)return;
|
||
const c=this.ctx,core=this.proximityFocus?(()=>{const g=this.fieldGeometry();return{x:g.cx,y:g.cy}})():this.projectRawXYZ(0,0,0),chosen=this.selectedPaths(projected);
|
||
c.save();c.globalCompositeOperation='screen';
|
||
chosen.forEach((x,i)=>{const score=Number((x.q.score??x.p.score)||0),self=x.p.client_id===this.selfId,col=self?[255,255,255]:scoreRGB(score),ctrl=this.pathControl(x.q,core,x.p.client_id),alpha=self?.36:i<3?.20:.07+.08*clamp((score-75)/25,0,1);c.strokeStyle=rgba(col,alpha);c.lineWidth=self?1.35:i<3?.9:.55;c.setLineDash(score<90?[2,7]:[]);c.beginPath();c.moveTo(x.q.x,x.q.y);c.quadraticCurveTo(ctrl.x,ctrl.y,core.x,core.y);c.stroke()});
|
||
c.setLineDash([]);c.restore();
|
||
}
|
||
drawParticles(now){
|
||
if(!this.particles.length)return;const c=this.ctx,by=new Map(this.projected.map(x=>[x.p.client_id,x])),core=this.proximityFocus?(()=>{const g=this.fieldGeometry();return{x:g.cx,y:g.cy}})():this.projectRawXYZ(0,0,0);
|
||
c.save();c.globalCompositeOperation='lighter';
|
||
for(let i=this.particles.length-1;i>=0;i--){const x=this.particles[i],t=(now-x.start)/x.duration;if(t<0)continue;if(t>=1){this.particles.splice(i,1);continue}const px=by.get(x.id);if(!px)continue;const q=px.q,ctrl=this.pathControl(q,core,x.id),e=smooth(t),u=1-e,bx=u*u*q.x+2*u*e*ctrl.x+e*e*core.x,byy=u*u*q.y+2*u*e*ctrl.y+e*e*core.y,r=(this.eco?2.0:5.0)*x.size*(.8+Math.sin(t*Math.PI)*.35);if(this.eco){c.fillStyle=rgba(x.color,.68);c.beginPath();c.arc(bx,byy,Math.max(1,r),0,Math.PI*2);c.fill()}else{const grad=c.createRadialGradient(bx,byy,0,bx,byy,r);grad.addColorStop(0,'rgba(255,255,255,.98)');grad.addColorStop(.2,rgba(x.color,.85));grad.addColorStop(1,rgba(x.color,0));c.fillStyle=grad;c.beginPath();c.arc(bx,byy,r,0,Math.PI*2);c.fill()}}
|
||
c.restore();
|
||
}
|
||
drawNodes(projected,now){
|
||
const c=this.ctx,sorted=[...projected].filter(x=>!x.p._group).sort((a,b)=>Number((b.q.score??b.p.score)||0)-Number((a.q.score??a.p.score)||0)),topIDs=new Set(sorted.slice(0,10).map(x=>x.p.client_id));
|
||
c.save();c.globalCompositeOperation='lighter';
|
||
for(const {p,q} of projected){const self=p.client_id===this.selfId,group=p._group||p._count>1,score=Number((q.score??(group?(p._bestScore||p.score):p.score))||0),col=self?[255,255,255]:scoreRGB(score),depth=this.proximityFocus?clamp(q.p,.88,1.12):clamp(.74+q.p*.18,.72,1.22),mass=group?Math.min(7,1+Math.log2((p._count||1)+1)*.82):0,near=clamp((score-75)/25,0,1),base=self?5.6:group?3.4+mass:1.15+clamp(score/75,0,1)*.55+near*2.25,breathe=.5+.5*Math.sin(now*.0016+hashInt(p.client_id)*.00001),r=Math.max(.9,base*depth*(.94+breathe*.08)),important=self||score>=90||group||topIDs.has(p.client_id);
|
||
if(important&&!this.eco){const hr=r*(self?5.0:group?3.0:3.1+near*1.2),grad=c.createRadialGradient(q.x,q.y,0,q.x,q.y,hr);grad.addColorStop(0,rgba(col,self?.62:.20+near*.12));grad.addColorStop(.3,rgba(col,self?.15:.05+near*.06));grad.addColorStop(1,rgba(col,0));c.fillStyle=grad;c.beginPath();c.arc(q.x,q.y,hr,0,Math.PI*2);c.fill()}
|
||
c.fillStyle=rgba(col,self?.99:group?.70:.10+.17*clamp(score/75,0,1)+near*.62);c.beginPath();c.arc(q.x,q.y,r,0,Math.PI*2);c.fill();
|
||
if(score>=95&&!group){c.strokeStyle=rgba(col,.35+near*.35);c.lineWidth=.7;c.beginPath();c.arc(q.x,q.y,r*(1.75+near*.55)+breathe,0,Math.PI*2);c.stroke()}
|
||
if(group){c.strokeStyle=rgba(col,.40);c.lineWidth=.75;c.beginPath();c.arc(q.x,q.y,r*1.34+breathe,0,Math.PI*2);c.stroke();if(!this.eco&&p._count>=5&&r>4.2){c.save();c.globalCompositeOperation='source-over';c.font='700 8px Inter,system-ui';c.textAlign='center';c.textBaseline='middle';c.fillStyle='rgba(236,249,255,.88)';c.fillText(p._count>999?`${Math.round(p._count/100)/10}k`:String(p._count),q.x,q.y+.4);c.restore()}}
|
||
if(self){c.strokeStyle='rgba(255,255,255,.96)';c.lineWidth=1.15;c.beginPath();c.arc(q.x,q.y,r*2.2+breathe*1.7,0,Math.PI*2);c.stroke()}
|
||
}
|
||
c.restore();
|
||
if(this.labels){c.save();c.globalCompositeOperation='source-over';c.font='10px Inter,system-ui';c.textBaseline='middle';let n=0,max=this.eco?8:24,occupied=[];for(const {p,q} of [...projected].sort((a,b)=>Number((b.q.score??b.p.score)||0)-Number((a.q.score??a.p.score)||0))){if(n>=max)break;const self=p.client_id===this.selfId,group=p._group||p._count>1,score=Number((q.score??p.score)||0),important=self||topIDs.has(p.client_id)||score>=95||this.hover?.p?.client_id===p.client_id;if(!important)continue;const text=self?`DU · ${score.toFixed(2)}`:group?`${p._count} Clients · best ${Number(p._bestScore||0).toFixed(1)}`:`#${p.rank||'—'} · ${score.toFixed(2)}`,w=c.measureText(text).width+14,x=clamp(q.x+10,4,this.width-w-4),y=clamp(q.y-9,4,this.height-22);if(!self&&occupied.some(b=>Math.abs(b.x-x)<(b.w+w)*.48&&Math.abs(b.y-y)<17))continue;occupied.push({x,y,w});c.fillStyle='rgba(2,7,14,.88)';c.fillRect(x,y,w,18);c.strokeStyle=rgba(self?[255,255,255]:scoreRGB(score),self?.55:.16);c.lineWidth=.5;c.strokeRect(x,y,w,18);c.fillStyle=rgba(self?[255,255,255]:scoreRGB(score),.98);c.fillText(text,x+7,y+9);n++}c.restore()}
|
||
}
|
||
drawCore(now){const c=this.ctx,g=this.proximityFocus?this.fieldGeometry():null,q=g?{x:g.cx,y:g.cy}:this.projectRawXYZ(0,0,0),pulse=.5+.5*Math.sin(now*.004);c.save();c.globalCompositeOperation='lighter';const radius=this.eco?30:48+pulse*7;if(!this.eco){const grad=c.createRadialGradient(q.x,q.y,0,q.x,q.y,radius);grad.addColorStop(0,'rgba(255,255,255,.99)');grad.addColorStop(.10,'rgba(99,243,255,.92)');grad.addColorStop(.44,'rgba(82,231,255,.13)');grad.addColorStop(1,'rgba(82,231,255,0)');c.fillStyle=grad;c.beginPath();c.arc(q.x,q.y,radius,0,Math.PI*2);c.fill()}c.fillStyle='#fff';c.beginPath();c.arc(q.x,q.y,5.7+pulse*.8,0,Math.PI*2);c.fill();c.strokeStyle=`rgba(82,231,255,${.54+pulse*.28})`;c.lineWidth=1;c.beginPath();c.arc(q.x,q.y,13+pulse*3,0,Math.PI*2);c.stroke();c.restore();if(this.labels&&this.width>610){c.save();c.font='800 10px Inter,system-ui';c.fillStyle='rgba(224,251,255,.92)';c.fillText('TASK · 100',q.x+20,q.y+3);c.restore()}}
|
||
pick(){if(!this.projected.length)return;let best=null,bestD=20;for(const x of this.projected){const d=Math.hypot(x.q.x-this.mouseX,x.q.y-this.mouseY);if(d<bestD){best=x;bestD=d}}this.hover=best;if(!best){this.tooltip.classList.add('hidden');return}const p=best.p,group=p._group||p._count>1,score=Number((best.q.score??p.score)||0),zone=score>=99?'99+ · unmittelbar am Task':score>=95?'95–99 · sehr nah':score>=90?'90–95 · nah':score>=75?'75–90 · gutes Feld':score>=50?'50–75 · mittlere Distanz':'<50 · weit';this.tooltip.innerHTML=group?`<strong>LOD-Gruppe · ${p._count} Clients</strong><small>Ø Score ${Number(p.score||0).toFixed(2)} · Best ${Number(p._bestScore||0).toFixed(2)}<br>${Number(p.guess_count||0).toLocaleString('de-DE')} Tipps</small>`:`<strong>${p.client_id===this.selfId?'DU':esc(String(p.client_id).slice(0,16))}</strong><small>Score ${score.toFixed(2)} · Rank #${p.rank||'—'}<br>${zone}<br>${Number(p.guess_count||0).toLocaleString('de-DE')} Tipps</small>`;this.tooltip.classList.remove('hidden');this.tooltip.style.left=clamp(this.mouseX+14,8,this.width-270)+'px';this.tooltip.style.top=clamp(this.mouseY+14,8,this.height-100)+'px'}
|
||
draw(now){const minFrame=this.eco?33:0;if(minFrame&&this.lastPaint&&now-this.lastPaint<minFrame){this.frame=requestAnimationFrame(t=>this.draw(t));return}this.lastPaint=now;const dt=Math.min(.075,(now-this.last)/1000);this.last=now;if(this.autoRotate&&!this.drag)this.yaw+=dt*.035;this.fpsFrames++;if(now-this.fpsStart>800){this.fps=Math.round(this.fpsFrames*1000/(now-this.fpsStart));this.fpsFrames=0;this.fpsStart=now}
|
||
const c=this.ctx;c.setTransform(this.dpr,0,0,this.dpr,0,0);this.drawBackground(now);this.drawAura(now);this.drawTargetField();this.drawRawShells();this.drawClouds(now);
|
||
const projected=[];for(const p of this.points)projected.push({p,q:this.projectPoint(p,now)});projected.sort((a,b)=>a.q.z-b.q.z);this.projected=projected;this.renderCount=projected.length;this.drawEdges(projected);this.drawParticles(now);this.drawNodes(projected,now);this.drawCore(now);if(this.hover)this.pick();
|
||
if(this.opts.onStats&&now>this.statsAt){this.statsAt=now+500;this.opts.onStats({fps:this.fps,render:this.renderCount,raw:this.rawPoints.length,groups:this.points.filter(p=>p._group).length})}
|
||
this.frame=requestAnimationFrame(t=>this.draw(t));
|
||
}
|
||
destroy(){cancelAnimationFrame(this.frame);this.ro.disconnect();this.canvas.remove();this.tooltip.remove()}
|
||
}
|
||
|
||
function markHTML(){return '<span class="mark"><i></i><i></i><i></i></span>'}
|
||
function setActive(id,on){const el=$(id);if(el)el.classList.toggle('active',!!on)}
|
||
function shortID(s,n=10){return String(s||'').slice(0,n)}
|
||
function fmtDate(v){try{return new Date(v).toLocaleString('de-DE')}catch{return '—'}}
|
||
function fmtScore(v){return Number(v||0).toFixed(2)}
|
||
function actionLabel(a){return ({set_range_bits:'Zahlenraum ändern',set_intervals:'Intervalle setzen',clear_intervals:'Intervalle erben',pause:'Task pausieren',resume:'Task fortsetzen',reroll:'Ziel neu würfeln',close:'Task beenden',regenerate_artifact:'NFT-Bild neu erzeugen'})[a]||a}
|
||
|
||
function proximityRows(points,selfId,limit=7){
|
||
const rows=(Array.isArray(points)?points:[]).filter(p=>!p._group).slice().sort((a,b)=>Number(b.score||0)-Number(a.score||0)).slice(0,limit);
|
||
const self=(points||[]).find(p=>p.client_id===selfId);if(self&&!rows.some(x=>x.client_id===selfId))rows.push(self);
|
||
return rows;
|
||
}
|
||
|
||
function userShell(){
|
||
app.className='hunt';
|
||
app.innerHTML=`
|
||
<div class="stage"><div id="map" class="neural-map"></div><div class="vignette"></div></div>
|
||
<header class="topbar glass">
|
||
<div class="brand">${markHTML()}<div><strong>NEURAL HUNT</strong><small>Social Probability Experiment · signed clients</small></div></div>
|
||
<div class="mode-status living" id="visualMode"><i></i><div><b id="modeTitle">LIVING</b><small id="modeDetail">Task auswählen</small></div></div>
|
||
<div class="mobile-quick"><span>RANK <b id="rankMobile">#—</b></span><span>SCORE <b id="scoreMobile">0.00</b></span><span id="guessMobile">—</span></div>
|
||
<div class="metrics"><span><b id="nodecount">0</b> Clients</span><span><b id="rendercount">0</b> Render</span><span><b id="fpscount">0</b> FPS</span><span>Task <b id="taskid">—</b></span><span class="state" id="systemState"><i></i><span id="status">initialisiert</span></span></div>
|
||
</header>
|
||
<aside class="signal-panel glass" id="signalPanel">
|
||
<div class="panel-title"><span>DEIN SIGNAL</span><span class="chip" id="guessCountdown">—</span></div>
|
||
<div class="rank-hero"><small>RANK</small><strong id="rank">#—</strong></div>
|
||
<div class="signal-metrics"><div><span>Score</span><b id="score">0.00</b></div><div><span>Wins</span><b id="wins">0</b></div><div><span>Clients</span><b id="clientmetric">0</b></div></div>
|
||
<div class="proximity-mini"><div class="leaderboard-head"><span>TARGET RADAR</span><small>100 = Task</small></div><div id="proximityRows"></div></div>
|
||
<div class="identity-block"><span class="eyebrow">IDENTITÄT</span><code id="cid">…</code><div class="identity-actions"><button id="exportid">Export</button><label class="button">Import<input id="importid" hidden type="file" accept="application/json"></label></div><div class="chips" id="unlocks"></div></div>
|
||
<div class="leaderboard-head"><span>LEADERBOARD</span><a href="/leaderboard">ECHTZEIT →</a></div><div class="leaderboard" id="leaders"></div>
|
||
</aside>
|
||
<div class="distance-legend glass"><b>TARGET FIELD</b><span>0 · WEIT</span><i></i><span>75</span><span>90</span><span>95</span><span>99+</span><span>100 · TASK</span></div>
|
||
<div class="node-limit glass"><span>MAX NODES</span><input id="maxnodes" type="range" min="100" max="25000" step="100" value="2000"><b id="maxnodesvalue">2000</b></div>
|
||
<nav class="dock glass" aria-label="3D-Steuerung">
|
||
<button id="chooseTask">TASKS</button><button id="toggleMobile">MOBILE</button><button id="toggleDetails">DETAILS</button><button id="toggleProximity" class="active">TARGET FIELD</button><button id="toggleRotate" class="active">ORBIT</button><button id="toggleLabels" class="active">LABELS</button><button id="toggleEdges" class="active">SIGNALWEGE</button><button id="toggleShells" class="active">SCORE-RINGE</button><button id="toggleLOD" class="active">LOD</button><button id="toggleEco">ECO</button><button id="resetView">ZENTRIEREN</button><a class="dock-link" href="/leaderboard">RANKING</a><a class="dock-link" href="/admin">ADMIN</a>
|
||
</nav>
|
||
<section id="taskLanding" class="task-landing visible">
|
||
<div class="task-landing-bg"></div>
|
||
<div class="task-landing-inner">
|
||
<div class="task-landing-head">
|
||
<div><span class="eyebrow">CHOOSE YOUR FIELD</span><h1>Wähle deinen Task</h1><p>Jeder Task ist ein eigener Wahrscheinlichkeitsraum. Du kannst jederzeit wechseln; deine Identität und bereits erreichte Bestwerte bleiben erhalten.</p></div>
|
||
<div class="task-landing-id glass"><span>DEINE IDENTITÄT</span><code id="landingCid">initialisiere …</code><button id="landingRefresh">AKTUALISIEREN</button></div>
|
||
</div>
|
||
<div id="taskCards" class="task-cards"><div class="task-card-loading">Tasks werden geladen …</div></div>
|
||
<div class="task-landing-foot"><span>Ein Client kann immer nur mit <b>einem</b> Task aktiv verbunden sein.</span><a href="/leaderboard">Echtzeit-Leaderboard →</a></div>
|
||
</div>
|
||
</section>`;
|
||
}
|
||
|
||
async function runUser(){
|
||
userShell(); let task=null,points=[],cid='',scheduler=null,countdownTimer=null,ws=null,submitting=false,nextGuessAt=0,refreshing=false,landingBusy=false,landingTimer=null,wsReconnectTimer=null,wsBackoff=500;
|
||
const map=new NeuralMap($('map'),{panelOffset:-115,onStats:s=>{if($('rendercount'))$('rendercount').textContent=s.render.toLocaleString('de-DE');if($('fpscount'))$('fpscount').textContent=s.fps}});
|
||
let detailsOpen=false;
|
||
const syncMobile=()=>{const on=document.documentElement.classList.contains('mobile-mode');$('toggleMobile').textContent=mobileButtonLabel();setActive('toggleMobile',on);$('signalPanel').classList.toggle('expanded',on&&detailsOpen);if(on){map.eco=true;map.labels=false;map.edges=false;map.zoom=Math.min(map.zoom,.96);setActive('toggleEco',true);setActive('toggleLabels',false);setActive('toggleEdges',false);if(+$('maxnodes').value>1000){$('maxnodes').value=1000;$('maxnodesvalue').textContent='1.000';map.update(points,cid,1000)}}else{map.eco=false;setActive('toggleEco',false)}map.resize()};
|
||
const status=(s,mode='living')=>{if($('status'))$('status').textContent=s;const m=$('visualMode');if(m){m.className=`mode-status ${mode}`;$('modeTitle').textContent=mode==='thinking'?'GUESS':mode==='researching'?'WIN':task?.paused?'PAUSED':'LIVING';$('modeDetail').textContent=s}};
|
||
const renderRadar=()=>{const rows=proximityRows(points,cid);$('proximityRows').innerHTML=rows.map((p,i)=>`<div class="proximity-row ${p.client_id===cid?'self':''}"><span>${p.client_id===cid?'DU':`#${p.rank||i+1}`}</span><div><i style="width:${clamp(Number(p.score||0),0,100)}%"></i></div><b>${fmtScore(p.score)}</b></div>`).join('')||'<div class="empty small">Noch keine Signale</div>'};
|
||
const render=()=>{points=Array.isArray(points)?points:[];const budget=Math.min(10000,Math.max(300,(+$('maxnodes').value||task?.default_max_nodes||2000)*3));if(points.length>budget){const own=points.find(p=>p.client_id===cid),top=points.filter(p=>p.client_id!==cid).sort((a,b)=>Number(b.score||0)-Number(a.score||0)).slice(0,budget-(own?1:0));points=own?[...top,own]:top}if($('nodecount'))$('nodecount').textContent=points.length.toLocaleString('de-DE');if($('clientmetric'))$('clientmetric').textContent=points.length.toLocaleString('de-DE');map.update(points,cid,+$('maxnodes').value);renderRadar()};
|
||
const countdown=()=>{let text='—';if(task?.paused)text='PAUSE';else if(task&&nextGuessAt){const sec=Math.max(0,Math.ceil((nextGuessAt-Date.now())/1000));text=sec?`${sec}s`:'jetzt'}$('guessCountdown').textContent=text;if($('guessMobile'))$('guessMobile').textContent=text};
|
||
async function refreshMe(){try{const me=await api('/api/me'),rank='#'+(me.rank||'—'),score=fmtScore(me.score);$('rank').textContent=rank;$('score').textContent=score;if($('rankMobile'))$('rankMobile').textContent=rank;if($('scoreMobile'))$('scoreMobile').textContent=score;$('wins').textContent=me.wins||0;$('unlocks').innerHTML=(me.unlocks||[]).map(x=>`<span>${esc(x)}</span>`).join('')}catch{}}
|
||
async function refreshLeaders(){try{const raw=await api('/api/leaderboard'),ls=Array.isArray(raw)?raw:[];$('leaders').innerHTML=ls.slice(0,12).map((l,i)=>`<div class="leader-row ${l.client_id===cid?'self':''}"><span class="leader-rank">${i+1}</span><span><b>${esc(shortID(l.client_id,11))}</b><small>${l.wins} Wins · live ${fmtScore(l.live_score)}</small></span><strong>${Number(l.best_score||0).toFixed(1)}</strong></div>`).join('')||'<div class="empty">Noch keine Teilnehmer</div>'}catch{}}
|
||
async function ensureSession(){if(!getToken()){const x=await loginIdentity();setToken(x.token);return}try{const me=await api('/api/me');if(me?.client_id!==cid)throw Object.assign(new Error('identity mismatch'),{status:401})}catch(e){if(e.status!==401)throw e;clearToken();const x=await loginIdentity();setToken(x.token)}}
|
||
function stopTimers(){if(scheduler){clearInterval(scheduler);scheduler=null}if(countdownTimer){clearInterval(countdownTimer);countdownTimer=null}nextGuessAt=0;countdown()}
|
||
function clearWSReconnect(){if(wsReconnectTimer){clearTimeout(wsReconnectTimer);wsReconnectTimer=null}}
|
||
function wsReady(){return !!ws&&ws.readyState===WebSocket.OPEN}
|
||
async function closeWS(){clearWSReconnect();if(!ws)return;const socket=ws;ws=null;socket._plannedClose=true;await new Promise(resolve=>{let done=false;const finish=()=>{if(done)return;done=true;resolve()};socket.addEventListener('close',finish,{once:true});try{socket.close(1000,'task switch')}catch{}setTimeout(finish,650)})}
|
||
async function stopTaskSession(){stopTimers();clearWSReconnect();await closeWS();submitting=false}
|
||
function taskCardName(t){return String(t.display_name||'').trim()||`Task ${String(t.id||'').slice(-8)}`}
|
||
function renderTaskCards(items){const host=$('taskCards');items=Array.isArray(items)?items:[];host.innerHTML=items.length?items.map((t,i)=>`<article class="task-card glass ${t.selected?'selected':''} ${t.paused?'paused':''}">
|
||
<div class="task-card-top"><span class="task-orbit">${String(i+1).padStart(2,'0')}</span><div><span class="eyebrow">${t.selected?'DEIN AKTUELLER TASK':'ACTIVE FIELD'}</span><h2>${esc(taskCardName(t))}</h2><code>${esc(shortID(t.id.slice(-16),16))}</code></div><span class="task-bit">${t.range_bits}<small>BIT</small></span></div>
|
||
<div class="task-style-preview"><img src="/api/public/tasks/${encodeURIComponent(t.id)}/style-reference?v=${encodeURIComponent(t.revision||0)}" alt="Style-Referenz für ${esc(taskCardName(t))}"><span>${t.has_custom_style_reference?'TASK STYLE':'DEFAULT STYLE'}</span></div>
|
||
<p>${esc(t.description||'Ein aktiver Neural-Hunt-Zahlenraum. Bewege dein Signal mit jedem besseren Tipp näher an den Task-Kern.')}</p>
|
||
<div class="task-card-stats"><span><small>CLIENTS</small><b>${Number(t.point_count||0).toLocaleString('de-DE')}</b></span><span><small>DEIN SCORE</small><b>${fmtScore(t.own_score)}</b></span><span><small>DEIN RANK</small><b>${t.own_rank?`#${t.own_rank}`:'—'}</b></span><span><small>STATUS</small><b>${t.paused?'PAUSE':'LIVE'}</b></span></div>
|
||
<button data-task-choice="${esc(t.id)}">${t.selected?'FORTSETZEN':'TASK WÄHLEN'} <span>→</span></button>
|
||
</article>`).join(''):'<div class="task-card-empty glass"><b>Keine aktiven Tasks</b><span>Der Server erzeugt gerade einen neuen Wahrscheinlichkeitsraum.</span></div>';
|
||
document.querySelectorAll('[data-task-choice]').forEach(b=>b.onclick=()=>enterTask(b.dataset.taskChoice));
|
||
}
|
||
async function showLanding(){if(landingBusy)return;landingBusy=true;try{await stopTaskSession();task=null;points=[];render();$('taskid').textContent='—';$('taskLanding').classList.add('visible');status('Task auswählen');const items=await api('/api/tasks');renderTaskCards(items)}catch(e){status(e.message||'Tasks konnten nicht geladen werden');$('taskCards').innerHTML=`<div class="task-card-empty glass"><b>Fehler beim Laden</b><span>${esc(e.message||'Unbekannter Fehler')}</span></div>`}finally{landingBusy=false}}
|
||
async function refreshTaskConfig(forcePoints=false){if(refreshing||!task)return;refreshing=true;try{const current=await api('/api/tasks/current');if(task&¤t.id!==task.id){setTimeout(()=>showLanding(),0);return}const changed=current.revision!==task.revision||current.public_seed!==task.public_seed||current.range_bits!==task.range_bits;task=current;$('taskid').textContent=task.display_name?shortID(task.display_name,16):shortID(task.id.slice(-12),12);if(changed||forcePoints){points=await api(`/api/tasks/${task.id}/points?limit=${Math.min(10000,Math.max(300,(+$('maxnodes').value||task.default_max_nodes||2000)*3))}`);points=Array.isArray(points)?points:[];render()}if(task.paused){status(`Task pausiert · ${task.range_bits} Bit`);nextGuessAt=0}else if(changed){status(`Task aktualisiert · ${task.range_bits} Bit`);nextGuessAt=Date.now()+Math.max(1,task.client_submit_interval_sec)*1000}}finally{refreshing=false}}
|
||
function scheduleWSReconnect(){if(wsReconnectTimer||!task||$('taskLanding').classList.contains('visible'))return;const delay=Math.min(8000,wsBackoff)+Math.floor(Math.random()*250);wsReconnectTimer=setTimeout(()=>{wsReconnectTimer=null;openWS()},delay);wsBackoff=Math.min(8000,Math.max(750,wsBackoff*1.7))}
|
||
async function recover409(e){
|
||
if(e.code==='task_inactive'||e.code==='selection_conflict'){await showLanding();return}
|
||
try{const current=await api('/api/tasks/current');if(!task||current.id!==task.id){await showLanding();return}task=current;$('taskid').textContent=task.display_name?shortID(task.display_name,16):shortID(task.id.slice(-12),12)}catch{}
|
||
if(e.code==='presence_required')openWS(true);else if(!wsReady())scheduleWSReconnect();
|
||
if(e.code==='task_config_changed')await refreshTaskConfig(true);
|
||
nextGuessAt=Date.now()+1500;status(e.code==='presence_required'?'Live-Verbindung wird automatisch wiederhergestellt …':'Client wird automatisch synchronisiert …')
|
||
}
|
||
async function submit(){if(!task||submitting)return;if(!wsReady()){scheduleWSReconnect();nextGuessAt=Date.now()+1000;status('Live-Verbindung wird wiederhergestellt …');return}submitting=true;status('signiert Tipp …','thinking');try{const current=await api('/api/tasks/current');if(current.id!==task.id){await showLanding();return}task=current;if(task.paused){status('Task pausiert');nextGuessAt=0;return}const seq=task.next_seq,guess=await deterministicGuess(task.id,task.public_seed,cid,seq,task.range_bits),signature=await sign(`guess|${task.id}|${seq}|${guess}`),correct=await api(`/api/tasks/${task.id}/guess`,{method:'POST',body:JSON.stringify({seq,guess,signature})});nextGuessAt=Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;status(correct?'Treffer — Task gelöst!':'Tipp akzeptiert',correct?'researching':'living');await Promise.all([refreshMe(),refreshLeaders()]);if(correct)setTimeout(()=>showLanding(),1300)}catch(e){if(e.status===409){await recover409(e);return}nextGuessAt=Date.now()+Math.max(2,task?.client_submit_interval_sec||11)*1000;status(e.message||'Tipp fehlgeschlagen')}finally{submitting=false}}
|
||
function openWS(force=false){if(!task||$('taskLanding').classList.contains('visible'))return;clearWSReconnect();if(ws&&(ws.readyState===WebSocket.OPEN||ws.readyState===WebSocket.CONNECTING)){if(!force)return;const old=ws;old._plannedClose=true;try{old.close(1000,'reconnect')}catch{}}const proto=location.protocol==='https:'?'wss':'ws',mx=+$('maxnodes').value||task.default_max_nodes||2000,socket=new WebSocket(`${proto}://${location.host}/api/ws?token=${encodeURIComponent(getToken())}&max_nodes=${encodeURIComponent(mx)}`);ws=socket;socket.onopen=()=>{if(ws!==socket)return;wsBackoff=500;status(task?.paused?'Task pausiert':'verbunden')};socket.onmessage=async ev=>{if(ws!==socket)return;const e=JSON.parse(ev.data);if(e.type==='snapshot'){points=Array.isArray(e.data)?e.data:[];render()}else if(e.type==='point'){const p=e.data,i=points.findIndex(x=>x.client_id===p.client_id);if(i<0)points.push(p);else points[i]=p;render()}else if(e.type==='points'){for(const p of (Array.isArray(e.data)?e.data:[])){const i=points.findIndex(x=>x.client_id===p.client_id);if(i<0)points.push(p);else points[i]=p}render()}else if(e.type==='task_changed'){await refreshTaskConfig(true);await Promise.all([refreshMe(),refreshLeaders()])}else if(e.type==='task_completed'){status('Task abgeschlossen — Folge-Task ist bereit','researching');setTimeout(()=>showLanding(),1300)}};socket.onclose=e=>{if(ws===socket)ws=null;if(!socket._plannedClose&&task&&!$('taskLanding').classList.contains('visible')){status('Live-Verbindung unterbrochen · verbinde automatisch neu …');scheduleWSReconnect()}};socket.onerror=()=>{if(!socket._plannedClose&&ws===socket)status('WebSocket-Fehler · Reconnect folgt automatisch')}}
|
||
async function enterTask(taskID){if(landingBusy)return;landingBusy=true;try{await stopTaskSession();task=await api('/api/tasks/select',{method:'POST',body:JSON.stringify({task_id:taskID})});$('taskLanding').classList.remove('visible');$('taskid').textContent=task.display_name?shortID(task.display_name,16):shortID(task.id.slice(-12),12);let max=+$('maxnodes').value||clamp(Number(task.default_max_nodes||2000),100,25000);if(document.documentElement.classList.contains('mobile-mode'))max=Math.min(max,1000);$('maxnodes').value=max;$('maxnodesvalue').textContent=max.toLocaleString('de-DE');points=await api(`/api/tasks/${task.id}/points?limit=${Math.min(10000,Math.max(300,max*3))}`);points=Array.isArray(points)?points:[];render();await Promise.all([refreshMe(),refreshLeaders()]);syncMobile();openWS();status(task.paused?'Task pausiert':`${task.range_bits} Bit · verbunden`);nextGuessAt=task.paused?0:Date.now()+Math.max(1,task.client_submit_interval_sec)*1000;scheduler=setInterval(()=>{if(task&&!task.paused&&nextGuessAt&&Date.now()>=nextGuessAt)submit()},300);countdownTimer=setInterval(countdown,250);countdown()}catch(e){status(e.message||'Task konnte nicht gestartet werden');$('taskLanding').classList.add('visible')}finally{landingBusy=false}}
|
||
try{const id=await ensureIdentity();cid=await clientId(id.publicJwk);$('cid').textContent=cid;$('landingCid').textContent=cid;await ensureSession();syncMobile();await Promise.all([refreshLeaders()]);await showLanding()}catch(e){status(e.message||'Startfehler');$('taskCards').innerHTML=`<div class="task-card-empty glass"><b>Startfehler</b><span>${esc(e.message||'')}</span></div>`}
|
||
$('landingRefresh').onclick=()=>showLanding();$('chooseTask').onclick=()=>showLanding();
|
||
$('maxnodes').addEventListener('input',e=>{$('maxnodesvalue').textContent=Number(e.target.value).toLocaleString('de-DE');render()});
|
||
$('toggleMobile').onclick=()=>toggleMobileMode();$('toggleDetails').onclick=()=>{detailsOpen=!detailsOpen;$('signalPanel').classList.toggle('expanded',detailsOpen);setActive('toggleDetails',detailsOpen)};window.addEventListener('neuralhunt-mobile-mode',syncMobile);
|
||
$('toggleProximity').onclick=()=>{map.proximityFocus=!map.proximityFocus;$('toggleProximity').textContent=map.proximityFocus?'TARGET FIELD':'RAW 3D';setActive('toggleProximity',map.proximityFocus)};$('toggleRotate').onclick=()=>{map.autoRotate=!map.autoRotate;setActive('toggleRotate',map.autoRotate)};$('toggleLabels').onclick=()=>{map.labels=!map.labels;setActive('toggleLabels',map.labels)};$('toggleEdges').onclick=()=>{map.edges=!map.edges;setActive('toggleEdges',map.edges)};$('toggleShells').onclick=()=>{map.shells=!map.shells;setActive('toggleShells',map.shells)};$('toggleLOD').onclick=()=>{map.lodEnabled=!map.lodEnabled;map.rebuild();setActive('toggleLOD',map.lodEnabled)};$('toggleEco').onclick=()=>{map.eco=!map.eco;map.resize();setActive('toggleEco',map.eco)};$('resetView').onclick=()=>map.resetView();
|
||
$('exportid').onclick=async()=>{const p=prompt('Passphrase für den verschlüsselten Identitäts-Export');if(!p)return;try{const text=await exportIdentity(p),a=document.createElement('a');a.href=URL.createObjectURL(new Blob([text],{type:'application/json'}));a.download=`neuralhunt-identity-${cid.slice(0,10)}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),5000)}catch(e){status(e.message||'Export fehlgeschlagen')}};
|
||
$('importid').onchange=async e=>{const f=e.target.files?.[0];if(!f)return;const p=prompt('Passphrase für diesen Identitäts-Export');if(!p)return;try{await importIdentity(await f.text(),p);clearToken();location.reload()}catch(err){status(err.message||'Import fehlgeschlagen')}};
|
||
addEventListener('beforeunload',()=>{stopTimers();clearWSReconnect();if(landingTimer)clearTimeout(landingTimer);if(ws){ws._plannedClose=true;ws.close()}window.removeEventListener('neuralhunt-mobile-mode',syncMobile);map.destroy()},{once:true});
|
||
}
|
||
|
||
function leaderboardShell(){
|
||
app.className='leaderboard-page';app.innerHTML=`
|
||
<header class="leaderboard-top glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / REALTIME LEADERBOARD</strong><small>Live score · wins · watermarked winner NFTs</small></div></div><div class="leaderboard-nav"><button id="lbMobile">MOBILE</button><a href="/">CLIENT</a><a href="/admin">ADMIN</a></div></header>
|
||
<main class="lb-main">
|
||
<section class="lb-hero"><div class="lb-title"><span class="eyebrow">PUBLIC SIGNAL</span><h1>Echtzeit-Ranking</h1><p>Live-Score zeigt die beste Position in einem aktuell aktiven Task. Gewinner-Artefakte werden öffentlich ausschließlich über eine serverseitig erzeugte Vorschau mit Wasserzeichen angezeigt.</p></div><div class="lb-controls glass"><div class="segmented"><button id="lbLive" class="active">LIVE</button><button id="lbAll">ALL-TIME</button></div><input id="lbSearch" placeholder="Client-ID / Task suchen"><span id="lbState">verbinde …</span></div></section>
|
||
<section id="lbPodium" class="lb-podium"></section>
|
||
<section class="nft-showcase"><div class="nft-showcase-head"><div><span class="eyebrow">WINNER ARTIFACTS</span><h2>NFT-Galerie</h2></div><span><b id="nftCount">0</b> Artefakte · nur Wasserzeichen-Vorschau</span></div><div id="nftGallery" class="nft-gallery"></div></section>
|
||
<section class="glass lb-table-wrap"><div class="lb-table-head"><span id="lbCount">0 Clients</span><span>automatische Aktualisierung über WebSocket</span></div><div class="lb-table" id="lbTable"></div></section>
|
||
</main>
|
||
<div id="nftLightbox" class="nft-lightbox hidden" role="dialog" aria-modal="true" aria-label="NFT Vorschau"><button id="nftClose" aria-label="Vorschau schließen">×</button><div class="nft-lightbox-card glass"><img id="nftLarge" alt="Wasserzeichen-Vorschau des Gewinner-Artefakts"><div><b id="nftLargeTitle">Winner NFT</b><small id="nftLargeMeta">Watermarked preview</small></div></div></div>`;
|
||
}
|
||
|
||
async function runLeaderboard(){
|
||
leaderboardShell();let mode='live',rows=[],artifacts=[],ws=null,refreshTimer=null;
|
||
const openNFT=a=>{if(!a?.preview_uri)return;$('nftLarge').src=a.preview_uri;$('nftLargeTitle').textContent=`Task ${shortID(a.task_id?.slice(-14),14)}`;$('nftLargeMeta').textContent=`Winner ${shortID(a.winner_client_id,18)} · ${a.range_bits} Bit · WATERMARKED PREVIEW`;$('nftLightbox').classList.remove('hidden')};
|
||
const closeNFT=()=>{$('nftLightbox').classList.add('hidden');$('nftLarge').removeAttribute('src')};
|
||
const render=()=>{
|
||
const q=$('lbSearch').value.trim().toLowerCase(),filtered=rows.filter(x=>!q||String(x.client_id).toLowerCase().includes(q)||String(x.nft_task_id||'').toLowerCase().includes(q));
|
||
$('lbCount').textContent=`${filtered.length.toLocaleString('de-DE')} Clients`;
|
||
const top=filtered.slice(0,3);
|
||
$('lbPodium').innerHTML=top.map((x,i)=>`<div class="podium rank-${i+1} glass">${x.nft_preview_uri?`<img class="podium-nft" src="${esc(x.nft_preview_uri)}" alt="Wasserzeichen NFT Vorschau" loading="lazy">`:`<span class="podium-rank">#${i+1}</span>`}<div><b>#${i+1} · ${esc(shortID(x.client_id,16))}</b><small>${x.connected?'● online':'○ offline'} · Best ${fmtScore(x.best_score)} · ${x.nft_count||0} NFTs</small></div><strong>${mode==='live'?fmtScore(x.live_score):`${x.wins} Wins`}</strong></div>`).join('');
|
||
$('lbTable').innerHTML=filtered.map((x,i)=>`<div class="lb-row"><span class="lb-rank">#${i+1}</span><span class="lb-id"><i class="presence ${x.connected?'on':''}"></i><b>${esc(shortID(x.client_id,22))}</b><small>${(x.unlocks||[]).slice(0,3).map(esc).join(' · ')||'keine Unlocks'}</small></span><div class="lb-stats"><span><small>LIVE</small><strong>${fmtScore(x.live_score)}</strong></span><span><small>BEST</small><strong>${fmtScore(x.best_score)}</strong></span><span><small>WINS</small><strong>${x.wins||0}</strong></span><span><small>TIPPS</small><strong>${Number(x.guess_count||0).toLocaleString('de-DE')}</strong></span></div><span class="lb-nft-cell">${x.nft_preview_uri?`<button class="lb-nft-button" data-nft-task="${esc(x.nft_task_id||'')}"><img src="${esc(x.nft_preview_uri)}" alt="Wasserzeichen NFT Vorschau" loading="lazy"><em>${x.nft_count||1}× NFT</em></button>`:'<small>—</small>'}</span></div>`).join('')||'<div class="empty">Noch keine Teilnehmer</div>';
|
||
const shownArtifacts=artifacts.filter(a=>!q||String(a.winner_client_id).toLowerCase().includes(q)||String(a.task_id).toLowerCase().includes(q));
|
||
$('nftCount').textContent=shownArtifacts.length.toLocaleString('de-DE');
|
||
$('nftGallery').innerHTML=shownArtifacts.length?shownArtifacts.map(a=>`<button class="nft-card" data-gallery-task="${esc(a.task_id)}"><span class="nft-image-wrap"><img src="${esc(a.preview_uri)}" alt="Wasserzeichen-Vorschau für Task ${esc(shortID(a.task_id,12))}" loading="lazy"><i>WATERMARKED</i></span><span><b>${esc(shortID(a.task_id.slice(-16),16))}</b><small>Winner ${esc(shortID(a.winner_client_id,14))} · ${a.range_bits} Bit</small></span></button>`).join(''):'<div class="empty">Noch keine fertigen Gewinner-Artefakte</div>';
|
||
document.querySelectorAll('[data-gallery-task]').forEach(b=>b.onclick=()=>openNFT(artifacts.find(a=>a.task_id===b.dataset.galleryTask)));
|
||
document.querySelectorAll('[data-nft-task]').forEach(b=>b.onclick=()=>openNFT(artifacts.find(a=>a.task_id===b.dataset.nftTask)||{task_id:b.dataset.nftTask,winner_client_id:filtered.find(x=>x.nft_task_id===b.dataset.nftTask)?.client_id,range_bits:'—',preview_uri:filtered.find(x=>x.nft_task_id===b.dataset.nftTask)?.nft_preview_uri}));
|
||
};
|
||
async function refresh(){try{const [x,a]=await Promise.all([api(`/api/public/leaderboard?mode=${mode}&limit=500`),api('/api/public/artifacts?limit=96')]);rows=Array.isArray(x)?x:[];artifacts=Array.isArray(a)?a:[];$('lbState').textContent='LIVE · '+new Date().toLocaleTimeString('de-DE');render()}catch(e){$('lbState').textContent=e.message}}
|
||
const debounce=()=>{clearTimeout(refreshTimer);refreshTimer=setTimeout(refresh,120)};
|
||
$('lbLive').onclick=()=>{mode='live';setActive('lbLive',true);setActive('lbAll',false);refresh()};$('lbAll').onclick=()=>{mode='alltime';setActive('lbLive',false);setActive('lbAll',true);refresh()};$('lbSearch').oninput=render;
|
||
$('lbMobile').onclick=()=>{toggleMobileMode();$('lbMobile').textContent=mobileButtonLabel();setActive('lbMobile',document.documentElement.classList.contains('mobile-mode'))};$('lbMobile').textContent=mobileButtonLabel();setActive('lbMobile',document.documentElement.classList.contains('mobile-mode'));
|
||
$('nftClose').onclick=closeNFT;$('nftLightbox').onclick=e=>{if(e.target===$('nftLightbox'))closeNFT()};addEventListener('keydown',e=>{if(e.key==='Escape')closeNFT()});
|
||
await refresh();const proto=location.protocol==='https:'?'wss':'ws';ws=new WebSocket(`${proto}://${location.host}/api/leaderboard/ws`);ws.onopen=()=>{$('lbState').textContent='LIVE · verbunden'};ws.onmessage=debounce;ws.onclose=()=>{$('lbState').textContent='WebSocket getrennt'};addEventListener('beforeunload',()=>{clearTimeout(refreshTimer);if(ws)ws.close()},{once:true});
|
||
}
|
||
|
||
function adminLoginShell(){app.className='login';app.innerHTML=`<div class="login-card glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / ADMIN</strong><small>Control plane</small></div></div><input id="adminuser" value="admin" placeholder="Benutzer"><input id="adminpass" type="password" placeholder="Passwort"><button id="adminlogin">ANMELDEN</button><p class="danger statusline" id="adminerr"></p><a href="/">← Client-Ansicht</a></div>`}
|
||
function adminShell(){
|
||
app.className='admin';app.innerHTML=`
|
||
<header class="admin-top glass"><div class="brand">${markHTML()}<div><strong>NEURAL HUNT / ADMIN</strong><small>Tasks · Clients · Runtime · Scheduler</small></div></div><div class="admin-actions"><button id="adminMobileToggle">MOBILE</button><a href="/">CLIENT</a><a href="/leaderboard">LEADERBOARD</a><button id="adminlogout">LOGOUT</button></div></header>
|
||
<div class="overviewStrip glass" id="overview"></div><div class="overviewStrip glass perf-strip" id="performance"></div>
|
||
<nav class="admin-mobile-tabs glass" id="adminMobileTabs"><button data-admin-panel="map" class="active">MAP</button><button data-admin-panel="tasks">TASKS</button><button data-admin-panel="control">CONTROL</button></nav>
|
||
<main class="adminGrid mobile-show-map" id="adminGrid">
|
||
<section class="glass tasklist"><div class="panel-title"><span>TASKS</span><span class="chip" id="taskCount">0</span></div><div class="toolbar"><select id="statusfilter"><option value="">alle Status</option><option>active</option><option>completed</option><option>closed</option></select><input id="taskquery" placeholder="Task / Winner"><button id="filter">FILTER</button></div><div class="tasks" id="tasks"></div></section>
|
||
<section class="adminMap glass"><div id="adminmap" class="neural-map"></div><div class="map-overlay top"><span><b id="selectedtask">Task wählen</b></span><span><b id="adminbits">—</b> Bit</span><span><b id="adminpoints">0</b> Clients</span><span><b id="adminrender">0</b> Render</span><span><b id="adminfps">0</b> FPS</span><span id="winner"></span><label class="inline-filter">Score ≥ <input id="adminMinScore" type="number" min="0" max="100" step="1" value="0"></label><input id="adminClientFilter" class="client-filter" placeholder="Client-ID filtern"></div><div class="map-overlay bottom"><label>MAX NODES <input id="adminmaxnodes" type="range" min="100" max="50000" step="100" value="5000"><b id="adminmaxvalue">5.000</b></label><button id="adminProximity" class="active">TARGET FIELD</button><button id="adminRotate" class="active">ORBIT</button><button id="adminEdges" class="active">SIGNALWEGE</button><button id="adminShells" class="active">SCORE-RINGE</button><button id="adminLOD" class="active">LOD</button><button id="adminEco">ECO</button><button id="adminReset">RESET</button></div></section>
|
||
<section class="glass settings"><div class="panel-title"><span>CONTROL PLANE</span><span class="chip">SQLite</span></div><div class="settings-tabs"><button id="tabRuntime" class="active">RUNTIME</button><button id="tabTask">TASK ACTIONS</button><button id="tabArtifact">ARTIFACT</button></div><div id="settingfields"></div><div class="actions"><button id="savesettings">SPEICHERN</button><button id="ensuretasks">ACTIVE TASKS SICHERN</button></div><p class="small statusline" id="adminstatus"></p></section>
|
||
</main>`;
|
||
}
|
||
|
||
async function runAdmin(){
|
||
if(!localStorage.getItem(adminTokenKey)){adminLoginShell();$('adminlogin').onclick=async()=>{try{const r=await fetch('/api/admin/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:$('adminuser').value,password:$('adminpass').value})});if(!r.ok)throw new Error('Login fehlgeschlagen');const x=await r.json();localStorage.setItem(adminTokenKey,x.token);location.reload()}catch(e){$('adminerr').textContent=e.message}};return}
|
||
adminShell();const map=new NeuralMap($('adminmap'),{admin:true,onStats:s=>{if($('adminrender'))$('adminrender').textContent=s.render.toLocaleString('de-DE');if($('adminfps'))$('adminfps').textContent=s.fps}});const adminDraftKey='neuralhunt.adminDraft.v2';let draft={};try{draft=JSON.parse(localStorage.getItem(adminDraftKey)||'{}')||{}}catch{draft={}};let tasks=[],selected=null,points=[],settings=null,actions=[],providers=null,artifactUsage=null,poll=null,tab=['runtime','task','artifact'].includes(draft.tab)?draft.tab:'runtime',loading=false;
|
||
const setAdminPanel=name=>{const grid=$('adminGrid');grid.classList.remove('mobile-show-map','mobile-show-tasks','mobile-show-control');grid.classList.add(`mobile-show-${name}`);document.querySelectorAll('[data-admin-panel]').forEach(b=>b.classList.toggle('active',b.dataset.adminPanel===name));if(name==='map')setTimeout(()=>map.resize(),30)};
|
||
const syncAdminMobile=()=>{const on=document.documentElement.classList.contains('mobile-mode');$('adminMobileToggle').textContent=mobileButtonLabel();setActive('adminMobileToggle',on);if(on){map.eco=true;map.labels=false;map.edges=false;map.zoom=Math.min(map.zoom,.94);setActive('adminEco',true);setActive('adminEdges',false);if(+$('adminmaxnodes').value>1500){$('adminmaxnodes').value=1500;$('adminmaxvalue').textContent='1.500'}}else{map.eco=false;setActive('adminEco',false)}map.resize()};
|
||
$('adminMobileToggle').onclick=()=>toggleMobileMode();document.querySelectorAll('[data-admin-panel]').forEach(b=>b.onclick=()=>setAdminPanel(b.dataset.adminPanel));window.addEventListener('neuralhunt-mobile-mode',syncAdminMobile);syncAdminMobile();
|
||
const runtimeKeys=['guess_min_interval_sec','client_submit_interval_sec','task_range_bits','active_task_count','presence_ttl_sec','default_max_nodes','public_score_precision'];
|
||
const labels={guess_min_interval_sec:'Server-Tippintervall (s)',client_submit_interval_sec:'Client-Tippintervall (s)',task_range_bits:'Default Zahlenraum (Bit)',active_task_count:'Parallele aktive Tasks',presence_ttl_sec:'Presence TTL (s)',default_max_nodes:'Default Max Nodes',public_score_precision:'Öffentliche Score-Präzision'};
|
||
const msg=s=>$('adminstatus').textContent=s;
|
||
const saveDraft=()=>{try{draft.tab=tab;draft.selectedTaskId=selected?.id||draft.selectedTaskId||'';draft.filters={status:$('statusfilter')?.value||'',q:$('taskquery')?.value||''};localStorage.setItem(adminDraftKey,JSON.stringify(draft))}catch{}};
|
||
const draftScope=()=>tab==='task'&&selected?`task:${selected.id}`:`global:${tab}`;
|
||
const draftFieldKey=el=>el.dataset.setting?`setting:${el.dataset.setting}`:el.dataset.settingString?`string:${el.dataset.settingString}`:el.dataset.draft||el.id||'';
|
||
const captureDraft=()=>{const scope=draftScope();draft.fields=draft.fields||{};draft.fields[scope]=draft.fields[scope]||{};document.querySelectorAll('#settingfields input,#settingfields textarea,#settingfields select').forEach(el=>{if(el.type==='file')return;const k=draftFieldKey(el);if(k)draft.fields[scope][k]=el.value});saveDraft()};
|
||
const restoreDraft=()=>{const scope=draftScope(),values=draft.fields?.[scope]||{};document.querySelectorAll('#settingfields input,#settingfields textarea,#settingfields select').forEach(el=>{if(el.type==='file')return;const k=draftFieldKey(el);if(k&&Object.prototype.hasOwnProperty.call(values,k))el.value=values[k]})};
|
||
const clearDraftKeys=keys=>{const scope=draftScope(),values=draft.fields?.[scope];if(values){keys.forEach(k=>delete values[k]);saveDraft()}};
|
||
function renderOverview(o){$('overview').innerHTML=`<span><b>${o.connected}</b> verbunden</span><span><b>${o.clients}</b> Identitäten</span><span><b>${o.active_tasks}</b> aktive Tasks</span><span><b>${o.completed_tasks}</b> abgeschlossen</span><span><b>${Number(o.guesses||0).toLocaleString('de-DE')}</b> Tipps</span><span><b>${o.artifacts_ready}</b> Artefakte</span>`}
|
||
function renderPerformance(p){const r=p?.runtime||{},w=p?.websocket||{},x=p?.process||{};$('performance').innerHTML=`<span><b>${Number(r.guesses_per_sec||0).toFixed(1)}</b> Guess/s</span><span><b>${Number(r.improvements_per_sec||0).toFixed(1)}</b> Improve/s</span><span><b>${Number(r.sqlite_writes_per_sec||0).toFixed(1)}</b> SQLite W/s</span><span><b>${Number(w.frames_per_sec||0).toFixed(0)}</b> WS Frames/s</span><span><b>${(Number(w.bytes_per_sec||0)/1048576).toFixed(2)}</b> WS MB/s</span><span><b>${Number(w.dropped_per_sec||0).toFixed(1)}</b> Drops/s</span><span><b>${Number(x.goroutines||0).toLocaleString('de-DE')}</b> Goroutines</span><span><b>${(Number(x.heap_bytes||0)/1048576).toFixed(1)}</b> Heap MB</span>`}
|
||
async function openAdminFile(taskID,kind){const popup=window.open('','_blank');try{const token=localStorage.getItem(adminTokenKey),r=await fetch(`/api/admin/tasks/${encodeURIComponent(taskID)}/${kind}`,{headers:{Authorization:'Bearer '+token}});if(!r.ok)throw new Error(await responseError(r,'Datei konnte nicht geöffnet werden'));const blob=await r.blob(),u=URL.createObjectURL(blob);if(popup)popup.location=u;else{const a=document.createElement('a');a.href=u;a.target='_blank';a.click()}setTimeout(()=>URL.revokeObjectURL(u),60000)}catch(e){if(popup)popup.close();msg(e.message)}}
|
||
function renderTasks(){tasks=Array.isArray(tasks)?tasks:[];$('taskCount').textContent=tasks.length;$('tasks').innerHTML=tasks.length?tasks.map(t=>`<button data-id="${esc(t.id)}" class="${selected?.id===t.id?'selected':''}"><span><b>${esc(t.display_name||t.id.slice(-12))}</b><small>${esc(t.id.slice(-12))} · ${fmtDate(t.created_at)}</small></span><span><em class="task-state ${esc(t.status)}">${t.paused?'PAUSED':esc(t.status)}</em><small>${t.range_bits} Bit · rev ${t.revision} · ${Number(t.point_count||0).toLocaleString('de-DE')} Clients · ${Number(t.guess_count||0).toLocaleString('de-DE')} Tipps</small></span><span>${esc(t.artifact_status||'—')}<small>${t.parent_task_id?`↳ ${esc(String(t.parent_task_id).slice(-7))}`:'ROOT'}</small><small class="artifactLinks">${t.artifact_uri?`<span data-artifact-task="${esc(t.id)}">Bild</span> · <span data-manifest-task="${esc(t.id)}">Manifest</span>`:''}</small></span></button>`).join(''):'<div class="empty">Keine Tasks</div>';document.querySelectorAll('#tasks button[data-id]').forEach(b=>b.onclick=e=>{const art=e.target.closest('[data-artifact-task]'),man=e.target.closest('[data-manifest-task]');if(art){e.preventDefault();e.stopPropagation();openAdminFile(art.dataset.artifactTask,'artifact');return}if(man){e.preventDefault();e.stopPropagation();openAdminFile(man.dataset.manifestTask,'manifest');return}openTask(tasks.find(t=>t.id===b.dataset.id))})}
|
||
function renderRuntime(){
|
||
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">GLOBAL RUNTIME</div>${runtimeKeys.map(k=>`<label><span>${esc(labels[k])}</span><input type="number" data-setting="${k}" value="${settings?.[k]??''}"></label>`).join('')}<p class="small">Diese Defaults gelten für neue Tasks. Task-spezifische Intervalle und Zahlenräume steuerst du im Tab „Task Actions“.</p></div>
|
||
<div class="control-section profile-cleanup"><div class="section-title">ALTE PROFILE BEREINIGEN</div><p class="small">Löscht ausschließlich Accounts, die <b>seit mindestens X Zeit inaktiv</b>, aktuell <b>nicht verbunden</b> und <b>niemals Gewinner</b> eines Tasks waren. Gewinner werden unabhängig vom Alter immer geschützt. Zugehörige Punkte, Unlocks und Task-Auswahl werden mit dem Profil entfernt.</p>
|
||
<div class="cleanup-controls"><label><span>Inaktiv seit mindestens</span><input id="profileCleanupValue" data-draft="profileCleanupValue" type="number" min="1" step="1" value="30"></label><label><span>Einheit</span><select id="profileCleanupUnit" data-draft="profileCleanupUnit"><option value="hours">Stunden</option><option value="days" selected>Tage</option><option value="weeks">Wochen</option></select></label></div>
|
||
<div class="cleanup-actions"><button id="previewProfileCleanup">PRÜFEN</button><button id="runProfileCleanup" class="danger-button">PROFILE LÖSCHEN</button></div><div id="profileCleanupResult" class="cleanup-result">Noch nicht geprüft.</div>
|
||
</div>`;$('savesettings').style.display='inline-block';$('ensuretasks').style.display='inline-block';
|
||
const cleanupSeconds=()=>{const v=Math.max(1,Number($('profileCleanupValue')?.value||0)),unit=$('profileCleanupUnit')?.value||'days',factor=unit==='hours'?3600:unit==='weeks'?7*86400:86400;return Math.round(v*factor)};
|
||
const showCleanup=p=>{const box=$('profileCleanupResult');if(!box)return;const eligible=Number(p?.eligible||0),wins=Number(p?.protected_winners||0),online=Number(p?.protected_connected||0),cutoff=p?.cutoff_ms?fmtDate(p.cutoff_ms):'—';box.innerHTML=`<b>${eligible.toLocaleString('de-DE')} löschbar</b><span>· ${wins.toLocaleString('de-DE')} alte Gewinner geschützt · ${online.toLocaleString('de-DE')} aktuell verbundene Accounts geschützt</span><small>Grenze: letzte Aktivität vor ${esc(cutoff)}</small>`;};
|
||
const previewCleanup=async()=>{try{captureDraft();const seconds=cleanupSeconds(),p=await api(`/api/admin/profiles/cleanup-preview?inactive_for_seconds=${seconds}`,{},true);showCleanup(p);return p}catch(e){msg(e.message);throw e}};
|
||
$('previewProfileCleanup').onclick=()=>previewCleanup().catch(()=>{});
|
||
$('runProfileCleanup').onclick=async()=>{try{const p=await previewCleanup();const n=Number(p?.eligible||0);if(!n){msg('Keine passenden inaktiven Nicht-Gewinner-Profile gefunden');return}const value=$('profileCleanupValue').value,unitLabel=$('profileCleanupUnit').selectedOptions[0]?.textContent||'';if(!confirm(`${n.toLocaleString('de-DE')} Profile endgültig löschen?\n\nKriterium: seit mindestens ${value} ${unitLabel} inaktiv, offline und niemals Gewinner.\nGewinner und aktuell verbundene Accounts bleiben geschützt.`))return;const out=await api('/api/admin/profiles/cleanup',{method:'POST',body:JSON.stringify({inactive_for_seconds:cleanupSeconds()})},true);msg(`${Number(out.deleted||0).toLocaleString('de-DE')} alte Profile gelöscht`);showCleanup({...p,eligible:Math.max(0,n-Number(out.deleted||0))});await load(true,false)}catch(e){msg(e.message)}};
|
||
}
|
||
function renderArtifact(){
|
||
const ps=providers?.providers||{},preset=settings?.artifact_preset||'legacy',u=artifactUsage||{},recent=Array.isArray(u.recent)?u.recent:[];
|
||
const usd=(n,d=4)=>Number(n||0).toLocaleString('de-DE',{style:'currency',currency:'USD',minimumFractionDigits:d,maximumFractionDigits:d});
|
||
const usageRows=recent.length?recent.map(r=>`<tr><td>${fmtDate(r.created_at)}</td><td>${r.kind==='character_anchor'?'ANCHOR':'KARTE'}</td><td>${esc(r.model||'—')}<small>${esc(r.quality||'—')} · ${esc(r.size||'—')}</small></td><td>${Number(r.input_text_tokens||0).toLocaleString('de-DE')} T + ${Number(r.input_image_tokens||0).toLocaleString('de-DE')} I → ${Number(r.output_tokens||0).toLocaleString('de-DE')}</td><td>${r.estimated_cost_usd==null?'—':usd(r.estimated_cost_usd,5)}</td><td><small>${esc(r.request_id?String(r.request_id).slice(-16):'—')}</small></td></tr>`).join(''):'<tr><td colspan="6" class="empty small">Noch keine OpenAI-Bildgenerierung protokolliert.</td></tr>';
|
||
if(preset==='raccoon_full_art_v1'){
|
||
const anchorReady=!!providers?.character_anchor;
|
||
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">RIFT FULL-ART COLLECTION</div><div class="provider-status"><span class="${ps.openai?'ready':'off'}">OPENAI · ${ps.openai?'API KEY READY':'API KEY FEHLT'}</span><span class="${anchorReady?'ready':'off'}">CHARACTER ANCHOR · ${anchorReady?'LOCKED':'NOCH NICHT ERZEUGT'}</span></div>
|
||
<label class="wide"><span>OpenAI Bildmodell</span><input data-setting-string="artifact_model" value="${esc(settings?.artifact_model||'gpt-image-2')}" placeholder="gpt-image-2"></label>
|
||
<div class="reference-admin-box"><div><div class="section-title">RIFT CHARACTER ANCHOR</div><p class="small">Der Anchor definiert ausschließlich, <b>wer RIFT ist</b>. Er wird einmalig als neutrales Character-Referenzbild erzeugt und anschließend für alle Tasks verwendet. Die visuelle Stilrichtung kommt getrennt aus der Style-Referenz des jeweiligen Tasks.</p>${anchorReady?'<p class="small ready-copy">Anchor vorhanden · Identität ist gesperrt. Es gibt absichtlich keinen Überschreiben-Button.</p>':'<p class="small">Du kannst den Anchor jetzt kontrolliert erzeugen. Falls du das nicht tust, erzeugt der Worker ihn weiterhin automatisch beim ersten Gewinnerbild als Sicherheits-Fallback.</p>'}</div><div class="reference-preview ${anchorReady?'has-image':''}">${anchorReady?'<img id="anchorPreview" alt="RIFT Character Anchor">':'<span>NO ANCHOR</span>'}</div></div>
|
||
${anchorReady?'':`<div class="task-config-actions anchor-actions"><button id="createCharacterAnchor" ${ps.openai?'':'disabled'}>RIFT-ANCHOR JETZT ERZEUGEN</button></div>`}
|
||
<div class="task-config-box"><div class="section-title">PIPELINE</div><p class="small">Provider <b>OpenAI</b> · Ausgabe <b>1024 × 1536</b> · Quality <b>${esc(settings?.artifact_quality||'medium')}</b> · Preset <b>raccoon_full_art_v1</b>.</p><p class="small">Jede Karten-Generierung sendet zwei getrennte Referenzen: <b>Image 1 = globaler RIFT-Character-Anchor</b>, <b>Image 2 = Style-Referenz des gewählten Tasks</b>. Ohne eigenen Task-Style wird das eingebettete <code>internal/artifact/assets/style_reference.jpg</code> nur als Default-Style verwendet.</p><p class="small">Theme, Kleidung, Accessoires, Szene, Pose, Stimmung, Farb-Akzente und Rarity werden deterministisch aus Task/Winner/Seed gewählt. Das Modell erzeugt nur die Full-Art-Illustration; das finale Kartenlayout wird anschließend programmgesteuert aufgebaut.</p></div>
|
||
<div class="section-title">OPENAI NUTZUNG & KOSTEN</div><div class="cost-grid"><div class="cost-card"><small>KOSTEN HEUTE</small><b id="artifactCostToday">${usd(u.today_cost_usd,4)}</b><span id="artifactCostTodayMeta">${Number(u.today_calls||0).toLocaleString('de-DE')} API-Calls · ${Number(u.today_cards||0).toLocaleString('de-DE')} Karten${Number(u.today_calls||0)>Number(u.today_priced_calls||0)?` · ${(Number(u.today_calls||0)-Number(u.today_priced_calls||0)).toLocaleString('de-DE')} ohne Kostendaten`:''}</span></div><div class="cost-card"><small>Ø KOSTEN PRO KARTE</small><b id="artifactAvgCardCost">${usd(u.avg_card_cost_usd,5)}</b><span id="artifactAvgCardCostMeta">${Number(u.priced_card_generations||0).toLocaleString('de-DE')} bepreiste Generierungen</span></div><div class="cost-card"><small>KOSTEN PRO 1.000 KARTEN</small><b id="artifactCostPer1000">${usd(u.cost_per_1000_usd,2)}</b><span>hochgerechnet aus dem bisherigen Kartenmittel</span></div></div>
|
||
<div class="usage-note">Die Token-Nutzung stammt direkt aus der OpenAI-Antwort. Die USD-Werte werden lokal daraus mit fest hinterlegten öffentlichen Standardpreisen berechnet; sie sind kein Rechnungsabgleich. Anchor-Kosten zählen in „heute“, aber nicht in den Karten-Durchschnitt.</div>
|
||
<div class="usage-table-wrap"><table class="usage-table"><thead><tr><th>Zeit</th><th>Typ</th><th>Modell</th><th>Tokens (Text + Bild → Output)</th><th>Kosten</th><th>Request</th></tr></thead><tbody id="artifactUsageRows">${usageRows}</tbody></table></div>
|
||
<p class="small">Task-spezifische Style-Bilder und optionale kreative Vorgaben pflegst du im Tab <b>TASK ACTIONS</b>.</p></div>`;
|
||
if(anchorReady){const img=$('anchorPreview');loadProtectedImage('/api/admin/artifact/character-anchor?ts='+Date.now(),img,true).catch(()=>{if(img)img.alt='Anchor konnte nicht geladen werden'})}
|
||
const create=$('createCharacterAnchor');if(create)create.onclick=async()=>{if(!confirm('RIFT Character Anchor jetzt einmalig erzeugen? Danach wird er absichtlich nicht automatisch überschrieben.'))return;create.disabled=true;create.textContent='ANCHOR WIRD ERZEUGT …';try{await api('/api/admin/artifact/character-anchor',{method:'POST'},true);msg('RIFT Character Anchor erzeugt und gesperrt');await load(true,true)}catch(e){msg(e.message);create.disabled=false;create.textContent='RIFT-ANCHOR JETZT ERZEUGEN'}};
|
||
}else{
|
||
$('settingfields').innerHTML=`<div class="control-section"><div class="section-title">LEGACY ARTIFACT GENERATION</div><div class="provider-status">${['local','openai','comfyui','a1111'].map(k=>`<span class="${ps[k]?'ready':'off'}">${k.toUpperCase()} · ${ps[k]?'READY':'ENV FEHLT'}</span>`).join('')}</div>
|
||
<label><span>Provider</span><select data-setting-string="artifact_provider"><option value="local">local</option><option value="openai">openai</option><option value="comfyui">comfyui</option><option value="a1111">a1111</option><option value="auto">auto fallback</option></select></label>
|
||
<label><span>Modell / Checkpoint</span><input data-setting-string="artifact_model" value="${esc(settings?.artifact_model||'')}"></label>
|
||
<label><span>Quality (OpenAI)</span><select data-setting-string="artifact_quality"><option>auto</option><option>low</option><option>medium</option><option>high</option></select></label>
|
||
<label><span>Breite</span><input type="number" data-setting="artifact_width" value="${settings?.artifact_width||1024}"></label><label><span>Höhe</span><input type="number" data-setting="artifact_height" value="${settings?.artifact_height||1024}"></label><label><span>Steps (lokale UIs)</span><input type="number" data-setting="artifact_steps" value="${settings?.artifact_steps||28}"></label>
|
||
<label class="wide"><span>Prompt-Zusatz</span><textarea data-setting-string="artifact_prompt" rows="4">${esc(settings?.artifact_prompt||'')}</textarea></label><label class="wide"><span>Negative Prompt</span><textarea data-setting-string="artifact_negative_prompt" rows="3">${esc(settings?.artifact_negative_prompt||'')}</textarea></label></div>`;
|
||
const pr=document.querySelector('[data-setting-string="artifact_provider"]'),qu=document.querySelector('[data-setting-string="artifact_quality"]');if(pr)pr.value=settings?.artifact_provider||'local';if(qu)qu.value=settings?.artifact_quality||'medium';
|
||
}
|
||
$('savesettings').style.display='inline-block';$('ensuretasks').style.display='none';
|
||
}
|
||
function payloadText(a){try{const p=typeof a.payload==='string'?JSON.parse(a.payload):a.payload;return Object.keys(p||{}).length?JSON.stringify(p):'—'}catch{return '—'}}
|
||
function renderTaskControl(){
|
||
$('savesettings').style.display='none';$('ensuretasks').style.display='none';if(!selected){$('settingfields').innerHTML='<div class="empty">Links einen Task auswählen.</div>';return}
|
||
const customStyle=!!String(selected.nft_style_reference||'').trim();
|
||
$('settingfields').innerHTML=`<div class="control-section task-control"><div class="section-title">TASK ${esc(selected.display_name||selected.id.slice(-12))}</div><div class="task-facts"><span><b>${selected.range_bits}</b> Bit</span><span><b>${selected.paused?'PAUSED':selected.status}</b> Status</span><span><b>${selected.revision}</b> Revision</span></div>
|
||
<div class="task-config-box"><div class="section-title">TASK-DARSTELLUNG & RIFT ART-DIRECTION</div>${selected.parent_task_id?`<span class="inherit-badge">↳ geerbt von ${esc(String(selected.parent_task_id).slice(-12))}</span>`:'<span class="inherit-badge">ROOT TASK</span>'}<p class="draft-note">Der Folge-Task erbt Anzeigename, Beschreibung, kreative Vorgaben, Ausschlüsse und die Style-Referenz. Entwürfe in Textfeldern bleiben bei Auto-Refresh erhalten.</p>
|
||
<label class="wide"><span>Anzeigename</span><input id="taskDisplayName" data-draft="taskDisplayName" maxlength="80" value="${esc(selected.display_name||'')}"></label>
|
||
<label class="wide"><span>Beschreibung für Landing-Page</span><textarea id="taskDescription" data-draft="taskDescription" rows="3" maxlength="1200">${esc(selected.description||'')}</textarea></label>
|
||
<div class="task-style-admin"><div class="reference-preview has-image"><img id="taskStylePreview" alt="Style-Referenz des Tasks"></div><div class="task-style-controls"><div class="section-title">NFT STYLE-REFERENZ · ${customStyle?'CUSTOM':'DEFAULT'}</div><p class="small">Dieses Bild definiert <b>wie</b> RIFT für diese Task-Serie gerendert wird. Der globale Character Anchor definiert separat <b>wer</b> RIFT ist. Nutzer sehen diese Vorschau auf der Task-Auswahl.</p><p class="small">${customStyle?`Aktuell: <code>${esc(String(selected.nft_style_reference).slice(0,18))}…</code>`:'Kein eigener Style hochgeladen · es wird das eingebettete Default-Style-Bild verwendet.'}</p><input id="taskStyleFile" type="file" accept="image/jpeg,image/png"><div class="task-config-actions style-actions"><button id="uploadTaskStyle">STYLE HOCHLADEN / ERSETZEN</button>${customStyle?'<button id="clearTaskStyle" class="danger-button">AUF DEFAULT ZURÜCK</button>':''}</div></div></div>
|
||
<label class="wide"><span>Kreative Vorgaben für RIFT-Karten dieses Tasks · optional</span><textarea id="taskNFTPrompt" data-draft="taskNFTPrompt" rows="6" maxlength="8000" placeholder="Leer lassen = automatische Theme-/Outfit-/Szenen-Generierung. Optional z.B. Winter, elegante Streetwear, keine Waffen …">${esc(selected.nft_prompt_instructions||'')}</textarea></label>
|
||
<label class="wide"><span>Zusätzliche Ausschlüsse · optional</span><textarea id="taskNFTNegative" data-draft="taskNFTNegative" rows="3" maxlength="4000" placeholder="Leer lassen = nur globale RIFT-Regeln. Optional z.B. kein Helm, keine Waffen, kein Schnee …">${esc(selected.nft_negative_prompt||'')}</textarea></label>
|
||
<div class="task-config-actions"><button id="saveTaskConfig">TASK-KONFIG SPEICHERN</button></div><div class="task-config-box pipeline-test-box"><div class="section-title">LOKALER PIPELINE-TEST</div><p class="small">Erzeugt eine komplette Testkarte <b>ohne OpenAI-Aufruf</b>. Der vorhandene <code>character_anchor.png</code> dient als Mock-Artwork, die Style-Referenz dieses Tasks als Hintergrund. Kartenlayout, Traits, Dateischreiben und SVG-Ausgabe werden lokal durchgespielt. Der echte Task-/Artifact-Status bleibt unverändert.</p><div class="task-config-actions"><button id="runPipelineTest">TEST-KARTE ERZEUGEN · 0 API-TOKENS</button></div></div></div>
|
||
<div class="section-title">AKTIONEN / SCHEDULER</div><label><span>Aktion</span><select id="actionType" data-draft="actionType"><option value="set_range_bits">Zahlenraum ändern</option><option value="set_intervals">Intervalle ändern</option><option value="clear_intervals">Intervalle auf Defaults</option><option value="pause">Pausieren</option><option value="resume">Fortsetzen</option><option value="reroll">Ziel neu würfeln</option><option value="close">Task beenden</option><option value="regenerate_artifact">NFT-Bild neu erzeugen</option></select></label><div id="actionPayload"></div>
|
||
<label class="wide"><span>Ausführen am</span><div class="schedule-row"><input id="actionAt" data-draft="actionAt" type="datetime-local"><button id="runAction">JETZT</button><button id="scheduleAction">PLANEN</button></div></label><div class="action-warning" id="actionWarning"></div>
|
||
<div class="section-title action-history-title">AKTIONSPLAN / AUDIT</div><div class="action-list">${actions.length?actions.map(a=>`<div class="action-item ${esc(a.status)}"><span><b>${esc(actionLabel(a.action_type))}</b><small>${fmtDate(a.execute_at)} · ${esc(payloadText(a))}</small>${a.error?`<small class="danger">${esc(a.error)}</small>`:''}</span><em>${esc(a.status)}</em>${a.status==='pending'?`<button data-cancel-action="${esc(a.id)}">×</button>`:''}</div>`).join(''):'<div class="empty small">Noch keine geplanten Aktionen</div>'}</div></div>`;
|
||
const styleImg=$('taskStylePreview');loadProtectedImage(`/api/admin/tasks/${selected.id}/style-reference?ts=${Date.now()}`,styleImg,true).catch(()=>{if(styleImg)styleImg.alt='Style-Referenz konnte nicht geladen werden'});
|
||
const uploadStyle=$('uploadTaskStyle');if(uploadStyle)uploadStyle.onclick=async()=>{const file=$('taskStyleFile')?.files?.[0];if(!file){msg('Bitte zuerst ein JPEG- oder PNG-Stylebild auswählen');return}const fd=new FormData();fd.append('file',file,file.name);uploadStyle.disabled=true;uploadStyle.textContent='STYLE WIRD HOCHGELADEN …';try{await api(`/api/admin/tasks/${selected.id}/style-reference`,{method:'PUT',body:fd},true);msg('Task-Style gespeichert · Folge-Task übernimmt ihn');await load(true,true)}catch(e){msg(e.message);uploadStyle.disabled=false;uploadStyle.textContent='STYLE HOCHLADEN / ERSETZEN'}};
|
||
const clearStyle=$('clearTaskStyle');if(clearStyle)clearStyle.onclick=async()=>{if(!confirm('Eigenen Task-Style entfernen und wieder den eingebetteten Default-Style verwenden?'))return;try{await api(`/api/admin/tasks/${selected.id}/style-reference`,{method:'DELETE'},true);msg('Task-Style auf Default zurückgesetzt');await load(true,true)}catch(e){msg(e.message)}};
|
||
const pipelineTest=$('runPipelineTest');if(pipelineTest)pipelineTest.onclick=async()=>{pipelineTest.disabled=true;pipelineTest.textContent='TEST-KARTE WIRD LOKAL ERZEUGT …';try{const r=await api(`/api/admin/tasks/${selected.id}/pipeline-test`,{method:'POST'},true);const popup=window.open('','_blank');const token=localStorage.getItem(adminTokenKey),resp=await fetch(r.url,{headers:{Authorization:'Bearer '+token}});if(!resp.ok)throw new Error(await responseError(resp,'Testkarte konnte nicht geöffnet werden'));const blob=await resp.blob(),u=URL.createObjectURL(blob);if(popup)popup.location=u;else{const a=document.createElement('a');a.href=u;a.target='_blank';a.click()}setTimeout(()=>URL.revokeObjectURL(u),60000);msg('Lokale Testkarte erzeugt · 0 API-Calls · $0.00')}catch(e){msg(e.message)}finally{pipelineTest.disabled=false;pipelineTest.textContent='TEST-KARTE ERZEUGEN · 0 API-TOKENS'}};
|
||
const renderPayload=()=>{const type=$('actionType').value,host=$('actionPayload');if(type==='set_range_bits')host.innerHTML=`<label><span>Task-Zahlenraum (Bit)</span><input id="actionBits" data-draft="actionBits" type="number" min="8" max="128" value="${selected.range_bits}"></label><label><span>Änderungsmodus</span><select id="actionMode" data-draft="actionMode"><option value="preserve">preserve · bestehendes Ziel</option><option value="reroll">reroll · neues Ziel</option></select></label><p class="small">Preserve erhält Seed/Sequenzen und re-skaliert Scores mathematisch. Reroll setzt Scores/Sequenzen zurück.</p>`;else if(type==='set_intervals')host.innerHTML=`<label><span>Server Minimum (s)</span><input id="actionServer" data-draft="actionServer" type="number" min="1" max="3600" value="${selected.guess_min_interval_sec??settings.guess_min_interval_sec}"></label><label><span>Client Submit (s)</span><input id="actionClient" data-draft="actionClient" type="number" min="2" max="7200" value="${selected.client_submit_interval_sec??settings.client_submit_interval_sec}"></label>`;else host.innerHTML=`<p class="small">${type==='reroll'?'Achtung: neues Ziel und neuer öffentlicher Seed; aktuelle Scores werden auf 0 gesetzt.':type==='close'?'Beendet den Task. Der automatisch erzeugte Folge-Task erbt Zahlenraum, Intervalle, Darstellung, RIFT-Prompt und Style-Referenz.':type==='regenerate_artifact'?'Nur für abgeschlossene Tasks: setzt das Artifact wieder auf queued.':'Keine weiteren Parameter.'}</p>`};
|
||
const defaultAt=new Date(Date.now()+5*60*1000);defaultAt.setMinutes(defaultAt.getMinutes()-defaultAt.getTimezoneOffset());$('actionAt').value=defaultAt.toISOString().slice(0,16);restoreDraft();renderPayload();restoreDraft();$('actionType').onchange=()=>{renderPayload();restoreDraft();captureDraft()};
|
||
$('saveTaskConfig').onclick=async()=>{try{captureDraft();await api(`/api/admin/tasks/${selected.id}/config`,{method:'PUT',body:JSON.stringify({display_name:$('taskDisplayName').value,description:$('taskDescription').value,nft_prompt_instructions:$('taskNFTPrompt').value,nft_negative_prompt:$('taskNFTNegative').value})},true);clearDraftKeys(['taskDisplayName','taskDescription','taskNFTPrompt','taskNFTNegative']);msg('Task-Konfiguration gespeichert · Folge-Task übernimmt sie');await load(true,true)}catch(e){msg(e.message)}};
|
||
const submitAction=async runNow=>{try{captureDraft();const type=$('actionType').value,payload={};if(type==='set_range_bits'){payload.bits=Number($('actionBits').value);payload.mode=$('actionMode').value}else if(type==='set_intervals'){payload.server_min_interval_sec=Number($('actionServer').value);payload.client_submit_interval_sec=Number($('actionClient').value)}const execute_at=runNow?null:new Date($('actionAt').value).toISOString();await api(`/api/admin/tasks/${selected.id}/actions`,{method:'POST',body:JSON.stringify({action_type:type,payload,execute_at})},true);msg(runNow?'Aktion ausgeführt':'Aktion geplant');await load(true,true)}catch(e){msg(e.message)}};
|
||
$('runAction').onclick=()=>submitAction(true);$('scheduleAction').onclick=()=>submitAction(false);document.querySelectorAll('[data-cancel-action]').forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/actions/${b.dataset.cancelAction}/cancel`,{method:'POST'},true);await load(true,true)}catch(e){msg(e.message)}});
|
||
}
|
||
// The control plane is intentionally NOT rebuilt by the 3-second telemetry poll.
|
||
// Replacing <select> nodes while a native dropdown is open makes the browser close
|
||
// the popup and also destroys caret/selection/scroll state in text fields. Drafts
|
||
// protect values, but they cannot protect native UI state. Only explicit user
|
||
// navigation/actions are allowed to rebuild this subtree.
|
||
function refreshArtifactUsageTelemetry(){
|
||
if(!$('artifactCostToday'))return;const u=artifactUsage||{},recent=Array.isArray(u.recent)?u.recent:[],usd=(n,d=4)=>Number(n||0).toLocaleString('de-DE',{style:'currency',currency:'USD',minimumFractionDigits:d,maximumFractionDigits:d});
|
||
$('artifactCostToday').textContent=usd(u.today_cost_usd,4);$('artifactCostTodayMeta').textContent=`${Number(u.today_calls||0).toLocaleString('de-DE')} API-Calls · ${Number(u.today_cards||0).toLocaleString('de-DE')} Karten${Number(u.today_calls||0)>Number(u.today_priced_calls||0)?` · ${(Number(u.today_calls||0)-Number(u.today_priced_calls||0)).toLocaleString('de-DE')} ohne Kostendaten`:''}`;$('artifactAvgCardCost').textContent=usd(u.avg_card_cost_usd,5);$('artifactAvgCardCostMeta').textContent=`${Number(u.priced_card_generations||0).toLocaleString('de-DE')} bepreiste Generierungen`;$('artifactCostPer1000').textContent=usd(u.cost_per_1000_usd,2);
|
||
$('artifactUsageRows').innerHTML=recent.length?recent.map(r=>`<tr><td>${fmtDate(r.created_at)}</td><td>${r.kind==='character_anchor'?'ANCHOR':'KARTE'}</td><td>${esc(r.model||'—')}<small>${esc(r.quality||'—')} · ${esc(r.size||'—')}</small></td><td>${Number(r.input_text_tokens||0).toLocaleString('de-DE')} T + ${Number(r.input_image_tokens||0).toLocaleString('de-DE')} I → ${Number(r.output_tokens||0).toLocaleString('de-DE')}</td><td>${r.estimated_cost_usd==null?'—':usd(r.estimated_cost_usd,5)}</td><td><small>${esc(r.request_id?String(r.request_id).slice(-16):'—')}</small></td></tr>`).join(''):'<tr><td colspan="6" class="empty small">Noch keine OpenAI-Bildgenerierung protokolliert.</td></tr>';
|
||
}
|
||
function renderSettings(){setActive('tabRuntime',tab==='runtime');setActive('tabTask',tab==='task');setActive('tabArtifact',tab==='artifact');if(tab==='runtime')renderRuntime();else if(tab==='artifact')renderArtifact();else renderTaskControl();restoreDraft()}
|
||
function renderMap(){points=Array.isArray(points)?points:[];const min=Number($('adminMinScore')?.value||0),needle=String($('adminClientFilter')?.value||'').trim().toLowerCase(),filtered=points.filter(p=>Number(p.score||0)>=min&&(!needle||String(p.client_id||'').toLowerCase().includes(needle)));$('adminpoints').textContent=`${filtered.length.toLocaleString('de-DE')} / ${points.length.toLocaleString('de-DE')}`;$('selectedtask').textContent=selected?.id||'Task wählen';$('adminbits').textContent=selected?.range_bits??'—';$('winner').textContent=selected?.winner_client_id?`Winner ${shortID(selected.winner_client_id,12)}`:selected?.paused?'PAUSED':'';map.update(filtered,'',+$('adminmaxnodes').value)}
|
||
async function refreshSelected(refreshControls=false){if(!selected){points=[];actions=[];renderMap();if(refreshControls)renderSettings();return}try{const [ps,as]=await Promise.all([api(`/api/admin/tasks/${selected.id}/points?limit=100000`,{},true),api(`/api/admin/tasks/${selected.id}/actions?limit=100`,{},true)]);points=Array.isArray(ps)?ps:[];actions=Array.isArray(as)?as:[];renderMap();if(refreshControls)renderSettings()}catch(e){msg(e.message)}}
|
||
async function load(keepMessage=false,refreshControls=false){if(loading)return;loading=true;try{const status=$('statusfilter').value,q=$('taskquery').value,dayStart=new Date();dayStart.setHours(0,0,0,0);const [ts,st,ov,pv,pf,au]=await Promise.all([api(`/api/admin/tasks?status=${encodeURIComponent(status)}&q=${encodeURIComponent(q)}&limit=300`,{},true),api('/api/admin/settings',{},true),api('/api/admin/overview',{},true),api('/api/admin/artifact/providers',{},true),api('/api/admin/performance',{},true),api(`/api/admin/artifact/usage?day_start_ms=${dayStart.getTime()}`,{},true)]);tasks=Array.isArray(ts)?ts:[];settings=st||{};providers=pv||{};artifactUsage=au||{};refreshArtifactUsageTelemetry();renderOverview(ov||{});renderPerformance(pf||{});if(selected){selected=tasks.find(t=>t.id===selected.id)||selected}renderTasks();if(selected)await refreshSelected(refreshControls);else if(refreshControls)renderSettings()}catch(e){if(e.status===401){localStorage.removeItem(adminTokenKey);location.reload();return}msg(e.message||'Laden fehlgeschlagen')}finally{loading=false}}
|
||
async function openTask(t){captureDraft();selected=t;draft.selectedTaskId=t?.id||'';saveDraft();await refreshSelected(true);renderTasks();if(document.documentElement.classList.contains('mobile-mode'))setAdminPanel('map')}
|
||
$('filter').onclick=()=>{saveDraft();load()};$('statusfilter').onchange=()=>{saveDraft();load()};$('taskquery').addEventListener('input',saveDraft);$('taskquery').addEventListener('keydown',e=>{if(e.key==='Enter'){saveDraft();load()}});$('adminmaxnodes').oninput=e=>{$('adminmaxvalue').textContent=Number(e.target.value).toLocaleString('de-DE');renderMap()};$('adminMinScore').oninput=renderMap;$('adminClientFilter').oninput=renderMap;
|
||
$('adminProximity').onclick=()=>{map.proximityFocus=!map.proximityFocus;$('adminProximity').textContent=map.proximityFocus?'TARGET FIELD':'RAW 3D';setActive('adminProximity',map.proximityFocus)};$('adminRotate').onclick=()=>{map.autoRotate=!map.autoRotate;setActive('adminRotate',map.autoRotate)};$('adminEdges').onclick=()=>{map.edges=!map.edges;setActive('adminEdges',map.edges)};$('adminShells').onclick=()=>{map.shells=!map.shells;setActive('adminShells',map.shells)};$('adminLOD').onclick=()=>{map.lodEnabled=!map.lodEnabled;map.rebuild();setActive('adminLOD',map.lodEnabled)};$('adminEco').onclick=()=>{map.eco=!map.eco;map.resize();setActive('adminEco',map.eco)};$('adminReset').onclick=()=>map.resetView();
|
||
$('tabRuntime').onclick=()=>{captureDraft();tab='runtime';saveDraft();renderSettings()};$('tabTask').onclick=()=>{captureDraft();tab='task';saveDraft();renderSettings()};$('tabArtifact').onclick=()=>{captureDraft();tab='artifact';saveDraft();renderSettings()};
|
||
$('savesettings').onclick=async()=>{try{captureDraft();const out={...settings};document.querySelectorAll('[data-setting]').forEach(i=>out[i.dataset.setting]=Number(i.value));document.querySelectorAll('[data-setting-string]').forEach(i=>out[i.dataset.settingString]=i.value);settings=await api('/api/admin/settings',{method:'PUT',body:JSON.stringify(out)},true);if(draft.fields)delete draft.fields[draftScope()];saveDraft();msg('gespeichert');renderSettings()}catch(e){msg(e.message)}};
|
||
$('ensuretasks').onclick=async()=>{try{await api('/api/admin/tasks/ensure',{method:'POST'},true);msg('aktive Tasks sichergestellt');await load(true,true)}catch(e){msg(e.message)}};
|
||
$('adminlogout').onclick=()=>{localStorage.removeItem(adminTokenKey);location.reload()};
|
||
$('settingfields').addEventListener('input',captureDraft);$('settingfields').addEventListener('change',captureDraft);if(draft.filters){$('statusfilter').value=draft.filters.status||'';$('taskquery').value=draft.filters.q||''}await load();const first=tasks.find(t=>t.id===draft.selectedTaskId)||(tasks.find(t=>t.status==='active')||tasks[0]);if(first)await openTask(first);else renderSettings();poll=setInterval(()=>load(true,false),3000);addEventListener('beforeunload',()=>{captureDraft();clearInterval(poll);window.removeEventListener('neuralhunt-mobile-mode',syncAdminMobile);map.destroy()},{once:true});
|
||
}
|
||
|
||
applyMobileMode(mobileModeEnabled());
|
||
const mobileMedia=matchMedia('(max-width: 850px)');mobileMedia.addEventListener?.('change',()=>{if(localStorage.getItem(mobileModeKey)===null)applyMobileMode(autoMobileMode())});
|
||
const path=location.pathname;
|
||
(path.startsWith('/admin')?runAdmin():path.startsWith('/leaderboard')?runLeaderboard():runUser()).catch(e=>{app.innerHTML=`<pre style="padding:2rem;color:#ff9bad">${esc(e.stack||e.message||e)}</pre>`});
|