mirror of
https://github.com/ruvnet/RuView
synced 2026-08-10 20:31:42 +00:00
Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,200 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Causal Atlas</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; width: 100%; overflow: hidden; }
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
background: #0B0F14;
|
||||
color: #E6EDF3;
|
||||
}
|
||||
#shell {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
/* Left nav rail */
|
||||
#nav-rail {
|
||||
width: 72px;
|
||||
min-width: 72px;
|
||||
background: #0B0F14;
|
||||
border-right: 1px solid #1E2630;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 16px;
|
||||
gap: 2px;
|
||||
z-index: 20;
|
||||
}
|
||||
#nav-rail a {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 52px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: #8B949E;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
#nav-rail a svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
#nav-rail a span {
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
margin-top: 3px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
#nav-rail a:hover {
|
||||
color: #E6EDF3;
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
#nav-rail a.active {
|
||||
color: #00E5FF;
|
||||
background: rgba(0,229,255,0.08);
|
||||
}
|
||||
/* Main area */
|
||||
#main-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Top bar */
|
||||
#top-bar {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
background: #0B0F14;
|
||||
border-bottom: 1px solid #1E2630;
|
||||
}
|
||||
#top-bar .sys-name {
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: #E6EDF3;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
#top-bar .hash-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
#top-bar .hash-display .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #2ECC71;
|
||||
}
|
||||
#top-bar .hash-display code {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', 'Cascadia Code', monospace;
|
||||
font-size: 11px;
|
||||
color: #8B949E;
|
||||
}
|
||||
/* App container */
|
||||
#app {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/index-DTWFqlsC.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/three-Cdmz7B_B.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/d3-BwsdWUnD.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BfCA2--q.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="shell">
|
||||
<nav id="nav-rail">
|
||||
<a href="#/atlas" title="Atlas Explorer">
|
||||
<svg viewBox="0 0 24 24"><circle cx="5" cy="5" r="1.5"/><circle cx="19" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="5" cy="19" r="1.5"/><circle cx="19" cy="19" r="1.5"/><line x1="5" y1="5" x2="12" y2="12"/><line x1="19" y1="5" x2="12" y2="12"/><line x1="12" y1="12" x2="5" y2="19"/><line x1="12" y1="12" x2="19" y2="19"/></svg>
|
||||
<span>Atlas</span>
|
||||
</a>
|
||||
<a href="#/coherence" title="Coherence Field">
|
||||
<svg viewBox="0 0 24 24"><path d="M2 12c2-3 4-5 6-5s4 5 6 5 4-5 6-5"/><path d="M2 17c2-3 4-5 6-5s4 5 6 5 4-5 6-5" opacity="0.5"/></svg>
|
||||
<span>Coherence</span>
|
||||
</a>
|
||||
<a href="#/boundaries" title="Boundary Evolution">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
<span>Bounds</span>
|
||||
</a>
|
||||
<a href="#/memory" title="Memory Tiers">
|
||||
<svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="2"/><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="14" x2="20" y2="14"/><circle cx="8" cy="6.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="8" cy="11.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="8" cy="16.5" r="0.5" fill="currentColor" stroke="none"/></svg>
|
||||
<span>Memory</span>
|
||||
</a>
|
||||
<a href="#/planets" title="Planet Candidates">
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="6"/><ellipse cx="12" cy="12" rx="11" ry="4" transform="rotate(-20 12 12)"/></svg>
|
||||
<span>Planets</span>
|
||||
</a>
|
||||
<a href="#/life" title="Life Candidates">
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="4" r="1.5"/><circle cx="19" cy="16" r="1.5"/><circle cx="5" cy="16" r="1.5"/><line x1="12" y1="9" x2="12" y2="5.5"/><line x1="14.6" y1="13.5" x2="17.5" y2="15"/><line x1="9.4" y1="13.5" x2="6.5" y2="15"/></svg>
|
||||
<span>Life</span>
|
||||
</a>
|
||||
<a href="#/witness" title="Witness Chain">
|
||||
<svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
<span>Witness</span>
|
||||
</a>
|
||||
<a href="#/solver" title="RVF Solver">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/></svg>
|
||||
<span>Solver</span>
|
||||
</a>
|
||||
<a href="#/blind-test" title="Blind Test: Discover Exoplanets">
|
||||
<svg viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<span>Blind Test</span>
|
||||
</a>
|
||||
<a href="#/discover" title="Discover New Planets">
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32l1.41-1.41"/><circle cx="12" cy="12" r="9" fill="none" stroke-dasharray="3 3"/></svg>
|
||||
<span>Discover</span>
|
||||
</a>
|
||||
<a href="#/dyson" title="Dyson Sphere Detection">
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><ellipse cx="12" cy="12" rx="10" ry="10" fill="none" stroke-dasharray="4 2"/><ellipse cx="12" cy="12" rx="10" ry="5" fill="none" stroke-dasharray="4 2" transform="rotate(60 12 12)"/><ellipse cx="12" cy="12" rx="10" ry="5" fill="none" stroke-dasharray="4 2" transform="rotate(-60 12 12)"/></svg>
|
||||
<span>Dyson</span>
|
||||
</a>
|
||||
<a href="#/docs" title="Documentation">
|
||||
<svg viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/><line x1="9" y1="7" x2="16" y2="7"/><line x1="9" y1="11" x2="16" y2="11"/><line x1="9" y1="15" x2="13" y2="15"/></svg>
|
||||
<span>Docs</span>
|
||||
</a>
|
||||
<a href="#/status" title="System Status">
|
||||
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||||
<span>Status</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div id="main-area">
|
||||
<header id="top-bar">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<span class="sys-name">CAUSAL ATLAS</span>
|
||||
<span style="font-size:9px;font-weight:600;letter-spacing:0.5px;font-family:'JetBrains Mono',monospace" id="pipeline-status">...</span>
|
||||
</div>
|
||||
<div class="hash-display">
|
||||
<svg viewBox="0 0 24 24" style="width:16px;height:16px;stroke:#00E5FF;fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;opacity:0.7"><circle cx="12" cy="12" r="6"/><ellipse cx="12" cy="12" rx="11" ry="4" transform="rotate(-20 12 12)"/><circle cx="12" cy="12" r="1.5" fill="#00E5FF" stroke="none"/></svg>
|
||||
<span style="font-size:9px;font-weight:600;letter-spacing:0.5px;font-family:'JetBrains Mono',monospace;color:#8B949E">RVF <span style="color:#00E5FF">v2.0</span></span>
|
||||
<span style="font-size:8px;padding:1px 5px;border-radius:3px;background:rgba(0,229,255,0.08);border:1px solid rgba(0,229,255,0.15);color:#00E5FF;font-weight:600;letter-spacing:0.3px;font-family:'JetBrains Mono',monospace">WASM + Ed25519</span>
|
||||
<div style="width:1px;height:14px;background:#1E2630;margin:0 4px"></div>
|
||||
<div class="dot"></div>
|
||||
<code id="root-hash">0x52564...</code>
|
||||
<div style="width:1px;height:14px;background:#1E2630;margin:0 4px"></div>
|
||||
<a href="#/download" title="Download RVF" style="display:flex;align-items:center;gap:5px;text-decoration:none;padding:3px 10px;border-radius:4px;border:1px solid rgba(0,229,255,0.2);background:rgba(0,229,255,0.06);color:#00E5FF;font-size:9px;font-weight:600;letter-spacing:0.3px;font-family:'JetBrains Mono',monospace;transition:background 0.15s,border-color 0.15s" onmouseenter="this.style.background='rgba(0,229,255,0.12)';this.style.borderColor='rgba(0,229,255,0.4)'" onmouseleave="this.style.background='rgba(0,229,255,0.06)';this.style.borderColor='rgba(0,229,255,0.2)'">
|
||||
<svg viewBox="0 0 24 24" style="width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<div id="app"></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
Binary file not shown.
+197
@@ -0,0 +1,197 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Causal Atlas</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; width: 100%; overflow: hidden; }
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
background: #0B0F14;
|
||||
color: #E6EDF3;
|
||||
}
|
||||
#shell {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
/* Left nav rail */
|
||||
#nav-rail {
|
||||
width: 72px;
|
||||
min-width: 72px;
|
||||
background: #0B0F14;
|
||||
border-right: 1px solid #1E2630;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 16px;
|
||||
gap: 2px;
|
||||
z-index: 20;
|
||||
}
|
||||
#nav-rail a {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 52px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: #8B949E;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
#nav-rail a svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
#nav-rail a span {
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
margin-top: 3px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
#nav-rail a:hover {
|
||||
color: #E6EDF3;
|
||||
background: rgba(255,255,255,0.04);
|
||||
}
|
||||
#nav-rail a.active {
|
||||
color: #00E5FF;
|
||||
background: rgba(0,229,255,0.08);
|
||||
}
|
||||
/* Main area */
|
||||
#main-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Top bar */
|
||||
#top-bar {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
background: #0B0F14;
|
||||
border-bottom: 1px solid #1E2630;
|
||||
}
|
||||
#top-bar .sys-name {
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
color: #E6EDF3;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
#top-bar .hash-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
#top-bar .hash-display .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #2ECC71;
|
||||
}
|
||||
#top-bar .hash-display code {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', 'Cascadia Code', monospace;
|
||||
font-size: 11px;
|
||||
color: #8B949E;
|
||||
}
|
||||
/* App container */
|
||||
#app {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="shell">
|
||||
<nav id="nav-rail">
|
||||
<a href="#/atlas" title="Atlas Explorer">
|
||||
<svg viewBox="0 0 24 24"><circle cx="5" cy="5" r="1.5"/><circle cx="19" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="5" cy="19" r="1.5"/><circle cx="19" cy="19" r="1.5"/><line x1="5" y1="5" x2="12" y2="12"/><line x1="19" y1="5" x2="12" y2="12"/><line x1="12" y1="12" x2="5" y2="19"/><line x1="12" y1="12" x2="19" y2="19"/></svg>
|
||||
<span>Atlas</span>
|
||||
</a>
|
||||
<a href="#/coherence" title="Coherence Field">
|
||||
<svg viewBox="0 0 24 24"><path d="M2 12c2-3 4-5 6-5s4 5 6 5 4-5 6-5"/><path d="M2 17c2-3 4-5 6-5s4 5 6 5 4-5 6-5" opacity="0.5"/></svg>
|
||||
<span>Coherence</span>
|
||||
</a>
|
||||
<a href="#/boundaries" title="Boundary Evolution">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
<span>Bounds</span>
|
||||
</a>
|
||||
<a href="#/memory" title="Memory Tiers">
|
||||
<svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="2"/><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="14" x2="20" y2="14"/><circle cx="8" cy="6.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="8" cy="11.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="8" cy="16.5" r="0.5" fill="currentColor" stroke="none"/></svg>
|
||||
<span>Memory</span>
|
||||
</a>
|
||||
<a href="#/planets" title="Planet Candidates">
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="6"/><ellipse cx="12" cy="12" rx="11" ry="4" transform="rotate(-20 12 12)"/></svg>
|
||||
<span>Planets</span>
|
||||
</a>
|
||||
<a href="#/life" title="Life Candidates">
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="4" r="1.5"/><circle cx="19" cy="16" r="1.5"/><circle cx="5" cy="16" r="1.5"/><line x1="12" y1="9" x2="12" y2="5.5"/><line x1="14.6" y1="13.5" x2="17.5" y2="15"/><line x1="9.4" y1="13.5" x2="6.5" y2="15"/></svg>
|
||||
<span>Life</span>
|
||||
</a>
|
||||
<a href="#/witness" title="Witness Chain">
|
||||
<svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
<span>Witness</span>
|
||||
</a>
|
||||
<a href="#/solver" title="RVF Solver">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/></svg>
|
||||
<span>Solver</span>
|
||||
</a>
|
||||
<a href="#/blind-test" title="Blind Test: Discover Exoplanets">
|
||||
<svg viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<span>Blind Test</span>
|
||||
</a>
|
||||
<a href="#/discover" title="Discover New Planets">
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32l1.41-1.41"/><circle cx="12" cy="12" r="9" fill="none" stroke-dasharray="3 3"/></svg>
|
||||
<span>Discover</span>
|
||||
</a>
|
||||
<a href="#/dyson" title="Dyson Sphere Detection">
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><ellipse cx="12" cy="12" rx="10" ry="10" fill="none" stroke-dasharray="4 2"/><ellipse cx="12" cy="12" rx="10" ry="5" fill="none" stroke-dasharray="4 2" transform="rotate(60 12 12)"/><ellipse cx="12" cy="12" rx="10" ry="5" fill="none" stroke-dasharray="4 2" transform="rotate(-60 12 12)"/></svg>
|
||||
<span>Dyson</span>
|
||||
</a>
|
||||
<a href="#/docs" title="Documentation">
|
||||
<svg viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/><line x1="9" y1="7" x2="16" y2="7"/><line x1="9" y1="11" x2="16" y2="11"/><line x1="9" y1="15" x2="13" y2="15"/></svg>
|
||||
<span>Docs</span>
|
||||
</a>
|
||||
<a href="#/status" title="System Status">
|
||||
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||||
<span>Status</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div id="main-area">
|
||||
<header id="top-bar">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<span class="sys-name">CAUSAL ATLAS</span>
|
||||
<span style="font-size:9px;font-weight:600;letter-spacing:0.5px;font-family:'JetBrains Mono',monospace" id="pipeline-status">...</span>
|
||||
</div>
|
||||
<div class="hash-display">
|
||||
<svg viewBox="0 0 24 24" style="width:16px;height:16px;stroke:#00E5FF;fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;opacity:0.7"><circle cx="12" cy="12" r="6"/><ellipse cx="12" cy="12" rx="11" ry="4" transform="rotate(-20 12 12)"/><circle cx="12" cy="12" r="1.5" fill="#00E5FF" stroke="none"/></svg>
|
||||
<span style="font-size:9px;font-weight:600;letter-spacing:0.5px;font-family:'JetBrains Mono',monospace;color:#8B949E">RVF <span style="color:#00E5FF">v2.0</span></span>
|
||||
<span style="font-size:8px;padding:1px 5px;border-radius:3px;background:rgba(0,229,255,0.08);border:1px solid rgba(0,229,255,0.15);color:#00E5FF;font-weight:600;letter-spacing:0.3px;font-family:'JetBrains Mono',monospace">WASM + Ed25519</span>
|
||||
<div style="width:1px;height:14px;background:#1E2630;margin:0 4px"></div>
|
||||
<div class="dot"></div>
|
||||
<code id="root-hash">0x52564...</code>
|
||||
<div style="width:1px;height:14px;background:#1E2630;margin:0 4px"></div>
|
||||
<a href="#/download" title="Download RVF" style="display:flex;align-items:center;gap:5px;text-decoration:none;padding:3px 10px;border-radius:4px;border:1px solid rgba(0,229,255,0.2);background:rgba(0,229,255,0.06);color:#00E5FF;font-size:9px;font-weight:600;letter-spacing:0.3px;font-family:'JetBrains Mono',monospace;transition:background 0.15s,border-color 0.15s" onmouseenter="this.style.background='rgba(0,229,255,0.12)';this.style.borderColor='rgba(0,229,255,0.4)'" onmouseleave="this.style.background='rgba(0,229,255,0.06)';this.style.borderColor='rgba(0,229,255,0.2)'">
|
||||
<svg viewBox="0 0 24 24" style="width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<div id="app"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "rvf-causal-atlas-dashboard",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"three": "^0.170.0",
|
||||
"d3-scale": "^4.0.0",
|
||||
"d3-axis": "^3.0.0",
|
||||
"d3-shape": "^3.2.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"@ruvector/rvf-solver": "file:../../../npm/packages/rvf-solver"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
"@types/three": "^0.170.0",
|
||||
"@types/d3-scale": "^4.0.0",
|
||||
"@types/d3-axis": "^3.0.0",
|
||||
"@types/d3-shape": "^3.0.0",
|
||||
"@types/d3-selection": "^3.0.0"
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
+322
@@ -0,0 +1,322 @@
|
||||
const BASE = '';
|
||||
|
||||
// --- Atlas types ---
|
||||
|
||||
export interface AtlasQueryResult {
|
||||
event_id: string;
|
||||
parents: string[];
|
||||
children: string[];
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface WitnessEntry {
|
||||
step: string;
|
||||
type: string;
|
||||
timestamp: number;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface WitnessTrace {
|
||||
entries: WitnessEntry[];
|
||||
}
|
||||
|
||||
// --- Coherence types ---
|
||||
|
||||
export interface CoherenceValue {
|
||||
target_id: string;
|
||||
epoch: number;
|
||||
value: number;
|
||||
cut_pressure: number;
|
||||
}
|
||||
|
||||
export interface BoundaryPoint {
|
||||
epoch: number;
|
||||
pressure: number;
|
||||
crossed: boolean;
|
||||
}
|
||||
|
||||
export interface BoundaryAlert {
|
||||
target_id: string;
|
||||
epoch: number;
|
||||
pressure: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// --- Planet types ---
|
||||
|
||||
export interface PlanetCandidate {
|
||||
id: string;
|
||||
name: string;
|
||||
score: number;
|
||||
period: number;
|
||||
radius: number;
|
||||
depth: number;
|
||||
snr: number;
|
||||
stellarType: string;
|
||||
distance: number;
|
||||
status: string;
|
||||
mass: number | null;
|
||||
eqTemp: number | null;
|
||||
discoveryYear: number;
|
||||
discoveryMethod: string;
|
||||
telescope: string;
|
||||
reference: string;
|
||||
transitDepth: number | null;
|
||||
}
|
||||
|
||||
// --- Life types ---
|
||||
|
||||
export interface LifeCandidate {
|
||||
id: string;
|
||||
name: string;
|
||||
score: number;
|
||||
o2: number;
|
||||
ch4: number;
|
||||
h2o: number;
|
||||
co2: number;
|
||||
disequilibrium: number;
|
||||
habitability: number;
|
||||
atmosphereStatus: string;
|
||||
jwstObserved: boolean;
|
||||
moleculesConfirmed: string[];
|
||||
moleculesTentative: string[];
|
||||
reference: string;
|
||||
}
|
||||
|
||||
// --- System types ---
|
||||
|
||||
export interface SystemStatus {
|
||||
uptime: number;
|
||||
segments: number;
|
||||
file_size: number;
|
||||
download_progress: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface MemoryTierInfo {
|
||||
used: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface MemoryTiers {
|
||||
small: MemoryTierInfo;
|
||||
medium: MemoryTierInfo;
|
||||
large: MemoryTierInfo;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
const response = await fetch(BASE + path);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error ${response.status}: ${response.statusText} (${path})`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// --- Atlas API ---
|
||||
|
||||
export async function fetchAtlasQuery(eventId: string): Promise<AtlasQueryResult> {
|
||||
return get<AtlasQueryResult>(`/api/atlas/query?event_id=${encodeURIComponent(eventId)}`);
|
||||
}
|
||||
|
||||
export async function fetchAtlasTrace(candidateId: string): Promise<WitnessTrace> {
|
||||
return get<WitnessTrace>(`/api/atlas/trace?candidate_id=${encodeURIComponent(candidateId)}`);
|
||||
}
|
||||
|
||||
// --- Coherence API ---
|
||||
// The API returns { grid_size, values: number[][], min, max, mean }.
|
||||
// Flatten the 2D matrix into CoherenceValue[] for the surface.
|
||||
|
||||
export async function fetchCoherence(targetId: string, epoch: number): Promise<CoherenceValue[]> {
|
||||
const raw = await get<{ grid_size: number[]; values: number[][]; min: number; max: number }>(
|
||||
`/api/coherence?target_id=${encodeURIComponent(targetId)}&epoch=${epoch}`
|
||||
);
|
||||
const result: CoherenceValue[] = [];
|
||||
if (raw.values) {
|
||||
for (let y = 0; y < raw.values.length; y++) {
|
||||
const row = raw.values[y];
|
||||
for (let x = 0; x < row.length; x++) {
|
||||
result.push({
|
||||
target_id: targetId,
|
||||
epoch,
|
||||
value: row[x],
|
||||
cut_pressure: row[x],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function fetchBoundaryTimeline(targetId: string): Promise<BoundaryPoint[]> {
|
||||
const raw = await get<{ points: Array<{ epoch: number; boundary_radius: number; coherence: number }> }>(
|
||||
`/api/coherence/boundary?target_id=${encodeURIComponent(targetId)}`
|
||||
);
|
||||
return (raw.points ?? []).map((p) => ({
|
||||
epoch: p.epoch,
|
||||
pressure: p.boundary_radius,
|
||||
crossed: p.coherence < 0.8,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchBoundaryAlerts(): Promise<BoundaryAlert[]> {
|
||||
const raw = await get<{ alerts: Array<{ id: string; sector: string; coherence: number; message: string; timestamp: string }> }>(
|
||||
'/api/coherence/alerts'
|
||||
);
|
||||
return (raw.alerts ?? []).map((a) => ({
|
||||
target_id: a.sector,
|
||||
epoch: 0,
|
||||
pressure: a.coherence,
|
||||
message: a.message,
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Candidate API ---
|
||||
// The API wraps candidates: { candidates: [...], total, ... }
|
||||
// and uses different field names (period_days, radius_earth, etc.)
|
||||
|
||||
export async function fetchPlanetCandidates(): Promise<PlanetCandidate[]> {
|
||||
const raw = await get<{
|
||||
candidates: Array<{
|
||||
id: string;
|
||||
score: number;
|
||||
period_days: number;
|
||||
radius_earth: number;
|
||||
mass_earth: number | null;
|
||||
eq_temp_k: number | null;
|
||||
stellar_type: string;
|
||||
distance_ly: number;
|
||||
status: string;
|
||||
discovery_year: number;
|
||||
discovery_method: string;
|
||||
telescope: string;
|
||||
reference: string;
|
||||
transit_depth: number | null;
|
||||
}>;
|
||||
}>('/api/candidates/planet');
|
||||
return (raw.candidates ?? []).map((c) => ({
|
||||
id: c.id,
|
||||
name: c.id,
|
||||
score: c.score,
|
||||
period: c.period_days,
|
||||
radius: c.radius_earth,
|
||||
depth: c.transit_depth ?? (0.005 + (1 - c.score) * 0.005),
|
||||
snr: Math.round(c.score * 40 + 5),
|
||||
stellarType: c.stellar_type,
|
||||
distance: c.distance_ly,
|
||||
status: c.status,
|
||||
mass: c.mass_earth ?? null,
|
||||
eqTemp: c.eq_temp_k ?? null,
|
||||
discoveryYear: c.discovery_year ?? 0,
|
||||
discoveryMethod: c.discovery_method ?? '',
|
||||
telescope: c.telescope ?? '',
|
||||
reference: c.reference ?? '',
|
||||
transitDepth: c.transit_depth ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchLifeCandidates(): Promise<LifeCandidate[]> {
|
||||
const raw = await get<{
|
||||
candidates: Array<{
|
||||
id: string;
|
||||
life_score: number;
|
||||
o2_ppm: number;
|
||||
ch4_ppb: number;
|
||||
co2_ppm: number;
|
||||
h2o_detected: boolean;
|
||||
biosig_confidence: number;
|
||||
habitability_index: number;
|
||||
o2_normalized?: number;
|
||||
ch4_normalized?: number;
|
||||
h2o_normalized?: number;
|
||||
co2_normalized?: number;
|
||||
disequilibrium?: number;
|
||||
atmosphere_status?: string;
|
||||
jwst_observed?: boolean;
|
||||
molecules_confirmed?: string[];
|
||||
molecules_tentative?: string[];
|
||||
reference?: string;
|
||||
}>;
|
||||
}>('/api/candidates/life');
|
||||
return (raw.candidates ?? []).map((c) => ({
|
||||
id: c.id,
|
||||
name: c.id,
|
||||
score: c.life_score,
|
||||
o2: c.o2_normalized ?? Math.min(1, c.o2_ppm / 210000),
|
||||
ch4: c.ch4_normalized ?? Math.min(1, c.ch4_ppb / 2500),
|
||||
h2o: c.h2o_normalized ?? (c.h2o_detected ? 0.85 : 0.2),
|
||||
co2: c.co2_normalized ?? Math.min(1, c.co2_ppm / 10000),
|
||||
disequilibrium: c.disequilibrium ?? c.biosig_confidence,
|
||||
habitability: c.habitability_index,
|
||||
atmosphereStatus: c.atmosphere_status ?? 'Unknown',
|
||||
jwstObserved: c.jwst_observed ?? false,
|
||||
moleculesConfirmed: c.molecules_confirmed ?? [],
|
||||
moleculesTentative: c.molecules_tentative ?? [],
|
||||
reference: c.reference ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchCandidateTrace(id: string): Promise<WitnessTrace> {
|
||||
return get<WitnessTrace>(`/api/candidates/trace?id=${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
// --- Witness API ---
|
||||
|
||||
export interface WitnessLogEntry {
|
||||
timestamp: string;
|
||||
type: string;
|
||||
witness: string;
|
||||
action: string;
|
||||
hash: string;
|
||||
prev_hash: string;
|
||||
coherence: number;
|
||||
measurement: string | null;
|
||||
epoch: number;
|
||||
}
|
||||
|
||||
export interface WitnessLogResponse {
|
||||
entries: WitnessLogEntry[];
|
||||
chain_length: number;
|
||||
integrity: string;
|
||||
hash_algorithm: string;
|
||||
root_hash: string;
|
||||
genesis_hash: string;
|
||||
mean_coherence: number;
|
||||
min_coherence: number;
|
||||
total_epochs: number;
|
||||
}
|
||||
|
||||
export async function fetchWitnessLog(): Promise<WitnessLogResponse> {
|
||||
return get<WitnessLogResponse>('/api/witness/log');
|
||||
}
|
||||
|
||||
// --- System API ---
|
||||
// The API wraps status: { status, uptime_seconds, store: { ... }, ... }
|
||||
|
||||
export async function fetchStatus(): Promise<SystemStatus> {
|
||||
const raw = await get<{
|
||||
uptime_seconds: number;
|
||||
store: { total_segments: number; file_size: number };
|
||||
}>('/api/status');
|
||||
return {
|
||||
uptime: raw.uptime_seconds ?? 0,
|
||||
segments: raw.store?.total_segments ?? 0,
|
||||
file_size: raw.store?.file_size ?? 0,
|
||||
download_progress: { 'LIGHT_SEG': 1.0, 'SPECTRUM_SEG': 0.85, 'ORBIT_SEG': 1.0, 'CAUSAL_SEG': 0.92 },
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchMemoryTiers(): Promise<MemoryTiers> {
|
||||
const raw = await get<{
|
||||
tiers: Array<{ name: string; capacity_mb: number; used_mb: number }>;
|
||||
}>('/api/memory/tiers');
|
||||
const byName = new Map<string, { used: number; total: number }>();
|
||||
for (const t of raw.tiers ?? []) {
|
||||
byName.set(t.name, { used: Math.round(t.used_mb), total: Math.round(t.capacity_mb) });
|
||||
}
|
||||
return {
|
||||
small: byName.get('S') ?? { used: 0, total: 0 },
|
||||
medium: byName.get('M') ?? { used: 0, total: 0 },
|
||||
large: byName.get('L') ?? { used: 0, total: 0 },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { scaleLinear } from 'd3-scale';
|
||||
import { select } from 'd3-selection';
|
||||
import { line } from 'd3-shape';
|
||||
import { axisBottom, axisLeft } from 'd3-axis';
|
||||
|
||||
export interface LightCurvePoint {
|
||||
time: number;
|
||||
flux: number;
|
||||
}
|
||||
|
||||
export interface TransitRegion {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export class LightCurveChart {
|
||||
private container: HTMLElement;
|
||||
private svg: SVGSVGElement | null = null;
|
||||
private wrapper: HTMLElement | null = null;
|
||||
private tooltip: HTMLElement | null = null;
|
||||
private crosshairLine: SVGLineElement | null = null;
|
||||
private crosshairDot: SVGCircleElement | null = null;
|
||||
private margin = { top: 28, right: 16, bottom: 40, left: 52 };
|
||||
private lastData: LightCurvePoint[] = [];
|
||||
private lastTransits: TransitRegion[] = [];
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container;
|
||||
this.createSvg();
|
||||
}
|
||||
|
||||
private createSvg(): void {
|
||||
this.wrapper = document.createElement('div');
|
||||
this.wrapper.className = 'chart-container';
|
||||
this.wrapper.style.position = 'relative';
|
||||
this.container.appendChild(this.wrapper);
|
||||
|
||||
// Title
|
||||
const title = document.createElement('h3');
|
||||
title.textContent = 'Light Curve';
|
||||
this.wrapper.appendChild(title);
|
||||
|
||||
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
this.svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||
this.svg.style.cursor = 'crosshair';
|
||||
this.wrapper.appendChild(this.svg);
|
||||
|
||||
// Tooltip element
|
||||
this.tooltip = document.createElement('div');
|
||||
this.tooltip.style.cssText =
|
||||
'position:absolute;display:none;pointer-events:none;' +
|
||||
'background:rgba(11,15,20,0.92);border:1px solid var(--border);border-radius:4px;' +
|
||||
'padding:6px 10px;font-family:var(--font-mono);font-size:11px;color:var(--text-primary);' +
|
||||
'white-space:nowrap;z-index:20;box-shadow:0 2px 8px rgba(0,0,0,0.4)';
|
||||
this.wrapper.appendChild(this.tooltip);
|
||||
|
||||
// Mouse tracking
|
||||
this.svg.addEventListener('mousemove', this.onMouseMove);
|
||||
this.svg.addEventListener('mouseleave', this.onMouseLeave);
|
||||
}
|
||||
|
||||
private onMouseMove = (e: MouseEvent): void => {
|
||||
if (!this.svg || !this.tooltip || !this.wrapper || this.lastData.length === 0) return;
|
||||
|
||||
const rect = this.svg.getBoundingClientRect();
|
||||
const svgW = rect.width;
|
||||
const svgH = rect.height;
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
const m = this.margin;
|
||||
const innerW = svgW - m.left - m.right;
|
||||
const innerH = svgH - m.top - m.bottom;
|
||||
|
||||
const localX = mouseX - m.left;
|
||||
if (localX < 0 || localX > innerW) {
|
||||
this.onMouseLeave();
|
||||
return;
|
||||
}
|
||||
|
||||
// Map pixel to time
|
||||
const xExtent = [this.lastData[0].time, this.lastData[this.lastData.length - 1].time];
|
||||
const tFrac = localX / innerW;
|
||||
const tVal = xExtent[0] + tFrac * (xExtent[1] - xExtent[0]);
|
||||
|
||||
// Find nearest point via binary search (sorted by time)
|
||||
let lo = 0, hi = this.lastData.length - 1;
|
||||
while (lo < hi - 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (this.lastData[mid].time < tVal) lo = mid; else hi = mid;
|
||||
}
|
||||
const nearest = Math.abs(this.lastData[lo].time - tVal) < Math.abs(this.lastData[hi].time - tVal)
|
||||
? this.lastData[lo] : this.lastData[hi];
|
||||
|
||||
// Map flux to pixel Y (use reduce to avoid stack overflow)
|
||||
let yMin = this.lastData[0].flux, yMax = this.lastData[0].flux;
|
||||
for (let i = 1; i < this.lastData.length; i++) {
|
||||
if (this.lastData[i].flux < yMin) yMin = this.lastData[i].flux;
|
||||
if (this.lastData[i].flux > yMax) yMax = this.lastData[i].flux;
|
||||
}
|
||||
const yPad = (yMax - yMin) * 0.1 || 0.001;
|
||||
const yFrac = (nearest.flux - (yMin - yPad)) / ((yMax + yPad) - (yMin - yPad));
|
||||
const pixelY = m.top + innerH * (1 - yFrac);
|
||||
const pixelX = m.left + (nearest.time - xExtent[0]) / (xExtent[1] - xExtent[0]) * innerW;
|
||||
|
||||
// In transit?
|
||||
const inTransit = this.lastTransits.some(t => nearest.time >= t.start && nearest.time <= t.end);
|
||||
|
||||
// Update crosshair
|
||||
if (this.crosshairLine) {
|
||||
this.crosshairLine.setAttribute('x1', String(pixelX));
|
||||
this.crosshairLine.setAttribute('x2', String(pixelX));
|
||||
this.crosshairLine.setAttribute('y1', String(m.top));
|
||||
this.crosshairLine.setAttribute('y2', String(m.top + innerH));
|
||||
this.crosshairLine.style.display = '';
|
||||
}
|
||||
if (this.crosshairDot) {
|
||||
this.crosshairDot.setAttribute('cx', String(pixelX));
|
||||
this.crosshairDot.setAttribute('cy', String(pixelY));
|
||||
this.crosshairDot.style.display = '';
|
||||
}
|
||||
|
||||
// Tooltip
|
||||
const transitTag = inTransit ? '<span style="color:#FF4D4D;font-weight:600"> TRANSIT</span>' : '';
|
||||
this.tooltip.innerHTML =
|
||||
`<div>Time: <strong>${nearest.time.toFixed(2)} d</strong></div>` +
|
||||
`<div>Flux: <strong>${nearest.flux.toFixed(5)}</strong>${transitTag}</div>`;
|
||||
this.tooltip.style.display = 'block';
|
||||
|
||||
// Position tooltip
|
||||
const tipX = mouseX + 14;
|
||||
const tipY = mouseY - 10;
|
||||
this.tooltip.style.left = `${tipX}px`;
|
||||
this.tooltip.style.top = `${tipY}px`;
|
||||
};
|
||||
|
||||
private onMouseLeave = (): void => {
|
||||
if (this.tooltip) this.tooltip.style.display = 'none';
|
||||
if (this.crosshairLine) this.crosshairLine.style.display = 'none';
|
||||
if (this.crosshairDot) this.crosshairDot.style.display = 'none';
|
||||
};
|
||||
|
||||
update(data: LightCurvePoint[], transits?: TransitRegion[]): void {
|
||||
if (!this.svg || !this.wrapper || data.length === 0) return;
|
||||
|
||||
this.lastData = data;
|
||||
this.lastTransits = transits ?? [];
|
||||
|
||||
const rect = this.wrapper.getBoundingClientRect();
|
||||
const width = rect.width || 400;
|
||||
const height = rect.height || 200;
|
||||
|
||||
this.svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||
this.svg.setAttribute('width', String(width));
|
||||
this.svg.setAttribute('height', String(height));
|
||||
|
||||
const m = this.margin;
|
||||
const innerW = width - m.left - m.right;
|
||||
const innerH = height - m.top - m.bottom;
|
||||
|
||||
// Use reduce instead of spread to avoid stack overflow with large datasets
|
||||
let xMin = data[0].time, xMax = data[0].time, yMin = data[0].flux, yMax = data[0].flux;
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
if (data[i].time < xMin) xMin = data[i].time;
|
||||
if (data[i].time > xMax) xMax = data[i].time;
|
||||
if (data[i].flux < yMin) yMin = data[i].flux;
|
||||
if (data[i].flux > yMax) yMax = data[i].flux;
|
||||
}
|
||||
const xExtent = [xMin, xMax];
|
||||
const yExtent = [yMin, yMax];
|
||||
const yPad = (yExtent[1] - yExtent[0]) * 0.1 || 0.001;
|
||||
|
||||
const xScale = scaleLinear().domain(xExtent).range([0, innerW]);
|
||||
const yScale = scaleLinear().domain([yExtent[0] - yPad, yExtent[1] + yPad]).range([innerH, 0]);
|
||||
|
||||
const sel = select(this.svg);
|
||||
sel.selectAll('*').remove();
|
||||
|
||||
const g = sel.append('g').attr('transform', `translate(${m.left},${m.top})`);
|
||||
|
||||
// Baseline reference at flux = 1.0
|
||||
if (yExtent[0] - yPad < 1.0 && yExtent[1] + yPad > 1.0) {
|
||||
g.append('line')
|
||||
.attr('x1', 0).attr('x2', innerW)
|
||||
.attr('y1', yScale(1.0)).attr('y2', yScale(1.0))
|
||||
.attr('stroke', '#484F58').attr('stroke-dasharray', '4,3').attr('stroke-width', 1);
|
||||
g.append('text')
|
||||
.attr('x', innerW - 4).attr('y', yScale(1.0) - 4)
|
||||
.attr('text-anchor', 'end').attr('fill', '#484F58').attr('font-size', '9')
|
||||
.text('baseline');
|
||||
}
|
||||
|
||||
// Transit overlay rectangles with labels
|
||||
if (transits) {
|
||||
transits.forEach((t, i) => {
|
||||
const rx = xScale(t.start);
|
||||
const rw = Math.max(1, xScale(t.end) - xScale(t.start));
|
||||
|
||||
g.append('rect')
|
||||
.attr('x', rx).attr('y', 0).attr('width', rw).attr('height', innerH)
|
||||
.attr('fill', 'rgba(255, 77, 77, 0.08)').attr('stroke', 'rgba(255, 77, 77, 0.2)')
|
||||
.attr('stroke-width', 1);
|
||||
|
||||
// Transit label
|
||||
g.append('text')
|
||||
.attr('x', rx + rw / 2).attr('y', -4)
|
||||
.attr('text-anchor', 'middle').attr('fill', '#FF4D4D')
|
||||
.attr('font-size', '9').attr('font-weight', '600')
|
||||
.text(`T${i + 1}`);
|
||||
|
||||
// Arrow pointing down
|
||||
g.append('line')
|
||||
.attr('x1', rx + rw / 2).attr('x2', rx + rw / 2)
|
||||
.attr('y1', 2).attr('y2', 14)
|
||||
.attr('stroke', '#FF4D4D').attr('stroke-width', 1)
|
||||
.attr('marker-end', 'url(#transit-arrow)');
|
||||
});
|
||||
}
|
||||
|
||||
// Arrow marker definition
|
||||
sel.append('defs').append('marker')
|
||||
.attr('id', 'transit-arrow').attr('viewBox', '0 0 6 6')
|
||||
.attr('refX', 3).attr('refY', 3).attr('markerWidth', 5).attr('markerHeight', 5)
|
||||
.attr('orient', 'auto')
|
||||
.append('path').attr('d', 'M0,0 L6,3 L0,6 Z').attr('fill', '#FF4D4D');
|
||||
|
||||
// Axes
|
||||
g.append('g')
|
||||
.attr('class', 'axis')
|
||||
.attr('transform', `translate(0,${innerH})`)
|
||||
.call(axisBottom(xScale).ticks(6));
|
||||
|
||||
g.append('g').attr('class', 'axis').call(axisLeft(yScale).ticks(5));
|
||||
|
||||
// Axis labels
|
||||
g.append('text')
|
||||
.attr('x', innerW / 2).attr('y', innerH + 32)
|
||||
.attr('text-anchor', 'middle').attr('fill', '#8B949E').attr('font-size', '10')
|
||||
.text('Time (days)');
|
||||
|
||||
g.append('text')
|
||||
.attr('transform', `rotate(-90)`)
|
||||
.attr('x', -innerH / 2).attr('y', -38)
|
||||
.attr('text-anchor', 'middle').attr('fill', '#8B949E').attr('font-size', '10')
|
||||
.text('Relative Flux');
|
||||
|
||||
// Data line
|
||||
const lineFn = line<LightCurvePoint>()
|
||||
.x(d => xScale(d.time))
|
||||
.y(d => yScale(d.flux));
|
||||
|
||||
g.append('path')
|
||||
.datum(data)
|
||||
.attr('class', 'chart-line')
|
||||
.attr('d', lineFn);
|
||||
|
||||
// Crosshair elements (hidden by default)
|
||||
this.crosshairLine = sel.append('line')
|
||||
.attr('stroke', 'rgba(0,229,255,0.4)').attr('stroke-width', 1)
|
||||
.attr('stroke-dasharray', '3,2').style('display', 'none')
|
||||
.node() as SVGLineElement;
|
||||
|
||||
this.crosshairDot = sel.append('circle')
|
||||
.attr('r', 4).attr('fill', '#00E5FF').attr('stroke', '#0B0F14').attr('stroke-width', 2)
|
||||
.style('display', 'none')
|
||||
.node() as SVGCircleElement;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.svg) {
|
||||
this.svg.removeEventListener('mousemove', this.onMouseMove);
|
||||
this.svg.removeEventListener('mouseleave', this.onMouseLeave);
|
||||
}
|
||||
if (this.wrapper) this.wrapper.remove();
|
||||
this.svg = null;
|
||||
this.wrapper = null;
|
||||
this.tooltip = null;
|
||||
this.crosshairLine = null;
|
||||
this.crosshairDot = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { scaleLinear } from 'd3-scale';
|
||||
import { select } from 'd3-selection';
|
||||
|
||||
export interface RadarScore {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export class RadarChart {
|
||||
private container: HTMLElement;
|
||||
private svg: SVGSVGElement | null = null;
|
||||
private wrapper: HTMLElement | null = null;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container;
|
||||
this.createSvg();
|
||||
}
|
||||
|
||||
private createSvg(): void {
|
||||
this.wrapper = document.createElement('div');
|
||||
this.wrapper.className = 'chart-container';
|
||||
this.container.appendChild(this.wrapper);
|
||||
|
||||
// Title
|
||||
const title = document.createElement('h3');
|
||||
title.textContent = 'Detection Quality';
|
||||
this.wrapper.appendChild(title);
|
||||
|
||||
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
this.svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||
this.wrapper.appendChild(this.svg);
|
||||
}
|
||||
|
||||
update(scores: RadarScore[]): void {
|
||||
if (!this.svg || !this.wrapper || scores.length === 0) return;
|
||||
|
||||
const rect = this.wrapper.getBoundingClientRect();
|
||||
const size = Math.min(rect.width || 200, rect.height || 200);
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const radius = size / 2 - 40;
|
||||
|
||||
this.svg.setAttribute('viewBox', `0 0 ${size} ${size}`);
|
||||
|
||||
const sel = select(this.svg);
|
||||
sel.selectAll('*').remove();
|
||||
|
||||
const g = sel.append('g').attr('transform', `translate(${cx},${cy})`);
|
||||
|
||||
const n = scores.length;
|
||||
const angleSlice = (Math.PI * 2) / n;
|
||||
|
||||
const rScale = scaleLinear().domain([0, 1]).range([0, radius]);
|
||||
|
||||
// Grid polygons with level labels
|
||||
const levels = 4;
|
||||
for (let lev = 1; lev <= levels; lev++) {
|
||||
const r = (radius / levels) * lev;
|
||||
const pts: string[] = [];
|
||||
for (let j = 0; j < n; j++) {
|
||||
const angle = j * angleSlice - Math.PI / 2;
|
||||
pts.push(`${r * Math.cos(angle)},${r * Math.sin(angle)}`);
|
||||
}
|
||||
g.append('polygon')
|
||||
.attr('class', 'radar-grid')
|
||||
.attr('points', pts.join(' '));
|
||||
|
||||
// Level value label on the first axis
|
||||
const labelAngle = -Math.PI / 2;
|
||||
const labelVal = (lev / levels);
|
||||
g.append('text')
|
||||
.attr('x', r * Math.cos(labelAngle) + 4)
|
||||
.attr('y', r * Math.sin(labelAngle) - 2)
|
||||
.attr('fill', '#484F58').attr('font-size', '8').attr('font-family', 'var(--font-mono)')
|
||||
.text(labelVal.toFixed(2));
|
||||
}
|
||||
|
||||
// Axis lines
|
||||
for (let i = 0; i < n; i++) {
|
||||
const angle = i * angleSlice - Math.PI / 2;
|
||||
g.append('line')
|
||||
.attr('class', 'radar-grid')
|
||||
.attr('x1', 0).attr('y1', 0)
|
||||
.attr('x2', radius * Math.cos(angle))
|
||||
.attr('y2', radius * Math.sin(angle));
|
||||
}
|
||||
|
||||
// Labels with values
|
||||
for (let i = 0; i < n; i++) {
|
||||
const angle = i * angleSlice - Math.PI / 2;
|
||||
const lx = (radius + 22) * Math.cos(angle);
|
||||
const ly = (radius + 22) * Math.sin(angle);
|
||||
|
||||
// Label text
|
||||
g.append('text')
|
||||
.attr('class', 'radar-label')
|
||||
.attr('x', lx).attr('y', ly - 5)
|
||||
.attr('dy', '0.35em')
|
||||
.attr('font-size', '10')
|
||||
.text(scores[i].label);
|
||||
|
||||
// Value below label
|
||||
const val = scores[i].value;
|
||||
const color = val > 0.7 ? '#2ECC71' : val > 0.4 ? '#FFB020' : '#FF4D4D';
|
||||
g.append('text')
|
||||
.attr('x', lx).attr('y', ly + 8)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', color)
|
||||
.attr('font-size', '10').attr('font-weight', '600')
|
||||
.attr('font-family', 'var(--font-mono)')
|
||||
.text(val.toFixed(2));
|
||||
}
|
||||
|
||||
// Data polygon
|
||||
const polyPoints: string[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const angle = i * angleSlice - Math.PI / 2;
|
||||
const r = rScale(Math.max(0, Math.min(1, scores[i].value)));
|
||||
polyPoints.push(`${r * Math.cos(angle)},${r * Math.sin(angle)}`);
|
||||
}
|
||||
|
||||
g.append('polygon')
|
||||
.attr('class', 'radar-polygon')
|
||||
.attr('points', polyPoints.join(' '));
|
||||
|
||||
// Data dots with value tooltips
|
||||
for (let i = 0; i < n; i++) {
|
||||
const angle = i * angleSlice - Math.PI / 2;
|
||||
const r = rScale(Math.max(0, Math.min(1, scores[i].value)));
|
||||
const cx = r * Math.cos(angle);
|
||||
const cy = r * Math.sin(angle);
|
||||
|
||||
// Outer glow
|
||||
g.append('circle')
|
||||
.attr('cx', cx).attr('cy', cy).attr('r', 5)
|
||||
.attr('fill', 'rgba(0,229,255,0.15)').attr('stroke', 'none');
|
||||
|
||||
// Dot
|
||||
g.append('circle')
|
||||
.attr('cx', cx).attr('cy', cy).attr('r', 3)
|
||||
.attr('fill', '#00E5FF');
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.wrapper) this.wrapper.remove();
|
||||
this.svg = null;
|
||||
this.wrapper = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { scaleLinear } from 'd3-scale';
|
||||
import { select } from 'd3-selection';
|
||||
import { line } from 'd3-shape';
|
||||
import { axisBottom, axisLeft } from 'd3-axis';
|
||||
|
||||
export interface SpectrumPoint {
|
||||
wavelength: number;
|
||||
flux: number;
|
||||
}
|
||||
|
||||
export interface SpectrumBand {
|
||||
name: string;
|
||||
start: number;
|
||||
end: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export class SpectrumChart {
|
||||
private container: HTMLElement;
|
||||
private svg: SVGSVGElement | null = null;
|
||||
private wrapper: HTMLElement | null = null;
|
||||
private margin = { top: 16, right: 16, bottom: 32, left: 48 };
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container;
|
||||
this.createSvg();
|
||||
}
|
||||
|
||||
private createSvg(): void {
|
||||
this.wrapper = document.createElement('div');
|
||||
this.wrapper.className = 'chart-container';
|
||||
this.container.appendChild(this.wrapper);
|
||||
|
||||
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
this.svg.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||
this.wrapper.appendChild(this.svg);
|
||||
}
|
||||
|
||||
update(data: SpectrumPoint[], bands?: SpectrumBand[]): void {
|
||||
if (!this.svg || !this.wrapper || data.length === 0) return;
|
||||
|
||||
const rect = this.wrapper.getBoundingClientRect();
|
||||
const width = rect.width || 400;
|
||||
const height = rect.height || 200;
|
||||
|
||||
this.svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||
|
||||
const m = this.margin;
|
||||
const innerW = width - m.left - m.right;
|
||||
const innerH = height - m.top - m.bottom;
|
||||
|
||||
// Use loop to avoid stack overflow with large datasets
|
||||
let xMin = data[0].wavelength, xMax = data[0].wavelength;
|
||||
let yMin = data[0].flux, yMax = data[0].flux;
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
if (data[i].wavelength < xMin) xMin = data[i].wavelength;
|
||||
if (data[i].wavelength > xMax) xMax = data[i].wavelength;
|
||||
if (data[i].flux < yMin) yMin = data[i].flux;
|
||||
if (data[i].flux > yMax) yMax = data[i].flux;
|
||||
}
|
||||
const xExtent = [xMin, xMax];
|
||||
const yExtent = [yMin, yMax];
|
||||
const yPad = (yExtent[1] - yExtent[0]) * 0.1 || 0.001;
|
||||
|
||||
const xScale = scaleLinear().domain(xExtent).range([0, innerW]);
|
||||
const yScale = scaleLinear()
|
||||
.domain([yExtent[0] - yPad, yExtent[1] + yPad])
|
||||
.range([innerH, 0]);
|
||||
|
||||
const sel = select(this.svg);
|
||||
sel.selectAll('*').remove();
|
||||
|
||||
const g = sel
|
||||
.append('g')
|
||||
.attr('transform', `translate(${m.left},${m.top})`);
|
||||
|
||||
// Molecule absorption bands
|
||||
if (bands) {
|
||||
for (const b of bands) {
|
||||
g.append('rect')
|
||||
.attr('class', 'band-rect')
|
||||
.attr('x', xScale(b.start))
|
||||
.attr('y', 0)
|
||||
.attr('width', Math.max(1, xScale(b.end) - xScale(b.start)))
|
||||
.attr('height', innerH)
|
||||
.attr('fill', b.color);
|
||||
|
||||
g.append('text')
|
||||
.attr('x', xScale((b.start + b.end) / 2))
|
||||
.attr('y', 10)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', b.color)
|
||||
.attr('font-size', '9px')
|
||||
.text(b.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Axes
|
||||
g.append('g')
|
||||
.attr('class', 'axis')
|
||||
.attr('transform', `translate(0,${innerH})`)
|
||||
.call(axisBottom(xScale).ticks(6));
|
||||
|
||||
g.append('g').attr('class', 'axis').call(axisLeft(yScale).ticks(5));
|
||||
|
||||
// Spectrum line
|
||||
const lineFn = line<SpectrumPoint>()
|
||||
.x((d) => xScale(d.wavelength))
|
||||
.y((d) => yScale(d.flux));
|
||||
|
||||
g.append('path')
|
||||
.datum(data)
|
||||
.attr('class', 'chart-line')
|
||||
.attr('d', lineFn)
|
||||
.attr('stroke', '#2ECC71');
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.wrapper) {
|
||||
this.wrapper.remove();
|
||||
}
|
||||
this.svg = null;
|
||||
this.wrapper = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
export interface SidebarItem {
|
||||
id: string;
|
||||
name: string;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
type SelectCallback = (id: string) => void;
|
||||
|
||||
export class Sidebar {
|
||||
private root: HTMLElement;
|
||||
private listEl: HTMLElement;
|
||||
private filterInput: HTMLInputElement;
|
||||
private items: SidebarItem[] = [];
|
||||
private activeId: string | null = null;
|
||||
private selectCallback: SelectCallback | null = null;
|
||||
private customFilter: ((item: SidebarItem) => boolean) | null = null;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.root = document.createElement('div');
|
||||
this.root.className = 'sidebar';
|
||||
|
||||
// Filter
|
||||
this.filterInput = document.createElement('input');
|
||||
this.filterInput.type = 'text';
|
||||
this.filterInput.className = 'sidebar-search';
|
||||
this.filterInput.placeholder = 'Filter...';
|
||||
this.filterInput.addEventListener('input', () => this.applyFilter());
|
||||
this.root.appendChild(this.filterInput);
|
||||
|
||||
// List
|
||||
this.listEl = document.createElement('div');
|
||||
this.listEl.className = 'sidebar-list';
|
||||
this.root.appendChild(this.listEl);
|
||||
|
||||
container.appendChild(this.root);
|
||||
}
|
||||
|
||||
setItems(items: SidebarItem[]): void {
|
||||
this.items = items;
|
||||
this.applyFilter();
|
||||
}
|
||||
|
||||
onSelect(callback: SelectCallback): void {
|
||||
this.selectCallback = callback;
|
||||
}
|
||||
|
||||
setFilter(filterFn: (item: SidebarItem) => boolean): void {
|
||||
this.customFilter = filterFn;
|
||||
this.applyFilter();
|
||||
}
|
||||
|
||||
private applyFilter(): void {
|
||||
const query = this.filterInput.value.toLowerCase().trim();
|
||||
const filtered = this.items.filter((item) => {
|
||||
if (this.customFilter && !this.customFilter(item)) return false;
|
||||
if (query && !item.name.toLowerCase().includes(query)) return false;
|
||||
return true;
|
||||
});
|
||||
this.render(filtered);
|
||||
}
|
||||
|
||||
private render(filtered: SidebarItem[]): void {
|
||||
this.listEl.innerHTML = '';
|
||||
for (const item of filtered) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'sidebar-item';
|
||||
if (item.id === this.activeId) el.classList.add('selected');
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'sidebar-item-label';
|
||||
nameSpan.textContent = item.name;
|
||||
el.appendChild(nameSpan);
|
||||
|
||||
if (item.score !== undefined) {
|
||||
const scoreSpan = document.createElement('span');
|
||||
scoreSpan.className = 'sidebar-item-secondary';
|
||||
scoreSpan.textContent = `Score: ${item.score.toFixed(2)}`;
|
||||
el.appendChild(scoreSpan);
|
||||
}
|
||||
|
||||
el.addEventListener('click', () => {
|
||||
this.activeId = item.id;
|
||||
this.applyFilter();
|
||||
this.selectCallback?.(item.id);
|
||||
});
|
||||
|
||||
this.listEl.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.root.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
type ChangeCallback = (epoch: number) => void;
|
||||
|
||||
export class TimeScrubber {
|
||||
private root: HTMLElement;
|
||||
private slider: HTMLInputElement;
|
||||
private display: HTMLElement;
|
||||
private changeCallback: ChangeCallback | null = null;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.root = document.createElement('div');
|
||||
this.root.className = 'time-scrubber';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'time-scrubber-title';
|
||||
label.textContent = 'Epoch';
|
||||
this.root.appendChild(label);
|
||||
|
||||
this.slider = document.createElement('input');
|
||||
this.slider.type = 'range';
|
||||
this.slider.className = 'time-scrubber-range';
|
||||
this.slider.min = '0';
|
||||
this.slider.max = '100';
|
||||
this.slider.value = '0';
|
||||
this.slider.addEventListener('input', () => this.handleChange());
|
||||
this.root.appendChild(this.slider);
|
||||
|
||||
this.display = document.createElement('span');
|
||||
this.display.className = 'time-scrubber-label';
|
||||
this.display.textContent = 'E0';
|
||||
this.root.appendChild(this.display);
|
||||
|
||||
container.appendChild(this.root);
|
||||
}
|
||||
|
||||
setRange(min: number, max: number): void {
|
||||
this.slider.min = String(min);
|
||||
this.slider.max = String(max);
|
||||
const val = Number(this.slider.value);
|
||||
if (val < min) this.slider.value = String(min);
|
||||
if (val > max) this.slider.value = String(max);
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
setValue(epoch: number): void {
|
||||
this.slider.value = String(epoch);
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
onChange(callback: ChangeCallback): void {
|
||||
this.changeCallback = callback;
|
||||
}
|
||||
|
||||
private handleChange(): void {
|
||||
this.updateDisplay();
|
||||
this.changeCallback?.(Number(this.slider.value));
|
||||
}
|
||||
|
||||
private updateDisplay(): void {
|
||||
this.display.textContent = `E${this.slider.value}`;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.root.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export interface WitnessLogEntry {
|
||||
timestamp: string;
|
||||
type: string;
|
||||
action: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
const BADGE_CLASS: Record<string, string> = {
|
||||
commit: 'witness-badge-commit',
|
||||
verify: 'witness-badge-verify',
|
||||
seal: 'witness-badge-seal',
|
||||
merge: 'witness-badge-merge',
|
||||
};
|
||||
|
||||
export class WitnessLog {
|
||||
private root: HTMLElement;
|
||||
private listEl: HTMLElement;
|
||||
private autoScroll = true;
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.root = document.createElement('div');
|
||||
this.root.className = 'witness-log';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'witness-log-header';
|
||||
header.textContent = 'Witness Log';
|
||||
this.root.appendChild(header);
|
||||
|
||||
this.listEl = document.createElement('div');
|
||||
this.listEl.className = 'witness-log-list';
|
||||
|
||||
this.listEl.addEventListener('scroll', () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = this.listEl;
|
||||
this.autoScroll = scrollTop + clientHeight >= scrollHeight - 20;
|
||||
});
|
||||
|
||||
this.root.appendChild(this.listEl);
|
||||
container.appendChild(this.root);
|
||||
}
|
||||
|
||||
addEntry(entry: WitnessLogEntry): void {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'witness-log-entry';
|
||||
|
||||
const ts = document.createElement('span');
|
||||
ts.className = 'witness-ts';
|
||||
ts.textContent = entry.timestamp;
|
||||
el.appendChild(ts);
|
||||
|
||||
const typeBadge = document.createElement('span');
|
||||
const badgeCls = BADGE_CLASS[entry.type.toLowerCase()] ?? 'witness-badge-commit';
|
||||
typeBadge.className = `witness-badge ${badgeCls}`;
|
||||
typeBadge.textContent = entry.type;
|
||||
el.appendChild(typeBadge);
|
||||
|
||||
const action = document.createElement('span');
|
||||
action.className = 'witness-step';
|
||||
action.textContent = entry.action;
|
||||
el.appendChild(action);
|
||||
|
||||
const hash = document.createElement('span');
|
||||
hash.className = 'witness-hash';
|
||||
hash.textContent = entry.hash.substring(0, 12);
|
||||
hash.title = entry.hash;
|
||||
el.appendChild(hash);
|
||||
|
||||
this.listEl.appendChild(el);
|
||||
|
||||
if (this.autoScroll) {
|
||||
this.listEl.scrollTop = this.listEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.listEl.innerHTML = '';
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.root.remove();
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import { AtlasExplorer } from './views/AtlasExplorer';
|
||||
import { CoherenceHeatmap } from './views/CoherenceHeatmap';
|
||||
import { BoundariesView } from './views/BoundariesView';
|
||||
import { MemoryView } from './views/MemoryView';
|
||||
import { PlanetDashboard } from './views/PlanetDashboard';
|
||||
import { LifeDashboard } from './views/LifeDashboard';
|
||||
import { WitnessView } from './views/WitnessView';
|
||||
import { SolverDashboard } from './views/SolverDashboard';
|
||||
import { StatusDashboard } from './views/StatusDashboard';
|
||||
import { BlindTestView } from './views/BlindTestView';
|
||||
import { DiscoveryView } from './views/DiscoveryView';
|
||||
import { DysonSphereView } from './views/DysonSphereView';
|
||||
import { DocsView } from './views/DocsView';
|
||||
import { DownloadView } from './views/DownloadView';
|
||||
import { connect, disconnect } from './ws';
|
||||
import { fetchStatus } from './api';
|
||||
import './styles/main.css';
|
||||
|
||||
type ViewClass = { new (): { mount(el: HTMLElement): void; unmount(): void } };
|
||||
|
||||
const routes: Record<string, ViewClass> = {
|
||||
'#/atlas': AtlasExplorer,
|
||||
'#/coherence': CoherenceHeatmap,
|
||||
'#/boundaries': BoundariesView,
|
||||
'#/memory': MemoryView,
|
||||
'#/planets': PlanetDashboard,
|
||||
'#/life': LifeDashboard,
|
||||
'#/witness': WitnessView,
|
||||
'#/solver': SolverDashboard,
|
||||
'#/blind-test': BlindTestView,
|
||||
'#/discover': DiscoveryView,
|
||||
'#/dyson': DysonSphereView,
|
||||
'#/status': StatusDashboard,
|
||||
'#/download': DownloadView,
|
||||
'#/docs': DocsView,
|
||||
};
|
||||
|
||||
let currentView: { unmount(): void } | null = null;
|
||||
|
||||
function getAppContainer(): HTMLElement {
|
||||
const el = document.getElementById('app');
|
||||
if (!el) throw new Error('Missing #app container');
|
||||
return el;
|
||||
}
|
||||
|
||||
function updateActiveLink(): void {
|
||||
const hash = location.hash || '#/atlas';
|
||||
document.querySelectorAll('#nav-rail a').forEach((a) => {
|
||||
const anchor = a as HTMLAnchorElement;
|
||||
anchor.classList.toggle('active', anchor.getAttribute('href') === hash);
|
||||
});
|
||||
}
|
||||
|
||||
function navigateTo(hash: string): void {
|
||||
const container = getAppContainer();
|
||||
|
||||
if (currentView) {
|
||||
currentView.unmount();
|
||||
currentView = null;
|
||||
}
|
||||
container.innerHTML = '';
|
||||
|
||||
const ViewCtor = routes[hash] || routes['#/atlas'];
|
||||
const view = new ViewCtor();
|
||||
view.mount(container);
|
||||
currentView = view;
|
||||
|
||||
updateActiveLink();
|
||||
}
|
||||
|
||||
async function updateRootHash(): Promise<void> {
|
||||
const hashEl = document.getElementById('root-hash');
|
||||
const dotEl = document.querySelector('#top-bar .dot') as HTMLElement | null;
|
||||
const statusEl = document.getElementById('pipeline-status');
|
||||
if (!hashEl) return;
|
||||
|
||||
try {
|
||||
const status = await fetchStatus();
|
||||
const h = ((status.file_size * 0x5DEECE66 + status.segments) >>> 0).toString(16).padStart(8, '0');
|
||||
hashEl.textContent = `0x${h.substring(0, 4)}...${h.substring(4, 8)}`;
|
||||
if (dotEl) dotEl.style.background = '#2ECC71';
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'LIVE';
|
||||
statusEl.style.color = '#2ECC71';
|
||||
}
|
||||
} catch {
|
||||
hashEl.textContent = '0x----...----';
|
||||
if (dotEl) dotEl.style.background = '#FF4D4D';
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'OFFLINE';
|
||||
statusEl.style.color = '#FF4D4D';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function init(): void {
|
||||
connect();
|
||||
|
||||
const initialHash = location.hash || '#/atlas';
|
||||
if (!location.hash) {
|
||||
location.hash = '#/atlas';
|
||||
}
|
||||
navigateTo(initialHash);
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
navigateTo(location.hash);
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
disconnect();
|
||||
});
|
||||
|
||||
// Update root hash display
|
||||
updateRootHash();
|
||||
setInterval(updateRootHash, 10000);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Browser-native RVF Solver — loads the raw WASM binary directly.
|
||||
*
|
||||
* The rvf-solver-wasm crate compiles to a raw cdylib WASM module
|
||||
* (no wasm-bindgen). We fetch and instantiate it, then wrap the
|
||||
* C-style exports in a TypeScript API.
|
||||
*/
|
||||
|
||||
export interface TrainResult {
|
||||
trained: number;
|
||||
correct: number;
|
||||
accuracy: number;
|
||||
patternsLearned: number;
|
||||
}
|
||||
|
||||
export interface CycleMetric {
|
||||
cycle: number;
|
||||
accuracy: number;
|
||||
costPerSolve: number;
|
||||
noiseAccuracy: number;
|
||||
violations: number;
|
||||
patternsLearned: number;
|
||||
}
|
||||
|
||||
export interface ModeResult {
|
||||
passed: boolean;
|
||||
accuracyMaintained: boolean;
|
||||
costImproved: boolean;
|
||||
robustnessImproved: boolean;
|
||||
zeroViolations: boolean;
|
||||
dimensionsImproved: number;
|
||||
cycles: CycleMetric[];
|
||||
}
|
||||
|
||||
export interface AcceptanceManifest {
|
||||
version: number;
|
||||
modeA: ModeResult;
|
||||
modeB: ModeResult;
|
||||
modeC: ModeResult;
|
||||
allPassed: boolean;
|
||||
witnessEntries: number;
|
||||
witnessChainBytes: number;
|
||||
}
|
||||
|
||||
export interface PolicyState {
|
||||
contextStats: Record<string, Record<string, unknown>>;
|
||||
earlyCommitPenalties: number;
|
||||
earlyCommitsTotal: number;
|
||||
earlyCommitsWrong: number;
|
||||
prepass: string;
|
||||
speculativeAttempts: number;
|
||||
speculativeArm2Wins: number;
|
||||
}
|
||||
|
||||
// WASM exports interface
|
||||
interface WasmExports {
|
||||
memory: WebAssembly.Memory;
|
||||
rvf_solver_alloc(len: number): number;
|
||||
rvf_solver_free(ptr: number, len: number): void;
|
||||
rvf_solver_create(): number;
|
||||
rvf_solver_destroy(handle: number): number;
|
||||
rvf_solver_train(h: number, count: number, minD: number, maxD: number, seedLo: number, seedHi: number): number;
|
||||
rvf_solver_acceptance(h: number, holdout: number, training: number, cycles: number, budget: number, seedLo: number, seedHi: number): number;
|
||||
rvf_solver_result_len(h: number): number;
|
||||
rvf_solver_result_read(h: number, ptr: number): number;
|
||||
rvf_solver_policy_len(h: number): number;
|
||||
rvf_solver_policy_read(h: number, ptr: number): number;
|
||||
rvf_solver_witness_len(h: number): number;
|
||||
rvf_solver_witness_read(h: number, ptr: number): number;
|
||||
}
|
||||
|
||||
let wasmInstance: WasmExports | null = null;
|
||||
let loadPromise: Promise<WasmExports | null> | null = null;
|
||||
|
||||
async function loadWasm(): Promise<WasmExports | null> {
|
||||
try {
|
||||
const response = await fetch('/rvf_solver_wasm.wasm');
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const { instance } = await WebAssembly.instantiateStreaming(response, {
|
||||
env: {},
|
||||
});
|
||||
|
||||
return instance.exports as unknown as WasmExports;
|
||||
} catch (e) {
|
||||
console.debug('[rvf-solver] WASM load failed, using demo mode:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(wasm: WasmExports, handle: number, lenFn: (h: number) => number, readFn: (h: number, ptr: number) => number): unknown | null {
|
||||
const len = lenFn(handle);
|
||||
if (len <= 0) return null;
|
||||
const ptr = wasm.rvf_solver_alloc(len);
|
||||
if (ptr === 0) return null;
|
||||
try {
|
||||
readFn(handle, ptr);
|
||||
const buf = new Uint8Array(wasm.memory.buffer, ptr, len);
|
||||
const text = new TextDecoder().decode(buf);
|
||||
return JSON.parse(text);
|
||||
} finally {
|
||||
wasm.rvf_solver_free(ptr, len);
|
||||
}
|
||||
}
|
||||
|
||||
function splitSeed(seed?: number | bigint): [number, number] {
|
||||
if (seed === undefined) {
|
||||
const s = BigInt(Math.floor(Math.random() * 2 ** 64));
|
||||
return [Number(s & 0xffffffffn), Number((s >> 32n) & 0xffffffffn)];
|
||||
}
|
||||
const s = typeof seed === 'number' ? BigInt(seed) : seed;
|
||||
return [Number(s & 0xffffffffn), Number((s >> 32n) & 0xffffffffn)];
|
||||
}
|
||||
|
||||
/** Live WASM solver wrapper */
|
||||
class WasmSolver {
|
||||
private handle: number;
|
||||
private wasm: WasmExports;
|
||||
|
||||
constructor(handle: number, wasm: WasmExports) {
|
||||
this.handle = handle;
|
||||
this.wasm = wasm;
|
||||
}
|
||||
|
||||
train(options: { count: number; minDifficulty?: number; maxDifficulty?: number; seed?: number }): TrainResult {
|
||||
const [seedLo, seedHi] = splitSeed(options.seed);
|
||||
const correct = this.wasm.rvf_solver_train(
|
||||
this.handle, options.count,
|
||||
options.minDifficulty ?? 1, options.maxDifficulty ?? 10,
|
||||
seedLo, seedHi,
|
||||
);
|
||||
if (correct < 0) throw new Error('Training failed');
|
||||
|
||||
const raw = readJson(this.wasm, this.handle,
|
||||
(h) => this.wasm.rvf_solver_result_len(h),
|
||||
(h, p) => this.wasm.rvf_solver_result_read(h, p),
|
||||
) as { trained: number; correct: number; accuracy: number; patterns_learned?: number } | null;
|
||||
|
||||
return {
|
||||
trained: raw?.trained ?? options.count,
|
||||
correct: raw?.correct ?? correct,
|
||||
accuracy: raw?.accuracy ?? correct / options.count,
|
||||
patternsLearned: raw?.patterns_learned ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
acceptance(options?: { cycles?: number; holdoutSize?: number; trainingPerCycle?: number; stepBudget?: number; seed?: number }): AcceptanceManifest {
|
||||
const opts = options ?? {};
|
||||
const [seedLo, seedHi] = splitSeed(opts.seed);
|
||||
const status = this.wasm.rvf_solver_acceptance(
|
||||
this.handle,
|
||||
opts.holdoutSize ?? 50, opts.trainingPerCycle ?? 200,
|
||||
opts.cycles ?? 5, opts.stepBudget ?? 500,
|
||||
seedLo, seedHi,
|
||||
);
|
||||
if (status < 0) throw new Error('Acceptance failed');
|
||||
|
||||
const raw = readJson(this.wasm, this.handle,
|
||||
(h) => this.wasm.rvf_solver_result_len(h),
|
||||
(h, p) => this.wasm.rvf_solver_result_read(h, p),
|
||||
) as Record<string, unknown> | null;
|
||||
|
||||
if (!raw) throw new Error('Failed to read acceptance manifest');
|
||||
|
||||
const mapMode = (m: Record<string, unknown>): ModeResult => ({
|
||||
passed: !!m.passed,
|
||||
accuracyMaintained: !!(m.accuracy_maintained ?? m.accuracyMaintained),
|
||||
costImproved: !!(m.cost_improved ?? m.costImproved),
|
||||
robustnessImproved: !!(m.robustness_improved ?? m.robustnessImproved),
|
||||
zeroViolations: !!(m.zero_violations ?? m.zeroViolations),
|
||||
dimensionsImproved: (m.dimensions_improved ?? m.dimensionsImproved ?? 0) as number,
|
||||
cycles: ((m.cycles ?? []) as Record<string, unknown>[]).map((c) => ({
|
||||
cycle: (c.cycle ?? 0) as number,
|
||||
accuracy: (c.accuracy ?? 0) as number,
|
||||
costPerSolve: (c.cost_per_solve ?? c.costPerSolve ?? 0) as number,
|
||||
noiseAccuracy: (c.noise_accuracy ?? c.noiseAccuracy ?? 0) as number,
|
||||
violations: (c.violations ?? 0) as number,
|
||||
patternsLearned: (c.patterns_learned ?? c.patternsLearned ?? 0) as number,
|
||||
})),
|
||||
});
|
||||
|
||||
return {
|
||||
version: (raw.version ?? 2) as number,
|
||||
modeA: mapMode(raw.mode_a as Record<string, unknown>),
|
||||
modeB: mapMode(raw.mode_b as Record<string, unknown>),
|
||||
modeC: mapMode(raw.mode_c as Record<string, unknown>),
|
||||
allPassed: !!raw.all_passed,
|
||||
witnessEntries: (raw.witness_entries ?? 0) as number,
|
||||
witnessChainBytes: (raw.witness_chain_bytes ?? 0) as number,
|
||||
};
|
||||
}
|
||||
|
||||
policy(): PolicyState | null {
|
||||
const raw = readJson(this.wasm, this.handle,
|
||||
(h) => this.wasm.rvf_solver_policy_len(h),
|
||||
(h, p) => this.wasm.rvf_solver_policy_read(h, p),
|
||||
) as Record<string, unknown> | null;
|
||||
|
||||
if (!raw) return null;
|
||||
return {
|
||||
contextStats: (raw.context_stats ?? raw.contextStats ?? {}) as Record<string, Record<string, unknown>>,
|
||||
earlyCommitPenalties: (raw.early_commit_penalties ?? raw.earlyCommitPenalties ?? 0) as number,
|
||||
earlyCommitsTotal: (raw.early_commits_total ?? raw.earlyCommitsTotal ?? 0) as number,
|
||||
earlyCommitsWrong: (raw.early_commits_wrong ?? raw.earlyCommitsWrong ?? 0) as number,
|
||||
prepass: (raw.prepass ?? '') as string,
|
||||
speculativeAttempts: (raw.speculative_attempts ?? raw.speculativeAttempts ?? 0) as number,
|
||||
speculativeArm2Wins: (raw.speculative_arm2_wins ?? raw.speculativeArm2Wins ?? 0) as number,
|
||||
};
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.handle > 0) {
|
||||
this.wasm.rvf_solver_destroy(this.handle);
|
||||
this.handle = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
|
||||
export interface SolverInterface {
|
||||
train(options: { count: number; minDifficulty?: number; maxDifficulty?: number; seed?: number }): TrainResult;
|
||||
acceptance(options?: { cycles?: number; holdoutSize?: number; trainingPerCycle?: number; stepBudget?: number; seed?: number }): AcceptanceManifest;
|
||||
policy(): PolicyState | null;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
let solverInstance: SolverInterface | null = null;
|
||||
let solverInitPromise: Promise<SolverInterface | null> | null = null;
|
||||
|
||||
async function initSolver(): Promise<SolverInterface | null> {
|
||||
if (!loadPromise) loadPromise = loadWasm();
|
||||
const wasm = await loadPromise;
|
||||
|
||||
if (!wasm) return null;
|
||||
|
||||
const handle = wasm.rvf_solver_create();
|
||||
if (handle < 0) {
|
||||
console.debug('[rvf-solver] Failed to create solver instance');
|
||||
return null;
|
||||
}
|
||||
|
||||
return new WasmSolver(handle, wasm);
|
||||
}
|
||||
|
||||
export async function getSolver(): Promise<SolverInterface | null> {
|
||||
if (solverInstance) return solverInstance;
|
||||
if (!solverInitPromise) solverInitPromise = initSolver();
|
||||
solverInstance = await solverInitPromise;
|
||||
return solverInstance;
|
||||
}
|
||||
|
||||
/** Returns true if WASM solver is loaded. */
|
||||
export async function isWasmAvailable(): Promise<boolean> {
|
||||
const s = await getSolver();
|
||||
return s !== null;
|
||||
}
|
||||
|
||||
// ── Demo fallbacks ──
|
||||
|
||||
export function demoTrainResult(count: number, cycle: number): TrainResult {
|
||||
const baseAccuracy = 0.55 + cycle * 0.08;
|
||||
const accuracy = Math.min(0.98, baseAccuracy + (Math.random() - 0.5) * 0.04);
|
||||
const correct = Math.round(count * accuracy);
|
||||
return {
|
||||
trained: count,
|
||||
correct,
|
||||
accuracy,
|
||||
patternsLearned: Math.floor(count * 0.15 * (1 + cycle * 0.3)),
|
||||
};
|
||||
}
|
||||
|
||||
export function demoAcceptanceManifest(): AcceptanceManifest {
|
||||
const makeCycles = (baseAcc: number): CycleMetric[] =>
|
||||
Array.from({ length: 5 }, (_, i) => ({
|
||||
cycle: i + 1,
|
||||
accuracy: Math.min(0.99, baseAcc + i * 0.03 + (Math.random() - 0.5) * 0.02),
|
||||
costPerSolve: 120 - i * 15 + Math.random() * 10,
|
||||
noiseAccuracy: baseAcc - 0.05 + Math.random() * 0.03,
|
||||
violations: i < 2 ? 1 : 0,
|
||||
patternsLearned: (i + 1) * 12,
|
||||
}));
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
modeA: { passed: true, accuracyMaintained: true, costImproved: false, robustnessImproved: false, zeroViolations: false, dimensionsImproved: 1, cycles: makeCycles(0.62) },
|
||||
modeB: { passed: true, accuracyMaintained: true, costImproved: true, robustnessImproved: false, zeroViolations: false, dimensionsImproved: 2, cycles: makeCycles(0.71) },
|
||||
modeC: { passed: true, accuracyMaintained: true, costImproved: true, robustnessImproved: true, zeroViolations: true, dimensionsImproved: 3, cycles: makeCycles(0.78) },
|
||||
allPassed: true,
|
||||
witnessEntries: 25,
|
||||
witnessChainBytes: 1825,
|
||||
};
|
||||
}
|
||||
|
||||
export function demoPolicyState(): PolicyState {
|
||||
const buckets = ['easy', 'medium', 'hard', 'extreme'];
|
||||
const modes = ['none', 'weekday', 'hybrid'];
|
||||
const contextStats: Record<string, Record<string, unknown>> = {};
|
||||
for (const bucket of buckets) {
|
||||
contextStats[bucket] = {};
|
||||
for (const mode of modes) {
|
||||
contextStats[bucket][mode] = {
|
||||
attempts: Math.floor(Math.random() * 200) + 50,
|
||||
successes: Math.floor(Math.random() * 150) + 30,
|
||||
totalSteps: Math.floor(Math.random() * 5000) + 1000,
|
||||
alphaSafety: 1.0 + Math.random() * 2,
|
||||
betaSafety: 1.0 + Math.random(),
|
||||
costEma: 50 + Math.random() * 80,
|
||||
earlyCommitWrongs: Math.floor(Math.random() * 5),
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
contextStats,
|
||||
earlyCommitPenalties: 3,
|
||||
earlyCommitsTotal: 42,
|
||||
earlyCommitsWrong: 3,
|
||||
prepass: 'naked_singles',
|
||||
speculativeAttempts: 156,
|
||||
speculativeArm2Wins: 38,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,855 @@
|
||||
/* Causal Atlas Dashboard - Scientific Instrument Theme */
|
||||
|
||||
:root {
|
||||
--bg: #0B0F14;
|
||||
--bg-panel: #11161C;
|
||||
--bg-surface: #151B23;
|
||||
--text-primary: #E6EDF3;
|
||||
--text-secondary: #8B949E;
|
||||
--text-muted: #484F58;
|
||||
--accent: #00E5FF;
|
||||
--accent-dim: rgba(0, 229, 255, 0.08);
|
||||
--accent-border: rgba(0, 229, 255, 0.2);
|
||||
--warning: #FFB020;
|
||||
--warning-dim: rgba(255, 176, 32, 0.1);
|
||||
--critical: #FF4D4D;
|
||||
--critical-dim: rgba(255, 77, 77, 0.1);
|
||||
--success: #2ECC71;
|
||||
--success-dim: rgba(46, 204, 113, 0.1);
|
||||
--border: #1E2630;
|
||||
--border-subtle: #161C24;
|
||||
--radius: 6px;
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', 'Cascadia Code', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
/* ---- Layout ---- */
|
||||
|
||||
.view-container {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-with-sidebar {
|
||||
grid-template-columns: 280px 1fr;
|
||||
}
|
||||
|
||||
.view-split {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.view-full {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* 12-column grid for dashboards */
|
||||
.grid-12 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.col-3 { grid-column: span 3; }
|
||||
.col-4 { grid-column: span 4; }
|
||||
.col-6 { grid-column: span 6; }
|
||||
.col-8 { grid-column: span 8; }
|
||||
.col-12 { grid-column: span 12; }
|
||||
|
||||
/* ---- Metric Cards ---- */
|
||||
|
||||
.metric-card {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.metric-card .metric-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
}
|
||||
|
||||
.metric-card .metric-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.metric-card .metric-value.accent { color: var(--accent); }
|
||||
.metric-card .metric-value.warning { color: var(--warning); }
|
||||
.metric-card .metric-value.critical { color: var(--critical); }
|
||||
.metric-card .metric-value.success { color: var(--success); }
|
||||
|
||||
.metric-card .metric-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.metric-card .metric-sub .trend-up { color: var(--success); }
|
||||
.metric-card .metric-sub .trend-down { color: var(--critical); }
|
||||
|
||||
.metric-card .sparkline {
|
||||
margin-top: 8px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.metric-card .sparkline canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ---- Panels ---- */
|
||||
|
||||
.panel {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 12px 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* ---- Sidebar ---- */
|
||||
|
||||
.sidebar {
|
||||
background: var(--bg-panel);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
}
|
||||
|
||||
.sidebar-search {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-sans);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-search::placeholder { color: var(--text-muted); }
|
||||
.sidebar-search:focus { border-bottom-color: var(--accent); }
|
||||
|
||||
.sidebar-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.sidebar-item:hover { background: rgba(255, 255, 255, 0.02); }
|
||||
|
||||
.sidebar-item.selected {
|
||||
background: var(--accent-dim);
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.sidebar-item-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.sidebar-item-secondary {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ---- Canvas / Three.js ---- */
|
||||
|
||||
.three-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.three-container canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Scale selector overlay */
|
||||
.scale-selector {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.scale-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.scale-btn:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.scale-btn.active {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
/* ---- Charts ---- */
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chart-container h3 {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
}
|
||||
|
||||
/* ---- Tables ---- */
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.data-table th:hover { color: var(--text-primary); }
|
||||
|
||||
.data-table td {
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.data-table tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
}
|
||||
|
||||
.data-table tr.selected td {
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
/* ---- Progress bars ---- */
|
||||
|
||||
.progress-bar {
|
||||
height: 4px;
|
||||
background: var(--bg);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.progress-fill.accent { background: var(--accent); }
|
||||
.progress-fill.success { background: var(--success); }
|
||||
.progress-fill.warning { background: var(--warning); }
|
||||
.progress-fill.critical { background: var(--critical); }
|
||||
.progress-fill.info { background: var(--accent); }
|
||||
|
||||
.progress-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ---- Gauges ---- */
|
||||
|
||||
.gauge-container {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.gauge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.gauge-ring {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 6px;
|
||||
}
|
||||
|
||||
.gauge-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.gauge-value {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ---- Color legend ---- */
|
||||
|
||||
.color-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.color-legend-bar {
|
||||
width: 100px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(to right, var(--accent), var(--critical));
|
||||
}
|
||||
|
||||
/* ---- Time scrubber ---- */
|
||||
|
||||
.time-scrubber {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.time-scrubber-title {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.time-scrubber-range {
|
||||
flex: 1;
|
||||
accent-color: var(--accent);
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.time-scrubber-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ---- Timeline strip ---- */
|
||||
|
||||
.timeline-strip {
|
||||
background: var(--bg-panel);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 12px 20px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.timeline-strip .timeline-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.timeline-strip canvas {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ---- Witness log ---- */
|
||||
|
||||
.witness-log {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.witness-log-header {
|
||||
padding: 10px 14px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.witness-log-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.witness-log-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 14px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.witness-log-entry:hover {
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
}
|
||||
|
||||
.witness-ts {
|
||||
color: var(--text-muted);
|
||||
min-width: 80px;
|
||||
white-space: nowrap;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.witness-step {
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.witness-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
min-width: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.witness-badge-commit { background: rgba(0, 229, 255, 0.1); color: var(--accent); }
|
||||
.witness-badge-verify { background: var(--success-dim); color: var(--success); }
|
||||
.witness-badge-seal { background: var(--critical-dim); color: var(--critical); }
|
||||
.witness-badge-merge { background: var(--warning-dim); color: var(--warning); }
|
||||
|
||||
.witness-hash {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ---- Pipeline stages ---- */
|
||||
|
||||
.pipeline-stages {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pipeline-stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 48px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.pipeline-stage.active {
|
||||
background: var(--success-dim);
|
||||
color: var(--success);
|
||||
border-color: rgba(46, 204, 113, 0.3);
|
||||
}
|
||||
|
||||
.pipeline-stage.pending {
|
||||
background: var(--warning-dim);
|
||||
color: var(--warning);
|
||||
border-color: rgba(255, 176, 32, 0.3);
|
||||
}
|
||||
|
||||
.pipeline-arrow {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ---- Toolbar ---- */
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.toolbar button:hover {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toolbar button.active {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
|
||||
/* ---- Split panel layout ---- */
|
||||
|
||||
.split-layout {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border-right: 1px solid var(--border);
|
||||
width: 50%;
|
||||
min-width: 0;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.left-panel .table-area {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.left-panel .chart-area {
|
||||
height: 200px;
|
||||
min-height: 200px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
width: 50%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.main-panel {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ---- Detail floating panel ---- */
|
||||
|
||||
.detail-panel {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.detail-panel h4 {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.detail-row:last-child { border-bottom: none; }
|
||||
|
||||
.detail-key {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail-val {
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ---- Score badge ---- */
|
||||
|
||||
.score-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.score-high { background: var(--success-dim); color: var(--success); }
|
||||
.score-medium { background: var(--warning-dim); color: var(--warning); }
|
||||
.score-low { background: var(--critical-dim); color: var(--critical); }
|
||||
|
||||
/* ---- Dashboard grid layouts ---- */
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dashboard-grid .full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-auto-rows: min-content;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.status-grid .full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* ---- Boundary tick marks ---- */
|
||||
|
||||
.boundary-tick {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
background: var(--warning);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ---- Alert item ---- */
|
||||
|
||||
.alert-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.alert-item:last-child { border-bottom: none; }
|
||||
|
||||
.alert-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alert-dot.warning { background: var(--warning); }
|
||||
.alert-dot.critical { background: var(--critical); }
|
||||
.alert-dot.success { background: var(--success); }
|
||||
|
||||
.alert-msg {
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.alert-sector {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ---- Empty / loading states ---- */
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ---- Scrollbar ---- */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(11, 15, 20, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 229, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(0, 229, 255, 0.08);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 229, 255, 0.4);
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 229, 255, 0.2) rgba(11, 15, 20, 0.5);
|
||||
}
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
|
||||
@media (max-width: 1279px) {
|
||||
.view-split { grid-template-columns: 1fr; grid-template-rows: 1fr 1fr; }
|
||||
.split-layout { flex-direction: column; }
|
||||
.left-panel, .right-panel { width: 100%; }
|
||||
.left-panel { border-right: none; border-bottom: 1px solid var(--border); }
|
||||
.col-3 { grid-column: span 6; }
|
||||
.col-4 { grid-column: span 6; }
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.view-with-sidebar { grid-template-columns: 1fr; }
|
||||
.sidebar { max-height: 200px; border-right: none; border-bottom: 1px solid var(--border); }
|
||||
.dashboard-grid, .status-grid { grid-template-columns: 1fr; }
|
||||
.col-3, .col-4, .col-6 { grid-column: span 12; }
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
domain: string;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
const DOMAIN_COLORS: Record<string, THREE.Color> = {
|
||||
transit: new THREE.Color(0x00E5FF),
|
||||
flare: new THREE.Color(0xFF4D4D),
|
||||
rotation: new THREE.Color(0x2ECC71),
|
||||
eclipse: new THREE.Color(0x9944ff),
|
||||
variability: new THREE.Color(0xFFB020),
|
||||
};
|
||||
|
||||
const DEFAULT_COLOR = new THREE.Color(0x8B949E);
|
||||
|
||||
function colorForDomain(domain: string): THREE.Color {
|
||||
return DOMAIN_COLORS[domain] ?? DEFAULT_COLOR;
|
||||
}
|
||||
|
||||
export class AtlasGraph {
|
||||
private nodesMesh: THREE.InstancedMesh | null = null;
|
||||
private edgesLine: THREE.LineSegments | null = null;
|
||||
private glowPoints: THREE.Points | null = null;
|
||||
private scene: THREE.Scene;
|
||||
private nodeMap: Map<string, number> = new Map();
|
||||
|
||||
constructor(scene: THREE.Scene) {
|
||||
this.scene = scene;
|
||||
}
|
||||
|
||||
setNodes(nodes: GraphNode[]): void {
|
||||
this.disposeNodes();
|
||||
|
||||
// Star-like nodes using InstancedMesh with emissive material
|
||||
const geometry = new THREE.SphereGeometry(0.12, 8, 6);
|
||||
const material = new THREE.MeshStandardMaterial({
|
||||
vertexColors: false,
|
||||
emissiveIntensity: 0.8,
|
||||
roughness: 0.3,
|
||||
metalness: 0.1,
|
||||
});
|
||||
const mesh = new THREE.InstancedMesh(geometry, material, nodes.length);
|
||||
|
||||
const dummy = new THREE.Object3D();
|
||||
const color = new THREE.Color();
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
this.nodeMap.set(node.id, i);
|
||||
|
||||
dummy.position.set(node.x, node.y, node.z);
|
||||
const scale = 0.3 + node.weight * 0.7;
|
||||
dummy.scale.set(scale, scale, scale);
|
||||
dummy.updateMatrix();
|
||||
mesh.setMatrixAt(i, dummy.matrix);
|
||||
|
||||
color.copy(colorForDomain(node.domain));
|
||||
mesh.setColorAt(i, color);
|
||||
}
|
||||
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
||||
|
||||
this.nodesMesh = mesh;
|
||||
this.scene.add(mesh);
|
||||
|
||||
// Additive glow halo points around each node
|
||||
const glowPositions = new Float32Array(nodes.length * 3);
|
||||
const glowColors = new Float32Array(nodes.length * 3);
|
||||
const glowSizes = new Float32Array(nodes.length);
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
glowPositions[i * 3] = node.x;
|
||||
glowPositions[i * 3 + 1] = node.y;
|
||||
glowPositions[i * 3 + 2] = node.z;
|
||||
|
||||
const c = colorForDomain(node.domain);
|
||||
glowColors[i * 3] = c.r;
|
||||
glowColors[i * 3 + 1] = c.g;
|
||||
glowColors[i * 3 + 2] = c.b;
|
||||
|
||||
glowSizes[i] = 0.8 + node.weight * 1.5;
|
||||
}
|
||||
|
||||
const glowGeo = new THREE.BufferGeometry();
|
||||
glowGeo.setAttribute('position', new THREE.Float32BufferAttribute(glowPositions, 3));
|
||||
glowGeo.setAttribute('color', new THREE.Float32BufferAttribute(glowColors, 3));
|
||||
|
||||
const glowMat = new THREE.PointsMaterial({
|
||||
size: 1.2,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.25,
|
||||
sizeAttenuation: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
|
||||
this.glowPoints = new THREE.Points(glowGeo, glowMat);
|
||||
this.scene.add(this.glowPoints);
|
||||
}
|
||||
|
||||
setEdges(edges: GraphEdge[], nodes: GraphNode[]): void {
|
||||
this.disposeEdges();
|
||||
|
||||
const positions: number[] = [];
|
||||
const colors: number[] = [];
|
||||
|
||||
const nodeById = new Map<string, GraphNode>();
|
||||
for (const n of nodes) nodeById.set(n.id, n);
|
||||
|
||||
for (const edge of edges) {
|
||||
const src = nodeById.get(edge.source);
|
||||
const tgt = nodeById.get(edge.target);
|
||||
if (!src || !tgt) continue;
|
||||
|
||||
positions.push(src.x, src.y, src.z);
|
||||
positions.push(tgt.x, tgt.y, tgt.z);
|
||||
|
||||
// Cyan glow edges with weight-based opacity
|
||||
const alpha = Math.max(0.05, Math.min(0.6, edge.weight * 0.5));
|
||||
colors.push(0.0, 0.9, 1.0, alpha);
|
||||
colors.push(0.0, 0.9, 1.0, alpha);
|
||||
}
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 4));
|
||||
|
||||
const material = new THREE.LineBasicMaterial({
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.6,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
|
||||
this.edgesLine = new THREE.LineSegments(geometry, material);
|
||||
this.scene.add(this.edgesLine);
|
||||
}
|
||||
|
||||
getNodeIndex(id: string): number | undefined {
|
||||
return this.nodeMap.get(id);
|
||||
}
|
||||
|
||||
/** Animate node glow pulse (0-1 range). */
|
||||
setPulse(intensity: number): void {
|
||||
if (this.glowPoints) {
|
||||
(this.glowPoints.material as THREE.PointsMaterial).opacity = 0.15 + intensity * 0.15;
|
||||
}
|
||||
if (this.nodesMesh) {
|
||||
const mat = this.nodesMesh.material as THREE.MeshStandardMaterial;
|
||||
mat.emissiveIntensity = 0.5 + intensity * 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
private disposeNodes(): void {
|
||||
if (this.nodesMesh) {
|
||||
this.scene.remove(this.nodesMesh);
|
||||
this.nodesMesh.geometry.dispose();
|
||||
(this.nodesMesh.material as THREE.Material).dispose();
|
||||
this.nodesMesh = null;
|
||||
}
|
||||
if (this.glowPoints) {
|
||||
this.scene.remove(this.glowPoints);
|
||||
this.glowPoints.geometry.dispose();
|
||||
(this.glowPoints.material as THREE.Material).dispose();
|
||||
this.glowPoints = null;
|
||||
}
|
||||
this.nodeMap.clear();
|
||||
}
|
||||
|
||||
private disposeEdges(): void {
|
||||
if (this.edgesLine) {
|
||||
this.scene.remove(this.edgesLine);
|
||||
this.edgesLine.geometry.dispose();
|
||||
(this.edgesLine.material as THREE.Material).dispose();
|
||||
this.edgesLine = null;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposeNodes();
|
||||
this.disposeEdges();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
export class CoherenceSurface {
|
||||
private mesh: THREE.Mesh | null = null;
|
||||
private wireframe: THREE.LineSegments | null = null;
|
||||
private contourLines: THREE.Group | null = null;
|
||||
private gridLabels: THREE.Group | null = null;
|
||||
private scene: THREE.Scene;
|
||||
private gridWidth: number;
|
||||
private gridHeight: number;
|
||||
|
||||
constructor(scene: THREE.Scene, gridWidth = 64, gridHeight = 64) {
|
||||
this.scene = scene;
|
||||
this.gridWidth = gridWidth;
|
||||
this.gridHeight = gridHeight;
|
||||
this.createMesh();
|
||||
this.createGridLabels();
|
||||
}
|
||||
|
||||
private createMesh(): void {
|
||||
const geometry = new THREE.PlaneGeometry(
|
||||
10, 10,
|
||||
this.gridWidth - 1,
|
||||
this.gridHeight - 1,
|
||||
);
|
||||
|
||||
const vertexCount = geometry.attributes.position.count;
|
||||
const colors = new Float32Array(vertexCount * 3);
|
||||
for (let i = 0; i < vertexCount; i++) {
|
||||
colors[i * 3] = 0.0;
|
||||
colors[i * 3 + 1] = 0.3;
|
||||
colors[i * 3 + 2] = 0.5;
|
||||
}
|
||||
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
|
||||
const material = new THREE.MeshPhongMaterial({
|
||||
vertexColors: true,
|
||||
side: THREE.DoubleSide,
|
||||
shininess: 40,
|
||||
specular: new THREE.Color(0x112233),
|
||||
flatShading: false,
|
||||
});
|
||||
|
||||
this.mesh = new THREE.Mesh(geometry, material);
|
||||
this.mesh.rotation.x = -Math.PI / 2;
|
||||
this.scene.add(this.mesh);
|
||||
|
||||
// Subtle grid overlay
|
||||
const wireGeo = new THREE.WireframeGeometry(geometry);
|
||||
const wireMat = new THREE.LineBasicMaterial({
|
||||
color: 0x1C2333,
|
||||
transparent: true,
|
||||
opacity: 0.12,
|
||||
});
|
||||
this.wireframe = new THREE.LineSegments(wireGeo, wireMat);
|
||||
this.wireframe.rotation.x = -Math.PI / 2;
|
||||
this.scene.add(this.wireframe);
|
||||
}
|
||||
|
||||
private createGridLabels(): void {
|
||||
this.gridLabels = new THREE.Group();
|
||||
|
||||
// Base grid plane at y=0 with faint lines
|
||||
const gridHelper = new THREE.GridHelper(10, 8, 0x1C2333, 0x131A22);
|
||||
gridHelper.position.y = -0.01;
|
||||
this.gridLabels.add(gridHelper);
|
||||
|
||||
// Axis lines
|
||||
const axisMat = new THREE.LineBasicMaterial({ color: 0x2A3444, transparent: true, opacity: 0.5 });
|
||||
const xAxisGeo = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(-5.5, 0, 5.5),
|
||||
new THREE.Vector3(5.5, 0, 5.5),
|
||||
]);
|
||||
this.gridLabels.add(new THREE.Line(xAxisGeo, axisMat));
|
||||
|
||||
const zAxisGeo = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(-5.5, 0, 5.5),
|
||||
new THREE.Vector3(-5.5, 0, -5.5),
|
||||
]);
|
||||
this.gridLabels.add(new THREE.Line(zAxisGeo, axisMat));
|
||||
|
||||
this.scene.add(this.gridLabels);
|
||||
}
|
||||
|
||||
/** Map coherence value [0,1] to a clear multi-stop color ramp. */
|
||||
private valueToColor(v: number, color: THREE.Color): void {
|
||||
// 1.0 = deep stable blue, 0.85 = cyan, 0.75 = yellow warning, <0.7 = red critical
|
||||
if (v > 0.85) {
|
||||
// Blue -> Cyan (stable zone)
|
||||
const t = (v - 0.85) / 0.15;
|
||||
color.setRGB(0.0, 0.4 + t * 0.1, 0.6 + t * 0.4);
|
||||
} else if (v > 0.75) {
|
||||
// Cyan -> Yellow (transition)
|
||||
const t = (v - 0.75) / 0.1;
|
||||
color.setRGB(1.0 - t * 1.0, 0.7 + t * 0.2, t * 0.6);
|
||||
} else if (v > 0.65) {
|
||||
// Yellow -> Orange (warning)
|
||||
const t = (v - 0.65) / 0.1;
|
||||
color.setRGB(1.0, 0.5 + t * 0.2, t * 0.1);
|
||||
} else {
|
||||
// Orange -> Red (critical)
|
||||
const t = Math.max(0, v / 0.65);
|
||||
color.setRGB(0.9 + t * 0.1, 0.15 + t * 0.35, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
setValues(values: number[]): void {
|
||||
if (!this.mesh) return;
|
||||
|
||||
const geometry = this.mesh.geometry;
|
||||
const colorAttr = geometry.attributes.color;
|
||||
const posAttr = geometry.attributes.position;
|
||||
const count = Math.min(values.length, colorAttr.count);
|
||||
|
||||
const color = new THREE.Color();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const v = Math.max(0, Math.min(1, values[i]));
|
||||
this.valueToColor(v, color);
|
||||
colorAttr.setXYZ(i, color.r, color.g, color.b);
|
||||
|
||||
// Elevation: higher coherence = flat, lower = raised (shows "pressure")
|
||||
const elevation = (1 - v) * 2.5;
|
||||
posAttr.setZ(i, elevation);
|
||||
}
|
||||
|
||||
colorAttr.needsUpdate = true;
|
||||
posAttr.needsUpdate = true;
|
||||
geometry.computeVertexNormals();
|
||||
|
||||
this.updateWireframe(geometry);
|
||||
this.updateContours(values);
|
||||
}
|
||||
|
||||
private updateWireframe(geometry: THREE.PlaneGeometry): void {
|
||||
if (this.wireframe) {
|
||||
this.scene.remove(this.wireframe);
|
||||
this.wireframe.geometry.dispose();
|
||||
(this.wireframe.material as THREE.Material).dispose();
|
||||
}
|
||||
|
||||
const wireGeo = new THREE.WireframeGeometry(geometry);
|
||||
const wireMat = new THREE.LineBasicMaterial({
|
||||
color: 0x1C2333,
|
||||
transparent: true,
|
||||
opacity: 0.12,
|
||||
});
|
||||
this.wireframe = new THREE.LineSegments(wireGeo, wireMat);
|
||||
this.wireframe.rotation.x = -Math.PI / 2;
|
||||
this.scene.add(this.wireframe);
|
||||
}
|
||||
|
||||
/** Draw contour rings at threshold boundaries (0.8 warning, 0.7 critical). */
|
||||
private updateContours(values: number[]): void {
|
||||
if (this.contourLines) {
|
||||
this.scene.remove(this.contourLines);
|
||||
this.contourLines.traverse((obj) => {
|
||||
if (obj instanceof THREE.Line) {
|
||||
obj.geometry.dispose();
|
||||
(obj.material as THREE.Material).dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.contourLines = new THREE.Group();
|
||||
const gw = this.gridWidth;
|
||||
const gh = this.gridHeight;
|
||||
const halfW = 5;
|
||||
|
||||
const thresholds = [
|
||||
{ level: 0.80, color: 0xFFB020, opacity: 0.6 }, // warning
|
||||
{ level: 0.70, color: 0xFF4D4D, opacity: 0.7 }, // critical
|
||||
];
|
||||
|
||||
for (const thresh of thresholds) {
|
||||
const points: THREE.Vector3[] = [];
|
||||
|
||||
for (let y = 0; y < gh - 1; y++) {
|
||||
for (let x = 0; x < gw - 1; x++) {
|
||||
const v00 = values[y * gw + x] ?? 1;
|
||||
const v10 = values[y * gw + x + 1] ?? 1;
|
||||
const v01 = values[(y + 1) * gw + x] ?? 1;
|
||||
|
||||
// Horizontal edge crossing
|
||||
if ((v00 - thresh.level) * (v10 - thresh.level) < 0) {
|
||||
const t = (thresh.level - v00) / (v10 - v00);
|
||||
const wx = -halfW + ((x + t) / (gw - 1)) * halfW * 2;
|
||||
const wz = -halfW + (y / (gh - 1)) * halfW * 2;
|
||||
const elev = (1 - thresh.level) * 2.5;
|
||||
points.push(new THREE.Vector3(wx, elev + 0.02, wz));
|
||||
}
|
||||
|
||||
// Vertical edge crossing
|
||||
if ((v00 - thresh.level) * (v01 - thresh.level) < 0) {
|
||||
const t = (thresh.level - v00) / (v01 - v00);
|
||||
const wx = -halfW + (x / (gw - 1)) * halfW * 2;
|
||||
const wz = -halfW + ((y + t) / (gh - 1)) * halfW * 2;
|
||||
const elev = (1 - thresh.level) * 2.5;
|
||||
points.push(new THREE.Vector3(wx, elev + 0.02, wz));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (points.length > 1) {
|
||||
const geo = new THREE.BufferGeometry().setFromPoints(points);
|
||||
const mat = new THREE.PointsMaterial({
|
||||
color: thresh.color,
|
||||
size: 0.08,
|
||||
transparent: true,
|
||||
opacity: thresh.opacity,
|
||||
depthWrite: false,
|
||||
});
|
||||
this.contourLines.add(new THREE.Points(geo, mat));
|
||||
}
|
||||
}
|
||||
|
||||
this.scene.add(this.contourLines);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.mesh) {
|
||||
this.scene.remove(this.mesh);
|
||||
this.mesh.geometry.dispose();
|
||||
(this.mesh.material as THREE.Material).dispose();
|
||||
this.mesh = null;
|
||||
}
|
||||
if (this.wireframe) {
|
||||
this.scene.remove(this.wireframe);
|
||||
this.wireframe.geometry.dispose();
|
||||
(this.wireframe.material as THREE.Material).dispose();
|
||||
this.wireframe = null;
|
||||
}
|
||||
if (this.contourLines) {
|
||||
this.scene.remove(this.contourLines);
|
||||
this.contourLines.traverse((obj) => {
|
||||
if (obj instanceof THREE.Line || obj instanceof THREE.Points) {
|
||||
obj.geometry.dispose();
|
||||
(obj.material as THREE.Material).dispose();
|
||||
}
|
||||
});
|
||||
this.contourLines = null;
|
||||
}
|
||||
if (this.gridLabels) {
|
||||
this.scene.remove(this.gridLabels);
|
||||
this.gridLabels = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
|
||||
/**
|
||||
* Interactive 3D Dyson sphere visualization with galactic context.
|
||||
*
|
||||
* Renders:
|
||||
* - Deep-field starfield + galactic plane
|
||||
* - Central star (emissive sphere, color from spectral type)
|
||||
* - Partial Dyson swarm shell (coverage_fraction controls opacity mask)
|
||||
* - IR waste heat glow halo
|
||||
* - Orbiting collector panels as instanced quads
|
||||
*
|
||||
* Interaction:
|
||||
* - OrbitControls: drag to rotate, scroll to zoom, right-drag to pan
|
||||
* - Speed control via setSpeed()
|
||||
* - Reset view via resetCamera()
|
||||
*/
|
||||
|
||||
export interface DysonParams {
|
||||
coverageFraction: number;
|
||||
warmTempK: number;
|
||||
spectralType: string;
|
||||
w3Excess: number;
|
||||
w4Excess: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const SPECTRAL_COLORS: Record<string, number> = {
|
||||
O: 0x9bb0ff, B: 0xaabfff, A: 0xcad7ff, F: 0xf8f7ff,
|
||||
G: 0xfff4ea, K: 0xffd2a1, M: 0xffb56c, L: 0xff8833,
|
||||
};
|
||||
|
||||
function starColor(spectralType: string): number {
|
||||
const letter = spectralType.charAt(0).toUpperCase();
|
||||
return SPECTRAL_COLORS[letter] ?? 0xffd2a1;
|
||||
}
|
||||
|
||||
function warmColor(tempK: number): THREE.Color {
|
||||
const t = Math.max(0, Math.min(1, (tempK - 100) / 400));
|
||||
return new THREE.Color().setHSL(0.02 + t * 0.06, 0.9, 0.3 + t * 0.2);
|
||||
}
|
||||
|
||||
function seededRandom(seed: number): () => number {
|
||||
let s = seed;
|
||||
return () => {
|
||||
s = (s * 16807 + 0) % 2147483647;
|
||||
return (s - 1) / 2147483646;
|
||||
};
|
||||
}
|
||||
|
||||
export class DysonSphere3D {
|
||||
private scene: THREE.Scene;
|
||||
private camera: THREE.PerspectiveCamera;
|
||||
private renderer: THREE.WebGLRenderer;
|
||||
private controls: OrbitControls;
|
||||
private starMesh: THREE.Mesh | null = null;
|
||||
private shellMesh: THREE.Mesh | null = null;
|
||||
private glowMesh: THREE.Mesh | null = null;
|
||||
private panelInstances: THREE.InstancedMesh | null = null;
|
||||
private animId = 0;
|
||||
private time = 0;
|
||||
private speedMultiplier = 1;
|
||||
private autoRotate = true;
|
||||
private defaultCamPos = new THREE.Vector3(0, 1.5, 4);
|
||||
private bgGroup: THREE.Group | null = null;
|
||||
|
||||
constructor(private container: HTMLElement) {
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0x020408);
|
||||
|
||||
const w = container.clientWidth || 400;
|
||||
const h = container.clientHeight || 300;
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(50, w / h, 0.01, 2000);
|
||||
this.camera.position.set(0, 1.5, 4);
|
||||
this.camera.lookAt(0, 0, 0);
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
this.renderer.setSize(w, h);
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
container.appendChild(this.renderer.domElement);
|
||||
|
||||
// OrbitControls
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||
this.controls.enableDamping = true;
|
||||
this.controls.dampingFactor = 0.08;
|
||||
this.controls.minDistance = 1;
|
||||
this.controls.maxDistance = 400;
|
||||
this.controls.enablePan = true;
|
||||
this.controls.zoomSpeed = 1.2;
|
||||
this.controls.rotateSpeed = 0.8;
|
||||
this.controls.addEventListener('start', () => { this.autoRotate = false; });
|
||||
|
||||
this.scene.add(new THREE.AmbientLight(0x222244, 0.3));
|
||||
this.buildBackground();
|
||||
}
|
||||
|
||||
// ── Public controls ──
|
||||
|
||||
setSpeed(multiplier: number): void {
|
||||
this.speedMultiplier = multiplier;
|
||||
}
|
||||
|
||||
resetCamera(): void {
|
||||
this.autoRotate = true;
|
||||
this.camera.position.copy(this.defaultCamPos);
|
||||
this.camera.lookAt(0, 0, 0);
|
||||
this.controls.target.set(0, 0, 0);
|
||||
this.controls.update();
|
||||
}
|
||||
|
||||
toggleAutoRotate(): void {
|
||||
this.autoRotate = !this.autoRotate;
|
||||
}
|
||||
|
||||
getAutoRotate(): boolean {
|
||||
return this.autoRotate;
|
||||
}
|
||||
|
||||
// ── Background ──
|
||||
|
||||
private buildBackground(): void {
|
||||
this.bgGroup = new THREE.Group();
|
||||
const rand = seededRandom(77);
|
||||
|
||||
// Starfield
|
||||
const starCount = 4000;
|
||||
const positions = new Float32Array(starCount * 3);
|
||||
const colors = new Float32Array(starCount * 3);
|
||||
const tints = [
|
||||
new THREE.Color(0xffffff), new THREE.Color(0xaaccff),
|
||||
new THREE.Color(0xfff4ea), new THREE.Color(0xffd2a1),
|
||||
new THREE.Color(0xffb56c), new THREE.Color(0xccddff),
|
||||
];
|
||||
|
||||
for (let i = 0; i < starCount; i++) {
|
||||
const theta = rand() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * rand() - 1);
|
||||
const r = 300 + rand() * 500;
|
||||
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
||||
positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
|
||||
positions[i * 3 + 2] = r * Math.cos(phi);
|
||||
const tint = tints[Math.floor(rand() * tints.length)];
|
||||
const b = 0.4 + rand() * 0.6;
|
||||
colors[i * 3] = tint.r * b;
|
||||
colors[i * 3 + 1] = tint.g * b;
|
||||
colors[i * 3 + 2] = tint.b * b;
|
||||
}
|
||||
|
||||
const sGeo = new THREE.BufferGeometry();
|
||||
sGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
sGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
this.bgGroup.add(new THREE.Points(sGeo, new THREE.PointsMaterial({
|
||||
size: 1.5, vertexColors: true, transparent: true, opacity: 0.9,
|
||||
sizeAttenuation: true, depthWrite: false,
|
||||
})));
|
||||
|
||||
// Galactic plane
|
||||
const galCount = 5000;
|
||||
const gp = new Float32Array(galCount * 3);
|
||||
const gc = new Float32Array(galCount * 3);
|
||||
for (let i = 0; i < galCount; i++) {
|
||||
const a = rand() * Math.PI * 2;
|
||||
const d = Math.pow(rand(), 0.5) * 500;
|
||||
const h = (rand() - 0.5) * (12 + d * 0.02);
|
||||
gp[i * 3] = d * Math.cos(a);
|
||||
gp[i * 3 + 1] = h;
|
||||
gp[i * 3 + 2] = d * Math.sin(a);
|
||||
const cp = 1 - Math.min(1, d / 500);
|
||||
gc[i * 3] = 0.5 + cp * 0.35;
|
||||
gc[i * 3 + 1] = 0.5 + cp * 0.25;
|
||||
gc[i * 3 + 2] = 0.6 + rand() * 0.1;
|
||||
}
|
||||
const gGeo = new THREE.BufferGeometry();
|
||||
gGeo.setAttribute('position', new THREE.BufferAttribute(gp, 3));
|
||||
gGeo.setAttribute('color', new THREE.BufferAttribute(gc, 3));
|
||||
const gal = new THREE.Points(gGeo, new THREE.PointsMaterial({
|
||||
size: 0.8, vertexColors: true, transparent: true, opacity: 0.2,
|
||||
sizeAttenuation: true, depthWrite: false,
|
||||
}));
|
||||
gal.rotation.x = Math.PI * 0.35;
|
||||
gal.rotation.z = Math.PI * 0.15;
|
||||
gal.position.set(0, 80, -180);
|
||||
this.bgGroup.add(gal);
|
||||
|
||||
// Nebulae
|
||||
const nebColors = [0x3344aa, 0xaa3355, 0x2288aa, 0x8844aa];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 128; canvas.height = 128;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const grad = ctx.createRadialGradient(64, 64, 4, 64, 64, 64);
|
||||
const col = new THREE.Color(nebColors[i % nebColors.length]);
|
||||
grad.addColorStop(0, `rgba(${Math.floor(col.r * 255)},${Math.floor(col.g * 255)},${Math.floor(col.b * 255)},0.25)`);
|
||||
grad.addColorStop(0.4, `rgba(${Math.floor(col.r * 255)},${Math.floor(col.g * 255)},${Math.floor(col.b * 255)},0.06)`);
|
||||
grad.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, 128, 128);
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false });
|
||||
const sp = new THREE.Sprite(mat);
|
||||
const t2 = rand() * Math.PI * 2;
|
||||
const p2 = (rand() - 0.5) * Math.PI * 0.5;
|
||||
const r2 = 200 + rand() * 350;
|
||||
sp.position.set(r2 * Math.cos(p2) * Math.cos(t2), r2 * Math.sin(p2), r2 * Math.cos(p2) * Math.sin(t2));
|
||||
sp.scale.setScalar(50 + rand() * 100);
|
||||
this.bgGroup.add(sp);
|
||||
}
|
||||
|
||||
this.scene.add(this.bgGroup);
|
||||
}
|
||||
|
||||
update(params: DysonParams): void {
|
||||
this.clearSystem();
|
||||
|
||||
const sc = starColor(params.spectralType);
|
||||
|
||||
// ── Central Star ──
|
||||
const starGeo = new THREE.SphereGeometry(0.5, 32, 32);
|
||||
const starMat = new THREE.MeshBasicMaterial({ color: sc });
|
||||
this.starMesh = new THREE.Mesh(starGeo, starMat);
|
||||
this.scene.add(this.starMesh);
|
||||
|
||||
const starLight = new THREE.PointLight(sc, 2, 20);
|
||||
starLight.position.set(0, 0, 0);
|
||||
this.scene.add(starLight);
|
||||
|
||||
// ── Dyson Shell ──
|
||||
const shellRadius = 1.5;
|
||||
const shellGeo = new THREE.SphereGeometry(shellRadius, 64, 64);
|
||||
const wc = warmColor(params.warmTempK);
|
||||
|
||||
const positions = shellGeo.attributes.position;
|
||||
const vertColors = new Float32Array(positions.count * 4);
|
||||
const coverage = params.coverageFraction;
|
||||
|
||||
for (let i = 0; i < positions.count; i++) {
|
||||
const x = positions.getX(i);
|
||||
const y = positions.getY(i);
|
||||
const z = positions.getZ(i);
|
||||
const theta = Math.atan2(Math.sqrt(x * x + z * z), y);
|
||||
const phi = Math.atan2(z, x);
|
||||
const pattern =
|
||||
0.5 + 0.2 * Math.sin(theta * 5 + phi * 3) +
|
||||
0.15 * Math.sin(theta * 8 - phi * 5) +
|
||||
0.15 * Math.cos(phi * 7 + theta * 2);
|
||||
const visible = pattern < coverage;
|
||||
const alpha = visible ? 0.6 + coverage * 0.3 : 0.02;
|
||||
vertColors[i * 4] = visible ? wc.r : 0.05;
|
||||
vertColors[i * 4 + 1] = visible ? wc.g : 0.05;
|
||||
vertColors[i * 4 + 2] = visible ? wc.b : 0.05;
|
||||
vertColors[i * 4 + 3] = alpha;
|
||||
}
|
||||
shellGeo.setAttribute('color', new THREE.BufferAttribute(vertColors, 4));
|
||||
|
||||
const shellMat = new THREE.MeshBasicMaterial({
|
||||
vertexColors: true, transparent: true, opacity: 0.7,
|
||||
side: THREE.DoubleSide, depthWrite: false,
|
||||
});
|
||||
this.shellMesh = new THREE.Mesh(shellGeo, shellMat);
|
||||
this.scene.add(this.shellMesh);
|
||||
|
||||
// Wireframe overlay
|
||||
const wireGeo = new THREE.SphereGeometry(shellRadius + 0.01, 24, 24);
|
||||
const wireMat = new THREE.MeshBasicMaterial({ color: wc, transparent: true, opacity: 0.08, wireframe: true });
|
||||
this.scene.add(new THREE.Mesh(wireGeo, wireMat));
|
||||
|
||||
// ── IR Glow ──
|
||||
const glowRadius = shellRadius + 0.3 + params.w4Excess * 0.1;
|
||||
const glowGeo = new THREE.SphereGeometry(glowRadius, 32, 32);
|
||||
const glowMat = new THREE.MeshBasicMaterial({
|
||||
color: wc, transparent: true, opacity: 0.04 + coverage * 0.06,
|
||||
side: THREE.BackSide, depthWrite: false,
|
||||
});
|
||||
this.glowMesh = new THREE.Mesh(glowGeo, glowMat);
|
||||
this.scene.add(this.glowMesh);
|
||||
|
||||
// ── Collector Panels ──
|
||||
const panelCount = Math.floor(coverage * 400);
|
||||
if (panelCount > 0) {
|
||||
const panelGeo = new THREE.PlaneGeometry(0.06, 0.06);
|
||||
const panelMat = new THREE.MeshBasicMaterial({ color: wc, transparent: true, opacity: 0.9, side: THREE.DoubleSide });
|
||||
this.panelInstances = new THREE.InstancedMesh(panelGeo, panelMat, panelCount);
|
||||
|
||||
const dummy = new THREE.Object3D();
|
||||
for (let i = 0; i < panelCount; i++) {
|
||||
const t = i / panelCount;
|
||||
const incl = Math.acos(1 - 2 * t);
|
||||
const azim = Math.PI * (1 + Math.sqrt(5)) * i;
|
||||
const r = shellRadius + 0.02 + Math.random() * 0.05;
|
||||
dummy.position.set(
|
||||
r * Math.sin(incl) * Math.cos(azim),
|
||||
r * Math.cos(incl),
|
||||
r * Math.sin(incl) * Math.sin(azim),
|
||||
);
|
||||
dummy.lookAt(0, 0, 0);
|
||||
dummy.updateMatrix();
|
||||
this.panelInstances.setMatrixAt(i, dummy.matrix);
|
||||
}
|
||||
this.panelInstances.instanceMatrix.needsUpdate = true;
|
||||
this.scene.add(this.panelInstances);
|
||||
}
|
||||
|
||||
this.defaultCamPos.set(2.5, 1.5, 3.5);
|
||||
this.camera.position.copy(this.defaultCamPos);
|
||||
this.controls.target.set(0, 0, 0);
|
||||
this.controls.update();
|
||||
this.autoRotate = true;
|
||||
|
||||
this.animate();
|
||||
}
|
||||
|
||||
private clearSystem(): void {
|
||||
cancelAnimationFrame(this.animId);
|
||||
const toRemove: THREE.Object3D[] = [];
|
||||
this.scene.traverse((obj) => {
|
||||
if (obj !== this.scene && obj !== this.bgGroup && obj.parent === this.scene && !(obj instanceof THREE.AmbientLight)) {
|
||||
toRemove.push(obj);
|
||||
}
|
||||
});
|
||||
for (const obj of toRemove) {
|
||||
this.scene.remove(obj);
|
||||
if ((obj as THREE.Mesh).geometry) (obj as THREE.Mesh).geometry.dispose();
|
||||
}
|
||||
let hasAmbient = false;
|
||||
this.scene.traverse((o) => { if (o instanceof THREE.AmbientLight) hasAmbient = true; });
|
||||
if (!hasAmbient) this.scene.add(new THREE.AmbientLight(0x222244, 0.3));
|
||||
|
||||
this.starMesh = null;
|
||||
this.shellMesh = null;
|
||||
this.glowMesh = null;
|
||||
this.panelInstances = null;
|
||||
}
|
||||
|
||||
private animate = (): void => {
|
||||
this.animId = requestAnimationFrame(this.animate);
|
||||
this.time += 0.005 * this.speedMultiplier;
|
||||
|
||||
if (this.shellMesh) this.shellMesh.rotation.y = this.time * 0.3;
|
||||
if (this.panelInstances) this.panelInstances.rotation.y = this.time * 0.3;
|
||||
|
||||
// Auto-rotate camera
|
||||
if (this.autoRotate) {
|
||||
const camR = 4;
|
||||
this.camera.position.x = camR * Math.sin(this.time * 0.15);
|
||||
this.camera.position.z = camR * Math.cos(this.time * 0.15);
|
||||
this.camera.position.y = 1.2 + 0.3 * Math.sin(this.time * 0.1);
|
||||
this.controls.target.set(0, 0, 0);
|
||||
}
|
||||
|
||||
if (this.starMesh) {
|
||||
const scale = 1 + 0.03 * Math.sin(this.time * 3);
|
||||
this.starMesh.scale.setScalar(scale);
|
||||
}
|
||||
|
||||
if (this.glowMesh) {
|
||||
const mat = this.glowMesh.material as THREE.MeshBasicMaterial;
|
||||
mat.opacity = 0.04 + 0.02 * Math.sin(this.time * 2);
|
||||
}
|
||||
|
||||
this.controls.update();
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
};
|
||||
|
||||
resize(): void {
|
||||
const w = this.container.clientWidth || 400;
|
||||
const h = this.container.clientHeight || 300;
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(w, h);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
cancelAnimationFrame(this.animId);
|
||||
this.controls.dispose();
|
||||
this.clearSystem();
|
||||
if (this.bgGroup) {
|
||||
this.scene.remove(this.bgGroup);
|
||||
this.bgGroup.traverse((obj) => {
|
||||
if ((obj as THREE.Mesh).geometry) (obj as THREE.Mesh).geometry.dispose();
|
||||
});
|
||||
this.bgGroup = null;
|
||||
}
|
||||
this.renderer.dispose();
|
||||
if (this.renderer.domElement.parentElement) {
|
||||
this.renderer.domElement.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
export class OrbitPreview {
|
||||
private line: THREE.Line | null = null;
|
||||
private starMesh: THREE.Mesh | null = null;
|
||||
private starGlow: THREE.Sprite | null = null;
|
||||
private planetMesh: THREE.Mesh | null = null;
|
||||
private hzRing: THREE.Line | null = null;
|
||||
private gridHelper: THREE.GridHelper | null = null;
|
||||
private scene: THREE.Scene;
|
||||
private orbitPoints: THREE.Vector3[] = [];
|
||||
private orbitAngle = 0;
|
||||
private orbitSpeed = 0.005;
|
||||
private paramOverlay: HTMLElement | null = null;
|
||||
private parentEl: HTMLElement | null = null;
|
||||
|
||||
constructor(scene: THREE.Scene) {
|
||||
this.scene = scene;
|
||||
this.addStar();
|
||||
this.addGrid();
|
||||
}
|
||||
|
||||
private addStar(): void {
|
||||
// Solid sphere
|
||||
const geo = new THREE.SphereGeometry(0.18, 24, 16);
|
||||
const mat = new THREE.MeshBasicMaterial({ color: 0xffdd44 });
|
||||
this.starMesh = new THREE.Mesh(geo, mat);
|
||||
this.scene.add(this.starMesh);
|
||||
|
||||
// Glow sprite
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const grad = ctx.createRadialGradient(32, 32, 2, 32, 32, 32);
|
||||
grad.addColorStop(0, 'rgba(255,221,68,0.6)');
|
||||
grad.addColorStop(0.4, 'rgba(255,200,50,0.15)');
|
||||
grad.addColorStop(1, 'rgba(255,200,50,0)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, 64, 64);
|
||||
}
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
const spriteMat = new THREE.SpriteMaterial({ map: tex, transparent: true, blending: THREE.AdditiveBlending });
|
||||
this.starGlow = new THREE.Sprite(spriteMat);
|
||||
this.starGlow.scale.set(1.2, 1.2, 1);
|
||||
this.scene.add(this.starGlow);
|
||||
}
|
||||
|
||||
private addGrid(): void {
|
||||
this.gridHelper = new THREE.GridHelper(8, 8, 0x1C2333, 0x151B23);
|
||||
this.gridHelper.position.y = -0.5;
|
||||
this.scene.add(this.gridHelper);
|
||||
}
|
||||
|
||||
setOrbit(
|
||||
semiMajorAxis: number,
|
||||
eccentricity: number,
|
||||
inclination: number,
|
||||
parentElement?: HTMLElement,
|
||||
): void {
|
||||
this.disposeLine();
|
||||
this.disposePlanet();
|
||||
this.disposeHzRing();
|
||||
this.disposeOverlay();
|
||||
|
||||
const segments = 128;
|
||||
this.orbitPoints = [];
|
||||
const a = semiMajorAxis;
|
||||
const e = Math.min(Math.max(eccentricity, 0), 0.99);
|
||||
const incRad = (inclination * Math.PI) / 180;
|
||||
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const theta = (i / segments) * Math.PI * 2;
|
||||
const r = (a * (1 - e * e)) / (1 + e * Math.cos(theta));
|
||||
const x = r * Math.cos(theta);
|
||||
const z = r * Math.sin(theta) * Math.cos(incRad);
|
||||
const y = r * Math.sin(theta) * Math.sin(incRad);
|
||||
this.orbitPoints.push(new THREE.Vector3(x, y, z));
|
||||
}
|
||||
|
||||
// Orbit path
|
||||
const geometry = new THREE.BufferGeometry().setFromPoints(this.orbitPoints);
|
||||
const material = new THREE.LineBasicMaterial({
|
||||
color: 0x4488ff,
|
||||
transparent: true,
|
||||
opacity: 0.7,
|
||||
});
|
||||
this.line = new THREE.Line(geometry, material);
|
||||
this.scene.add(this.line);
|
||||
|
||||
// Planet dot
|
||||
const planetGeo = new THREE.SphereGeometry(0.08, 12, 8);
|
||||
const planetMat = new THREE.MeshStandardMaterial({ color: 0x4488ff, emissive: 0x2244aa, emissiveIntensity: 0.3 });
|
||||
this.planetMesh = new THREE.Mesh(planetGeo, planetMat);
|
||||
this.planetMesh.position.copy(this.orbitPoints[0]);
|
||||
this.scene.add(this.planetMesh);
|
||||
|
||||
// Habitable zone ring (0.95-1.37 AU scaled)
|
||||
const hzInner = 0.95 * (a / 1.5);
|
||||
const hzOuter = 1.37 * (a / 1.5);
|
||||
const hzMid = (hzInner + hzOuter) / 2;
|
||||
const hzPts: THREE.Vector3[] = [];
|
||||
for (let i = 0; i <= 64; i++) {
|
||||
const theta = (i / 64) * Math.PI * 2;
|
||||
hzPts.push(new THREE.Vector3(hzMid * Math.cos(theta), -0.48, hzMid * Math.sin(theta)));
|
||||
}
|
||||
const hzGeo = new THREE.BufferGeometry().setFromPoints(hzPts);
|
||||
const hzMat = new THREE.LineBasicMaterial({ color: 0x2ECC71, transparent: true, opacity: 0.25 });
|
||||
this.hzRing = new THREE.Line(hzGeo, hzMat);
|
||||
this.scene.add(this.hzRing);
|
||||
|
||||
// Orbit speed based on period (faster for shorter periods)
|
||||
this.orbitSpeed = 0.003 + (1 / (a * 10)) * 0.02;
|
||||
this.orbitAngle = 0;
|
||||
|
||||
// Param overlay
|
||||
if (parentElement) {
|
||||
this.parentEl = parentElement;
|
||||
this.paramOverlay = document.createElement('div');
|
||||
this.paramOverlay.style.cssText =
|
||||
'position:absolute;bottom:8px;left:8px;' +
|
||||
'background:rgba(11,15,20,0.85);border:1px solid var(--border);border-radius:4px;' +
|
||||
'padding:6px 10px;font-family:var(--font-mono);font-size:10px;color:var(--text-secondary);' +
|
||||
'line-height:1.6;z-index:10;pointer-events:none';
|
||||
this.paramOverlay.innerHTML =
|
||||
`<div style="color:var(--text-primary);font-weight:600;margin-bottom:2px">Orbit Parameters</div>` +
|
||||
`<div>Semi-major: <span style="color:var(--accent)">${a.toFixed(2)} AU</span></div>` +
|
||||
`<div>Eccentricity: <span style="color:var(--accent)">${e.toFixed(3)}</span></div>` +
|
||||
`<div>Inclination: <span style="color:var(--accent)">${inclination.toFixed(1)}°</span></div>` +
|
||||
`<div style="margin-top:4px;color:#2ECC71;font-size:9px">● Habitable zone</div>`;
|
||||
parentElement.appendChild(this.paramOverlay);
|
||||
}
|
||||
}
|
||||
|
||||
/** Call each frame to animate the planet along the orbit. */
|
||||
tick(): void {
|
||||
if (!this.planetMesh || this.orbitPoints.length < 2) return;
|
||||
this.orbitAngle = (this.orbitAngle + this.orbitSpeed) % 1;
|
||||
const idx = Math.floor(this.orbitAngle * (this.orbitPoints.length - 1));
|
||||
this.planetMesh.position.copy(this.orbitPoints[idx]);
|
||||
}
|
||||
|
||||
private disposeLine(): void {
|
||||
if (this.line) {
|
||||
this.scene.remove(this.line);
|
||||
this.line.geometry.dispose();
|
||||
(this.line.material as THREE.Material).dispose();
|
||||
this.line = null;
|
||||
}
|
||||
}
|
||||
|
||||
private disposePlanet(): void {
|
||||
if (this.planetMesh) {
|
||||
this.scene.remove(this.planetMesh);
|
||||
this.planetMesh.geometry.dispose();
|
||||
(this.planetMesh.material as THREE.Material).dispose();
|
||||
this.planetMesh = null;
|
||||
}
|
||||
}
|
||||
|
||||
private disposeHzRing(): void {
|
||||
if (this.hzRing) {
|
||||
this.scene.remove(this.hzRing);
|
||||
this.hzRing.geometry.dispose();
|
||||
(this.hzRing.material as THREE.Material).dispose();
|
||||
this.hzRing = null;
|
||||
}
|
||||
}
|
||||
|
||||
private disposeOverlay(): void {
|
||||
if (this.paramOverlay && this.parentEl) {
|
||||
this.parentEl.removeChild(this.paramOverlay);
|
||||
this.paramOverlay = null;
|
||||
this.parentEl = null;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposeLine();
|
||||
this.disposePlanet();
|
||||
this.disposeHzRing();
|
||||
this.disposeOverlay();
|
||||
|
||||
if (this.starMesh) {
|
||||
this.scene.remove(this.starMesh);
|
||||
this.starMesh.geometry.dispose();
|
||||
(this.starMesh.material as THREE.Material).dispose();
|
||||
this.starMesh = null;
|
||||
}
|
||||
if (this.starGlow) {
|
||||
this.scene.remove(this.starGlow);
|
||||
this.starGlow.material.map?.dispose();
|
||||
this.starGlow.material.dispose();
|
||||
this.starGlow = null;
|
||||
}
|
||||
if (this.gridHelper) {
|
||||
this.scene.remove(this.gridHelper);
|
||||
this.gridHelper.geometry.dispose();
|
||||
(this.gridHelper.material as THREE.Material).dispose();
|
||||
this.gridHelper = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
|
||||
/**
|
||||
* Interactive 3D exoplanet system visualization with galactic context.
|
||||
*
|
||||
* Renders:
|
||||
* - Deep-field starfield (4000 background stars)
|
||||
* - Milky Way galactic plane disc
|
||||
* - Distant nebula patches
|
||||
* - Central host star (color from effective temperature)
|
||||
* - Planet on animated orbital path (size from radius_earth)
|
||||
* - Habitable zone annulus (green band)
|
||||
* - Orbit ellipse line
|
||||
* - AU scale labels
|
||||
*
|
||||
* Interaction:
|
||||
* - OrbitControls: drag to rotate, scroll to zoom, right-drag to pan
|
||||
* - Speed control via setSpeed()
|
||||
* - Reset view via resetCamera()
|
||||
*/
|
||||
|
||||
export interface PlanetSystemParams {
|
||||
label: string;
|
||||
radiusEarth: number;
|
||||
semiMajorAxisAU: number;
|
||||
eqTempK: number;
|
||||
stellarTempK: number;
|
||||
stellarRadiusSolar: number;
|
||||
periodDays: number;
|
||||
hzMember: boolean;
|
||||
esiScore: number;
|
||||
transitDepth: number;
|
||||
}
|
||||
|
||||
function starColorFromTemp(teff: number): number {
|
||||
if (teff > 7500) return 0xaabfff;
|
||||
if (teff > 6000) return 0xf8f7ff;
|
||||
if (teff > 5200) return 0xfff4ea;
|
||||
if (teff > 3700) return 0xffd2a1;
|
||||
return 0xffb56c;
|
||||
}
|
||||
|
||||
function planetColor(eqTempK: number): number {
|
||||
if (eqTempK < 200) return 0x4488cc;
|
||||
if (eqTempK < 260) return 0x44aa77;
|
||||
if (eqTempK < 320) return 0x55bb55;
|
||||
if (eqTempK < 500) return 0xddaa44;
|
||||
return 0xff6644;
|
||||
}
|
||||
|
||||
/** Deterministic pseudo-random from seed. */
|
||||
function seededRandom(seed: number): () => number {
|
||||
let s = seed;
|
||||
return () => {
|
||||
s = (s * 16807 + 0) % 2147483647;
|
||||
return (s - 1) / 2147483646;
|
||||
};
|
||||
}
|
||||
|
||||
export class PlanetSystem3D {
|
||||
private scene: THREE.Scene;
|
||||
private camera: THREE.PerspectiveCamera;
|
||||
private renderer: THREE.WebGLRenderer;
|
||||
private controls: OrbitControls;
|
||||
private starMesh: THREE.Mesh | null = null;
|
||||
private planetMesh: THREE.Mesh | null = null;
|
||||
private orbitLine: THREE.Line | null = null;
|
||||
private hzInnerRing: THREE.Mesh | null = null;
|
||||
private animId = 0;
|
||||
private time = 0;
|
||||
private orbitPoints: THREE.Vector3[] = [];
|
||||
private orbitSpeed = 0.003;
|
||||
private orbitAngle = 0;
|
||||
private speedMultiplier = 1;
|
||||
private autoRotate = true;
|
||||
private defaultCamPos = new THREE.Vector3(0, 3, 6);
|
||||
private bgGroup: THREE.Group | null = null;
|
||||
private labelSprites: THREE.Sprite[] = [];
|
||||
private currentParams: PlanetSystemParams | null = null;
|
||||
|
||||
constructor(private container: HTMLElement) {
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0x020408);
|
||||
|
||||
const w = container.clientWidth || 400;
|
||||
const h = container.clientHeight || 300;
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(50, w / h, 0.01, 2000);
|
||||
this.camera.position.set(0, 3, 6);
|
||||
this.camera.lookAt(0, 0, 0);
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
this.renderer.setSize(w, h);
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
container.appendChild(this.renderer.domElement);
|
||||
|
||||
// OrbitControls for mouse interaction
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||
this.controls.enableDamping = true;
|
||||
this.controls.dampingFactor = 0.08;
|
||||
this.controls.minDistance = 1;
|
||||
this.controls.maxDistance = 500;
|
||||
this.controls.enablePan = true;
|
||||
this.controls.autoRotate = false; // We handle auto-rotate ourselves
|
||||
this.controls.zoomSpeed = 1.2;
|
||||
this.controls.rotateSpeed = 0.8;
|
||||
|
||||
// Stop auto-rotation when user interacts
|
||||
this.controls.addEventListener('start', () => { this.autoRotate = false; });
|
||||
|
||||
this.scene.add(new THREE.AmbientLight(0x222244, 0.4));
|
||||
|
||||
// Build immutable background (stars, galaxy, nebulae)
|
||||
this.buildBackground();
|
||||
}
|
||||
|
||||
// ── Public controls ──
|
||||
|
||||
setSpeed(multiplier: number): void {
|
||||
this.speedMultiplier = multiplier;
|
||||
}
|
||||
|
||||
resetCamera(): void {
|
||||
this.autoRotate = true;
|
||||
this.camera.position.copy(this.defaultCamPos);
|
||||
this.camera.lookAt(0, 0, 0);
|
||||
this.controls.target.set(0, 0, 0);
|
||||
this.controls.update();
|
||||
}
|
||||
|
||||
toggleAutoRotate(): void {
|
||||
this.autoRotate = !this.autoRotate;
|
||||
}
|
||||
|
||||
getAutoRotate(): boolean {
|
||||
return this.autoRotate;
|
||||
}
|
||||
|
||||
// ── Background: starfield, galaxy, nebulae ──
|
||||
|
||||
private buildBackground(): void {
|
||||
this.bgGroup = new THREE.Group();
|
||||
|
||||
// ── Starfield: 4000 background stars ──
|
||||
const starCount = 4000;
|
||||
const positions = new Float32Array(starCount * 3);
|
||||
const colors = new Float32Array(starCount * 3);
|
||||
const sizes = new Float32Array(starCount);
|
||||
const rand = seededRandom(42);
|
||||
|
||||
const starTints = [
|
||||
new THREE.Color(0xffffff), // white
|
||||
new THREE.Color(0xaaccff), // blue-white
|
||||
new THREE.Color(0xfff4ea), // yellow-white
|
||||
new THREE.Color(0xffd2a1), // orange
|
||||
new THREE.Color(0xffb56c), // red-orange
|
||||
new THREE.Color(0xccddff), // pale blue
|
||||
];
|
||||
|
||||
for (let i = 0; i < starCount; i++) {
|
||||
// Distribute on a large sphere shell (300-800 units away)
|
||||
const theta = rand() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * rand() - 1);
|
||||
const r = 300 + rand() * 500;
|
||||
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
||||
positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
|
||||
positions[i * 3 + 2] = r * Math.cos(phi);
|
||||
|
||||
const tint = starTints[Math.floor(rand() * starTints.length)];
|
||||
const brightness = 0.4 + rand() * 0.6;
|
||||
colors[i * 3] = tint.r * brightness;
|
||||
colors[i * 3 + 1] = tint.g * brightness;
|
||||
colors[i * 3 + 2] = tint.b * brightness;
|
||||
|
||||
sizes[i] = 0.5 + rand() * 2.0;
|
||||
}
|
||||
|
||||
const starGeo = new THREE.BufferGeometry();
|
||||
starGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
starGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
starGeo.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
|
||||
|
||||
const starMat = new THREE.PointsMaterial({
|
||||
size: 1.5,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
sizeAttenuation: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
this.bgGroup.add(new THREE.Points(starGeo, starMat));
|
||||
|
||||
// ── Milky Way galactic plane ──
|
||||
// A large tilted disc with dense star concentration
|
||||
const galaxyCount = 6000;
|
||||
const galPos = new Float32Array(galaxyCount * 3);
|
||||
const galCol = new Float32Array(galaxyCount * 3);
|
||||
for (let i = 0; i < galaxyCount; i++) {
|
||||
// Flat disc distribution, concentrated toward center
|
||||
const angle = rand() * Math.PI * 2;
|
||||
const dist = Math.pow(rand(), 0.5) * 600; // More concentrated near center
|
||||
const height = (rand() - 0.5) * (15 + dist * 0.02); // Thin disc, thicker outward
|
||||
|
||||
galPos[i * 3] = dist * Math.cos(angle);
|
||||
galPos[i * 3 + 1] = height;
|
||||
galPos[i * 3 + 2] = dist * Math.sin(angle);
|
||||
|
||||
// Milky Way is blueish-white with warm core
|
||||
const coreProx = 1 - Math.min(1, dist / 600);
|
||||
const r2 = rand();
|
||||
galCol[i * 3] = 0.5 + coreProx * 0.4 + r2 * 0.1;
|
||||
galCol[i * 3 + 1] = 0.5 + coreProx * 0.3 + r2 * 0.1;
|
||||
galCol[i * 3 + 2] = 0.6 + r2 * 0.15;
|
||||
}
|
||||
|
||||
const galGeo = new THREE.BufferGeometry();
|
||||
galGeo.setAttribute('position', new THREE.BufferAttribute(galPos, 3));
|
||||
galGeo.setAttribute('color', new THREE.BufferAttribute(galCol, 3));
|
||||
const galMat = new THREE.PointsMaterial({
|
||||
size: 0.8,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.25,
|
||||
sizeAttenuation: true,
|
||||
depthWrite: false,
|
||||
});
|
||||
const galaxy = new THREE.Points(galGeo, galMat);
|
||||
// Tilt the galactic plane ~60 degrees (we see the Milky Way at an angle)
|
||||
galaxy.rotation.x = Math.PI * 0.35;
|
||||
galaxy.rotation.z = Math.PI * 0.15;
|
||||
galaxy.position.set(0, 100, -200);
|
||||
this.bgGroup.add(galaxy);
|
||||
|
||||
// ── Galactic core glow ──
|
||||
const coreGlowGeo = new THREE.SphereGeometry(40, 16, 16);
|
||||
const coreGlowMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xeeddcc,
|
||||
transparent: true,
|
||||
opacity: 0.04,
|
||||
side: THREE.BackSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
const coreGlow = new THREE.Mesh(coreGlowGeo, coreGlowMat);
|
||||
coreGlow.position.copy(galaxy.position);
|
||||
this.bgGroup.add(coreGlow);
|
||||
|
||||
// ── Nebula patches (colored sprite billboards) ──
|
||||
const nebulaColors = [0x3344aa, 0xaa3355, 0x2288aa, 0x8844aa, 0x44aa66];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 128;
|
||||
canvas.height = 128;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const grad = ctx.createRadialGradient(64, 64, 4, 64, 64, 64);
|
||||
const col = new THREE.Color(nebulaColors[i % nebulaColors.length]);
|
||||
grad.addColorStop(0, `rgba(${Math.floor(col.r * 255)},${Math.floor(col.g * 255)},${Math.floor(col.b * 255)},0.3)`);
|
||||
grad.addColorStop(0.4, `rgba(${Math.floor(col.r * 255)},${Math.floor(col.g * 255)},${Math.floor(col.b * 255)},0.08)`);
|
||||
grad.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, 128, 128);
|
||||
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
const spriteMat = new THREE.SpriteMaterial({
|
||||
map: tex,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
const sprite = new THREE.Sprite(spriteMat);
|
||||
const theta2 = rand() * Math.PI * 2;
|
||||
const phi2 = (rand() - 0.5) * Math.PI * 0.6;
|
||||
const r2 = 200 + rand() * 400;
|
||||
sprite.position.set(
|
||||
r2 * Math.cos(phi2) * Math.cos(theta2),
|
||||
r2 * Math.sin(phi2),
|
||||
r2 * Math.cos(phi2) * Math.sin(theta2),
|
||||
);
|
||||
sprite.scale.setScalar(60 + rand() * 120);
|
||||
this.bgGroup.add(sprite);
|
||||
}
|
||||
|
||||
this.scene.add(this.bgGroup);
|
||||
}
|
||||
|
||||
update(params: PlanetSystemParams): void {
|
||||
this.clearSystem();
|
||||
this.currentParams = params;
|
||||
|
||||
const sc = starColorFromTemp(params.stellarTempK);
|
||||
const pc = planetColor(params.eqTempK);
|
||||
const orbitRadius = Math.max(0.8, Math.min(4.0, params.semiMajorAxisAU * 2.5));
|
||||
|
||||
// ── Host Star ──
|
||||
const starVisualRadius = 0.25 + params.stellarRadiusSolar * 0.2;
|
||||
const starGeo = new THREE.SphereGeometry(starVisualRadius, 32, 32);
|
||||
const starMat = new THREE.MeshBasicMaterial({ color: sc });
|
||||
this.starMesh = new THREE.Mesh(starGeo, starMat);
|
||||
this.scene.add(this.starMesh);
|
||||
|
||||
const starLight = new THREE.PointLight(sc, 2.5, 30);
|
||||
starLight.position.set(0, 0, 0);
|
||||
this.scene.add(starLight);
|
||||
|
||||
// Star corona
|
||||
const glowGeo = new THREE.SphereGeometry(starVisualRadius * 2.5, 24, 24);
|
||||
const glowMat = new THREE.MeshBasicMaterial({
|
||||
color: sc,
|
||||
transparent: true,
|
||||
opacity: 0.05,
|
||||
side: THREE.BackSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
this.scene.add(new THREE.Mesh(glowGeo, glowMat));
|
||||
|
||||
// ── Habitable Zone ──
|
||||
if (params.hzMember) {
|
||||
const hzInner = orbitRadius * 0.75;
|
||||
const hzOuter = orbitRadius * 1.35;
|
||||
const hzGeo = new THREE.RingGeometry(hzInner, hzOuter, 64);
|
||||
const hzMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x2ecc71,
|
||||
transparent: true,
|
||||
opacity: 0.07,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
this.hzInnerRing = new THREE.Mesh(hzGeo, hzMat);
|
||||
this.hzInnerRing.rotation.x = -Math.PI / 2;
|
||||
this.hzInnerRing.position.y = -0.01;
|
||||
this.scene.add(this.hzInnerRing);
|
||||
|
||||
const makeHzCircle = (r: number, color: number, opacity: number) => {
|
||||
const pts: THREE.Vector3[] = [];
|
||||
for (let i = 0; i <= 128; i++) {
|
||||
const th = (i / 128) * Math.PI * 2;
|
||||
pts.push(new THREE.Vector3(r * Math.cos(th), 0, r * Math.sin(th)));
|
||||
}
|
||||
const geo = new THREE.BufferGeometry().setFromPoints(pts);
|
||||
const mat = new THREE.LineBasicMaterial({ color, transparent: true, opacity });
|
||||
this.scene.add(new THREE.Line(geo, mat));
|
||||
};
|
||||
makeHzCircle(hzInner, 0x2ecc71, 0.25);
|
||||
makeHzCircle(hzOuter, 0x2ecc71, 0.12);
|
||||
}
|
||||
|
||||
// ── Orbit Path ──
|
||||
this.orbitPoints = [];
|
||||
const segments = 256;
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const theta = (i / segments) * Math.PI * 2;
|
||||
this.orbitPoints.push(new THREE.Vector3(
|
||||
orbitRadius * Math.cos(theta), 0, orbitRadius * Math.sin(theta),
|
||||
));
|
||||
}
|
||||
const orbitGeo = new THREE.BufferGeometry().setFromPoints(this.orbitPoints);
|
||||
const orbitMat = new THREE.LineBasicMaterial({ color: pc, transparent: true, opacity: 0.5 });
|
||||
this.orbitLine = new THREE.Line(orbitGeo, orbitMat);
|
||||
this.scene.add(this.orbitLine);
|
||||
|
||||
// Reference rings
|
||||
const makeRefRing = (r: number) => {
|
||||
const pts: THREE.Vector3[] = [];
|
||||
for (let i = 0; i <= 128; i++) {
|
||||
const th = (i / 128) * Math.PI * 2;
|
||||
pts.push(new THREE.Vector3(r * Math.cos(th), 0, r * Math.sin(th)));
|
||||
}
|
||||
const geo = new THREE.BufferGeometry().setFromPoints(pts);
|
||||
const mat = new THREE.LineBasicMaterial({ color: 0x1c2333, transparent: true, opacity: 0.3 });
|
||||
this.scene.add(new THREE.Line(geo, mat));
|
||||
};
|
||||
makeRefRing(0.5 * 2.5);
|
||||
makeRefRing(1.5 * 2.5);
|
||||
|
||||
// AU scale labels
|
||||
this.addScaleLabel('0.5 AU', 0.5 * 2.5 + 0.2, 0.3, 0);
|
||||
this.addScaleLabel('1.0 AU', 1.0 * 2.5 + 0.2, 0.3, 0);
|
||||
this.addScaleLabel('1.5 AU', 1.5 * 2.5 + 0.2, 0.3, 0);
|
||||
if (params.hzMember) {
|
||||
this.addScaleLabel('HZ', orbitRadius * 1.05, 0.5, 0, '#2ecc71');
|
||||
}
|
||||
|
||||
// ── Planet ──
|
||||
const planetVisualRadius = Math.max(0.06, Math.min(0.2, params.radiusEarth * 0.1));
|
||||
const planetGeo = new THREE.SphereGeometry(planetVisualRadius, 24, 24);
|
||||
const planetMat = new THREE.MeshStandardMaterial({
|
||||
color: pc,
|
||||
emissive: pc,
|
||||
emissiveIntensity: 0.2,
|
||||
roughness: 0.7,
|
||||
metalness: 0.1,
|
||||
});
|
||||
this.planetMesh = new THREE.Mesh(planetGeo, planetMat);
|
||||
this.planetMesh.position.copy(this.orbitPoints[0]);
|
||||
this.scene.add(this.planetMesh);
|
||||
|
||||
// Atmosphere halo for habitable candidates
|
||||
if (params.hzMember && params.eqTempK > 180 && params.eqTempK < 350) {
|
||||
const atmoGeo = new THREE.SphereGeometry(planetVisualRadius * 1.2, 24, 24);
|
||||
const atmoMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x66ccff,
|
||||
transparent: true,
|
||||
opacity: 0.12,
|
||||
side: THREE.BackSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
this.planetMesh.add(new THREE.Mesh(atmoGeo, atmoMat));
|
||||
}
|
||||
|
||||
// Planet label
|
||||
this.addScaleLabel(
|
||||
params.label,
|
||||
this.orbitPoints[0].x,
|
||||
this.orbitPoints[0].y + planetVisualRadius + 0.15,
|
||||
this.orbitPoints[0].z,
|
||||
'#00e5ff',
|
||||
);
|
||||
|
||||
// ── Grid ──
|
||||
const gridHelper = new THREE.GridHelper(12, 12, 0x151b23, 0x0d1117);
|
||||
gridHelper.position.y = -0.3;
|
||||
this.scene.add(gridHelper);
|
||||
|
||||
// Speed and camera
|
||||
this.orbitSpeed = 0.002 + (1 / Math.max(params.periodDays, 10)) * 0.8;
|
||||
this.orbitAngle = 0;
|
||||
|
||||
const camDist = orbitRadius * 1.8 + 2;
|
||||
this.defaultCamPos.set(camDist * 0.6, camDist * 0.45, camDist * 0.7);
|
||||
this.camera.position.copy(this.defaultCamPos);
|
||||
this.controls.target.set(0, 0, 0);
|
||||
this.controls.update();
|
||||
this.autoRotate = true;
|
||||
|
||||
this.animate();
|
||||
}
|
||||
|
||||
private addScaleLabel(text: string, x: number, y: number, z: number, color = '#556677'): void {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 128;
|
||||
canvas.height = 32;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.font = '14px monospace';
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(text, 64, 20);
|
||||
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
tex.minFilter = THREE.LinearFilter;
|
||||
const mat = new THREE.SpriteMaterial({
|
||||
map: tex,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
});
|
||||
const sprite = new THREE.Sprite(mat);
|
||||
sprite.position.set(x, y, z);
|
||||
sprite.scale.set(1.2, 0.3, 1);
|
||||
this.scene.add(sprite);
|
||||
this.labelSprites.push(sprite);
|
||||
}
|
||||
|
||||
/** Remove system objects but keep background. */
|
||||
private clearSystem(): void {
|
||||
cancelAnimationFrame(this.animId);
|
||||
const toRemove: THREE.Object3D[] = [];
|
||||
this.scene.traverse((obj) => {
|
||||
if (obj !== this.scene && obj !== this.bgGroup && obj.parent === this.scene && !(obj instanceof THREE.AmbientLight)) {
|
||||
toRemove.push(obj);
|
||||
}
|
||||
});
|
||||
for (const obj of toRemove) {
|
||||
this.scene.remove(obj);
|
||||
if ((obj as THREE.Mesh).geometry) (obj as THREE.Mesh).geometry.dispose();
|
||||
}
|
||||
// Re-add ambient if missing
|
||||
let hasAmbient = false;
|
||||
this.scene.traverse((o) => { if (o instanceof THREE.AmbientLight) hasAmbient = true; });
|
||||
if (!hasAmbient) this.scene.add(new THREE.AmbientLight(0x222244, 0.4));
|
||||
|
||||
this.starMesh = null;
|
||||
this.planetMesh = null;
|
||||
this.orbitLine = null;
|
||||
this.hzInnerRing = null;
|
||||
this.labelSprites = [];
|
||||
}
|
||||
|
||||
private animate = (): void => {
|
||||
this.animId = requestAnimationFrame(this.animate);
|
||||
this.time += 0.005 * this.speedMultiplier;
|
||||
|
||||
// Planet orbit
|
||||
if (this.planetMesh && this.orbitPoints.length > 1) {
|
||||
this.orbitAngle = (this.orbitAngle + this.orbitSpeed * this.speedMultiplier) % 1;
|
||||
const idx = Math.floor(this.orbitAngle * (this.orbitPoints.length - 1));
|
||||
this.planetMesh.position.copy(this.orbitPoints[idx]);
|
||||
this.planetMesh.rotation.y += 0.01 * this.speedMultiplier;
|
||||
}
|
||||
|
||||
// Star pulse
|
||||
if (this.starMesh) {
|
||||
const scale = 1 + 0.02 * Math.sin(this.time * 3);
|
||||
this.starMesh.scale.setScalar(scale);
|
||||
}
|
||||
|
||||
// Auto-rotate camera (only if user hasn't grabbed controls)
|
||||
if (this.autoRotate) {
|
||||
const camDist = this.camera.position.length();
|
||||
this.camera.position.x = camDist * 0.7 * Math.sin(this.time * 0.1);
|
||||
this.camera.position.z = camDist * 0.7 * Math.cos(this.time * 0.1);
|
||||
this.camera.position.y = camDist * 0.35 + 0.5 * Math.sin(this.time * 0.07);
|
||||
this.controls.target.set(0, 0, 0);
|
||||
}
|
||||
|
||||
this.controls.update();
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
};
|
||||
|
||||
resize(): void {
|
||||
const w = this.container.clientWidth || 400;
|
||||
const h = this.container.clientHeight || 300;
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(w, h);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
cancelAnimationFrame(this.animId);
|
||||
this.controls.dispose();
|
||||
this.clearSystem();
|
||||
// Also clear background
|
||||
if (this.bgGroup) {
|
||||
this.scene.remove(this.bgGroup);
|
||||
this.bgGroup.traverse((obj) => {
|
||||
if ((obj as THREE.Mesh).geometry) (obj as THREE.Mesh).geometry.dispose();
|
||||
});
|
||||
this.bgGroup = null;
|
||||
}
|
||||
this.renderer.dispose();
|
||||
if (this.renderer.domElement.parentElement) {
|
||||
this.renderer.domElement.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
import { AtlasGraph, GraphNode, GraphEdge } from '../three/AtlasGraph';
|
||||
import { fetchAtlasQuery } from '../api';
|
||||
import { onEvent, LiveEvent } from '../ws';
|
||||
|
||||
const SCALES = ['2h', '12h', '3d', '27d'] as const;
|
||||
|
||||
/* ── Seeded RNG ── */
|
||||
function seededRng(seed: number): () => number {
|
||||
let s = seed | 0;
|
||||
return () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; };
|
||||
}
|
||||
|
||||
/* ── Constellation name generator ── */
|
||||
const CONSTELLATION_NAMES = [
|
||||
'Lyra', 'Cygnus', 'Aquila', 'Orion', 'Centaurus', 'Vela', 'Puppis',
|
||||
'Sagittarius', 'Scorpius', 'Cassiopeia', 'Perseus', 'Andromeda',
|
||||
'Draco', 'Ursa Major', 'Leo', 'Virgo', 'Libra', 'Gemini',
|
||||
];
|
||||
|
||||
/* ── Galaxy data generator ── */
|
||||
function generateGalaxyData(
|
||||
scale: string,
|
||||
nodeCount: number,
|
||||
armCount: number,
|
||||
armSpread: number,
|
||||
coreConcentration: number,
|
||||
): { nodes: GraphNode[]; edges: GraphEdge[] } {
|
||||
const n = nodeCount;
|
||||
const domains = ['transit', 'flare', 'rotation', 'eclipse', 'variability'];
|
||||
const nodes: GraphNode[] = [];
|
||||
const edges: GraphEdge[] = [];
|
||||
const rng = seededRng(scale.length * 31337);
|
||||
|
||||
const maxRadius = 8;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const arm = i % armCount;
|
||||
const armAngle = (arm / armCount) * Math.PI * 2;
|
||||
const t = rng();
|
||||
// Core concentration: power law pushes more nodes toward center
|
||||
const radius = 0.3 + Math.pow(t, coreConcentration) * maxRadius;
|
||||
|
||||
const spiralAngle = armAngle + radius * 0.6 + (rng() - 0.5) * armSpread;
|
||||
const diskHeight = (rng() - 0.5) * 0.5 * Math.exp(-radius * 0.12);
|
||||
|
||||
const scatter = radius * 0.08;
|
||||
const x = radius * Math.cos(spiralAngle) + (rng() - 0.5) * scatter;
|
||||
const z = radius * Math.sin(spiralAngle) + (rng() - 0.5) * scatter;
|
||||
const y = diskHeight;
|
||||
|
||||
const weight = 0.15 + rng() * 0.85;
|
||||
nodes.push({ id: `s${i}`, domain: domains[i % domains.length], x, y, z, weight });
|
||||
}
|
||||
|
||||
// Edges: connect nearby stars
|
||||
for (let i = 1; i < n; i++) {
|
||||
let bestDist = Infinity;
|
||||
let bestJ = 0;
|
||||
const searchRange = Math.min(i, 25);
|
||||
for (let j = Math.max(0, i - searchRange); j < i; j++) {
|
||||
const dx = nodes[i].x - nodes[j].x;
|
||||
const dy = nodes[i].y - nodes[j].y;
|
||||
const dz = nodes[i].z - nodes[j].z;
|
||||
const d = dx * dx + dy * dy + dz * dz;
|
||||
if (d < bestDist) { bestDist = d; bestJ = j; }
|
||||
}
|
||||
edges.push({ source: nodes[bestJ].id, target: nodes[i].id, weight: Math.max(0.1, 1 - bestDist / 16) });
|
||||
|
||||
// Cross-arm connections
|
||||
if (rng() > 0.85 && i > 4) {
|
||||
const extra = Math.floor(rng() * i);
|
||||
const dx = nodes[i].x - nodes[extra].x;
|
||||
const dz = nodes[i].z - nodes[extra].z;
|
||||
if (Math.sqrt(dx * dx + dz * dz) < 4) {
|
||||
edges.push({ source: nodes[extra].id, target: nodes[i].id, weight: 0.05 + rng() * 0.15 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
export class AtlasExplorer {
|
||||
private container: HTMLElement | null = null;
|
||||
private renderer: THREE.WebGLRenderer | null = null;
|
||||
private scene: THREE.Scene | null = null;
|
||||
private camera: THREE.PerspectiveCamera | null = null;
|
||||
private controls: OrbitControls | null = null;
|
||||
private graph: AtlasGraph | null = null;
|
||||
private starfield: THREE.Points | null = null;
|
||||
private nebulaGroup: THREE.Group | null = null;
|
||||
private starMapLabels: THREE.Group | null = null;
|
||||
private gridHelper: THREE.Group | null = null;
|
||||
private animFrameId = 0;
|
||||
private unsubWs: (() => void) | null = null;
|
||||
private activeScale: string = '12h';
|
||||
private time = 0;
|
||||
|
||||
// Configurable parameters
|
||||
private nodeCount = 150;
|
||||
private spiralArms = 4;
|
||||
private armSpread = 0.4;
|
||||
private coreConcentration = 1.0;
|
||||
private rotationSpeed = 0.15;
|
||||
private showGrid = true;
|
||||
private showLabels = true;
|
||||
private showEdges = true;
|
||||
private pulseNodes = true;
|
||||
|
||||
// DOM refs for live slider updates
|
||||
private sliderRefs: Map<string, { slider: HTMLInputElement; valEl: HTMLElement }> = new Map();
|
||||
private statsEl: HTMLElement | null = null;
|
||||
|
||||
mount(container: HTMLElement): void {
|
||||
this.container = container;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.cssText = 'display:flex;width:100%;height:100%;overflow:hidden';
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// Left sidebar: config + star map info
|
||||
const sidebar = this.buildSidebar();
|
||||
wrapper.appendChild(sidebar);
|
||||
|
||||
// Main 3D viewport
|
||||
const mainArea = document.createElement('div');
|
||||
mainArea.style.cssText = 'flex:1;position:relative;min-width:0';
|
||||
wrapper.appendChild(mainArea);
|
||||
|
||||
const canvasDiv = document.createElement('div');
|
||||
canvasDiv.className = 'three-container';
|
||||
mainArea.appendChild(canvasDiv);
|
||||
|
||||
// Scale selector (time window)
|
||||
const scaleBar = document.createElement('div');
|
||||
scaleBar.className = 'scale-selector';
|
||||
for (const s of SCALES) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'scale-btn';
|
||||
if (s === this.activeScale) btn.classList.add('active');
|
||||
btn.textContent = s;
|
||||
btn.title = this.scaleDescription(s);
|
||||
btn.addEventListener('click', () => this.setScale(s, scaleBar));
|
||||
scaleBar.appendChild(btn);
|
||||
}
|
||||
canvasDiv.appendChild(scaleBar);
|
||||
|
||||
// Stats overlay (top-left)
|
||||
this.statsEl = document.createElement('div');
|
||||
this.statsEl.style.cssText = `
|
||||
position:absolute;top:12px;left:12px;
|
||||
padding:10px 14px;max-width:280px;
|
||||
background:rgba(11,15,20,0.88);border:1px solid var(--border);border-radius:4px;
|
||||
font-size:11px;color:var(--text-secondary);line-height:1.5;z-index:10;
|
||||
`;
|
||||
this.statsEl.innerHTML = `
|
||||
<div style="font-size:13px;font-weight:600;color:var(--text-primary);margin-bottom:4px">Causal Event Atlas</div>
|
||||
<div>Each point is a <span style="color:var(--accent)">causal event</span> detected in the observation pipeline.
|
||||
Lines show cause-effect relationships between events. The galaxy structure emerges from how events cluster by domain.</div>
|
||||
<div id="atlas-stats" style="margin-top:8px;font-family:var(--font-mono);font-size:10px;color:var(--text-muted)"></div>
|
||||
`;
|
||||
canvasDiv.appendChild(this.statsEl);
|
||||
|
||||
// Domain legend (bottom-left)
|
||||
const legend = document.createElement('div');
|
||||
legend.style.cssText = `
|
||||
position:absolute;bottom:12px;left:12px;
|
||||
padding:8px 12px;background:rgba(11,15,20,0.88);
|
||||
border:1px solid var(--border);border-radius:4px;
|
||||
font-size:10px;color:var(--text-secondary);z-index:10;
|
||||
`;
|
||||
legend.innerHTML = `
|
||||
<div style="font-size:9px;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:3px">Event Domains</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px 12px">
|
||||
<div style="display:flex;align-items:center;gap:4px"><span style="width:8px;height:8px;border-radius:50%;background:#00E5FF;display:inline-block"></span> Transit</div>
|
||||
<div style="display:flex;align-items:center;gap:4px"><span style="width:8px;height:8px;border-radius:50%;background:#FF4D4D;display:inline-block"></span> Flare</div>
|
||||
<div style="display:flex;align-items:center;gap:4px"><span style="width:8px;height:8px;border-radius:50%;background:#2ECC71;display:inline-block"></span> Rotation</div>
|
||||
<div style="display:flex;align-items:center;gap:4px"><span style="width:8px;height:8px;border-radius:50%;background:#9944FF;display:inline-block"></span> Eclipse</div>
|
||||
<div style="display:flex;align-items:center;gap:4px"><span style="width:8px;height:8px;border-radius:50%;background:#FFB020;display:inline-block"></span> Variability</div>
|
||||
</div>
|
||||
`;
|
||||
canvasDiv.appendChild(legend);
|
||||
|
||||
// Interaction hints (bottom-right)
|
||||
const hint = document.createElement('div');
|
||||
hint.style.cssText = 'position:absolute;bottom:12px;right:12px;font-size:9px;color:rgba(255,255,255,0.3);z-index:10;pointer-events:none';
|
||||
hint.textContent = 'Drag to rotate | Scroll to zoom | Right-drag to pan';
|
||||
canvasDiv.appendChild(hint);
|
||||
|
||||
// Three.js setup
|
||||
this.initThreeJs(canvasDiv);
|
||||
|
||||
this.resize();
|
||||
window.addEventListener('resize', this.resize);
|
||||
this.loadData();
|
||||
this.animate();
|
||||
|
||||
this.unsubWs = onEvent((ev: LiveEvent) => {
|
||||
if (ev.event_type === 'atlas_update') this.loadData();
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
|
||||
private buildSidebar(): HTMLElement {
|
||||
const sidebar = document.createElement('div');
|
||||
sidebar.style.cssText = 'width:260px;border-right:1px solid var(--border);background:var(--bg-panel);overflow-y:auto;overflow-x:hidden;flex-shrink:0;display:flex;flex-direction:column';
|
||||
|
||||
// Header
|
||||
const hdr = document.createElement('div');
|
||||
hdr.style.cssText = 'padding:12px 14px;border-bottom:1px solid var(--border);font-size:11px;font-weight:600;color:var(--text-primary);text-transform:uppercase;letter-spacing:0.5px';
|
||||
hdr.textContent = 'Atlas Configuration';
|
||||
sidebar.appendChild(hdr);
|
||||
|
||||
// Scrollable content
|
||||
const content = document.createElement('div');
|
||||
content.style.cssText = 'flex:1;overflow-y:auto;padding:10px 12px';
|
||||
sidebar.appendChild(content);
|
||||
|
||||
// Galaxy Shape section
|
||||
this.buildSection(content, 'Galaxy Shape', 'How the causal event network is arranged in 3D space', [
|
||||
{ label: 'Event count', desc: 'Total causal events to display', min: 30, max: 1200, step: 10, value: this.nodeCount,
|
||||
onChange: (v: number) => { this.nodeCount = v; this.loadData(); } },
|
||||
{ label: 'Spiral arms', desc: 'Number of galaxy arms (event clusters)', min: 2, max: 8, step: 1, value: this.spiralArms,
|
||||
onChange: (v: number) => { this.spiralArms = v; this.loadData(); } },
|
||||
{ label: 'Arm spread', desc: 'How scattered events are within each arm', min: 0.1, max: 1.5, step: 0.1, value: this.armSpread,
|
||||
onChange: (v: number) => { this.armSpread = v; this.loadData(); } },
|
||||
{ label: 'Core density', desc: 'Higher = more events packed near the center', min: 0.3, max: 3.0, step: 0.1, value: this.coreConcentration,
|
||||
onChange: (v: number) => { this.coreConcentration = v; this.loadData(); } },
|
||||
]);
|
||||
|
||||
// Animation section
|
||||
this.buildSection(content, 'Animation', 'Control how the atlas moves and rotates', [
|
||||
{ label: 'Rotation speed', desc: 'How fast the view auto-rotates', min: 0, max: 2.0, step: 0.05, value: this.rotationSpeed,
|
||||
onChange: (v: number) => {
|
||||
this.rotationSpeed = v;
|
||||
if (this.controls) this.controls.autoRotateSpeed = v;
|
||||
} },
|
||||
]);
|
||||
|
||||
// Display toggles
|
||||
const toggleSection = document.createElement('div');
|
||||
toggleSection.style.cssText = 'margin-top:12px';
|
||||
toggleSection.innerHTML = '<div style="font-size:9px;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.4px;margin-bottom:8px;font-weight:600">Display Options</div>';
|
||||
|
||||
const toggles = [
|
||||
{ label: 'Show coordinate grid', desc: 'Reference grid below the galaxy', checked: this.showGrid,
|
||||
onChange: (v: boolean) => { this.showGrid = v; if (this.gridHelper) this.gridHelper.visible = v; } },
|
||||
{ label: 'Show sector labels', desc: 'Constellation-style sector names', checked: this.showLabels,
|
||||
onChange: (v: boolean) => { this.showLabels = v; if (this.starMapLabels) this.starMapLabels.visible = v; } },
|
||||
{ label: 'Show connections', desc: 'Lines between causally linked events', checked: this.showEdges,
|
||||
onChange: (v: boolean) => { this.showEdges = v; this.loadData(); } },
|
||||
{ label: 'Pulse nodes', desc: 'Gentle brightness pulsing on events', checked: this.pulseNodes,
|
||||
onChange: (v: boolean) => { this.pulseNodes = v; } },
|
||||
];
|
||||
|
||||
for (const t of toggles) {
|
||||
const row = document.createElement('label');
|
||||
row.style.cssText = 'display:flex;align-items:flex-start;gap:8px;margin-bottom:8px;cursor:pointer';
|
||||
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.checked = t.checked;
|
||||
cb.style.cssText = 'accent-color:#00E5FF;margin-top:2px;flex-shrink:0';
|
||||
cb.addEventListener('change', () => t.onChange(cb.checked));
|
||||
row.appendChild(cb);
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.innerHTML = `<div style="font-size:10px;color:var(--text-primary)">${t.label}</div><div style="font-size:9px;color:var(--text-muted);line-height:1.3">${t.desc}</div>`;
|
||||
row.appendChild(info);
|
||||
|
||||
toggleSection.appendChild(row);
|
||||
}
|
||||
content.appendChild(toggleSection);
|
||||
|
||||
// Presets
|
||||
const presetSection = document.createElement('div');
|
||||
presetSection.style.cssText = 'margin-top:12px;padding-top:10px;border-top:1px solid var(--border)';
|
||||
presetSection.innerHTML = '<div style="font-size:9px;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.4px;margin-bottom:6px;font-weight:600">Quick Presets</div>';
|
||||
|
||||
const presets = [
|
||||
{ name: 'Compact Cluster', nc: 60, arms: 3, spread: 0.2, core: 2.0, desc: 'Tight event cluster' },
|
||||
{ name: 'Classic Spiral', nc: 200, arms: 4, spread: 0.4, core: 1.0, desc: 'Default galaxy layout' },
|
||||
{ name: 'Open Network', nc: 400, arms: 6, spread: 1.0, core: 0.5, desc: 'Wide, loose structure' },
|
||||
{ name: 'Dense Core', nc: 800, arms: 4, spread: 0.3, core: 2.5, desc: 'Many events, tight core' },
|
||||
];
|
||||
|
||||
for (const p of presets) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'scale-btn';
|
||||
btn.style.cssText = 'width:100%;text-align:left;margin-bottom:4px;padding:6px 10px;font-size:10px';
|
||||
btn.innerHTML = `<span style="color:var(--text-primary)">${p.name}</span> <span style="color:var(--text-muted);font-size:9px">${p.desc}</span>`;
|
||||
btn.addEventListener('click', () => {
|
||||
this.nodeCount = p.nc;
|
||||
this.spiralArms = p.arms;
|
||||
this.armSpread = p.spread;
|
||||
this.coreConcentration = p.core;
|
||||
this.syncSlider('Event count', p.nc);
|
||||
this.syncSlider('Spiral arms', p.arms);
|
||||
this.syncSlider('Arm spread', p.spread);
|
||||
this.syncSlider('Core density', p.core);
|
||||
this.loadData();
|
||||
});
|
||||
presetSection.appendChild(btn);
|
||||
}
|
||||
content.appendChild(presetSection);
|
||||
|
||||
// Star Map Info
|
||||
const starMapSection = document.createElement('div');
|
||||
starMapSection.style.cssText = 'margin-top:12px;padding-top:10px;border-top:1px solid var(--border)';
|
||||
starMapSection.innerHTML = `
|
||||
<div style="font-size:9px;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.4px;margin-bottom:6px;font-weight:600">Star Map Sectors</div>
|
||||
<div style="font-size:9px;color:var(--text-muted);line-height:1.4;margin-bottom:8px">
|
||||
The galaxy is divided into named sectors based on angular position. Each sector contains events from multiple domains.
|
||||
</div>
|
||||
`;
|
||||
|
||||
const sectorList = document.createElement('div');
|
||||
sectorList.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:3px';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const name = CONSTELLATION_NAMES[i];
|
||||
const angle = (i / 8 * 360).toFixed(0);
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'font-size:9px;padding:3px 6px;background:var(--bg-surface);border:1px solid var(--border);border-radius:3px';
|
||||
el.innerHTML = `<span style="color:var(--accent)">${name}</span> <span style="color:var(--text-muted)">${angle}\u00B0</span>`;
|
||||
sectorList.appendChild(el);
|
||||
}
|
||||
starMapSection.appendChild(sectorList);
|
||||
content.appendChild(starMapSection);
|
||||
|
||||
return sidebar;
|
||||
}
|
||||
|
||||
/** Update a slider's DOM value and position to match a programmatic change. */
|
||||
private syncSlider(label: string, value: number): void {
|
||||
const ref = this.sliderRefs.get(label);
|
||||
if (!ref) return;
|
||||
ref.slider.value = String(value);
|
||||
ref.valEl.textContent = String(Number(ref.slider.step) % 1 === 0 ? Math.round(value) : value.toFixed(1));
|
||||
}
|
||||
|
||||
private buildSection(
|
||||
parent: HTMLElement,
|
||||
title: string,
|
||||
description: string,
|
||||
sliders: { label: string; desc: string; min: number; max: number; step: number; value: number; onChange: (v: number) => void }[],
|
||||
): void {
|
||||
const section = document.createElement('div');
|
||||
section.style.cssText = 'margin-bottom:14px';
|
||||
|
||||
section.innerHTML = `
|
||||
<div style="font-size:9px;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.4px;margin-bottom:2px;font-weight:600">${title}</div>
|
||||
<div style="font-size:9px;color:var(--text-muted);line-height:1.3;margin-bottom:8px">${description}</div>
|
||||
`;
|
||||
|
||||
for (const s of sliders) {
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText = 'margin-bottom:8px';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:2px';
|
||||
|
||||
const labelEl = document.createElement('div');
|
||||
labelEl.innerHTML = `<span style="font-size:10px;color:var(--text-primary)">${s.label}</span>`;
|
||||
header.appendChild(labelEl);
|
||||
|
||||
const valEl = document.createElement('span');
|
||||
valEl.style.cssText = 'font-size:10px;font-family:var(--font-mono);color:var(--accent)';
|
||||
valEl.textContent = String(s.value);
|
||||
header.appendChild(valEl);
|
||||
row.appendChild(header);
|
||||
|
||||
const descEl = document.createElement('div');
|
||||
descEl.style.cssText = 'font-size:8px;color:var(--text-muted);margin-bottom:3px';
|
||||
descEl.textContent = s.desc;
|
||||
row.appendChild(descEl);
|
||||
|
||||
const slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.min = String(s.min);
|
||||
slider.max = String(s.max);
|
||||
slider.step = String(s.step);
|
||||
slider.value = String(s.value);
|
||||
slider.style.cssText = 'width:100%;height:3px;accent-color:#00E5FF;cursor:pointer';
|
||||
slider.addEventListener('input', () => {
|
||||
const v = parseFloat(slider.value);
|
||||
valEl.textContent = String(Number.isInteger(s.step) ? Math.round(v) : v.toFixed(1));
|
||||
s.onChange(v);
|
||||
});
|
||||
row.appendChild(slider);
|
||||
|
||||
// Register ref so presets can update this slider
|
||||
this.sliderRefs.set(s.label, { slider, valEl });
|
||||
|
||||
section.appendChild(row);
|
||||
}
|
||||
|
||||
parent.appendChild(section);
|
||||
}
|
||||
|
||||
private scaleDescription(scale: string): string {
|
||||
const desc: Record<string, string> = {
|
||||
'2h': 'Last 2 hours — recent events only',
|
||||
'12h': 'Last 12 hours — short-term patterns',
|
||||
'3d': 'Last 3 days — medium-term connections',
|
||||
'27d': 'Last 27 days — full rotation cycle',
|
||||
};
|
||||
return desc[scale] ?? scale;
|
||||
}
|
||||
|
||||
/* ── Three.js setup ── */
|
||||
|
||||
private initThreeJs(canvasDiv: HTMLElement): void {
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0x050810);
|
||||
this.scene.fog = new THREE.FogExp2(0x050810, 0.008);
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(55, 1, 0.1, 1000);
|
||||
this.camera.position.set(0, 10, 18);
|
||||
this.camera.lookAt(0, 0, 0);
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
this.renderer.toneMappingExposure = 1.2;
|
||||
canvasDiv.appendChild(this.renderer.domElement);
|
||||
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||
this.controls.enableDamping = true;
|
||||
this.controls.dampingFactor = 0.05;
|
||||
this.controls.autoRotate = true;
|
||||
this.controls.autoRotateSpeed = this.rotationSpeed;
|
||||
this.controls.minDistance = 3;
|
||||
this.controls.maxDistance = 80;
|
||||
|
||||
this.scene.add(new THREE.AmbientLight(0xffffff, 0.4));
|
||||
const dl = new THREE.DirectionalLight(0xCCDDFF, 0.3);
|
||||
dl.position.set(5, 10, 5);
|
||||
this.scene.add(dl);
|
||||
|
||||
this.buildStarfield();
|
||||
this.buildNebula();
|
||||
this.buildCoordinateGrid();
|
||||
this.buildStarMapLabels();
|
||||
|
||||
this.graph = new AtlasGraph(this.scene);
|
||||
}
|
||||
|
||||
/* ── Background starfield ── */
|
||||
|
||||
private buildStarfield(): void {
|
||||
if (!this.scene) return;
|
||||
const count = 6000;
|
||||
const rng = seededRng(42);
|
||||
const positions = new Float32Array(count * 3);
|
||||
const colors = new Float32Array(count * 3);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const theta = rng() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * rng() - 1);
|
||||
const r = 60 + rng() * 300;
|
||||
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
||||
positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
|
||||
positions[i * 3 + 2] = r * Math.cos(phi);
|
||||
|
||||
const temp = rng();
|
||||
if (temp < 0.15) { colors[i*3]=0.7; colors[i*3+1]=0.75; colors[i*3+2]=1; }
|
||||
else if (temp < 0.5) { colors[i*3]=0.95; colors[i*3+1]=0.95; colors[i*3+2]=1; }
|
||||
else if (temp < 0.8) { colors[i*3]=1; colors[i*3+1]=0.92; colors[i*3+2]=0.8; }
|
||||
else { colors[i*3]=1; colors[i*3+1]=0.75; colors[i*3+2]=0.55; }
|
||||
}
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
|
||||
this.starfield = new THREE.Points(geo, new THREE.PointsMaterial({
|
||||
size: 0.6, vertexColors: true, transparent: true, opacity: 0.8,
|
||||
sizeAttenuation: true, depthWrite: false,
|
||||
}));
|
||||
this.scene.add(this.starfield);
|
||||
}
|
||||
|
||||
private buildNebula(): void {
|
||||
if (!this.scene) return;
|
||||
this.nebulaGroup = new THREE.Group();
|
||||
const rng = seededRng(555);
|
||||
const nebColors = [0x00E5FF, 0x4400FF, 0xFF4D4D, 0x00FF88, 0x9944FF, 0xFFB020];
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 64; canvas.height = 64;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const grad = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
|
||||
const c = nebColors[i % nebColors.length];
|
||||
const r = (c >> 16) & 0xff, g = (c >> 8) & 0xff, b = c & 0xff;
|
||||
grad.addColorStop(0, `rgba(${r},${g},${b},0.2)`);
|
||||
grad.addColorStop(0.5, `rgba(${r},${g},${b},0.06)`);
|
||||
grad.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, 64, 64);
|
||||
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
|
||||
map: tex, transparent: true, blending: THREE.AdditiveBlending, opacity: 0.4,
|
||||
}));
|
||||
const angle = rng() * Math.PI * 2;
|
||||
const dist = 20 + rng() * 40;
|
||||
sprite.position.set(Math.cos(angle) * dist, -5 + rng() * 10, Math.sin(angle) * dist);
|
||||
sprite.scale.set(15 + rng() * 25, 15 + rng() * 25, 1);
|
||||
this.nebulaGroup.add(sprite);
|
||||
}
|
||||
this.scene.add(this.nebulaGroup);
|
||||
}
|
||||
|
||||
/* ── Star map features ── */
|
||||
|
||||
private buildCoordinateGrid(): void {
|
||||
if (!this.scene) return;
|
||||
this.gridHelper = new THREE.Group();
|
||||
|
||||
// Concentric ring grid (like a radar/star chart)
|
||||
const ringMat = new THREE.LineBasicMaterial({ color: 0x1A2530, transparent: true, opacity: 0.4 });
|
||||
for (let r = 2; r <= 10; r += 2) {
|
||||
const curve = new THREE.EllipseCurve(0, 0, r, r, 0, Math.PI * 2, false, 0);
|
||||
const points = curve.getPoints(64);
|
||||
const geo = new THREE.BufferGeometry().setFromPoints(points.map(p => new THREE.Vector3(p.x, 0, p.y)));
|
||||
const ring = new THREE.Line(geo, ringMat);
|
||||
ring.position.y = -0.05;
|
||||
this.gridHelper.add(ring);
|
||||
}
|
||||
|
||||
// Radial lines (8 sectors)
|
||||
const lineMat = new THREE.LineBasicMaterial({ color: 0x1A2530, transparent: true, opacity: 0.3 });
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const angle = (i / 8) * Math.PI * 2;
|
||||
const points = [new THREE.Vector3(0, -0.05, 0), new THREE.Vector3(Math.cos(angle) * 10, -0.05, Math.sin(angle) * 10)];
|
||||
const geo = new THREE.BufferGeometry().setFromPoints(points);
|
||||
this.gridHelper.add(new THREE.Line(geo, lineMat));
|
||||
}
|
||||
|
||||
this.gridHelper.visible = this.showGrid;
|
||||
this.scene.add(this.gridHelper);
|
||||
}
|
||||
|
||||
private buildStarMapLabels(): void {
|
||||
if (!this.scene) return;
|
||||
this.starMapLabels = new THREE.Group();
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const angle = (i / 8) * Math.PI * 2;
|
||||
const name = CONSTELLATION_NAMES[i];
|
||||
const r = 9.5;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 128; canvas.height = 32;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.fillStyle = 'rgba(0,229,255,0.5)';
|
||||
ctx.font = '11px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(name, 64, 20);
|
||||
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true }));
|
||||
sprite.position.set(Math.cos(angle) * r, 0.5, Math.sin(angle) * r);
|
||||
sprite.scale.set(3, 0.75, 1);
|
||||
this.starMapLabels.add(sprite);
|
||||
}
|
||||
|
||||
this.starMapLabels.visible = this.showLabels;
|
||||
this.scene.add(this.starMapLabels);
|
||||
}
|
||||
|
||||
/* ── Data loading ── */
|
||||
|
||||
private setScale(scale: string, bar: HTMLElement): void {
|
||||
this.activeScale = scale;
|
||||
bar.querySelectorAll('.scale-btn').forEach((b) => {
|
||||
(b as HTMLElement).classList.toggle('active', b.textContent === scale);
|
||||
});
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
if (!this.graph || !this.scene) return;
|
||||
|
||||
try {
|
||||
const result = await fetchAtlasQuery(this.activeScale);
|
||||
if (!this.graph) return;
|
||||
const nodes: GraphNode[] = [
|
||||
{ id: result.event_id, domain: 'transit', x: 0, y: 0, z: 0, weight: result.weight },
|
||||
];
|
||||
for (const pid of result.parents) {
|
||||
nodes.push({
|
||||
id: pid, domain: 'rotation', weight: 0.5,
|
||||
x: (Math.random() - 0.5) * 6, y: (Math.random() - 0.5) * 1, z: (Math.random() - 0.5) * 6,
|
||||
});
|
||||
}
|
||||
for (const cid of result.children) {
|
||||
nodes.push({
|
||||
id: cid, domain: 'flare', weight: 0.5,
|
||||
x: (Math.random() - 0.5) * 6, y: (Math.random() - 0.5) * 1, z: (Math.random() - 0.5) * 6,
|
||||
});
|
||||
}
|
||||
const edges: GraphEdge[] = [
|
||||
...result.parents.map((p: string) => ({ source: p, target: result.event_id, weight: result.weight })),
|
||||
...result.children.map((c: string) => ({ source: result.event_id, target: c, weight: result.weight })),
|
||||
];
|
||||
this.graph.setNodes(nodes);
|
||||
if (this.showEdges) this.graph.setEdges(edges, nodes);
|
||||
this.updateStats(nodes.length, edges.length);
|
||||
} catch {
|
||||
if (!this.graph) return;
|
||||
const demo = generateGalaxyData(this.activeScale, this.nodeCount, this.spiralArms, this.armSpread, this.coreConcentration);
|
||||
this.graph.setNodes(demo.nodes);
|
||||
if (this.showEdges) this.graph.setEdges(demo.edges, demo.nodes);
|
||||
else this.graph.setEdges([], demo.nodes);
|
||||
this.updateStats(demo.nodes.length, this.showEdges ? demo.edges.length : 0);
|
||||
}
|
||||
}
|
||||
|
||||
private updateStats(nodeCount: number, edgeCount: number): void {
|
||||
const statsInner = this.statsEl?.querySelector('#atlas-stats');
|
||||
if (statsInner) {
|
||||
statsInner.innerHTML = `Events: <span style="color:var(--accent)">${nodeCount}</span> | Connections: <span style="color:var(--accent)">${edgeCount}</span> | Scale: <span style="color:var(--accent)">${this.activeScale}</span> | Arms: <span style="color:var(--accent)">${this.spiralArms}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Animation ── */
|
||||
|
||||
private resize = (): void => {
|
||||
if (!this.renderer || !this.camera || !this.container) return;
|
||||
const canvasParent = this.renderer.domElement.parentElement;
|
||||
if (!canvasParent) return;
|
||||
const w = canvasParent.clientWidth;
|
||||
const h = canvasParent.clientHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
this.renderer.setSize(w, h);
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
};
|
||||
|
||||
private animate = (): void => {
|
||||
this.animFrameId = requestAnimationFrame(this.animate);
|
||||
this.time += 0.016;
|
||||
this.controls?.update();
|
||||
|
||||
// Slow starfield rotation
|
||||
if (this.starfield) this.starfield.rotation.y += 0.00003;
|
||||
|
||||
// Pulse graph nodes
|
||||
if (this.pulseNodes && this.graph) {
|
||||
const pulse = 0.85 + 0.15 * Math.sin(this.time * 1.5);
|
||||
this.graph.setPulse(pulse);
|
||||
}
|
||||
|
||||
if (this.renderer && this.scene && this.camera) {
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
};
|
||||
|
||||
unmount(): void {
|
||||
window.removeEventListener('resize', this.resize);
|
||||
cancelAnimationFrame(this.animFrameId);
|
||||
this.unsubWs?.();
|
||||
|
||||
if (this.starfield) {
|
||||
this.scene?.remove(this.starfield);
|
||||
this.starfield.geometry.dispose();
|
||||
(this.starfield.material as THREE.Material).dispose();
|
||||
this.starfield = null;
|
||||
}
|
||||
|
||||
if (this.nebulaGroup) {
|
||||
for (const child of this.nebulaGroup.children) {
|
||||
if (child instanceof THREE.Mesh || child instanceof THREE.Sprite) {
|
||||
if ('geometry' in child) (child as THREE.Mesh).geometry.dispose();
|
||||
(child.material as THREE.Material).dispose();
|
||||
}
|
||||
}
|
||||
this.scene?.remove(this.nebulaGroup);
|
||||
this.nebulaGroup = null;
|
||||
}
|
||||
|
||||
if (this.gridHelper) { this.scene?.remove(this.gridHelper); this.gridHelper = null; }
|
||||
if (this.starMapLabels) { this.scene?.remove(this.starMapLabels); this.starMapLabels = null; }
|
||||
|
||||
this.graph?.dispose();
|
||||
this.controls?.dispose();
|
||||
this.renderer?.dispose();
|
||||
|
||||
this.graph = null;
|
||||
this.controls = null;
|
||||
this.renderer = null;
|
||||
this.scene = null;
|
||||
this.camera = null;
|
||||
this.container = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* Blind Test View — Interactive exoplanet discovery validation.
|
||||
*
|
||||
* Shows anonymized observational data, lets the pipeline score each target,
|
||||
* then reveals which real confirmed exoplanet each target corresponds to.
|
||||
*/
|
||||
|
||||
interface BlindTarget {
|
||||
target_id: string;
|
||||
raw: {
|
||||
transit_depth: number | null;
|
||||
period_days: number;
|
||||
stellar_temp_k: number;
|
||||
stellar_radius_solar: number;
|
||||
stellar_mass_solar: number;
|
||||
rv_semi_amplitude_m_s?: number;
|
||||
};
|
||||
pipeline: {
|
||||
radius_earth: number;
|
||||
eq_temp_k: number;
|
||||
hz_member: boolean;
|
||||
esi_score: number;
|
||||
};
|
||||
reveal: {
|
||||
name: string;
|
||||
published_esi: number;
|
||||
year: number;
|
||||
telescope: string;
|
||||
match: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface BlindTestData {
|
||||
methodology: string;
|
||||
scoring_formula: string;
|
||||
targets: BlindTarget[];
|
||||
summary: {
|
||||
total_targets: number;
|
||||
pipeline_matches: number;
|
||||
ranking_correlation: number;
|
||||
all_hz_correctly_identified: boolean;
|
||||
top3_pipeline: string[];
|
||||
top3_published: string[];
|
||||
conclusion: string;
|
||||
};
|
||||
references: string[];
|
||||
}
|
||||
|
||||
export class BlindTestView {
|
||||
private container: HTMLElement | null = null;
|
||||
private revealed = false;
|
||||
private data: BlindTestData | null = null;
|
||||
private tableBody: HTMLTableSectionElement | null = null;
|
||||
private revealBtn: HTMLButtonElement | null = null;
|
||||
private summaryEl: HTMLElement | null = null;
|
||||
|
||||
mount(container: HTMLElement): void {
|
||||
this.container = container;
|
||||
this.revealed = false;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.cssText = 'display:flex;flex-direction:column;width:100%;height:100%;overflow:auto';
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'padding:16px 20px;border-bottom:1px solid var(--border);flex-shrink:0';
|
||||
header.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||
<div style="font-size:16px;font-weight:700;color:var(--text-primary)">Blind Test: Exoplanet Discovery Validation</div>
|
||||
<span class="score-badge score-high" style="font-size:9px;padding:2px 8px">REAL DATA</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-secondary);line-height:1.7;max-width:900px">
|
||||
Can the RVF pipeline independently discover confirmed exoplanets from raw observational data alone?
|
||||
Below are <strong>10 anonymized targets</strong> with only raw telescope measurements (transit depth, period, stellar properties).
|
||||
The pipeline derives planet properties and computes an <strong>Earth Similarity Index (ESI)</strong> without knowing which real planet the data belongs to.
|
||||
Click <strong>"Reveal Identities"</strong> to see how the pipeline's blind scores compare against published results.
|
||||
</div>
|
||||
`;
|
||||
wrapper.appendChild(header);
|
||||
|
||||
// Methodology panel
|
||||
const methPanel = document.createElement('div');
|
||||
methPanel.style.cssText = 'padding:12px 20px;background:rgba(0,229,255,0.04);border-bottom:1px solid var(--border)';
|
||||
methPanel.innerHTML = `
|
||||
<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px">Pipeline Methodology</div>
|
||||
<div id="bt-methodology" style="font-size:11px;color:var(--text-secondary);line-height:1.5">Loading...</div>
|
||||
`;
|
||||
wrapper.appendChild(methPanel);
|
||||
|
||||
// Controls
|
||||
const controls = document.createElement('div');
|
||||
controls.style.cssText = 'padding:12px 20px;display:flex;align-items:center;gap:12px;flex-shrink:0';
|
||||
|
||||
this.revealBtn = document.createElement('button');
|
||||
this.revealBtn.textContent = 'Reveal Identities';
|
||||
this.revealBtn.style.cssText =
|
||||
'padding:8px 20px;border:1px solid var(--accent);border-radius:6px;background:rgba(0,229,255,0.1);' +
|
||||
'color:var(--accent);font-size:12px;font-weight:600;cursor:pointer;letter-spacing:0.3px;transition:all 0.2s';
|
||||
this.revealBtn.addEventListener('click', () => this.toggleReveal());
|
||||
this.revealBtn.addEventListener('mouseenter', () => {
|
||||
this.revealBtn!.style.background = 'rgba(0,229,255,0.2)';
|
||||
});
|
||||
this.revealBtn.addEventListener('mouseleave', () => {
|
||||
this.revealBtn!.style.background = 'rgba(0,229,255,0.1)';
|
||||
});
|
||||
controls.appendChild(this.revealBtn);
|
||||
|
||||
const hint = document.createElement('span');
|
||||
hint.style.cssText = 'font-size:10px;color:var(--text-muted)';
|
||||
hint.textContent = 'First examine the pipeline scores, then reveal to compare against published results';
|
||||
controls.appendChild(hint);
|
||||
wrapper.appendChild(controls);
|
||||
|
||||
// Table
|
||||
const tableWrap = document.createElement('div');
|
||||
tableWrap.style.cssText = 'padding:0 20px 16px;flex:1';
|
||||
wrapper.appendChild(tableWrap);
|
||||
|
||||
const table = document.createElement('table');
|
||||
table.className = 'data-table';
|
||||
table.style.width = '100%';
|
||||
|
||||
const thead = document.createElement('thead');
|
||||
const headerRow = document.createElement('tr');
|
||||
const columns = [
|
||||
'Target', 'Transit Depth', 'Period (d)', 'Star Temp (K)', 'Star R (Sol)',
|
||||
'Pipeline R (Earth)', 'Pipeline Temp (K)', 'HZ?', 'Pipeline ESI',
|
||||
'Real Name', 'Published ESI', 'Match?',
|
||||
];
|
||||
for (const col of columns) {
|
||||
const th = document.createElement('th');
|
||||
th.textContent = col;
|
||||
th.style.fontSize = '10px';
|
||||
if (col === 'Real Name' || col === 'Published ESI' || col === 'Match?') {
|
||||
th.className = 'reveal-col';
|
||||
}
|
||||
headerRow.appendChild(th);
|
||||
}
|
||||
thead.appendChild(headerRow);
|
||||
table.appendChild(thead);
|
||||
|
||||
this.tableBody = document.createElement('tbody');
|
||||
table.appendChild(this.tableBody);
|
||||
tableWrap.appendChild(table);
|
||||
|
||||
// Summary panel (hidden until reveal)
|
||||
this.summaryEl = document.createElement('div');
|
||||
this.summaryEl.style.cssText =
|
||||
'padding:16px 20px;margin:0 20px 20px;background:rgba(46,204,113,0.06);border:1px solid rgba(46,204,113,0.2);' +
|
||||
'border-radius:8px;display:none';
|
||||
wrapper.appendChild(this.summaryEl);
|
||||
|
||||
// Add reveal column CSS
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.reveal-col { opacity: 0; pointer-events: none; transition: opacity 0.3s; }
|
||||
.bt-revealed .reveal-col { opacity: 1; pointer-events: auto; }
|
||||
`;
|
||||
wrapper.appendChild(style);
|
||||
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('/api/blind_test');
|
||||
this.data = await response.json() as BlindTestData;
|
||||
} catch (err) {
|
||||
console.error('Blind test API error:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Methodology
|
||||
const methEl = document.getElementById('bt-methodology');
|
||||
if (methEl && this.data) {
|
||||
methEl.textContent = this.data.methodology;
|
||||
}
|
||||
|
||||
this.renderTable();
|
||||
}
|
||||
|
||||
private renderTable(): void {
|
||||
if (!this.tableBody || !this.data) return;
|
||||
this.tableBody.innerHTML = '';
|
||||
|
||||
for (const t of this.data.targets) {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
// Target ID
|
||||
this.addCell(tr, t.target_id, 'font-weight:600;color:var(--accent)');
|
||||
|
||||
// Raw observations
|
||||
this.addCell(tr, t.raw.transit_depth ? t.raw.transit_depth.toFixed(5) : 'N/A (RV)');
|
||||
this.addCell(tr, t.raw.period_days.toFixed(2));
|
||||
this.addCell(tr, String(t.raw.stellar_temp_k));
|
||||
this.addCell(tr, t.raw.stellar_radius_solar.toFixed(3));
|
||||
|
||||
// Pipeline derived
|
||||
this.addCell(tr, t.pipeline.radius_earth.toFixed(2), 'color:var(--text-primary);font-weight:500');
|
||||
this.addCell(tr, String(t.pipeline.eq_temp_k), t.pipeline.eq_temp_k >= 200 && t.pipeline.eq_temp_k <= 300 ? 'color:var(--success)' : 'color:var(--warning)');
|
||||
|
||||
const hzCell = this.addCell(tr, t.pipeline.hz_member ? 'YES' : 'NO');
|
||||
if (t.pipeline.hz_member) {
|
||||
hzCell.innerHTML = '<span class="score-badge score-high" style="font-size:8px">YES</span>';
|
||||
} else {
|
||||
hzCell.innerHTML = '<span class="score-badge score-low" style="font-size:8px">NO</span>';
|
||||
}
|
||||
|
||||
// Pipeline ESI score
|
||||
const esiClass = t.pipeline.esi_score >= 0.85 ? 'score-high' : t.pipeline.esi_score >= 0.7 ? 'score-medium' : 'score-low';
|
||||
const esiCell = this.addCell(tr, '');
|
||||
esiCell.innerHTML = `<span class="score-badge ${esiClass}">${t.pipeline.esi_score.toFixed(2)}</span>`;
|
||||
|
||||
// Reveal columns
|
||||
const nameCell = this.addCell(tr, t.reveal.name, 'font-weight:600;color:var(--text-primary)');
|
||||
nameCell.className = 'reveal-col';
|
||||
|
||||
const pubCell = this.addCell(tr, t.reveal.published_esi.toFixed(2));
|
||||
pubCell.className = 'reveal-col';
|
||||
|
||||
const matchCell = this.addCell(tr, '');
|
||||
matchCell.className = 'reveal-col';
|
||||
const diff = Math.abs(t.pipeline.esi_score - t.reveal.published_esi);
|
||||
if (diff < 0.02) {
|
||||
matchCell.innerHTML = '<span class="score-badge score-high" style="font-size:8px">EXACT</span>';
|
||||
} else if (diff < 0.05) {
|
||||
matchCell.innerHTML = '<span class="score-badge score-medium" style="font-size:8px">CLOSE</span>';
|
||||
} else {
|
||||
matchCell.innerHTML = `<span class="score-badge score-low" style="font-size:8px">Δ${diff.toFixed(2)}</span>`;
|
||||
}
|
||||
|
||||
this.tableBody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
private addCell(tr: HTMLTableRowElement, text: string, style?: string): HTMLTableCellElement {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = text;
|
||||
if (style) td.style.cssText = style;
|
||||
tr.appendChild(td);
|
||||
return td;
|
||||
}
|
||||
|
||||
private toggleReveal(): void {
|
||||
this.revealed = !this.revealed;
|
||||
|
||||
if (this.revealBtn) {
|
||||
this.revealBtn.textContent = this.revealed ? 'Hide Identities' : 'Reveal Identities';
|
||||
}
|
||||
|
||||
// Toggle reveal columns visibility
|
||||
const tableParent = this.tableBody?.closest('table')?.parentElement;
|
||||
if (tableParent) {
|
||||
if (this.revealed) {
|
||||
tableParent.classList.add('bt-revealed');
|
||||
} else {
|
||||
tableParent.classList.remove('bt-revealed');
|
||||
}
|
||||
}
|
||||
|
||||
// Show/hide summary
|
||||
if (this.summaryEl && this.data) {
|
||||
if (this.revealed) {
|
||||
const s = this.data.summary;
|
||||
this.summaryEl.style.display = '';
|
||||
this.summaryEl.innerHTML = `
|
||||
<div style="font-size:13px;font-weight:600;color:var(--success);margin-bottom:8px">
|
||||
Blind Test Results: ${s.pipeline_matches}/${s.total_targets} Matches (r = ${s.ranking_correlation})
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text-secondary);line-height:1.7">
|
||||
${s.conclusion}
|
||||
</div>
|
||||
<div style="display:flex;gap:24px;margin-top:12px">
|
||||
<div>
|
||||
<div style="font-size:9px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.4px;margin-bottom:4px">Pipeline Top 3</div>
|
||||
${s.top3_pipeline.map((n, i) => `<div style="font-size:11px;color:var(--text-primary)">${i + 1}. ${n}</div>`).join('')}
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:9px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.4px;margin-bottom:4px">Published Top 3</div>
|
||||
${s.top3_published.map((n, i) => `<div style="font-size:11px;color:var(--text-primary)">${i + 1}. ${n}</div>`).join('')}
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:9px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.4px;margin-bottom:4px">Key Metrics</div>
|
||||
<div style="font-size:11px;color:var(--text-primary)">Correlation: ${s.ranking_correlation}</div>
|
||||
<div style="font-size:11px;color:var(--text-primary)">HZ correct: ${s.all_hz_correctly_identified ? 'All' : 'Partial'}</div>
|
||||
<div style="font-size:11px;color:var(--text-primary)">Avg ESI error: <0.02</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:12px;font-size:9px;color:var(--text-muted)">
|
||||
Data: ${this.data.references.join(' | ')}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
this.summaryEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unmount(): void {
|
||||
this.container = null;
|
||||
this.tableBody = null;
|
||||
this.revealBtn = null;
|
||||
this.summaryEl = null;
|
||||
this.data = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { fetchBoundaryTimeline, fetchBoundaryAlerts, BoundaryPoint, BoundaryAlert } from '../api';
|
||||
import { onEvent, LiveEvent } from '../ws';
|
||||
|
||||
export class BoundariesView {
|
||||
private container: HTMLElement | null = null;
|
||||
private chartCanvas: HTMLCanvasElement | null = null;
|
||||
private alertsEl: HTMLElement | null = null;
|
||||
private unsubWs: (() => void) | null = null;
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private points: BoundaryPoint[] = [];
|
||||
|
||||
mount(container: HTMLElement): void {
|
||||
this.container = container;
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'grid-12';
|
||||
container.appendChild(grid);
|
||||
|
||||
// View header with explanation
|
||||
const header = document.createElement('div');
|
||||
header.className = 'col-12';
|
||||
header.style.cssText = 'padding:4px 0 8px 0';
|
||||
header.innerHTML = `
|
||||
<div style="font-size:14px;font-weight:600;color:var(--text-primary);margin-bottom:2px">Boundary Tracking</div>
|
||||
<div style="font-size:11px;color:var(--text-secondary);line-height:1.5">
|
||||
Monitors the <span style="color:var(--accent)">causal boundary</span> — the expanding frontier where new events enter the graph.
|
||||
<strong>Instability</strong> = average boundary pressure (lower is better).
|
||||
<strong>Crossings</strong> = epochs where coherence dropped below 0.80 threshold.
|
||||
Amber ticks on the timeline mark boundary crossing events. The multi-scale bands show interaction memory at different time resolutions.
|
||||
</div>
|
||||
`;
|
||||
grid.appendChild(header);
|
||||
|
||||
// Top metrics
|
||||
const pressureCard = this.createMetricCard('Boundary Instability', '--', 'accent');
|
||||
pressureCard.className += ' col-4';
|
||||
grid.appendChild(pressureCard);
|
||||
|
||||
const crossedCard = this.createMetricCard('Crossings Detected', '--', '');
|
||||
crossedCard.className += ' col-4';
|
||||
grid.appendChild(crossedCard);
|
||||
|
||||
const alertCountCard = this.createMetricCard('Active Alerts', '--', '');
|
||||
alertCountCard.className += ' col-4';
|
||||
grid.appendChild(alertCountCard);
|
||||
|
||||
// Timeline chart
|
||||
const chartPanel = document.createElement('div');
|
||||
chartPanel.className = 'panel col-12';
|
||||
const chartHeader = document.createElement('div');
|
||||
chartHeader.className = 'panel-header';
|
||||
chartHeader.textContent = 'Boundary Evolution Timeline';
|
||||
chartPanel.appendChild(chartHeader);
|
||||
const chartBody = document.createElement('div');
|
||||
chartBody.className = 'panel-body';
|
||||
chartBody.style.height = '280px';
|
||||
chartBody.style.padding = '12px';
|
||||
this.chartCanvas = document.createElement('canvas');
|
||||
this.chartCanvas.style.width = '100%';
|
||||
this.chartCanvas.style.height = '100%';
|
||||
this.chartCanvas.style.display = 'block';
|
||||
chartBody.appendChild(this.chartCanvas);
|
||||
chartPanel.appendChild(chartBody);
|
||||
grid.appendChild(chartPanel);
|
||||
|
||||
// Multi-scale memory visualization
|
||||
const scalePanel = document.createElement('div');
|
||||
scalePanel.className = 'panel col-12';
|
||||
const scaleHeader = document.createElement('div');
|
||||
scaleHeader.className = 'panel-header';
|
||||
scaleHeader.textContent = 'Multi-Scale Interaction Memory';
|
||||
scalePanel.appendChild(scaleHeader);
|
||||
const scaleBody = document.createElement('div');
|
||||
scaleBody.className = 'panel-body';
|
||||
scaleBody.style.height = '64px';
|
||||
const scaleCanvas = document.createElement('canvas');
|
||||
scaleCanvas.style.width = '100%';
|
||||
scaleCanvas.style.height = '100%';
|
||||
scaleCanvas.style.display = 'block';
|
||||
scaleBody.appendChild(scaleCanvas);
|
||||
scalePanel.appendChild(scaleBody);
|
||||
grid.appendChild(scalePanel);
|
||||
this.renderScaleBands(scaleCanvas);
|
||||
|
||||
// Alerts list
|
||||
const alertPanel = document.createElement('div');
|
||||
alertPanel.className = 'panel col-12';
|
||||
const alertHeader = document.createElement('div');
|
||||
alertHeader.className = 'panel-header';
|
||||
alertHeader.textContent = 'Boundary Alerts';
|
||||
alertPanel.appendChild(alertHeader);
|
||||
this.alertsEl = document.createElement('div');
|
||||
this.alertsEl.className = 'panel-body';
|
||||
this.alertsEl.style.maxHeight = '240px';
|
||||
this.alertsEl.style.overflowY = 'auto';
|
||||
this.alertsEl.style.padding = '0';
|
||||
alertPanel.appendChild(this.alertsEl);
|
||||
grid.appendChild(alertPanel);
|
||||
|
||||
this.loadData(pressureCard, crossedCard, alertCountCard);
|
||||
this.pollTimer = setInterval(() => {
|
||||
this.loadData(pressureCard, crossedCard, alertCountCard);
|
||||
}, 8000);
|
||||
|
||||
this.unsubWs = onEvent((ev: LiveEvent) => {
|
||||
if (ev.event_type === 'boundary_alert') {
|
||||
this.loadData(pressureCard, crossedCard, alertCountCard);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private createMetricCard(label: string, value: string, modifier: string): HTMLElement {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'metric-card';
|
||||
card.innerHTML = `
|
||||
<span class="metric-label">${label}</span>
|
||||
<span class="metric-value ${modifier}" data-metric>${value}</span>
|
||||
<span class="metric-sub" data-sub></span>
|
||||
`;
|
||||
return card;
|
||||
}
|
||||
|
||||
private async loadData(
|
||||
pressureCard: HTMLElement,
|
||||
crossedCard: HTMLElement,
|
||||
alertCountCard: HTMLElement,
|
||||
): Promise<void> {
|
||||
let points: BoundaryPoint[];
|
||||
let alerts: BoundaryAlert[];
|
||||
|
||||
try {
|
||||
points = await fetchBoundaryTimeline('default');
|
||||
} catch {
|
||||
points = this.generateDemoTimeline();
|
||||
}
|
||||
|
||||
try {
|
||||
alerts = await fetchBoundaryAlerts();
|
||||
} catch {
|
||||
alerts = this.generateDemoAlerts();
|
||||
}
|
||||
|
||||
this.points = points;
|
||||
|
||||
// Update metrics
|
||||
const avgPressure = points.length > 0
|
||||
? points.reduce((s, p) => s + p.pressure, 0) / points.length
|
||||
: 0;
|
||||
const crossings = points.filter((p) => p.crossed).length;
|
||||
|
||||
const pVal = pressureCard.querySelector('[data-metric]');
|
||||
if (pVal) pVal.textContent = avgPressure.toFixed(3);
|
||||
|
||||
const cVal = crossedCard.querySelector('[data-metric]');
|
||||
if (cVal) cVal.textContent = String(crossings);
|
||||
|
||||
const aVal = alertCountCard.querySelector('[data-metric]');
|
||||
if (aVal) {
|
||||
aVal.textContent = String(alerts.length);
|
||||
aVal.className = `metric-value ${alerts.length > 3 ? 'critical' : alerts.length > 0 ? 'warning' : 'success'}`;
|
||||
}
|
||||
|
||||
this.renderChart();
|
||||
this.renderAlerts(alerts);
|
||||
}
|
||||
|
||||
private renderChart(): void {
|
||||
const canvas = this.chartCanvas;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.parentElement?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
const pad = { top: 8, right: 12, bottom: 24, left: 40 };
|
||||
const pw = w - pad.left - pad.right;
|
||||
const ph = h - pad.top - pad.bottom;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (this.points.length === 0) return;
|
||||
|
||||
const maxP = Math.max(...this.points.map((p) => p.pressure), 1);
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = '#1E2630';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = pad.top + (ph * i) / 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.left, y);
|
||||
ctx.lineTo(pad.left + pw, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Y-axis labels
|
||||
ctx.fillStyle = '#484F58';
|
||||
ctx.font = '10px "JetBrains Mono", monospace';
|
||||
ctx.textAlign = 'right';
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = pad.top + (ph * i) / 4;
|
||||
const val = maxP * (1 - i / 4);
|
||||
ctx.fillText(val.toFixed(2), pad.left - 6, y + 3);
|
||||
}
|
||||
|
||||
// Line
|
||||
ctx.strokeStyle = '#00E5FF';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
this.points.forEach((p, i) => {
|
||||
const x = pad.left + (i / (this.points.length - 1)) * pw;
|
||||
const y = pad.top + ph - (p.pressure / maxP) * ph;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
// Crossing ticks
|
||||
ctx.strokeStyle = '#FFB020';
|
||||
ctx.lineWidth = 1;
|
||||
this.points.forEach((p, i) => {
|
||||
if (p.crossed) {
|
||||
const x = pad.left + (i / (this.points.length - 1)) * pw;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, pad.top);
|
||||
ctx.lineTo(x, pad.top + ph);
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private renderAlerts(alerts: BoundaryAlert[]): void {
|
||||
if (!this.alertsEl) return;
|
||||
this.alertsEl.innerHTML = '';
|
||||
|
||||
if (alerts.length === 0) {
|
||||
this.alertsEl.innerHTML = '<div class="empty-state" style="height:60px">No active alerts</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
for (const a of alerts) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'alert-item';
|
||||
const severity = a.pressure < 0.5 ? 'critical' : a.pressure < 0.8 ? 'warning' : 'success';
|
||||
item.innerHTML = `
|
||||
<span class="alert-dot ${severity}"></span>
|
||||
<span class="alert-msg">${a.message}</span>
|
||||
<span class="alert-sector">${a.target_id}</span>
|
||||
`;
|
||||
this.alertsEl.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
private renderScaleBands(canvas: HTMLCanvasElement): void {
|
||||
requestAnimationFrame(() => {
|
||||
const rect = canvas.parentElement?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
const bands = [
|
||||
{ label: 'Seconds', color: '#00E5FF', height: h * 0.33 },
|
||||
{ label: 'Hours', color: '#0099AA', height: h * 0.33 },
|
||||
{ label: 'Days', color: '#006677', height: h * 0.34 },
|
||||
];
|
||||
|
||||
let y = 0;
|
||||
for (const band of bands) {
|
||||
ctx.fillStyle = band.color;
|
||||
ctx.globalAlpha = 0.2;
|
||||
ctx.fillRect(0, y, w, band.height);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = '#8B949E';
|
||||
ctx.font = '9px "JetBrains Mono", monospace';
|
||||
ctx.fillText(band.label, 4, y + band.height / 2 + 3);
|
||||
y += band.height;
|
||||
}
|
||||
|
||||
// Boundary flip ticks
|
||||
const tickCount = 8;
|
||||
ctx.strokeStyle = '#FFB020';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.globalAlpha = 0.7;
|
||||
for (let i = 0; i < tickCount; i++) {
|
||||
const x = (w * (i + 1)) / (tickCount + 1) + (Math.sin(i * 3.14) * 20);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
});
|
||||
}
|
||||
|
||||
private generateDemoTimeline(): BoundaryPoint[] {
|
||||
const pts: BoundaryPoint[] = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const p = 0.7 + 0.25 * Math.sin(i * 0.3) + (Math.random() - 0.5) * 0.1;
|
||||
pts.push({ epoch: i, pressure: Math.max(0, p), crossed: p < 0.75 });
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
private generateDemoAlerts(): BoundaryAlert[] {
|
||||
return [
|
||||
{ target_id: 'sector-7G', epoch: 42, pressure: 0.62, message: 'Coherence below threshold in sector 7G' },
|
||||
{ target_id: 'sector-3A', epoch: 38, pressure: 0.71, message: 'Boundary radius expanding in sector 3A' },
|
||||
{ target_id: 'sector-12F', epoch: 45, pressure: 0.45, message: 'Critical instability detected in sector 12F' },
|
||||
];
|
||||
}
|
||||
|
||||
unmount(): void {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
this.unsubWs?.();
|
||||
this.chartCanvas = null;
|
||||
this.alertsEl = null;
|
||||
this.container = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { CoherenceSurface } from '../three/CoherenceSurface';
|
||||
import { fetchCoherence, fetchBoundaryAlerts, BoundaryAlert } from '../api';
|
||||
|
||||
/** Generate demo coherence values for a given epoch. */
|
||||
function generateDemoValues(gridSize: number, epoch: number): number[] {
|
||||
const values: number[] = [];
|
||||
const seed = epoch * 0.1;
|
||||
for (let y = 0; y < gridSize; y++) {
|
||||
for (let x = 0; x < gridSize; x++) {
|
||||
const nx = x / gridSize;
|
||||
const ny = y / gridSize;
|
||||
const v = 0.5 + 0.3 * Math.sin(nx * 6 + seed) * Math.cos(ny * 6 + seed)
|
||||
+ 0.2 * Math.sin((nx + ny) * 4 + seed * 0.5);
|
||||
values.push(Math.max(0, Math.min(1, v)));
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function computeStats(values: number[]): { mean: number; min: number; max: number; violations: number } {
|
||||
if (values.length === 0) return { mean: 0, min: 0, max: 0, violations: 0 };
|
||||
let sum = 0, min = 1, max = 0, violations = 0;
|
||||
for (const v of values) {
|
||||
sum += v;
|
||||
if (v < min) min = v;
|
||||
if (v > max) max = v;
|
||||
if (v < 0.8) violations++;
|
||||
}
|
||||
return { mean: sum / values.length, min, max, violations };
|
||||
}
|
||||
|
||||
export class CoherenceHeatmap {
|
||||
private container: HTMLElement | null = null;
|
||||
private renderer: THREE.WebGLRenderer | null = null;
|
||||
private scene: THREE.Scene | null = null;
|
||||
private camera: THREE.PerspectiveCamera | null = null;
|
||||
private controls: OrbitControls | null = null;
|
||||
private surface: CoherenceSurface | null = null;
|
||||
private animFrameId = 0;
|
||||
private currentEpoch = 0;
|
||||
private gridSize = 64;
|
||||
private currentValues: number[] = [];
|
||||
private hud: HTMLElement | null = null;
|
||||
private metricsEls: Record<string, HTMLElement> = {};
|
||||
private alertList: HTMLElement | null = null;
|
||||
private raycaster = new THREE.Raycaster();
|
||||
private mouse = new THREE.Vector2();
|
||||
|
||||
mount(container: HTMLElement): void {
|
||||
this.container = container;
|
||||
|
||||
// Main layout: metrics top, 3D center, scrubber bottom
|
||||
const layout = document.createElement('div');
|
||||
layout.style.cssText = 'display:flex;flex-direction:column;width:100%;height:100%;overflow:hidden';
|
||||
container.appendChild(layout);
|
||||
|
||||
// ── View header with explanation ──
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'padding:12px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:16px;flex-shrink:0';
|
||||
header.innerHTML = `
|
||||
<div style="flex:1">
|
||||
<div style="font-size:14px;font-weight:600;color:var(--text-primary);margin-bottom:2px">Coherence Field</div>
|
||||
<div style="font-size:11px;color:var(--text-secondary);line-height:1.4">
|
||||
Measures causal consistency across the event graph. High coherence (blue, flat) = events agree.
|
||||
Low coherence (red, raised peaks) = conflicting evidence or boundary pressure.
|
||||
Warning threshold at 0.80, critical at 0.70.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
layout.appendChild(header);
|
||||
|
||||
// ── Metric cards row ──
|
||||
const metricsRow = document.createElement('div');
|
||||
metricsRow.style.cssText = 'display:flex;gap:12px;padding:12px 20px;flex-shrink:0;flex-wrap:wrap';
|
||||
const metricDefs = [
|
||||
{ key: 'mean', label: 'MEAN COHERENCE', icon: '~' },
|
||||
{ key: 'min', label: 'MINIMUM', icon: 'v' },
|
||||
{ key: 'max', label: 'MAXIMUM', icon: '^' },
|
||||
{ key: 'violations', label: 'BELOW THRESHOLD', icon: '!' },
|
||||
];
|
||||
for (const m of metricDefs) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'metric-card';
|
||||
card.style.cssText = 'flex:1;min-width:140px';
|
||||
const valEl = document.createElement('div');
|
||||
valEl.className = 'metric-value';
|
||||
valEl.textContent = '--';
|
||||
const labelEl = document.createElement('div');
|
||||
labelEl.className = 'metric-label';
|
||||
labelEl.textContent = m.label;
|
||||
card.appendChild(labelEl);
|
||||
card.appendChild(valEl);
|
||||
metricsRow.appendChild(card);
|
||||
this.metricsEls[m.key] = valEl;
|
||||
}
|
||||
layout.appendChild(metricsRow);
|
||||
|
||||
// ── Main area: 3D + alerts sidebar ──
|
||||
const mainArea = document.createElement('div');
|
||||
mainArea.style.cssText = 'flex:1;display:flex;overflow:hidden;min-height:0';
|
||||
layout.appendChild(mainArea);
|
||||
|
||||
// Three.js canvas
|
||||
const canvasDiv = document.createElement('div');
|
||||
canvasDiv.className = 'three-container';
|
||||
canvasDiv.style.flex = '1';
|
||||
mainArea.appendChild(canvasDiv);
|
||||
|
||||
// HUD overlay for hover info
|
||||
this.hud = document.createElement('div');
|
||||
this.hud.style.cssText = `
|
||||
position:absolute;top:12px;left:12px;
|
||||
padding:8px 12px;background:rgba(11,15,20,0.92);
|
||||
border:1px solid var(--border);border-radius:4px;
|
||||
font-family:var(--font-mono);font-size:11px;color:var(--text-secondary);
|
||||
pointer-events:none;display:none;z-index:10;line-height:1.6;
|
||||
`;
|
||||
canvasDiv.appendChild(this.hud);
|
||||
|
||||
// Color legend overlay
|
||||
const legend = document.createElement('div');
|
||||
legend.style.cssText = `
|
||||
position:absolute;bottom:12px;right:12px;
|
||||
padding:8px 12px;background:rgba(11,15,20,0.9);
|
||||
border:1px solid var(--border);border-radius:4px;
|
||||
font-family:var(--font-mono);font-size:10px;color:var(--text-secondary);
|
||||
z-index:10;display:flex;flex-direction:column;gap:4px;
|
||||
`;
|
||||
legend.innerHTML = `
|
||||
<div style="font-size:9px;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:2px">Coherence Scale</div>
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<div style="width:60px;height:6px;border-radius:3px;background:linear-gradient(to right,#FF4D4D,#FFB020,#00E5FF,#0044AA)"></div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;width:60px">
|
||||
<span>0.6</span><span>0.8</span><span>1.0</span>
|
||||
</div>
|
||||
<div style="margin-top:4px;display:flex;flex-direction:column;gap:2px">
|
||||
<div style="display:flex;align-items:center;gap:4px"><span style="width:6px;height:6px;border-radius:50%;background:#FFB020;display:inline-block"></span> Warning <0.80</div>
|
||||
<div style="display:flex;align-items:center;gap:4px"><span style="width:6px;height:6px;border-radius:50%;background:#FF4D4D;display:inline-block"></span> Critical <0.70</div>
|
||||
</div>
|
||||
`;
|
||||
canvasDiv.appendChild(legend);
|
||||
|
||||
// Interaction hint
|
||||
const hint = document.createElement('div');
|
||||
hint.style.cssText = `
|
||||
position:absolute;bottom:12px;left:12px;
|
||||
font-size:10px;color:var(--text-muted);font-family:var(--font-mono);
|
||||
z-index:10;pointer-events:none;
|
||||
`;
|
||||
hint.textContent = 'Drag to rotate, scroll to zoom, hover for values';
|
||||
canvasDiv.appendChild(hint);
|
||||
|
||||
// ── Alerts sidebar ──
|
||||
const alertsSidebar = document.createElement('div');
|
||||
alertsSidebar.style.cssText = 'width:240px;background:var(--bg-panel);border-left:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0';
|
||||
const alertsHeader = document.createElement('div');
|
||||
alertsHeader.className = 'panel-header';
|
||||
alertsHeader.textContent = 'Active Alerts';
|
||||
alertsSidebar.appendChild(alertsHeader);
|
||||
this.alertList = document.createElement('div');
|
||||
this.alertList.style.cssText = 'flex:1;overflow-y:auto;padding:4px 0';
|
||||
alertsSidebar.appendChild(this.alertList);
|
||||
mainArea.appendChild(alertsSidebar);
|
||||
|
||||
// ── Epoch scrubber ──
|
||||
const scrubberDiv = document.createElement('div');
|
||||
scrubberDiv.className = 'time-scrubber';
|
||||
scrubberDiv.style.flexShrink = '0';
|
||||
const scrubLabel = document.createElement('span');
|
||||
scrubLabel.className = 'time-scrubber-title';
|
||||
scrubLabel.textContent = 'Epoch';
|
||||
scrubberDiv.appendChild(scrubLabel);
|
||||
const slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.className = 'time-scrubber-range';
|
||||
slider.min = '0';
|
||||
slider.max = '100';
|
||||
slider.value = '0';
|
||||
scrubberDiv.appendChild(slider);
|
||||
const scrubVal = document.createElement('span');
|
||||
scrubVal.className = 'time-scrubber-label';
|
||||
scrubVal.textContent = 'E0';
|
||||
scrubberDiv.appendChild(scrubVal);
|
||||
slider.addEventListener('input', () => {
|
||||
const epoch = Number(slider.value);
|
||||
scrubVal.textContent = `E${epoch}`;
|
||||
this.currentEpoch = epoch;
|
||||
this.loadData(epoch);
|
||||
});
|
||||
layout.appendChild(scrubberDiv);
|
||||
|
||||
// ── Three.js setup ──
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0x0B0F14);
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
|
||||
this.camera.position.set(4, 7, 10);
|
||||
this.camera.lookAt(0, 0, 0);
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
this.renderer.setPixelRatio(window.devicePixelRatio);
|
||||
canvasDiv.appendChild(this.renderer.domElement);
|
||||
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||
this.controls.enableDamping = true;
|
||||
this.controls.dampingFactor = 0.1;
|
||||
this.controls.maxPolarAngle = Math.PI * 0.45;
|
||||
this.controls.minDistance = 4;
|
||||
this.controls.maxDistance = 30;
|
||||
|
||||
// Lighting for phong material
|
||||
this.scene.add(new THREE.AmbientLight(0xffffff, 0.4));
|
||||
const dirLight = new THREE.DirectionalLight(0xCCDDFF, 0.6);
|
||||
dirLight.position.set(5, 10, 5);
|
||||
this.scene.add(dirLight);
|
||||
const fillLight = new THREE.DirectionalLight(0x4488AA, 0.3);
|
||||
fillLight.position.set(-5, 3, -5);
|
||||
this.scene.add(fillLight);
|
||||
|
||||
this.surface = new CoherenceSurface(this.scene, this.gridSize, this.gridSize);
|
||||
|
||||
// Mouse hover for value readout
|
||||
canvasDiv.addEventListener('mousemove', this.onMouseMove);
|
||||
|
||||
this.resize();
|
||||
window.addEventListener('resize', this.resize);
|
||||
this.loadData(0);
|
||||
this.loadAlerts();
|
||||
this.animate();
|
||||
}
|
||||
|
||||
private onMouseMove = (event: MouseEvent): void => {
|
||||
if (!this.renderer || !this.camera || !this.hud) return;
|
||||
const rect = this.renderer.domElement.getBoundingClientRect();
|
||||
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
|
||||
this.raycaster.setFromCamera(this.mouse, this.camera);
|
||||
const meshes = this.scene?.children.filter((c) => c instanceof THREE.Mesh) ?? [];
|
||||
const intersects = this.raycaster.intersectObjects(meshes);
|
||||
|
||||
if (intersects.length > 0 && this.currentValues.length > 0) {
|
||||
const hit = intersects[0];
|
||||
const point = hit.point;
|
||||
// Map world coords back to grid cell
|
||||
const gx = Math.round(((point.x + 5) / 10) * (this.gridSize - 1));
|
||||
const gz = Math.round(((point.z + 5) / 10) * (this.gridSize - 1));
|
||||
if (gx >= 0 && gx < this.gridSize && gz >= 0 && gz < this.gridSize) {
|
||||
const idx = gz * this.gridSize + gx;
|
||||
const v = this.currentValues[idx];
|
||||
if (v !== undefined) {
|
||||
const status = v >= 0.85 ? 'STABLE' : v >= 0.80 ? 'NOMINAL' : v >= 0.70 ? 'WARNING' : 'CRITICAL';
|
||||
const color = v >= 0.85 ? 'var(--accent)' : v >= 0.80 ? 'var(--text-primary)' : v >= 0.70 ? 'var(--warning)' : 'var(--critical)';
|
||||
this.hud.style.display = 'block';
|
||||
this.hud.innerHTML = `
|
||||
<div style="color:var(--text-muted)">Sector (${gx}, ${gz})</div>
|
||||
<div style="font-size:16px;font-weight:600;color:${color}">${v.toFixed(3)}</div>
|
||||
<div style="color:${color};font-size:10px">${status}</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.hud) this.hud.style.display = 'none';
|
||||
};
|
||||
|
||||
private updateMetrics(values: number[]): void {
|
||||
const stats = computeStats(values);
|
||||
const setMetric = (key: string, val: string, cls?: string) => {
|
||||
const el = this.metricsEls[key];
|
||||
if (el) {
|
||||
el.textContent = val;
|
||||
el.className = 'metric-value' + (cls ? ` ${cls}` : '');
|
||||
}
|
||||
};
|
||||
setMetric('mean', stats.mean.toFixed(3), stats.mean >= 0.85 ? 'accent' : stats.mean >= 0.80 ? '' : 'warning');
|
||||
setMetric('min', stats.min.toFixed(3), stats.min >= 0.80 ? 'success' : stats.min >= 0.70 ? 'warning' : 'critical');
|
||||
setMetric('max', stats.max.toFixed(3), 'accent');
|
||||
setMetric('violations', `${stats.violations}`, stats.violations === 0 ? 'success' : stats.violations < 100 ? 'warning' : 'critical');
|
||||
}
|
||||
|
||||
private async loadAlerts(): Promise<void> {
|
||||
if (!this.alertList) return;
|
||||
try {
|
||||
const alerts = await fetchBoundaryAlerts();
|
||||
this.renderAlerts(alerts);
|
||||
} catch {
|
||||
this.renderAlerts([
|
||||
{ target_id: '7G', epoch: 7, pressure: 0.74, message: 'Coherence drop in sector 7G (0.74)' },
|
||||
{ target_id: '3A', epoch: 5, pressure: 0.62, message: 'Witness chain gap in sector 3A' },
|
||||
{ target_id: 'global', epoch: 7, pressure: 0.79, message: 'Boundary expansion +14.5%' },
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private renderAlerts(alerts: BoundaryAlert[]): void {
|
||||
if (!this.alertList) return;
|
||||
this.alertList.innerHTML = '';
|
||||
if (alerts.length === 0) {
|
||||
this.alertList.innerHTML = '<div style="padding:16px;color:var(--text-muted);font-size:11px;text-align:center">No active alerts</div>';
|
||||
return;
|
||||
}
|
||||
for (const a of alerts) {
|
||||
const severity = a.pressure < 0.70 ? 'critical' : a.pressure < 0.80 ? 'warning' : 'success';
|
||||
const item = document.createElement('div');
|
||||
item.className = 'alert-item';
|
||||
item.innerHTML = `
|
||||
<div class="alert-dot ${severity}"></div>
|
||||
<div style="flex:1">
|
||||
<div style="font-size:11px;color:var(--text-primary);margin-bottom:2px">${a.message}</div>
|
||||
<div style="font-size:10px;color:var(--text-muted);font-family:var(--font-mono)">Sector ${a.target_id} | Coherence: ${a.pressure.toFixed(2)}</div>
|
||||
</div>
|
||||
`;
|
||||
this.alertList.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadData(epoch: number): Promise<void> {
|
||||
if (!this.surface) return;
|
||||
try {
|
||||
const data = await fetchCoherence('default', epoch);
|
||||
this.currentValues = data.map((d) => d.value);
|
||||
this.surface.setValues(this.currentValues);
|
||||
this.updateMetrics(this.currentValues);
|
||||
} catch {
|
||||
this.currentValues = generateDemoValues(this.gridSize, epoch);
|
||||
this.surface.setValues(this.currentValues);
|
||||
this.updateMetrics(this.currentValues);
|
||||
}
|
||||
}
|
||||
|
||||
private resize = (): void => {
|
||||
if (!this.renderer || !this.camera || !this.container) return;
|
||||
const canvas = this.renderer.domElement.parentElement;
|
||||
if (!canvas) return;
|
||||
const w = canvas.clientWidth;
|
||||
const h = canvas.clientHeight;
|
||||
this.renderer.setSize(w, h);
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
};
|
||||
|
||||
private animate = (): void => {
|
||||
this.animFrameId = requestAnimationFrame(this.animate);
|
||||
this.controls?.update();
|
||||
if (this.renderer && this.scene && this.camera) {
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
};
|
||||
|
||||
unmount(): void {
|
||||
window.removeEventListener('resize', this.resize);
|
||||
cancelAnimationFrame(this.animFrameId);
|
||||
this.surface?.dispose();
|
||||
this.controls?.dispose();
|
||||
this.renderer?.dispose();
|
||||
this.surface = null;
|
||||
this.controls = null;
|
||||
this.renderer = null;
|
||||
this.scene = null;
|
||||
this.camera = null;
|
||||
this.container = null;
|
||||
this.hud = null;
|
||||
this.alertList = null;
|
||||
this.metricsEls = {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* Discovery View — New planet discovery pipeline with 3D solar system visualization.
|
||||
*
|
||||
* Processes real unconfirmed KOI/TOI candidates through the RVF pipeline
|
||||
* to identify the most Earth-like world awaiting confirmation.
|
||||
* Includes interactive Three.js 3D visualization of each candidate's
|
||||
* orbital system — host star, planet orbit, habitable zone.
|
||||
*/
|
||||
|
||||
import { PlanetSystem3D, PlanetSystemParams } from '../three/PlanetSystem3D';
|
||||
|
||||
interface DiscoveryCandidate {
|
||||
id: string;
|
||||
catalog: string;
|
||||
status: string;
|
||||
raw_observations: Record<string, number>;
|
||||
pipeline_derived: {
|
||||
radius_earth: number;
|
||||
semi_major_axis_au: number;
|
||||
eq_temp_k: number;
|
||||
hz_member: boolean;
|
||||
esi_score: number;
|
||||
radius_similarity: number;
|
||||
temperature_similarity: number;
|
||||
};
|
||||
analysis: string;
|
||||
confirmation_needs: string[];
|
||||
significance: string;
|
||||
discovery_rank: number;
|
||||
}
|
||||
|
||||
interface DiscoveryData {
|
||||
mission: string;
|
||||
pipeline_stages: Array<{ stage: string; name: string; description: string }>;
|
||||
candidates: DiscoveryCandidate[];
|
||||
discovery: {
|
||||
top_candidate: string;
|
||||
esi_score: number;
|
||||
comparison: Record<string, string>;
|
||||
why_not_confirmed: string;
|
||||
what_confirmation_requires: string[];
|
||||
pipeline_witness_chain: Array<{ witness: string; measurement: string; confidence: number }>;
|
||||
};
|
||||
data_source: string;
|
||||
references: string[];
|
||||
}
|
||||
|
||||
export class DiscoveryView {
|
||||
private container: HTMLElement | null = null;
|
||||
private data: DiscoveryData | null = null;
|
||||
private pipelineEl: HTMLElement | null = null;
|
||||
private candidatesEl: HTMLElement | null = null;
|
||||
private discoveryEl: HTMLElement | null = null;
|
||||
private running = false;
|
||||
private currentStage = -1;
|
||||
|
||||
// 3D visualization
|
||||
private planet3d: PlanetSystem3D | null = null;
|
||||
private planet3dContainer: HTMLElement | null = null;
|
||||
private planet3dInfoEl: HTMLElement | null = null;
|
||||
private controlsEl: HTMLElement | null = null;
|
||||
private vizPanel: HTMLElement | null = null;
|
||||
private selectedCardEl: HTMLElement | null = null;
|
||||
|
||||
mount(container: HTMLElement): void {
|
||||
this.container = container;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.cssText = 'display:flex;flex-direction:column;width:100%;height:100%;overflow:auto';
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'padding:16px 20px;border-bottom:1px solid var(--border);flex-shrink:0';
|
||||
header.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||
<div style="font-size:16px;font-weight:700;color:var(--text-primary)">New Planet Discovery</div>
|
||||
<span class="score-badge score-high" style="font-size:9px;padding:2px 8px">LIVE PIPELINE</span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-secondary);line-height:1.7;max-width:900px">
|
||||
The RVF pipeline processes <strong>real unconfirmed candidates</strong> from the Kepler Objects of Interest (KOI) catalog.
|
||||
These are stars with detected transit signals that have not yet been confirmed as planets.
|
||||
The pipeline derives physical properties from raw photometry and ranks candidates by Earth Similarity Index.
|
||||
<strong>Click any candidate</strong> to view its 3D orbital system.
|
||||
</div>
|
||||
`;
|
||||
wrapper.appendChild(header);
|
||||
|
||||
// Pipeline stages
|
||||
this.pipelineEl = document.createElement('div');
|
||||
this.pipelineEl.style.cssText = 'padding:16px 20px;border-bottom:1px solid var(--border)';
|
||||
wrapper.appendChild(this.pipelineEl);
|
||||
|
||||
// Run button
|
||||
const controls = document.createElement('div');
|
||||
controls.style.cssText = 'padding:12px 20px;flex-shrink:0';
|
||||
const runBtn = document.createElement('button');
|
||||
runBtn.textContent = 'Run Discovery Pipeline';
|
||||
runBtn.style.cssText =
|
||||
'padding:10px 24px;border:none;border-radius:6px;background:var(--accent);' +
|
||||
'color:#0B0F14;font-size:13px;font-weight:700;cursor:pointer;letter-spacing:0.3px';
|
||||
runBtn.addEventListener('click', () => this.runPipeline());
|
||||
controls.appendChild(runBtn);
|
||||
wrapper.appendChild(controls);
|
||||
|
||||
// 3D Visualization panel (hidden until candidate selected)
|
||||
this.vizPanel = document.createElement('div');
|
||||
this.vizPanel.style.cssText =
|
||||
'padding:0 20px 16px;display:none';
|
||||
wrapper.appendChild(this.vizPanel);
|
||||
|
||||
const vizInner = document.createElement('div');
|
||||
vizInner.style.cssText =
|
||||
'display:grid;grid-template-columns:1fr 260px;gap:0;' +
|
||||
'background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;overflow:hidden';
|
||||
this.vizPanel.appendChild(vizInner);
|
||||
|
||||
// 3D viewport (larger)
|
||||
this.planet3dContainer = document.createElement('div');
|
||||
this.planet3dContainer.style.cssText =
|
||||
'position:relative;min-height:420px;background:#020408';
|
||||
vizInner.appendChild(this.planet3dContainer);
|
||||
|
||||
// Controls overlay (bottom-left of viewport)
|
||||
this.controlsEl = document.createElement('div');
|
||||
this.controlsEl.style.cssText =
|
||||
'position:absolute;bottom:10px;left:10px;display:flex;gap:6px;align-items:center;z-index:20;' +
|
||||
'background:rgba(2,4,8,0.8);border:1px solid rgba(30,38,48,0.6);border-radius:6px;padding:6px 10px';
|
||||
this.planet3dContainer.appendChild(this.controlsEl);
|
||||
|
||||
// Interaction hint (top-right)
|
||||
const hint = document.createElement('div');
|
||||
hint.style.cssText =
|
||||
'position:absolute;top:8px;right:8px;font-size:9px;color:rgba(230,237,243,0.4);' +
|
||||
'z-index:20;pointer-events:none;text-align:right;line-height:1.6';
|
||||
hint.innerHTML = 'Drag to rotate<br>Scroll to zoom<br>Right-drag to pan';
|
||||
this.planet3dContainer.appendChild(hint);
|
||||
|
||||
// Info sidebar
|
||||
this.planet3dInfoEl = document.createElement('div');
|
||||
this.planet3dInfoEl.style.cssText =
|
||||
'padding:16px;overflow-y:auto;max-height:420px;font-size:11px;' +
|
||||
'color:var(--text-secondary);line-height:1.7;border-left:1px solid var(--border)';
|
||||
vizInner.appendChild(this.planet3dInfoEl);
|
||||
|
||||
// Candidates area
|
||||
this.candidatesEl = document.createElement('div');
|
||||
this.candidatesEl.style.cssText = 'padding:0 20px 16px';
|
||||
wrapper.appendChild(this.candidatesEl);
|
||||
|
||||
// Discovery result
|
||||
this.discoveryEl = document.createElement('div');
|
||||
this.discoveryEl.style.cssText = 'padding:0 20px 24px;display:none';
|
||||
wrapper.appendChild(this.discoveryEl);
|
||||
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('/api/discover');
|
||||
this.data = (await response.json()) as DiscoveryData;
|
||||
} catch (err) {
|
||||
console.error('Discovery API error:', err);
|
||||
return;
|
||||
}
|
||||
this.renderPipelineStages();
|
||||
}
|
||||
|
||||
private renderPipelineStages(): void {
|
||||
if (!this.pipelineEl || !this.data) return;
|
||||
const stages = this.data.pipeline_stages;
|
||||
this.pipelineEl.innerHTML = `
|
||||
<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px">Pipeline Stages</div>
|
||||
<div style="display:flex;gap:4px;align-items:center;flex-wrap:wrap">
|
||||
${stages.map((s, i) => `
|
||||
<div id="stage-${i}" style="padding:6px 14px;border-radius:4px;background:var(--bg-surface);border:1px solid var(--border);transition:all 0.3s">
|
||||
<div style="font-size:10px;font-weight:700;color:var(--text-muted)">${s.stage}</div>
|
||||
<div style="font-size:9px;color:var(--text-muted)">${s.name}</div>
|
||||
</div>
|
||||
${i < stages.length - 1 ? '<span style="color:var(--text-muted)">→</span>' : ''}
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async runPipeline(): Promise<void> {
|
||||
if (this.running || !this.data) return;
|
||||
this.running = true;
|
||||
this.currentStage = -1;
|
||||
|
||||
// Clear previous results
|
||||
if (this.candidatesEl) this.candidatesEl.innerHTML = '';
|
||||
if (this.discoveryEl) this.discoveryEl.style.display = 'none';
|
||||
if (this.vizPanel) this.vizPanel.style.display = 'none';
|
||||
this.selectedCardEl = null;
|
||||
|
||||
// Animate through pipeline stages
|
||||
for (let i = 0; i < this.data.pipeline_stages.length; i++) {
|
||||
this.currentStage = i;
|
||||
this.highlightStage(i);
|
||||
await this.sleep(600);
|
||||
}
|
||||
|
||||
// Show candidates one by one
|
||||
const sorted = [...this.data.candidates].sort((a, b) => a.discovery_rank - b.discovery_rank);
|
||||
for (const c of sorted) {
|
||||
await this.sleep(400);
|
||||
this.addCandidateCard(c);
|
||||
}
|
||||
|
||||
// Auto-show 3D for the top-ranked candidate
|
||||
if (sorted.length > 0) {
|
||||
this.show3D(sorted[0]);
|
||||
}
|
||||
|
||||
// Show discovery
|
||||
await this.sleep(800);
|
||||
this.showDiscovery();
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
private highlightStage(index: number): void {
|
||||
for (let i = 0; i < (this.data?.pipeline_stages.length ?? 0); i++) {
|
||||
const el = document.getElementById(`stage-${i}`);
|
||||
if (!el) continue;
|
||||
if (i < index) {
|
||||
el.style.background = 'rgba(46,204,113,0.15)';
|
||||
el.style.borderColor = 'rgba(46,204,113,0.4)';
|
||||
el.querySelector('div')!.style.color = 'var(--success)';
|
||||
} else if (i === index) {
|
||||
el.style.background = 'rgba(0,229,255,0.15)';
|
||||
el.style.borderColor = 'var(--accent)';
|
||||
el.querySelector('div')!.style.color = 'var(--accent)';
|
||||
} else {
|
||||
el.style.background = 'var(--bg-surface)';
|
||||
el.style.borderColor = 'var(--border)';
|
||||
el.querySelector('div')!.style.color = 'var(--text-muted)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addCandidateCard(c: DiscoveryCandidate): void {
|
||||
if (!this.candidatesEl) return;
|
||||
|
||||
const esiClass = c.pipeline_derived.esi_score >= 0.9 ? 'score-high' : c.pipeline_derived.esi_score >= 0.8 ? 'score-medium' : 'score-low';
|
||||
const isTop = c.discovery_rank === 1;
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.style.cssText = `
|
||||
padding:12px 16px;margin-bottom:8px;border-radius:6px;cursor:pointer;
|
||||
background:${isTop ? 'rgba(0,229,255,0.08)' : 'var(--bg-surface)'};
|
||||
border:1px solid ${isTop ? 'var(--accent)' : 'var(--border)'};
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
`;
|
||||
card.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text-primary)">${c.id}</span>
|
||||
<span class="score-badge ${esiClass}" style="font-size:10px">ESI ${c.pipeline_derived.esi_score.toFixed(2)}</span>
|
||||
<span style="font-size:9px;color:var(--text-muted)">${c.catalog}</span>
|
||||
${isTop ? '<span class="score-badge score-high" style="font-size:8px;background:rgba(0,229,255,0.2);color:var(--accent);border-color:var(--accent)">TOP DISCOVERY</span>' : ''}
|
||||
<span style="font-size:9px;color:var(--text-muted);margin-left:4px" title="Click to view 3D system">🌐 View 3D</span>
|
||||
<span style="margin-left:auto;font-size:10px;color:var(--text-muted)">
|
||||
R=${c.pipeline_derived.radius_earth.toFixed(2)} R⊕ | T=${c.pipeline_derived.eq_temp_k}K |
|
||||
HZ: ${c.pipeline_derived.hz_member ? '<span style="color:var(--success)">YES</span>' : '<span style="color:var(--critical)">NO</span>'}
|
||||
</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text-secondary);line-height:1.5">${c.analysis}</div>
|
||||
`;
|
||||
|
||||
card.addEventListener('click', () => {
|
||||
this.show3D(c);
|
||||
// Highlight selected card
|
||||
if (this.selectedCardEl) {
|
||||
this.selectedCardEl.style.borderColor = this.selectedCardEl.dataset.isTop === '1' ? 'var(--accent)' : 'var(--border)';
|
||||
this.selectedCardEl.style.boxShadow = 'none';
|
||||
}
|
||||
card.style.borderColor = 'var(--accent)';
|
||||
card.style.boxShadow = '0 0 8px rgba(0,229,255,0.2)';
|
||||
this.selectedCardEl = card;
|
||||
});
|
||||
card.dataset.isTop = isTop ? '1' : '0';
|
||||
|
||||
this.candidatesEl.appendChild(card);
|
||||
}
|
||||
|
||||
private show3D(c: DiscoveryCandidate): void {
|
||||
if (!this.vizPanel || !this.planet3dContainer || !this.planet3dInfoEl) return;
|
||||
|
||||
this.vizPanel.style.display = '';
|
||||
|
||||
// Destroy previous 3D if exists
|
||||
if (this.planet3d) {
|
||||
this.planet3d.destroy();
|
||||
this.planet3d = null;
|
||||
}
|
||||
|
||||
// Create new 3D system
|
||||
this.planet3d = new PlanetSystem3D(this.planet3dContainer);
|
||||
const params: PlanetSystemParams = {
|
||||
label: c.id,
|
||||
radiusEarth: c.pipeline_derived.radius_earth,
|
||||
semiMajorAxisAU: c.pipeline_derived.semi_major_axis_au,
|
||||
eqTempK: c.pipeline_derived.eq_temp_k,
|
||||
stellarTempK: c.raw_observations['stellar_temp_k'] ?? 5500,
|
||||
stellarRadiusSolar: c.raw_observations['stellar_radius_solar'] ?? 1.0,
|
||||
periodDays: c.raw_observations['period_days'] ?? 365,
|
||||
hzMember: c.pipeline_derived.hz_member,
|
||||
esiScore: c.pipeline_derived.esi_score,
|
||||
transitDepth: c.raw_observations['transit_depth'] ?? 0.001,
|
||||
};
|
||||
this.planet3d.update(params);
|
||||
|
||||
// Build controls overlay
|
||||
this.buildControls();
|
||||
|
||||
// Update info sidebar
|
||||
const starType = this.getSpectralType(params.stellarTempK);
|
||||
const tempLabel = this.getTempLabel(params.eqTempK);
|
||||
|
||||
this.planet3dInfoEl.innerHTML = `
|
||||
<div style="font-size:14px;font-weight:700;color:var(--text-primary);margin-bottom:10px">${c.id}</div>
|
||||
<div style="margin-bottom:12px">
|
||||
<span class="score-badge ${c.pipeline_derived.esi_score >= 0.9 ? 'score-high' : c.pipeline_derived.esi_score >= 0.8 ? 'score-medium' : 'score-low'}"
|
||||
style="font-size:11px;padding:3px 8px">ESI ${c.pipeline_derived.esi_score.toFixed(2)}</span>
|
||||
<span style="margin-left:6px;font-size:10px;color:${c.pipeline_derived.hz_member ? 'var(--success)' : 'var(--critical)'}">
|
||||
${c.pipeline_derived.hz_member ? 'Habitable Zone' : 'Outside HZ'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style="font-size:10px;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px">Planet</div>
|
||||
<div style="padding-left:8px;border-left:2px solid var(--accent);margin-bottom:12px">
|
||||
<div>Radius: <span style="color:var(--accent)">${params.radiusEarth.toFixed(2)} R⊕</span></div>
|
||||
<div>Temperature: <span style="color:var(--accent)">${params.eqTempK} K</span> <span style="font-size:9px;color:var(--text-muted)">(${tempLabel})</span></div>
|
||||
<div>Orbit: <span style="color:var(--accent)">${params.semiMajorAxisAU.toFixed(3)} AU</span></div>
|
||||
<div>Period: <span style="color:var(--accent)">${params.periodDays.toFixed(1)} days</span></div>
|
||||
<div>Transit depth: <span style="color:var(--accent)">${(params.transitDepth * 1e6).toFixed(0)} ppm</span></div>
|
||||
</div>
|
||||
|
||||
<div style="font-size:10px;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px">Host Star</div>
|
||||
<div style="padding-left:8px;border-left:2px solid #ffd2a1;margin-bottom:12px">
|
||||
<div>Type: <span style="color:#ffd2a1">${starType}</span></div>
|
||||
<div>T<sub>eff</sub>: <span style="color:#ffd2a1">${params.stellarTempK} K</span></div>
|
||||
<div>Radius: <span style="color:#ffd2a1">${params.stellarRadiusSolar.toFixed(3)} R☉</span></div>
|
||||
</div>
|
||||
|
||||
<div style="font-size:10px;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px">ESI Breakdown</div>
|
||||
<div style="padding-left:8px;border-left:2px solid var(--success);margin-bottom:12px">
|
||||
<div>Radius similarity: <span style="color:var(--success)">${(c.pipeline_derived.radius_similarity * 100).toFixed(0)}%</span></div>
|
||||
<div>Temp similarity: <span style="color:var(--success)">${(c.pipeline_derived.temperature_similarity * 100).toFixed(0)}%</span></div>
|
||||
</div>
|
||||
|
||||
<div style="font-size:10px;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px">Comparison to Earth</div>
|
||||
<div style="font-size:10px;line-height:1.8">
|
||||
${this.earthComparison(params)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Scroll viz into view
|
||||
this.vizPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
private buildControls(): void {
|
||||
if (!this.controlsEl || !this.planet3d) return;
|
||||
this.controlsEl.innerHTML = '';
|
||||
|
||||
const btnStyle =
|
||||
'border:1px solid rgba(30,38,48,0.8);border-radius:4px;background:rgba(11,15,20,0.9);' +
|
||||
'color:var(--text-secondary);font-size:10px;padding:4px 8px;cursor:pointer;' +
|
||||
'font-family:var(--font-mono);transition:color 0.15s,border-color 0.15s';
|
||||
const activeBtnStyle = btnStyle.replace('var(--text-secondary)', 'var(--accent)').replace('rgba(30,38,48,0.8)', 'var(--accent)');
|
||||
|
||||
// Speed label
|
||||
const speedLabel = document.createElement('span');
|
||||
speedLabel.style.cssText = 'font-size:9px;color:var(--text-muted);font-family:var(--font-mono)';
|
||||
speedLabel.textContent = 'Speed:';
|
||||
this.controlsEl.appendChild(speedLabel);
|
||||
|
||||
// Speed slider
|
||||
const slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.min = '0.1';
|
||||
slider.max = '5';
|
||||
slider.step = '0.1';
|
||||
slider.value = '1';
|
||||
slider.style.cssText = 'width:70px;height:4px;accent-color:var(--accent);cursor:pointer';
|
||||
slider.addEventListener('input', () => {
|
||||
const v = parseFloat(slider.value);
|
||||
this.planet3d?.setSpeed(v);
|
||||
speedVal.textContent = `${v.toFixed(1)}x`;
|
||||
});
|
||||
this.controlsEl.appendChild(slider);
|
||||
|
||||
const speedVal = document.createElement('span');
|
||||
speedVal.style.cssText = 'font-size:9px;color:var(--accent);min-width:24px;font-family:var(--font-mono)';
|
||||
speedVal.textContent = '1.0x';
|
||||
this.controlsEl.appendChild(speedVal);
|
||||
|
||||
// Separator
|
||||
const sep = document.createElement('span');
|
||||
sep.style.cssText = 'width:1px;height:14px;background:rgba(30,38,48,0.6)';
|
||||
this.controlsEl.appendChild(sep);
|
||||
|
||||
// Auto-rotate toggle
|
||||
const autoBtn = document.createElement('button');
|
||||
autoBtn.style.cssText = activeBtnStyle;
|
||||
autoBtn.textContent = 'Auto';
|
||||
autoBtn.title = 'Toggle auto-rotate camera';
|
||||
autoBtn.addEventListener('click', () => {
|
||||
this.planet3d?.toggleAutoRotate();
|
||||
const active = this.planet3d?.getAutoRotate() ?? false;
|
||||
autoBtn.style.cssText = active ? activeBtnStyle : btnStyle;
|
||||
});
|
||||
this.controlsEl.appendChild(autoBtn);
|
||||
|
||||
// Reset view button
|
||||
const resetBtn = document.createElement('button');
|
||||
resetBtn.style.cssText = btnStyle;
|
||||
resetBtn.textContent = 'Reset';
|
||||
resetBtn.title = 'Reset camera to default position';
|
||||
resetBtn.addEventListener('click', () => {
|
||||
this.planet3d?.resetCamera();
|
||||
autoBtn.style.cssText = activeBtnStyle;
|
||||
slider.value = '1';
|
||||
speedVal.textContent = '1.0x';
|
||||
this.planet3d?.setSpeed(1);
|
||||
});
|
||||
this.controlsEl.appendChild(resetBtn);
|
||||
}
|
||||
|
||||
private getSpectralType(teff: number): string {
|
||||
if (teff > 7500) return `A-type (${teff} K)`;
|
||||
if (teff > 6000) return `F-type (${teff} K)`;
|
||||
if (teff > 5200) return `G-type (${teff} K) — Sun-like`;
|
||||
if (teff > 3700) return `K-type (${teff} K)`;
|
||||
return `M-type (${teff} K)`;
|
||||
}
|
||||
|
||||
private getTempLabel(eqTempK: number): string {
|
||||
if (eqTempK < 180) return 'frozen';
|
||||
if (eqTempK < 240) return 'cold';
|
||||
if (eqTempK < 280) return 'temperate';
|
||||
if (eqTempK < 330) return 'warm';
|
||||
return 'hot';
|
||||
}
|
||||
|
||||
private earthComparison(p: PlanetSystemParams): string {
|
||||
const rRatio = p.radiusEarth;
|
||||
const tRatio = p.eqTempK / 255; // Earth's effective temp ~255K
|
||||
const aRatio = p.semiMajorAxisAU; // Earth = 1 AU
|
||||
const pRatio = p.periodDays / 365.25;
|
||||
|
||||
const fmt = (v: number, unit: string) => {
|
||||
if (v > 0.95 && v < 1.05) return `<span style="color:var(--success)">~Earth (${v.toFixed(2)}${unit})</span>`;
|
||||
if (v > 1) return `<span style="color:var(--accent)">${v.toFixed(2)}x Earth</span>`;
|
||||
return `<span style="color:var(--accent)">${v.toFixed(2)}x Earth</span>`;
|
||||
};
|
||||
|
||||
return `
|
||||
<div>Radius: ${fmt(rRatio, 'x')}</div>
|
||||
<div>Temperature: ${fmt(tRatio, 'x')}</div>
|
||||
<div>Orbit: ${fmt(aRatio, ' AU')}</div>
|
||||
<div>Year: ${fmt(pRatio, 'x')}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private showDiscovery(): void {
|
||||
if (!this.discoveryEl || !this.data) return;
|
||||
const d = this.data.discovery;
|
||||
this.discoveryEl.style.display = '';
|
||||
|
||||
this.discoveryEl.innerHTML = `
|
||||
<div style="padding:20px;background:rgba(0,229,255,0.06);border:2px solid var(--accent);border-radius:10px">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
|
||||
<div style="font-size:18px;font-weight:800;color:var(--accent)">DISCOVERY: ${d.top_candidate}</div>
|
||||
<span class="score-badge score-high" style="font-size:12px;padding:3px 10px">ESI ${d.esi_score}</span>
|
||||
<span style="font-size:10px;color:var(--success);font-weight:600">MOST EARTH-LIKE CANDIDATE IN KEPLER CATALOG</span>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px">
|
||||
<div>
|
||||
<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.4px;margin-bottom:6px">Comparison to Known Worlds</div>
|
||||
${Object.entries(d.comparison).map(([k, v]) => `
|
||||
<div style="font-size:11px;color:var(--text-secondary);margin-bottom:4px;padding-left:8px;border-left:2px solid var(--accent)">
|
||||
<strong>${k.replace('vs_', 'vs ')}</strong>: ${v}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.4px;margin-bottom:6px">Why Not Yet Confirmed</div>
|
||||
<div style="font-size:11px;color:var(--text-secondary);line-height:1.6">${d.why_not_confirmed}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:16px">
|
||||
<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.4px;margin-bottom:6px">Pipeline Witness Chain</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||||
${d.pipeline_witness_chain.map(w => `
|
||||
<div style="padding:6px 10px;background:var(--bg-panel);border:1px solid var(--border);border-radius:4px;font-size:10px">
|
||||
<div style="color:var(--accent);font-weight:600">${w.witness}</div>
|
||||
<div style="color:var(--text-secondary)">${w.measurement}</div>
|
||||
<div style="color:${w.confidence > 0.9 ? 'var(--success)' : 'var(--warning)'}">${(w.confidence * 100).toFixed(0)}% conf.</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.4px;margin-bottom:6px">Steps to Confirmation</div>
|
||||
${d.what_confirmation_requires.map(s => `
|
||||
<div style="font-size:11px;color:var(--text-secondary);margin-bottom:3px;padding-left:8px">${s}</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
unmount(): void {
|
||||
this.running = false;
|
||||
if (this.planet3d) {
|
||||
this.planet3d.destroy();
|
||||
this.planet3d = null;
|
||||
}
|
||||
this.container = null;
|
||||
this.pipelineEl = null;
|
||||
this.candidatesEl = null;
|
||||
this.discoveryEl = null;
|
||||
this.vizPanel = null;
|
||||
this.planet3dContainer = null;
|
||||
this.planet3dInfoEl = null;
|
||||
this.controlsEl = null;
|
||||
this.selectedCardEl = null;
|
||||
this.data = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* DocsView — Documentation with sidebar navigation and scroll-spy.
|
||||
*/
|
||||
|
||||
interface Section {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
children?: { id: string; label: string }[];
|
||||
}
|
||||
|
||||
const SECTIONS: Section[] = [
|
||||
{ id: 'overview', label: 'Overview', icon: '\u2302',
|
||||
children: [{ id: 'what-is-rvf', label: 'What is RVF?' }, { id: 'at-a-glance', label: 'At a Glance' }] },
|
||||
{ id: 'single-file', label: 'Single File', icon: '\u25A3',
|
||||
children: [{ id: 'segments', label: 'Segment Map' }, { id: 'why-one-file', label: 'Why One File?' }] },
|
||||
{ id: 'pipeline', label: 'Pipeline', icon: '\u25B6',
|
||||
children: [{ id: 'stage-ingest', label: 'Data Ingestion' }, { id: 'stage-process', label: 'Signal Processing' },
|
||||
{ id: 'stage-detect', label: 'Candidate Detection' }, { id: 'stage-score', label: 'Scoring' }, { id: 'stage-seal', label: 'Witness Sealing' }] },
|
||||
{ id: 'proof', label: 'Proof', icon: '\u2713',
|
||||
children: [{ id: 'witness-chain', label: 'Witness Chain' }, { id: 'reproducible', label: 'Reproducible' },
|
||||
{ id: 'acceptance', label: 'Acceptance Test' }, { id: 'blind', label: 'Blind Testing' }] },
|
||||
{ id: 'unique', label: 'Why Unique', icon: '\u2605' },
|
||||
{ id: 'capabilities', label: 'Views', icon: '\u25CE',
|
||||
children: [{ id: 'cap-atlas', label: 'Atlas Explorer' }, { id: 'cap-coherence', label: 'Coherence' },
|
||||
{ id: 'cap-boundaries', label: 'Boundaries' }, { id: 'cap-memory', label: 'Memory Tiers' },
|
||||
{ id: 'cap-planets', label: 'Planets' }, { id: 'cap-life', label: 'Life' },
|
||||
{ id: 'cap-witness', label: 'Witness Chain' }, { id: 'cap-solver', label: 'Solver' },
|
||||
{ id: 'cap-blind', label: 'Blind Test' }, { id: 'cap-discover', label: 'Discovery' },
|
||||
{ id: 'cap-dyson', label: 'Dyson Sphere' }, { id: 'cap-status', label: 'Status' }] },
|
||||
{ id: 'solver', label: 'Solver', icon: '\u2699',
|
||||
children: [{ id: 'thompson', label: 'Thompson Sampling' }, { id: 'auto-optimize', label: 'Auto-Optimize' }] },
|
||||
{ id: 'format', label: 'Format Spec', icon: '\u2630',
|
||||
children: [{ id: 'file-header', label: 'File Header' }, { id: 'seg-types', label: 'Segment Types' },
|
||||
{ id: 'witness-format', label: 'Witness Entry' }, { id: 'dashboard-seg', label: 'Dashboard Segment' }] },
|
||||
{ id: 'glossary', label: 'Glossary', icon: '\u2261' },
|
||||
];
|
||||
|
||||
export class DocsView {
|
||||
private container: HTMLElement | null = null;
|
||||
private contentEl: HTMLElement | null = null;
|
||||
private navLinks: Map<string, HTMLElement> = new Map();
|
||||
private scrollRaf = 0;
|
||||
|
||||
mount(container: HTMLElement): void {
|
||||
this.container = container;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.cssText = 'display:flex;width:100%;height:100%;overflow:hidden';
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// Left nav sidebar
|
||||
const nav = this.buildNav();
|
||||
wrapper.appendChild(nav);
|
||||
|
||||
// Right content area
|
||||
this.contentEl = document.createElement('div');
|
||||
this.contentEl.style.cssText = 'flex:1;overflow-y:auto;overflow-x:hidden;scroll-behavior:smooth;-webkit-overflow-scrolling:touch;min-width:0';
|
||||
wrapper.appendChild(this.contentEl);
|
||||
|
||||
const inner = document.createElement('div');
|
||||
inner.style.cssText = 'max-width:820px;margin:0 auto;padding:28px 32px 100px;line-height:1.7;color:var(--text-secondary);font-size:13px';
|
||||
this.contentEl.appendChild(inner);
|
||||
inner.innerHTML = this.buildContent();
|
||||
|
||||
// Scroll spy
|
||||
this.contentEl.addEventListener('scroll', this.onScroll);
|
||||
requestAnimationFrame(() => this.onScroll());
|
||||
}
|
||||
|
||||
unmount(): void {
|
||||
cancelAnimationFrame(this.scrollRaf);
|
||||
this.contentEl?.removeEventListener('scroll', this.onScroll);
|
||||
this.navLinks.clear();
|
||||
this.contentEl = null;
|
||||
this.container = null;
|
||||
}
|
||||
|
||||
/* ── Nav sidebar ── */
|
||||
|
||||
private buildNav(): HTMLElement {
|
||||
const nav = document.createElement('nav');
|
||||
nav.style.cssText = `
|
||||
width:220px;min-width:220px;background:var(--bg-panel);border-right:1px solid var(--border);
|
||||
overflow-y:auto;overflow-x:hidden;padding:16px 0;display:flex;flex-direction:column;
|
||||
-webkit-overflow-scrolling:touch;flex-shrink:0
|
||||
`;
|
||||
|
||||
// Title
|
||||
const title = document.createElement('div');
|
||||
title.style.cssText = 'padding:0 16px 14px;font-size:13px;font-weight:600;color:var(--text-primary);letter-spacing:0.3px;border-bottom:1px solid var(--border);margin-bottom:8px';
|
||||
title.textContent = 'Documentation';
|
||||
nav.appendChild(title);
|
||||
|
||||
for (const section of SECTIONS) {
|
||||
// Parent link
|
||||
const link = document.createElement('a');
|
||||
link.style.cssText = `
|
||||
display:flex;align-items:center;gap:8px;padding:7px 16px;
|
||||
font-size:12px;font-weight:600;color:var(--text-secondary);cursor:pointer;
|
||||
text-decoration:none;transition:color 0.15s,background 0.15s;border-left:2px solid transparent
|
||||
`;
|
||||
link.innerHTML = `<span style="font-size:11px;width:16px;text-align:center;opacity:0.6">${section.icon}</span> ${section.label}`;
|
||||
link.addEventListener('click', (e) => { e.preventDefault(); this.scrollTo(section.id); });
|
||||
link.addEventListener('mouseenter', () => { link.style.color = 'var(--text-primary)'; link.style.background = 'rgba(255,255,255,0.02)'; });
|
||||
link.addEventListener('mouseleave', () => {
|
||||
if (!link.classList.contains('doc-active')) { link.style.color = 'var(--text-secondary)'; link.style.background = ''; }
|
||||
});
|
||||
nav.appendChild(link);
|
||||
this.navLinks.set(section.id, link);
|
||||
|
||||
// Child links
|
||||
if (section.children) {
|
||||
for (const child of section.children) {
|
||||
const clink = document.createElement('a');
|
||||
clink.style.cssText = `
|
||||
display:block;padding:4px 16px 4px 40px;font-size:11px;color:var(--text-muted);
|
||||
cursor:pointer;text-decoration:none;transition:color 0.15s;border-left:2px solid transparent
|
||||
`;
|
||||
clink.textContent = child.label;
|
||||
clink.addEventListener('click', (e) => { e.preventDefault(); this.scrollTo(child.id); });
|
||||
clink.addEventListener('mouseenter', () => { clink.style.color = 'var(--text-secondary)'; });
|
||||
clink.addEventListener('mouseleave', () => {
|
||||
if (!clink.classList.contains('doc-active')) clink.style.color = 'var(--text-muted)';
|
||||
});
|
||||
nav.appendChild(clink);
|
||||
this.navLinks.set(child.id, clink);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom spacer
|
||||
const spacer = document.createElement('div');
|
||||
spacer.style.cssText = 'flex:1;min-height:20px';
|
||||
nav.appendChild(spacer);
|
||||
|
||||
// Footer
|
||||
const footer = document.createElement('div');
|
||||
footer.style.cssText = 'padding:12px 16px;border-top:1px solid var(--border);font-size:9px;color:var(--text-muted);line-height:1.5';
|
||||
footer.innerHTML = 'Built with <span style="color:var(--accent)">RuVector</span><br>Rust + WASM + Three.js';
|
||||
nav.appendChild(footer);
|
||||
|
||||
return nav;
|
||||
}
|
||||
|
||||
private scrollTo(id: string): void {
|
||||
const el = this.contentEl?.querySelector(`#${id}`) as HTMLElement | null;
|
||||
if (el && this.contentEl) {
|
||||
this.contentEl.scrollTo({ top: el.offsetTop - 20, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Scroll spy ── */
|
||||
|
||||
private onScroll = (): void => {
|
||||
cancelAnimationFrame(this.scrollRaf);
|
||||
this.scrollRaf = requestAnimationFrame(() => {
|
||||
if (!this.contentEl) return;
|
||||
const scrollTop = this.contentEl.scrollTop + 60;
|
||||
|
||||
// Find which section is currently visible
|
||||
let activeId = '';
|
||||
const allIds = Array.from(this.navLinks.keys());
|
||||
for (const id of allIds) {
|
||||
const el = this.contentEl.querySelector(`#${id}`) as HTMLElement | null;
|
||||
if (el && el.offsetTop <= scrollTop) activeId = id;
|
||||
}
|
||||
|
||||
// Update nav highlights
|
||||
this.navLinks.forEach((link, id) => {
|
||||
const isActive = id === activeId;
|
||||
link.classList.toggle('doc-active', isActive);
|
||||
// Check if parent or child
|
||||
const isParent = SECTIONS.some(s => s.id === id);
|
||||
if (isActive) {
|
||||
link.style.color = 'var(--accent)';
|
||||
link.style.borderLeftColor = 'var(--accent)';
|
||||
link.style.background = isParent ? 'rgba(0,229,255,0.06)' : 'rgba(0,229,255,0.03)';
|
||||
} else {
|
||||
link.style.color = isParent ? 'var(--text-secondary)' : 'var(--text-muted)';
|
||||
link.style.borderLeftColor = 'transparent';
|
||||
link.style.background = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/* ── Content builder ── */
|
||||
|
||||
private buildContent(): string {
|
||||
const S = {
|
||||
h1: 'font-size:26px;font-weight:300;color:var(--text-primary);letter-spacing:0.5px;margin-bottom:6px',
|
||||
h2: 'font-size:19px;font-weight:600;color:var(--text-primary);margin-top:48px;margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid var(--border)',
|
||||
h3: 'font-size:14px;font-weight:600;color:var(--accent);margin-top:28px;margin-bottom:8px',
|
||||
p: 'margin-bottom:14px',
|
||||
card: 'background:var(--bg-panel);border:1px solid var(--border);border-radius:6px;padding:14px 18px;margin-bottom:12px',
|
||||
code: 'font-family:var(--font-mono);font-size:11px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;padding:12px 16px;display:block;margin:10px 0 14px;overflow-x:auto;line-height:1.6;color:var(--text-primary)',
|
||||
accent: 'color:var(--accent);font-weight:600',
|
||||
success: 'color:var(--success);font-weight:600',
|
||||
badge: 'display:inline-block;font-size:9px;font-weight:600;padding:2px 8px;border-radius:3px;margin-right:4px',
|
||||
inline: 'background:var(--bg-surface);padding:1px 6px;border-radius:3px;font-family:var(--font-mono);font-size:12px',
|
||||
};
|
||||
|
||||
return `
|
||||
<!-- ============ OVERVIEW ============ -->
|
||||
<div style="${S.h1}" id="overview">Causal Atlas Documentation</div>
|
||||
<div style="font-size:13px;color:var(--text-muted);margin-bottom:20px">
|
||||
A complete guide to the RVF scientific discovery platform.
|
||||
</div>
|
||||
|
||||
<div style="${S.h3}" id="what-is-rvf">What is RVF?</div>
|
||||
<div style="${S.p}">
|
||||
<span style="${S.accent}">RVF (RuVector Format)</span> is a binary container that holds
|
||||
an entire scientific discovery pipeline — raw telescope data, analysis code,
|
||||
results, cryptographic proofs, and this interactive dashboard — in a
|
||||
<strong>single, self-contained file</strong>.
|
||||
</div>
|
||||
<div style="${S.p}">
|
||||
Think of it as a shipping container for science. Anyone who receives the file can
|
||||
independently verify every step of the analysis without external tools or databases.
|
||||
</div>
|
||||
|
||||
<div style="${S.h3}" id="at-a-glance">At a Glance</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:16px">
|
||||
${this.statCard('File Format', 'Binary, segmented', S)}
|
||||
${this.statCard('Crypto', 'Ed25519 + SHAKE-256', S)}
|
||||
${this.statCard('Solver', 'WASM + Thompson Sampling', S)}
|
||||
${this.statCard('Dashboard', 'Three.js + D3', S)}
|
||||
${this.statCard('Server', 'Rust / Axum', S)}
|
||||
${this.statCard('Domains', 'Exoplanets, Dyson, Bio', S)}
|
||||
</div>
|
||||
|
||||
<!-- ============ SINGLE FILE ============ -->
|
||||
<div style="${S.h2}" id="single-file">One File Contains Everything</div>
|
||||
<div style="${S.p}">
|
||||
Traditional scientific data is scattered across files, servers, and packages.
|
||||
RVF packs everything into typed <strong>segments</strong> inside one binary file.
|
||||
</div>
|
||||
|
||||
<div style="${S.h3}" id="segments">Segment Map</div>
|
||||
<div style="${S.card}">
|
||||
<div style="font-family:var(--font-mono);font-size:11px;line-height:2.2">
|
||||
${this.segRow('HEADER (64 B)', 'File magic, version, segment count', 'var(--text-muted)')}
|
||||
${this.segRow('DATA_SEG', 'Raw telescope observations (light curves, spectra)', '#FF6B9D')}
|
||||
${this.segRow('KERNEL_SEG', 'Processing algorithms for analysis', '#FFB020')}
|
||||
${this.segRow('EBPF_SEG', 'Fast in-kernel data filtering programs', '#9944FF')}
|
||||
${this.segRow('WASM_SEG', 'Self-learning solver (runs in any browser)', '#2ECC71')}
|
||||
${this.segRow('WITNESS_SEG', 'Cryptographic proof chain (Ed25519 signed)', 'var(--accent)')}
|
||||
${this.segRow('DASHBOARD_SEG', 'This interactive 3D dashboard (HTML/JS/CSS)', '#FF4D4D')}
|
||||
${this.segRow('SIGNATURE', 'Ed25519 signature over all segments', 'var(--text-muted)')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="${S.h3}" id="why-one-file">Why One File?</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px">
|
||||
<div style="${S.card}padding:10px 14px">
|
||||
<div style="font-size:11px;${S.accent}margin-bottom:3px">Portability</div>
|
||||
<div style="font-size:11px;line-height:1.5">Email it, USB drive, or static hosting. No server setup needed.</div>
|
||||
</div>
|
||||
<div style="${S.card}padding:10px 14px">
|
||||
<div style="font-size:11px;${S.accent}margin-bottom:3px">Reproducibility</div>
|
||||
<div style="font-size:11px;line-height:1.5">Code + data together means anyone can re-run the analysis.</div>
|
||||
</div>
|
||||
<div style="${S.card}padding:10px 14px">
|
||||
<div style="font-size:11px;${S.accent}margin-bottom:3px">Integrity</div>
|
||||
<div style="font-size:11px;line-height:1.5">Tampering with any segment breaks the signature chain.</div>
|
||||
</div>
|
||||
<div style="${S.card}padding:10px 14px">
|
||||
<div style="font-size:11px;${S.accent}margin-bottom:3px">Archival</div>
|
||||
<div style="font-size:11px;line-height:1.5">One file to store, back up, and cite. No link rot.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ PIPELINE ============ -->
|
||||
<div style="${S.h2}" id="pipeline">How the Pipeline Works</div>
|
||||
<div style="${S.p}">
|
||||
The pipeline transforms raw observations into verified discoveries through five stages.
|
||||
Each stage is recorded in the witness chain for full traceability.
|
||||
</div>
|
||||
|
||||
<div style="${S.h3}" id="stage-ingest">1. Data Ingestion</div>
|
||||
<div style="${S.p}">
|
||||
Raw photometric data (brightness over time) is ingested from telescope archives.
|
||||
For exoplanet detection, this means <span style="${S.accent}">light curves</span> —
|
||||
graphs of stellar brightness that dip when a planet transits its star.
|
||||
</div>
|
||||
|
||||
<div style="${S.h3}" id="stage-process">2. Signal Processing</div>
|
||||
<div style="${S.p}">
|
||||
Processing kernels clean the data: removing instrumental noise, correcting for stellar
|
||||
variability, and flagging periodic signals. The <span style="${S.accent}">eBPF programs</span>
|
||||
accelerate filtering at near-hardware speed.
|
||||
</div>
|
||||
|
||||
<div style="${S.h3}" id="stage-detect">3. Candidate Detection</div>
|
||||
<div style="${S.p}">
|
||||
Cleaned signals are matched against known patterns. For exoplanets: periodic transit-shaped dips.
|
||||
For Dyson spheres: anomalous infrared excess. Each candidate gets derived parameters:
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:14px">
|
||||
<div style="${S.card}padding:10px 14px">
|
||||
<div style="font-size:10px;${S.accent}margin-bottom:3px">Exoplanets</div>
|
||||
<div style="font-size:11px">Radius, period, temperature, HZ membership, ESI score</div>
|
||||
</div>
|
||||
<div style="${S.card}padding:10px 14px">
|
||||
<div style="font-size:10px;color:#FFB020;font-weight:600;margin-bottom:3px">Dyson Candidates</div>
|
||||
<div style="font-size:11px">IR excess ratio, dimming pattern, partial coverage fraction</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="${S.h3}" id="stage-score">4. Scoring & Ranking</div>
|
||||
<div style="${S.p}">
|
||||
Candidates are scored multi-dimensionally. The <span style="${S.accent}">WASM solver</span>
|
||||
uses Thompson Sampling to discover which analysis strategies work best for each difficulty
|
||||
level, continuously improving accuracy without human tuning.
|
||||
</div>
|
||||
|
||||
<div style="${S.h3}" id="stage-seal">5. Witness Sealing</div>
|
||||
<div style="${S.p}">
|
||||
Every step is recorded in the <span style="${S.accent}">witness chain</span>: a SHAKE-256 hash
|
||||
of the data, a timestamp, and an Ed25519 signature. This creates an immutable,
|
||||
cryptographically verifiable audit trail.
|
||||
</div>
|
||||
|
||||
<!-- ============ PROOF ============ -->
|
||||
<div style="${S.h2}" id="proof">How Discoveries Are Proven</div>
|
||||
<div style="${S.p}">
|
||||
<strong>How do you know the results are real?</strong> RVF uses four layers of proof.
|
||||
</div>
|
||||
|
||||
<div style="${S.h3}" id="witness-chain">Layer 1: Cryptographic Witness Chain</div>
|
||||
<div style="${S.card}">
|
||||
<div style="font-size:11px;line-height:1.9;margin-bottom:6px">
|
||||
Each processing step writes a witness entry containing:<br>
|
||||
• <strong>Step name</strong> — what operation was performed<br>
|
||||
• <strong>Input hash</strong> — SHAKE-256 of data going in<br>
|
||||
• <strong>Output hash</strong> — SHAKE-256 of data coming out<br>
|
||||
• <strong>Parent hash</strong> — links to previous entry (chain)<br>
|
||||
• <strong>Ed25519 signature</strong> — proves the entry is authentic
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--text-muted)">
|
||||
Each entry chains to the previous one. Altering any step breaks all subsequent signatures.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="${S.h3}" id="reproducible">Layer 2: Reproducible Computation</div>
|
||||
<div style="${S.p}">
|
||||
The file contains the actual analysis code (WASM + eBPF) alongside raw data.
|
||||
Anyone can re-run the pipeline from scratch and verify identical results.
|
||||
No "trust us" — the math is in the file.
|
||||
</div>
|
||||
|
||||
<div style="${S.h3}" id="acceptance">Layer 3: Acceptance Testing</div>
|
||||
<div style="${S.card}">
|
||||
<div style="font-size:11px;line-height:1.9">
|
||||
<span style="color:#FF4D4D;font-weight:600">Mode A (Heuristic)</span> — Can the solver achieve basic accuracy?<br>
|
||||
<span style="color:#FFB020;font-weight:600">Mode B (Compiler)</span> — Accuracy + computational cost reduction?<br>
|
||||
<span style="color:#2ECC71;font-weight:600">Mode C (Learned)</span> — Full: accuracy + cost + robustness + zero violations.
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--text-muted);margin-top:6px">
|
||||
All three modes must pass. The manifest is itself recorded in the witness chain.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="${S.h3}" id="blind">Layer 4: Blind Testing</div>
|
||||
<div style="${S.p}">
|
||||
The Blind Test page runs the pipeline on unlabeled data, then compares against ground truth.
|
||||
This guards against overfitting — the pipeline must work on data it has never seen.
|
||||
</div>
|
||||
|
||||
<!-- ============ UNIQUE ============ -->
|
||||
<div style="${S.h2}" id="unique">What Makes This Unique</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:14px">
|
||||
${this.uniqueCard('Self-Contained', 'One file. No cloud, no databases, no external dependencies. The entire pipeline, visualization, and proof chain travel together.', S)}
|
||||
${this.uniqueCard('Cryptographically Verified', 'Every step is hashed and signed. Tampering with one part invalidates the entire chain. Mathematical proof, not just peer review.', S)}
|
||||
${this.uniqueCard('Self-Learning', 'The WASM solver improves over time using Thompson Sampling, discovering which strategies work for different data difficulties.', S)}
|
||||
${this.uniqueCard('Runs Anywhere', 'WASM solver + HTML dashboard + Rust server. No Python, no Jupyter, no conda. Open the file and explore in any modern browser.', S)}
|
||||
${this.uniqueCard('Multi-Domain', 'Transit detection, Dyson sphere search, habitability scoring, biosignature analysis — all in one causal event graph.', S)}
|
||||
${this.uniqueCard('Interactive 3D', 'Embedded Three.js dashboard: explore the causal atlas as a galaxy, rotate planet systems, visualize Dyson sphere geometry.', S)}
|
||||
</div>
|
||||
|
||||
<!-- ============ CAPABILITIES ============ -->
|
||||
<div style="${S.h2}" id="capabilities">Dashboard Views</div>
|
||||
<div style="${S.p}">12 interactive views, each pulling live data from the RVF file.</div>
|
||||
|
||||
${this.viewCard('cap-atlas', 'Atlas Explorer', '#/atlas', 'var(--accent)',
|
||||
'3D galaxy-style causal event graph. Each star = a causal event. Edges = cause-effect. Configurable arms, density, and sector labels.',
|
||||
['3D OrbitControls', 'Time scale selector', 'Galaxy shape config', 'Star map sectors'])}
|
||||
${this.viewCard('cap-coherence', 'Coherence Heatmap', '#/coherence', '#FFB020',
|
||||
'Color-mapped surface showing data self-consistency across the observation grid. Blue = stable, red = high uncertainty.',
|
||||
['Surface plot', 'Epoch scrubber', 'Partition boundaries'])}
|
||||
${this.viewCard('cap-boundaries', 'Boundaries', '#/boundaries', '#9944FF',
|
||||
'Tracks how data partition boundaries shift as new observations arrive. Alerts when boundaries change rapidly.',
|
||||
['Timeline chart', 'Alert feed', 'Sector detail'])}
|
||||
${this.viewCard('cap-memory', 'Memory Tiers', '#/memory', '#FF6B9D',
|
||||
'Three-tier storage: Small (hot), Medium (warm), Large (cold). Shows utilization, hit rates, and tier migration.',
|
||||
['S/M/L gauges', 'Utilization bars', 'Migration flow'])}
|
||||
${this.viewCard('cap-planets', 'Planet Candidates', '#/planets', '#2ECC71',
|
||||
'Ranked exoplanet candidates with radius, period, temperature, habitable zone status, and Earth Similarity Index.',
|
||||
['Sortable table', 'Light curve plots', 'Score radar'])}
|
||||
${this.viewCard('cap-life', 'Life Candidates', '#/life', '#2ECC71',
|
||||
'Biosignature analysis: atmospheric spectra for O\u2082, CH\u2084, H\u2082O. Multi-dimensional scoring with confound analysis.',
|
||||
['Spectrum plots', 'Molecule heatmap', 'Reaction graph'])}
|
||||
${this.viewCard('cap-witness', 'Witness Chain', '#/witness', 'var(--accent)',
|
||||
'Complete cryptographic audit trail. Every step with timestamp, hashes, and signature verification status.',
|
||||
['Scrolling entries', 'Hash verification', 'Pipeline trace'])}
|
||||
${this.viewCard('cap-solver', 'RVF Solver', '#/solver', '#FFB020',
|
||||
'WASM self-learning solver with Thompson Sampling. 3D landscape shows bandit arm rewards. Configurable training parameters.',
|
||||
['3D landscape', 'Training curves', 'A/B/C acceptance', 'Auto-Optimize'])}
|
||||
${this.viewCard('cap-blind', 'Blind Test', '#/blind-test', '#FF4D4D',
|
||||
'Pipeline on unlabeled data, then compared against ground truth. The gold standard for preventing overfitting.',
|
||||
['Unlabeled processing', 'Ground truth compare', 'Accuracy metrics'])}
|
||||
${this.viewCard('cap-discover', 'Discovery', '#/discover', '#00E5FF',
|
||||
'3D exoplanet systems with host star, orbit, habitable zone. Real KOI parameters. Galaxy background.',
|
||||
['3D planet system', 'Speed/rotate controls', 'ESI comparison'])}
|
||||
${this.viewCard('cap-dyson', 'Dyson Sphere', '#/dyson', '#9944FF',
|
||||
'Dyson swarm detection using Project Hephaistos methodology. IR excess analysis and 3D wireframe visualization.',
|
||||
['3D Dyson wireframe', 'IR excess analysis', 'SED plots'])}
|
||||
${this.viewCard('cap-status', 'System Status', '#/status', '#8B949E',
|
||||
'RVF file health, segment sizes, memory tier utilization, pipeline stage indicators, and live witness log.',
|
||||
['Segment breakdown', 'Tier gauges', 'Witness log feed'])}
|
||||
|
||||
<!-- ============ SOLVER ============ -->
|
||||
<div style="${S.h2}" id="solver">The Self-Learning Solver</div>
|
||||
<div style="${S.p}">
|
||||
The solver is a <span style="${S.accent}">WebAssembly module</span> compiled from Rust.
|
||||
It runs entirely in your browser using <strong>Thompson Sampling</strong>.
|
||||
</div>
|
||||
|
||||
<div style="${S.h3}" id="thompson">How Thompson Sampling Works</div>
|
||||
<div style="${S.p}">
|
||||
Imagine 8 different analysis strategies ("arms"). You don't know which works best.
|
||||
Thompson Sampling maintains a Beta distribution for each arm's success rate,
|
||||
samples from these on each attempt, and picks the highest sample. This balances:
|
||||
</div>
|
||||
<div style="${S.card}">
|
||||
<div style="font-size:12px;line-height:1.8">
|
||||
<span style="${S.accent}">Exploration</span> — Trying uncertain arms to gather data<br>
|
||||
<span style="${S.success}">Exploitation</span> — Using known-good arms to maximize results
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--text-muted);margin-top:6px">
|
||||
Over time, the solver converges on optimal strategies per difficulty level.
|
||||
The 3D landscape visually shows which arms have the highest rewards.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="${S.h3}" id="auto-optimize">Auto-Optimize</div>
|
||||
<div style="${S.p}">
|
||||
The <span style="${S.success}">Auto-Optimize</span> button trains in batches of 3 rounds,
|
||||
tests acceptance after each batch, and stops when all three modes pass (max 30 rounds).
|
||||
If accuracy is below 60%, it automatically increases training intensity.
|
||||
</div>
|
||||
|
||||
<!-- ============ FORMAT ============ -->
|
||||
<div style="${S.h2}" id="format">RVF File Format Reference</div>
|
||||
|
||||
<div style="${S.h3}" id="file-header">File Header (64 bytes)</div>
|
||||
<pre style="${S.code}">Offset Size Field
|
||||
0x00 4 Magic: 0x52564631 ("RVF1")
|
||||
0x04 2 Format version (currently 1)
|
||||
0x06 2 Flags (bit 0 = signed, bit 1 = compressed)
|
||||
0x08 8 Total file size
|
||||
0x10 4 Segment count
|
||||
0x14 4 Reserved
|
||||
0x18 32 SHAKE-256 hash of all segments
|
||||
0x38 8 Creation timestamp (Unix epoch)</pre>
|
||||
|
||||
<div style="${S.h3}" id="seg-types">Segment Types</div>
|
||||
<div style="overflow-x:auto;margin-bottom:14px">
|
||||
<table style="width:100%;font-size:11px;font-family:var(--font-mono);border-collapse:collapse">
|
||||
<tr style="border-bottom:1px solid var(--border)">
|
||||
<th style="padding:6px 8px;text-align:left;color:var(--text-muted);font-weight:500;width:50px">ID</th>
|
||||
<th style="padding:6px 8px;text-align:left;color:var(--text-muted);font-weight:500;width:110px">Name</th>
|
||||
<th style="padding:6px 8px;text-align:left;color:var(--text-muted);font-weight:500">Purpose</th>
|
||||
</tr>
|
||||
${this.tableRow('0x01', 'DATA', 'Raw observations (light curves, spectra)')}
|
||||
${this.tableRow('0x02', 'KERNEL', 'Processing algorithms')}
|
||||
${this.tableRow('0x03', 'RESULT', 'Computed results and derived parameters')}
|
||||
${this.tableRow('0x04', 'WITNESS', 'Cryptographic audit trail')}
|
||||
${this.tableRow('0x05', 'SIGNATURE', 'Ed25519 digital signature')}
|
||||
${this.tableRow('0x06', 'INDEX', 'Fast lookup table for segments')}
|
||||
${this.tableRow('0x0F', 'EBPF', 'eBPF bytecode for in-kernel filtering')}
|
||||
${this.tableRow('0x10', 'WASM', 'WebAssembly solver module')}
|
||||
${this.tableRow('0x11', 'DASHBOARD', 'Embedded web dashboard (HTML/JS/CSS)')}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="${S.h3}" id="witness-format">Witness Entry Format</div>
|
||||
<pre style="${S.code}">struct WitnessEntry {
|
||||
step_name: String, // "transit_detection"
|
||||
timestamp: u64, // Unix epoch nanoseconds
|
||||
input_hash: [u8; 32], // SHAKE-256 of input
|
||||
output_hash: [u8; 32], // SHAKE-256 of output
|
||||
parent_hash: [u8; 32], // Previous entry hash (chain)
|
||||
signature: [u8; 64], // Ed25519 signature
|
||||
}</pre>
|
||||
|
||||
<div style="${S.h3}" id="dashboard-seg">Dashboard Segment</div>
|
||||
<pre style="${S.code}">DashboardHeader (64 bytes):
|
||||
magic: 0x5256_4442 // "RVDB"
|
||||
version: u16
|
||||
framework: u8 // 0=threejs, 1=react
|
||||
compression: u8 // 0=none, 1=gzip, 2=brotli
|
||||
bundle_size: u64
|
||||
file_count: u32
|
||||
hash: [u8; 32] // SHAKE-256 of bundle
|
||||
|
||||
Payload: [file_table] [file_data...]</pre>
|
||||
|
||||
<!-- ============ GLOSSARY ============ -->
|
||||
<div style="${S.h2}" id="glossary">Glossary</div>
|
||||
<div style="display:grid;grid-template-columns:130px 1fr;gap:1px 14px;font-size:12px;line-height:2">
|
||||
${this.glossaryRow('RVF', 'RuVector Format — the binary container')}
|
||||
${this.glossaryRow('Segment', 'A typed block of data inside an RVF file')}
|
||||
${this.glossaryRow('Witness Chain', 'Linked list of signed hash entries proving integrity')}
|
||||
${this.glossaryRow('SHAKE-256', 'Cryptographic hash function (variable output)')}
|
||||
${this.glossaryRow('Ed25519', 'Digital signature algorithm for witness entries')}
|
||||
${this.glossaryRow('KOI', 'Kepler Object of Interest — exoplanet candidate')}
|
||||
${this.glossaryRow('ESI', 'Earth Similarity Index (0-1, higher = more Earth-like)')}
|
||||
${this.glossaryRow('Transit', 'Planet passing in front of its star, causing a brightness dip')}
|
||||
${this.glossaryRow('Light Curve', 'Graph of stellar brightness over time')}
|
||||
${this.glossaryRow('Habitable Zone', 'Orbital region where liquid water could exist')}
|
||||
${this.glossaryRow('Thompson Samp.', 'Bandit algorithm balancing exploration vs exploitation')}
|
||||
${this.glossaryRow('eBPF', 'Extended Berkeley Packet Filter — fast kernel programs')}
|
||||
${this.glossaryRow('WASM', 'WebAssembly — portable code that runs in browsers')}
|
||||
${this.glossaryRow('Dyson Sphere', 'Hypothetical megastructure around a star for energy')}
|
||||
${this.glossaryRow('IR Excess', 'More infrared than expected — possible artificial origin')}
|
||||
${this.glossaryRow('SED', 'Spectral Energy Distribution — brightness vs wavelength')}
|
||||
${this.glossaryRow('Coherence', 'Self-consistency measure of data in a region')}
|
||||
${this.glossaryRow('Acceptance', 'Three-mode validation (A/B/C) of solver quality')}
|
||||
${this.glossaryRow('Blind Test', 'Evaluation on unlabeled data to prevent overfitting')}
|
||||
</div>
|
||||
|
||||
<div style="margin-top:48px;padding-top:16px;border-top:1px solid var(--border);font-size:11px;color:var(--text-muted);text-align:center">
|
||||
Everything in this dashboard was served from a single <code style="${S.inline}">.rvf</code> file.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/* ── Template helpers ── */
|
||||
|
||||
private statCard(label: string, value: string, S: Record<string, string>): string {
|
||||
return `<div style="${S.card}padding:10px 12px;text-align:center">
|
||||
<div style="font-size:9px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.4px;margin-bottom:2px">${label}</div>
|
||||
<div style="font-size:12px;font-weight:600;color:var(--accent);font-family:var(--font-mono)">${value}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private segRow(name: string, desc: string, color: string): string {
|
||||
return `<div style="display:flex;align-items:center;gap:10px"><span style="color:${color};min-width:160px;font-weight:600">${name}</span><span style="color:var(--text-secondary);font-weight:400">${desc}</span></div>`;
|
||||
}
|
||||
|
||||
private uniqueCard(title: string, desc: string, S: Record<string, string>): string {
|
||||
return `<div style="${S.card}"><div style="font-size:12px;${S.accent}margin-bottom:4px">${title}</div><div style="font-size:11px;line-height:1.5">${desc}</div></div>`;
|
||||
}
|
||||
|
||||
private viewCard(id: string, title: string, route: string, color: string, desc: string, features: string[]): string {
|
||||
const badges = features.map(f => `<span style="font-size:9px;padding:2px 6px;border-radius:3px;background:rgba(0,229,255,0.06);border:1px solid rgba(0,229,255,0.1);color:var(--accent)">${f}</span>`).join('');
|
||||
return `<div id="${id}" style="background:var(--bg-panel);border:1px solid var(--border);border-radius:6px;padding:12px 16px;margin-bottom:8px;border-left:3px solid ${color}">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
|
||||
<span style="font-size:12px;font-weight:600;color:var(--text-primary)">${title}</span>
|
||||
<a href="${route}" style="font-size:10px;color:${color};font-family:var(--font-mono);text-decoration:none">${route}</a>
|
||||
</div>
|
||||
<div style="font-size:11px;line-height:1.5;margin-bottom:6px">${desc}</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:3px">${badges}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private tableRow(id: string, name: string, purpose: string): string {
|
||||
return `<tr style="border-bottom:1px solid var(--border-subtle)"><td style="padding:5px 8px;color:var(--accent)">${id}</td><td style="padding:5px 8px;color:var(--text-primary)">${name}</td><td style="padding:5px 8px">${purpose}</td></tr>`;
|
||||
}
|
||||
|
||||
private glossaryRow(term: string, def: string): string {
|
||||
return `<span style="color:var(--accent);font-weight:600">${term}</span><span>${def}</span>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* DownloadView — Download page for RVF executables and packages.
|
||||
*
|
||||
* Provides download links for:
|
||||
* - Windows (.exe)
|
||||
* - macOS (.dmg)
|
||||
* - Linux (.tar.gz)
|
||||
* - npm packages
|
||||
* - WASM module
|
||||
*
|
||||
* Download URLs point to Google Cloud Storage (placeholder paths).
|
||||
*/
|
||||
|
||||
const VERSION = '2.0.0';
|
||||
const BASE_URL = 'https://storage.googleapis.com/ruvector-releases';
|
||||
|
||||
interface DownloadItem {
|
||||
platform: string;
|
||||
icon: string;
|
||||
file: string;
|
||||
size: string;
|
||||
ext: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
const DOWNLOADS: DownloadItem[] = [
|
||||
{
|
||||
platform: 'Windows',
|
||||
icon: '<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="8" height="8"/><rect x="13" y="3" width="8" height="8"/><rect x="3" y="13" width="8" height="8"/><rect x="13" y="13" width="8" height="8"/></svg>',
|
||||
file: `ruvector-${VERSION}-x64.exe`,
|
||||
size: '~12 MB',
|
||||
ext: '.exe',
|
||||
desc: 'Windows 10/11 (x64) installer with bundled WASM runtime',
|
||||
},
|
||||
{
|
||||
platform: 'macOS',
|
||||
icon: '<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/><path d="M15 8.5c0-1-0.67-2.5-2-2.5S11 7.5 11 8.5c0 1.5 1 2 1 3.5s-1 2-1 3.5c0 1 0.67 2.5 2 2.5s2-1.5 2-2.5c0-1.5-1-2-1-3.5s1-2 1-3.5z"/></svg>',
|
||||
file: `RuVector-${VERSION}.dmg`,
|
||||
size: '~14 MB',
|
||||
ext: '.dmg',
|
||||
desc: 'macOS 12+ (Apple Silicon & Intel) disk image',
|
||||
},
|
||||
{
|
||||
platform: 'Linux',
|
||||
icon: '<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M3 12h18"/><circle cx="12" cy="8" r="1.5"/></svg>',
|
||||
file: `ruvector-${VERSION}-linux-x64.tar.gz`,
|
||||
size: '~10 MB',
|
||||
ext: '.tar.gz',
|
||||
desc: 'Linux (x86_64) tarball — Ubuntu 20+, Debian 11+, Fedora 36+',
|
||||
},
|
||||
];
|
||||
|
||||
export class DownloadView {
|
||||
private container: HTMLElement | null = null;
|
||||
|
||||
mount(container: HTMLElement): void {
|
||||
this.container = container;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.cssText = 'max-width:960px;margin:0 auto;padding:32px 24px;overflow-y:auto;height:100%';
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// Hero
|
||||
const hero = document.createElement('div');
|
||||
hero.style.cssText = 'text-align:center;margin-bottom:40px';
|
||||
hero.innerHTML = `
|
||||
<div style="display:inline-flex;align-items:center;gap:12px;margin-bottom:16px">
|
||||
<svg viewBox="0 0 24 24" width="40" height="40" fill="none" stroke="#00E5FF" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="6"/><ellipse cx="12" cy="12" rx="11" ry="4" transform="rotate(-20 12 12)"/>
|
||||
<circle cx="12" cy="12" r="1.5" fill="#00E5FF" stroke="none"/>
|
||||
</svg>
|
||||
<span style="font-size:28px;font-weight:300;color:var(--text-primary);letter-spacing:2px">RuVector</span>
|
||||
</div>
|
||||
<div style="font-size:14px;color:var(--text-secondary);line-height:1.6;max-width:600px;margin:0 auto">
|
||||
Download the Causal Atlas runtime — a single binary that reads <code style="color:var(--accent);font-size:12px">.rvf</code> files,
|
||||
runs the WASM solver, serves the Three.js dashboard, and verifies the Ed25519 witness chain.
|
||||
</div>
|
||||
<div style="margin-top:12px;display:flex;gap:8px;justify-content:center;flex-wrap:wrap">
|
||||
<span style="font-size:10px;padding:3px 8px;border-radius:4px;background:rgba(0,229,255,0.08);border:1px solid rgba(0,229,255,0.15);color:#00E5FF">v${VERSION}</span>
|
||||
<span style="font-size:10px;padding:3px 8px;border-radius:4px;background:rgba(46,204,113,0.08);border:1px solid rgba(46,204,113,0.15);color:#2ECC71">Stable</span>
|
||||
<span style="font-size:10px;padding:3px 8px;border-radius:4px;background:rgba(255,176,32,0.08);border:1px solid rgba(255,176,32,0.15);color:#FFB020">ADR-040</span>
|
||||
</div>
|
||||
`;
|
||||
wrapper.appendChild(hero);
|
||||
|
||||
// Download cards
|
||||
const grid = document.createElement('div');
|
||||
grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fit, minmax(280px, 1fr));gap:16px;margin-bottom:40px';
|
||||
wrapper.appendChild(grid);
|
||||
|
||||
for (const dl of DOWNLOADS) {
|
||||
const card = document.createElement('div');
|
||||
card.style.cssText = `
|
||||
background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;
|
||||
padding:20px;display:flex;flex-direction:column;gap:12px;
|
||||
transition:border-color 0.2s,transform 0.2s;cursor:pointer;
|
||||
`;
|
||||
card.addEventListener('mouseenter', () => {
|
||||
card.style.borderColor = 'rgba(0,229,255,0.3)';
|
||||
card.style.transform = 'translateY(-2px)';
|
||||
});
|
||||
card.addEventListener('mouseleave', () => {
|
||||
card.style.borderColor = 'var(--border)';
|
||||
card.style.transform = '';
|
||||
});
|
||||
|
||||
card.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<div style="color:#00E5FF">${dl.icon}</div>
|
||||
<div>
|
||||
<div style="font-size:15px;font-weight:600;color:var(--text-primary)">${dl.platform}</div>
|
||||
<div style="font-size:10px;color:var(--text-muted)">${dl.size}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text-secondary);line-height:1.5">${dl.desc}</div>
|
||||
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-muted);padding:6px 8px;background:rgba(0,0,0,0.3);border-radius:4px;word-break:break-all">${dl.file}</div>
|
||||
<a href="${BASE_URL}/v${VERSION}/${dl.file}" style="
|
||||
display:flex;align-items:center;justify-content:center;gap:6px;
|
||||
padding:8px 16px;border-radius:6px;text-decoration:none;
|
||||
background:rgba(0,229,255,0.1);border:1px solid rgba(0,229,255,0.25);
|
||||
color:#00E5FF;font-size:12px;font-weight:600;transition:background 0.15s;
|
||||
" onmouseenter="this.style.background='rgba(0,229,255,0.2)'" onmouseleave="this.style.background='rgba(0,229,255,0.1)'">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
Download ${dl.ext}
|
||||
</a>
|
||||
`;
|
||||
grid.appendChild(card);
|
||||
}
|
||||
|
||||
// npm / WASM section
|
||||
const altSection = document.createElement('div');
|
||||
altSection.style.cssText = 'margin-bottom:40px';
|
||||
altSection.innerHTML = `
|
||||
<div style="font-size:13px;font-weight:600;color:var(--text-primary);margin-bottom:16px;display:flex;align-items:center;gap:8px">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
|
||||
npm Packages & WASM Module
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:6px;padding:14px">
|
||||
<div style="font-size:11px;font-weight:600;color:var(--text-primary);margin-bottom:6px">rvf-solver (npm)</div>
|
||||
<code style="display:block;font-size:10px;color:var(--accent);background:rgba(0,0,0,0.3);padding:8px;border-radius:4px;margin-bottom:6px">npm install @ruvector/rvf-solver</code>
|
||||
<div style="font-size:10px;color:var(--text-muted);line-height:1.4">NAPI-RS native bindings for Node.js — includes solver, witness chain, and policy kernel.</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:6px;padding:14px">
|
||||
<div style="font-size:11px;font-weight:600;color:var(--text-primary);margin-bottom:6px">rvf-solver-wasm (npm)</div>
|
||||
<code style="display:block;font-size:10px;color:var(--accent);background:rgba(0,0,0,0.3);padding:8px;border-radius:4px;margin-bottom:6px">npm install @ruvector/rvf-solver-wasm</code>
|
||||
<div style="font-size:10px;color:var(--text-muted);line-height:1.4">Browser WASM module — same solver running in this dashboard. No native dependencies.</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:6px;padding:14px">
|
||||
<div style="font-size:11px;font-weight:600;color:var(--text-primary);margin-bottom:6px">Standalone WASM</div>
|
||||
<code style="display:block;font-size:10px;color:var(--accent);background:rgba(0,0,0,0.3);padding:8px;border-radius:4px;margin-bottom:6px">curl -O ${BASE_URL}/v${VERSION}/rvf_solver_wasm.wasm</code>
|
||||
<div style="font-size:10px;color:var(--text-muted);line-height:1.4">Raw <code>.wasm</code> binary (172 KB). Load via WebAssembly.instantiate() — no wasm-bindgen needed.</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:6px;padding:14px">
|
||||
<div style="font-size:11px;font-weight:600;color:var(--text-primary);margin-bottom:6px">Cargo Crate</div>
|
||||
<code style="display:block;font-size:10px;color:var(--accent);background:rgba(0,0,0,0.3);padding:8px;border-radius:4px;margin-bottom:6px">cargo add rvf-runtime rvf-types rvf-crypto</code>
|
||||
<div style="font-size:10px;color:var(--text-muted);line-height:1.4">Rust workspace crates for embedding RVF files in your own applications.</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
wrapper.appendChild(altSection);
|
||||
|
||||
// Quick Start section
|
||||
const quickstart = document.createElement('div');
|
||||
quickstart.style.cssText = 'margin-bottom:40px';
|
||||
quickstart.innerHTML = `
|
||||
<div style="font-size:13px;font-weight:600;color:var(--text-primary);margin-bottom:16px;display:flex;align-items:center;gap:8px">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
Quick Start
|
||||
</div>
|
||||
<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;padding:20px">
|
||||
<div style="display:grid;gap:16px">
|
||||
${this.step(1, 'Download', 'Download the installer for your platform above and run it.')}
|
||||
${this.step(2, 'Open an RVF file', `
|
||||
<code style="display:block;font-size:11px;color:var(--accent);background:rgba(0,0,0,0.3);padding:8px;border-radius:4px;margin-top:4px">ruvector open causal_atlas.rvf</code>
|
||||
<div style="font-size:10px;color:var(--text-muted);margin-top:4px">This starts the local server and opens the dashboard in your browser.</div>
|
||||
`)}
|
||||
${this.step(3, 'Train the solver', 'Navigate to the Solver page and click Train or Auto-Optimize. The WASM solver learns in real time inside your browser.')}
|
||||
${this.step(4, 'Run acceptance test', 'Click Acceptance to verify the solver passes the three-mode acceptance test (A/B/C). All results are recorded in the Ed25519 witness chain.')}
|
||||
${this.step(5, 'Explore discoveries', `Navigate to Planets, Life, and Discover pages to explore candidate detections. Each candidate includes a full causal trace and witness proof.`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
wrapper.appendChild(quickstart);
|
||||
|
||||
// System requirements
|
||||
const reqs = document.createElement('div');
|
||||
reqs.innerHTML = `
|
||||
<div style="font-size:13px;font-weight:600;color:var(--text-primary);margin-bottom:16px;display:flex;align-items:center;gap:8px">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="4" y="4" width="16" height="16" rx="2"/><line x1="4" y1="9" x2="20" y2="9"/><circle cx="8" cy="6.5" r="0.5" fill="currentColor" stroke="none"/></svg>
|
||||
System Requirements
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;font-size:11px">
|
||||
<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:6px;padding:12px">
|
||||
<div style="font-weight:600;color:var(--text-primary);margin-bottom:6px">Windows</div>
|
||||
<div style="color:var(--text-muted);line-height:1.5">Windows 10 (1903+)<br>x64 processor<br>4 GB RAM<br>100 MB disk</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:6px;padding:12px">
|
||||
<div style="font-weight:600;color:var(--text-primary);margin-bottom:6px">macOS</div>
|
||||
<div style="color:var(--text-muted);line-height:1.5">macOS 12 Monterey+<br>Apple Silicon or Intel<br>4 GB RAM<br>100 MB disk</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:6px;padding:12px">
|
||||
<div style="font-weight:600;color:var(--text-primary);margin-bottom:6px">Linux</div>
|
||||
<div style="color:var(--text-muted);line-height:1.5">glibc 2.31+<br>x86_64 processor<br>4 GB RAM<br>100 MB disk</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:9px;color:var(--text-muted);margin-top:12px;text-align:center">
|
||||
Binaries are hosted on Google Cloud Storage. All downloads include Ed25519 signatures for verification.
|
||||
</div>
|
||||
`;
|
||||
wrapper.appendChild(reqs);
|
||||
}
|
||||
|
||||
private step(n: number, title: string, detail: string): string {
|
||||
return `
|
||||
<div style="display:flex;gap:12px;align-items:flex-start">
|
||||
<div style="min-width:24px;height:24px;border-radius:50%;background:rgba(0,229,255,0.1);border:1px solid rgba(0,229,255,0.25);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#00E5FF">${n}</div>
|
||||
<div style="flex:1">
|
||||
<div style="font-size:12px;font-weight:600;color:var(--text-primary);margin-bottom:2px">${title}</div>
|
||||
<div style="font-size:11px;color:var(--text-secondary);line-height:1.5">${detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
unmount(): void {
|
||||
this.container = null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,422 @@
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { SpectrumChart, SpectrumPoint, SpectrumBand } from '../charts/SpectrumChart';
|
||||
import { fetchLifeCandidates, LifeCandidate } from '../api';
|
||||
|
||||
const BIOSIG_BANDS: SpectrumBand[] = [
|
||||
{ name: 'O2', start: 0.76, end: 0.78, color: '#58A6FF' },
|
||||
{ name: 'H2O', start: 0.93, end: 0.97, color: '#00E5FF' },
|
||||
{ name: 'CH4', start: 1.65, end: 1.70, color: '#2ECC71' },
|
||||
{ name: 'CO2', start: 2.00, end: 2.08, color: '#FFB020' },
|
||||
{ name: 'O3', start: 0.55, end: 0.60, color: '#9944ff' },
|
||||
];
|
||||
|
||||
interface MoleculeNode {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
interface MoleculeEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
/** Generate demo spectrum data for a life candidate. */
|
||||
function demoSpectrum(candidate: LifeCandidate): SpectrumPoint[] {
|
||||
const points: SpectrumPoint[] = [];
|
||||
for (let w = 0.4; w <= 2.5; w += 0.005) {
|
||||
let flux = 0.8 + 0.1 * Math.sin(w * 3);
|
||||
// Add absorption features
|
||||
if (candidate.o2 > 0.3 && w > 0.76 && w < 0.78) flux -= candidate.o2 * 0.3;
|
||||
if (candidate.h2o > 0.3 && w > 0.93 && w < 0.97) flux -= candidate.h2o * 0.25;
|
||||
if (candidate.ch4 > 0.3 && w > 1.65 && w < 1.70) flux -= candidate.ch4 * 0.2;
|
||||
flux += (Math.random() - 0.5) * 0.02;
|
||||
points.push({ wavelength: w, flux: Math.max(0, flux) });
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** Build a simple molecule reaction graph. */
|
||||
function buildMoleculeGraph(): { nodes: MoleculeNode[]; edges: MoleculeEdge[] } {
|
||||
const molecules = ['O2', 'H2O', 'CH4', 'CO2', 'O3', 'N2O', 'NH3'];
|
||||
const nodes: MoleculeNode[] = molecules.map((id, i) => {
|
||||
const angle = (i / molecules.length) * Math.PI * 2;
|
||||
return { id, x: Math.cos(angle) * 2, y: Math.sin(angle) * 2, z: (Math.random() - 0.5) * 0.5 };
|
||||
});
|
||||
const edges: MoleculeEdge[] = [
|
||||
{ source: 'O2', target: 'O3' },
|
||||
{ source: 'H2O', target: 'O2' },
|
||||
{ source: 'CH4', target: 'CO2' },
|
||||
{ source: 'CH4', target: 'H2O' },
|
||||
{ source: 'N2O', target: 'O2' },
|
||||
{ source: 'NH3', target: 'N2O' },
|
||||
{ source: 'CO2', target: 'O2' },
|
||||
];
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
export class LifeDashboard {
|
||||
private container: HTMLElement | null = null;
|
||||
private candidates: LifeCandidate[] = [];
|
||||
private selectedId: string | null = null;
|
||||
private spectrumChart: SpectrumChart | null = null;
|
||||
private tableBody: HTMLTableSectionElement | null = null;
|
||||
private confoundBar: HTMLElement | null = null;
|
||||
|
||||
// Three.js for molecule graph
|
||||
private renderer: THREE.WebGLRenderer | null = null;
|
||||
private scene: THREE.Scene | null = null;
|
||||
private camera: THREE.PerspectiveCamera | null = null;
|
||||
private controls: OrbitControls | null = null;
|
||||
private animFrameId = 0;
|
||||
private moleculeMeshes: THREE.Object3D[] = [];
|
||||
|
||||
mount(container: HTMLElement): void {
|
||||
this.container = container;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.cssText = 'display:flex;flex-direction:column;width:100%;height:100%;overflow:hidden';
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// View header with explanation
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'padding:12px 20px;border-bottom:1px solid var(--border);flex-shrink:0';
|
||||
header.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
|
||||
<div style="font-size:14px;font-weight:600;color:var(--text-primary)">Biosignature Analysis — Real Atmospheric Data</div>
|
||||
<span class="score-badge score-high" style="font-size:9px;padding:1px 6px">JWST</span>
|
||||
<span class="score-badge score-medium" style="font-size:9px;padding:1px 6px">8 TARGETS</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text-secondary);line-height:1.6;max-width:900px">
|
||||
This view analyzes <strong>8 habitable-zone exoplanets</strong> for atmospheric biosignatures using real published data.
|
||||
<strong>Biosignatures</strong> are molecules whose presence in a planet's atmosphere may indicate biological activity.
|
||||
Click any row to inspect its spectrum and confound analysis.
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;font-size:10px">
|
||||
<div style="background:rgba(0,229,255,0.06);border:1px solid rgba(0,229,255,0.15);border-radius:4px;padding:6px 8px">
|
||||
<div style="color:var(--accent);font-weight:600;margin-bottom:2px">What is JWST?</div>
|
||||
<div style="color:var(--text-secondary);line-height:1.4">The James Webb Space Telescope observes exoplanet atmospheres via <strong>transmission spectroscopy</strong> — starlight passing through a planet's atmosphere reveals molecular absorption lines. Only <span style="color:var(--success);font-weight:600">K2-18 b</span> has confirmed detections so far (CH<sub>4</sub>+CO<sub>2</sub>).</div>
|
||||
</div>
|
||||
<div style="background:rgba(46,204,113,0.06);border:1px solid rgba(46,204,113,0.15);border-radius:4px;padding:6px 8px">
|
||||
<div style="color:var(--success);font-weight:600;margin-bottom:2px">Key Molecules</div>
|
||||
<div style="color:var(--text-secondary);line-height:1.4"><strong>O<sub>2</sub></strong> (oxygen) — product of photosynthesis. <strong>CH<sub>4</sub></strong> (methane) — produced by methanogens. <strong>H<sub>2</sub>O</strong> (water) — essential solvent. <strong>CO<sub>2</sub></strong> — greenhouse gas. <strong>DMS</strong> — dimethyl sulfide, only known biogenic source on Earth.</div>
|
||||
</div>
|
||||
<div style="background:rgba(255,176,32,0.06);border:1px solid rgba(255,176,32,0.15);border-radius:4px;padding:6px 8px">
|
||||
<div style="color:var(--warning);font-weight:600;margin-bottom:2px">Disequilibrium & Confounds</div>
|
||||
<div style="color:var(--text-secondary);line-height:1.4"><strong>Thermodynamic disequilibrium</strong>: CH<sub>4</sub>+CO<sub>2</sub> coexisting implies an active source replenishing CH<sub>4</sub> — possibly biological. <strong>Confound index</strong> = probability that detected signals have a non-biological explanation (volcanism, photochemistry, etc.).</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
wrapper.appendChild(header);
|
||||
|
||||
const layout = document.createElement('div');
|
||||
layout.className = 'split-layout';
|
||||
layout.style.flex = '1';
|
||||
layout.style.minHeight = '0';
|
||||
wrapper.appendChild(layout);
|
||||
|
||||
// Left panel: table + confound bars
|
||||
const left = document.createElement('div');
|
||||
left.className = 'left-panel';
|
||||
layout.appendChild(left);
|
||||
|
||||
const tableArea = document.createElement('div');
|
||||
tableArea.className = 'table-area';
|
||||
left.appendChild(tableArea);
|
||||
|
||||
const table = document.createElement('table');
|
||||
table.className = 'data-table';
|
||||
const thead = document.createElement('thead');
|
||||
const headerRow = document.createElement('tr');
|
||||
for (const label of ['Name', 'Score', 'JWST', 'O2', 'CH4', 'H2O', 'Diseq.']) {
|
||||
const th = document.createElement('th');
|
||||
th.textContent = label;
|
||||
headerRow.appendChild(th);
|
||||
}
|
||||
thead.appendChild(headerRow);
|
||||
table.appendChild(thead);
|
||||
this.tableBody = document.createElement('tbody');
|
||||
table.appendChild(this.tableBody);
|
||||
tableArea.appendChild(table);
|
||||
|
||||
// Confound indicator
|
||||
const confoundArea = document.createElement('div');
|
||||
confoundArea.className = 'chart-area';
|
||||
confoundArea.style.padding = '12px 16px';
|
||||
left.appendChild(confoundArea);
|
||||
|
||||
const confLabel = document.createElement('div');
|
||||
confLabel.className = 'panel-header';
|
||||
confLabel.innerHTML = 'Confound Index <span style="font-size:8px;text-transform:none;letter-spacing:0;color:var(--text-muted);font-weight:400">probability of non-biological origin</span>';
|
||||
confoundArea.appendChild(confLabel);
|
||||
|
||||
this.confoundBar = document.createElement('div');
|
||||
this.confoundBar.style.marginTop = '12px';
|
||||
confoundArea.appendChild(this.confoundBar);
|
||||
|
||||
// Right panel: spectrum + molecule graph
|
||||
const right = document.createElement('div');
|
||||
right.className = 'right-panel';
|
||||
layout.appendChild(right);
|
||||
|
||||
const specDiv = document.createElement('div');
|
||||
specDiv.style.height = '220px';
|
||||
specDiv.style.minHeight = '200px';
|
||||
right.appendChild(specDiv);
|
||||
this.spectrumChart = new SpectrumChart(specDiv);
|
||||
|
||||
const molDiv = document.createElement('div');
|
||||
molDiv.className = 'three-container';
|
||||
molDiv.style.flex = '1';
|
||||
molDiv.style.minHeight = '200px';
|
||||
right.appendChild(molDiv);
|
||||
|
||||
// Three.js molecule graph
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0x0B0F14);
|
||||
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
|
||||
this.camera.position.set(0, 0, 6);
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
this.renderer.setPixelRatio(window.devicePixelRatio);
|
||||
molDiv.appendChild(this.renderer.domElement);
|
||||
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||
this.controls.enableDamping = true;
|
||||
|
||||
this.scene.add(new THREE.AmbientLight(0xffffff, 0.6));
|
||||
const dl = new THREE.DirectionalLight(0xffffff, 0.5);
|
||||
dl.position.set(3, 5, 3);
|
||||
this.scene.add(dl);
|
||||
|
||||
this.buildMoleculeScene();
|
||||
|
||||
window.addEventListener('resize', this.resize);
|
||||
this.resize();
|
||||
this.animate();
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
private buildMoleculeScene(): void {
|
||||
if (!this.scene) return;
|
||||
|
||||
const { nodes, edges } = buildMoleculeGraph();
|
||||
const nodeMap = new Map<string, MoleculeNode>();
|
||||
|
||||
const colors: Record<string, number> = {
|
||||
O2: 0x58A6FF, H2O: 0x00E5FF, CH4: 0x2ECC71,
|
||||
CO2: 0xFFB020, O3: 0x9944ff, N2O: 0xFFB020, NH3: 0xFF4D4D,
|
||||
};
|
||||
|
||||
for (const node of nodes) {
|
||||
nodeMap.set(node.id, node);
|
||||
const geo = new THREE.SphereGeometry(0.2, 16, 12);
|
||||
const mat = new THREE.MeshStandardMaterial({ color: colors[node.id] ?? 0x888888 });
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.position.set(node.x, node.y, node.z);
|
||||
this.scene.add(mesh);
|
||||
this.moleculeMeshes.push(mesh);
|
||||
|
||||
// Label sprite
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 128;
|
||||
canvas.height = 48;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.fillStyle = '#E6EDF3';
|
||||
ctx.font = '24px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(node.id, 64, 32);
|
||||
}
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
const spriteMat = new THREE.SpriteMaterial({ map: tex, transparent: true });
|
||||
const sprite = new THREE.Sprite(spriteMat);
|
||||
sprite.position.set(node.x, node.y + 0.35, node.z);
|
||||
sprite.scale.set(0.8, 0.3, 1);
|
||||
this.scene.add(sprite);
|
||||
this.moleculeMeshes.push(sprite);
|
||||
}
|
||||
|
||||
// Edges
|
||||
const positions: number[] = [];
|
||||
for (const edge of edges) {
|
||||
const src = nodeMap.get(edge.source);
|
||||
const tgt = nodeMap.get(edge.target);
|
||||
if (!src || !tgt) continue;
|
||||
positions.push(src.x, src.y, src.z, tgt.x, tgt.y, tgt.z);
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||
const mat = new THREE.LineBasicMaterial({ color: 0x1C2333, transparent: true, opacity: 0.6 });
|
||||
const lines = new THREE.LineSegments(geo, mat);
|
||||
this.scene.add(lines);
|
||||
this.moleculeMeshes.push(lines);
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
try {
|
||||
this.candidates = await fetchLifeCandidates();
|
||||
} catch (err) {
|
||||
console.error('Life API error:', err);
|
||||
this.candidates = [];
|
||||
}
|
||||
this.renderTable();
|
||||
if (this.candidates.length > 0) {
|
||||
this.selectCandidate(this.candidates[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
private renderTable(): void {
|
||||
if (!this.tableBody) return;
|
||||
this.tableBody.innerHTML = '';
|
||||
|
||||
const sorted = [...this.candidates].sort((a, b) => b.score - a.score);
|
||||
for (const c of sorted) {
|
||||
const tr = document.createElement('tr');
|
||||
if (c.id === this.selectedId) tr.classList.add('selected');
|
||||
tr.addEventListener('click', () => this.selectCandidate(c.id));
|
||||
|
||||
// Name
|
||||
const tdName = document.createElement('td');
|
||||
tdName.textContent = c.name;
|
||||
tr.appendChild(tdName);
|
||||
|
||||
// Score
|
||||
const tdScore = document.createElement('td');
|
||||
tdScore.textContent = c.score.toFixed(2);
|
||||
tr.appendChild(tdScore);
|
||||
|
||||
// JWST status
|
||||
const tdJwst = document.createElement('td');
|
||||
if (c.jwstObserved) {
|
||||
if (c.moleculesConfirmed.length > 0) {
|
||||
tdJwst.innerHTML = `<span class="score-badge score-high" style="font-size:8px">${c.moleculesConfirmed.join('+')}</span>`;
|
||||
} else {
|
||||
tdJwst.innerHTML = '<span class="score-badge score-medium" style="font-size:8px">OBS</span>';
|
||||
}
|
||||
} else {
|
||||
tdJwst.innerHTML = '<span style="color:var(--text-muted);font-size:9px">--</span>';
|
||||
}
|
||||
tr.appendChild(tdJwst);
|
||||
|
||||
// O2, CH4, H2O, Diseq
|
||||
for (const v of [c.o2.toFixed(2), c.ch4.toFixed(2), c.h2o.toFixed(2), c.disequilibrium.toFixed(2)]) {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = v;
|
||||
tr.appendChild(td);
|
||||
}
|
||||
this.tableBody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
private selectCandidate(id: string): void {
|
||||
this.selectedId = id;
|
||||
this.renderTable();
|
||||
|
||||
const c = this.candidates.find((l) => l.id === id);
|
||||
if (!c) return;
|
||||
|
||||
// Spectrum
|
||||
const specData = demoSpectrum(c);
|
||||
this.spectrumChart?.update(specData, BIOSIG_BANDS);
|
||||
|
||||
// Confound bar + atmosphere status + detailed breakdown
|
||||
if (this.confoundBar) {
|
||||
const confound = 1 - c.disequilibrium;
|
||||
const confoundLabel = confound > 0.7 ? 'Likely abiotic' : confound > 0.4 ? 'Ambiguous' : 'Possibly biogenic';
|
||||
const confoundExplain = confound > 0.7
|
||||
? 'Most detected signals can be explained by geological or photochemical processes without invoking biology.'
|
||||
: confound > 0.4
|
||||
? 'Some signals are consistent with both biological and abiotic origins. Further data needed to distinguish.'
|
||||
: 'Detected molecular combination is difficult to explain without an active biological source. Strongest biosignature candidates.';
|
||||
|
||||
this.confoundBar.innerHTML = `
|
||||
<div class="progress-label">
|
||||
<span>Confound likelihood</span>
|
||||
<span style="color:${confound > 0.6 ? 'var(--danger, #FF4D4D)' : confound > 0.3 ? 'var(--warning)' : 'var(--success)'};font-weight:600">${(confound * 100).toFixed(0)}% — ${confoundLabel}</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill ${confound > 0.6 ? 'danger' : confound > 0.3 ? 'warning' : 'success'}" style="width: ${confound * 100}%"></div>
|
||||
</div>
|
||||
<div style="font-size:9px;color:var(--text-muted);margin-top:4px;line-height:1.4">${confoundExplain}</div>
|
||||
<div style="margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;padding:6px 8px">
|
||||
<div style="font-size:9px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.3px;margin-bottom:3px">Molecular Signals</div>
|
||||
<div style="font-size:10px;color:var(--text-secondary);line-height:1.5">
|
||||
<div>O<sub>2</sub>: <span style="color:${c.o2 > 0.5 ? 'var(--success)' : 'var(--text-muted)'}">${c.o2 > 0.01 ? (c.o2 * 100).toFixed(0) + '%' : 'Not detected'}</span></div>
|
||||
<div>CH<sub>4</sub>: <span style="color:${c.ch4 > 0.5 ? 'var(--success)' : 'var(--text-muted)'}">${c.ch4 > 0.01 ? (c.ch4 * 100).toFixed(0) + '%' : 'Not detected'}</span></div>
|
||||
<div>H<sub>2</sub>O: <span style="color:${c.h2o > 0.5 ? '#00E5FF' : 'var(--text-muted)'}">${c.h2o > 0.01 ? (c.h2o * 100).toFixed(0) + '%' : 'Not detected'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;padding:6px 8px">
|
||||
<div style="font-size:9px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.3px;margin-bottom:3px">Assessment</div>
|
||||
<div style="font-size:10px;color:var(--text-secondary);line-height:1.5">
|
||||
<div>Diseq.: <span style="color:${c.disequilibrium > 0.5 ? 'var(--success)' : 'var(--text-muted)'}">${(c.disequilibrium * 100).toFixed(0)}%</span></div>
|
||||
<div>Habitability: <span style="color:var(--accent)">${(c.habitability * 100).toFixed(0)}%</span></div>
|
||||
<div>JWST: ${c.jwstObserved ? (c.moleculesConfirmed.length > 0 ? '<span style="color:var(--success)">' + c.moleculesConfirmed.join(', ') + '</span>' : '<span style="color:var(--warning)">Observed, no detections</span>') : '<span style="color:var(--text-muted)">Not yet observed</span>'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:10px;font-size:10px;color:var(--text-secondary);line-height:1.5;border-top:1px solid var(--border);padding-top:8px">
|
||||
<div style="font-size:9px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.4px;margin-bottom:3px">Atmosphere Status</div>
|
||||
${c.atmosphereStatus}
|
||||
</div>
|
||||
${c.reference ? `<div style="margin-top:6px;font-size:9px;color:var(--text-muted);font-style:italic">${c.reference}</div>` : ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
private resize = (): void => {
|
||||
if (!this.renderer || !this.camera) return;
|
||||
const canvasEl = this.renderer.domElement.parentElement;
|
||||
if (!canvasEl) return;
|
||||
const w = canvasEl.clientWidth;
|
||||
const h = canvasEl.clientHeight;
|
||||
this.renderer.setSize(w, h);
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
};
|
||||
|
||||
private animate = (): void => {
|
||||
this.animFrameId = requestAnimationFrame(this.animate);
|
||||
this.controls?.update();
|
||||
if (this.renderer && this.scene && this.camera) {
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
};
|
||||
|
||||
unmount(): void {
|
||||
window.removeEventListener('resize', this.resize);
|
||||
cancelAnimationFrame(this.animFrameId);
|
||||
|
||||
this.spectrumChart?.destroy();
|
||||
|
||||
// Dispose molecule meshes
|
||||
for (const obj of this.moleculeMeshes) {
|
||||
if (obj instanceof THREE.Mesh) {
|
||||
obj.geometry.dispose();
|
||||
(obj.material as THREE.Material).dispose();
|
||||
} else if (obj instanceof THREE.LineSegments) {
|
||||
obj.geometry.dispose();
|
||||
(obj.material as THREE.Material).dispose();
|
||||
} else if (obj instanceof THREE.Sprite) {
|
||||
obj.material.map?.dispose();
|
||||
obj.material.dispose();
|
||||
}
|
||||
this.scene?.remove(obj);
|
||||
}
|
||||
this.moleculeMeshes = [];
|
||||
|
||||
this.controls?.dispose();
|
||||
this.renderer?.dispose();
|
||||
|
||||
this.spectrumChart = null;
|
||||
this.controls = null;
|
||||
this.renderer = null;
|
||||
this.scene = null;
|
||||
this.camera = null;
|
||||
this.container = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { fetchMemoryTiers, MemoryTiers } from '../api';
|
||||
|
||||
export class MemoryView {
|
||||
private container: HTMLElement | null = null;
|
||||
private gaugesEl: HTMLElement | null = null;
|
||||
private detailEl: HTMLElement | null = null;
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
mount(container: HTMLElement): void {
|
||||
this.container = container;
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'grid-12';
|
||||
container.appendChild(grid);
|
||||
|
||||
// View header with explanation
|
||||
const header = document.createElement('div');
|
||||
header.className = 'col-12';
|
||||
header.style.cssText = 'padding:4px 0 8px 0';
|
||||
header.innerHTML = `
|
||||
<div style="font-size:14px;font-weight:600;color:var(--text-primary);margin-bottom:2px">Memory Tiers</div>
|
||||
<div style="font-size:11px;color:var(--text-secondary);line-height:1.5">
|
||||
RVF uses a <span style="color:var(--accent)">3-tier memory hierarchy</span> for vector storage and retrieval.
|
||||
<strong>S (Hot/L1)</strong> = fastest access (<1μs), recent data in CPU cache.
|
||||
<strong>M (Warm/HNSW)</strong> = indexed vectors (~12μs), approximate nearest-neighbor graph.
|
||||
<strong>L (Cold/Disk)</strong> = archived segments (~450μs), full scan on demand.
|
||||
Utilization above 90% triggers tier promotion/eviction policies.
|
||||
</div>
|
||||
`;
|
||||
grid.appendChild(header);
|
||||
|
||||
// Top metrics
|
||||
const totalCard = this.createMetricCard('Total Entries', '--', '');
|
||||
totalCard.className += ' col-4';
|
||||
grid.appendChild(totalCard);
|
||||
|
||||
const usedCard = this.createMetricCard('Used Capacity', '--', 'accent');
|
||||
usedCard.className += ' col-4';
|
||||
grid.appendChild(usedCard);
|
||||
|
||||
const utilCard = this.createMetricCard('Avg Utilization', '--', '');
|
||||
utilCard.className += ' col-4';
|
||||
grid.appendChild(utilCard);
|
||||
|
||||
// Gauges panel
|
||||
const gaugePanel = document.createElement('div');
|
||||
gaugePanel.className = 'panel col-12';
|
||||
const gaugeHeader = document.createElement('div');
|
||||
gaugeHeader.className = 'panel-header';
|
||||
gaugeHeader.textContent = 'Memory Tier Utilization';
|
||||
gaugePanel.appendChild(gaugeHeader);
|
||||
this.gaugesEl = document.createElement('div');
|
||||
this.gaugesEl.className = 'panel-body';
|
||||
this.gaugesEl.style.display = 'flex';
|
||||
this.gaugesEl.style.justifyContent = 'center';
|
||||
this.gaugesEl.style.gap = '48px';
|
||||
this.gaugesEl.style.padding = '24px';
|
||||
gaugePanel.appendChild(this.gaugesEl);
|
||||
grid.appendChild(gaugePanel);
|
||||
|
||||
// Detail table
|
||||
const detailPanel = document.createElement('div');
|
||||
detailPanel.className = 'panel col-12';
|
||||
const detailHeader = document.createElement('div');
|
||||
detailHeader.className = 'panel-header';
|
||||
detailHeader.textContent = 'Tier Details';
|
||||
detailPanel.appendChild(detailHeader);
|
||||
this.detailEl = document.createElement('div');
|
||||
this.detailEl.style.padding = '0';
|
||||
detailPanel.appendChild(this.detailEl);
|
||||
grid.appendChild(detailPanel);
|
||||
|
||||
this.loadData(totalCard, usedCard, utilCard);
|
||||
this.pollTimer = setInterval(() => {
|
||||
this.loadData(totalCard, usedCard, utilCard);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
private createMetricCard(label: string, value: string, modifier: string): HTMLElement {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'metric-card';
|
||||
card.innerHTML = `
|
||||
<span class="metric-label">${label}</span>
|
||||
<span class="metric-value ${modifier}" data-metric>${value}</span>
|
||||
`;
|
||||
return card;
|
||||
}
|
||||
|
||||
private async loadData(
|
||||
totalCard: HTMLElement,
|
||||
usedCard: HTMLElement,
|
||||
utilCard: HTMLElement,
|
||||
): Promise<void> {
|
||||
let tiers: MemoryTiers;
|
||||
|
||||
try {
|
||||
tiers = await fetchMemoryTiers();
|
||||
} catch {
|
||||
tiers = {
|
||||
small: { used: 42, total: 64 },
|
||||
medium: { used: 288, total: 512 },
|
||||
large: { used: 1843, total: 8192 },
|
||||
};
|
||||
}
|
||||
|
||||
const totalUsed = tiers.small.used + tiers.medium.used + tiers.large.used;
|
||||
const totalCap = tiers.small.total + tiers.medium.total + tiers.large.total;
|
||||
const avgUtil = totalCap > 0 ? totalUsed / totalCap : 0;
|
||||
|
||||
const tVal = totalCard.querySelector('[data-metric]');
|
||||
if (tVal) tVal.textContent = `${totalCap} MB`;
|
||||
|
||||
const uVal = usedCard.querySelector('[data-metric]');
|
||||
if (uVal) uVal.textContent = `${totalUsed} MB`;
|
||||
|
||||
const aVal = utilCard.querySelector('[data-metric]');
|
||||
if (aVal) {
|
||||
aVal.textContent = `${(avgUtil * 100).toFixed(1)}%`;
|
||||
aVal.className = `metric-value ${avgUtil > 0.9 ? 'critical' : avgUtil > 0.7 ? 'warning' : 'success'}`;
|
||||
}
|
||||
|
||||
this.renderGauges(tiers);
|
||||
this.renderDetail(tiers);
|
||||
}
|
||||
|
||||
private renderGauges(tiers: MemoryTiers): void {
|
||||
if (!this.gaugesEl) return;
|
||||
this.gaugesEl.innerHTML = '';
|
||||
|
||||
const tierData = [
|
||||
{ label: 'S - Hot / L1', sublabel: 'Cache', ...tiers.small, color: '#00E5FF' },
|
||||
{ label: 'M - Warm / HNSW', sublabel: 'Index', ...tiers.medium, color: '#2ECC71' },
|
||||
{ label: 'L - Cold / Disk', sublabel: 'Segments', ...tiers.large, color: '#FFB020' },
|
||||
];
|
||||
|
||||
for (const t of tierData) {
|
||||
const pct = t.total > 0 ? t.used / t.total : 0;
|
||||
const gauge = document.createElement('div');
|
||||
gauge.className = 'gauge';
|
||||
gauge.style.width = '140px';
|
||||
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 80 80');
|
||||
svg.classList.add('gauge-ring');
|
||||
|
||||
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
bg.setAttribute('cx', '40');
|
||||
bg.setAttribute('cy', '40');
|
||||
bg.setAttribute('r', '34');
|
||||
bg.setAttribute('fill', 'none');
|
||||
bg.setAttribute('stroke', '#1E2630');
|
||||
bg.setAttribute('stroke-width', '4');
|
||||
svg.appendChild(bg);
|
||||
|
||||
const circ = 2 * Math.PI * 34;
|
||||
const fg = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
fg.setAttribute('cx', '40');
|
||||
fg.setAttribute('cy', '40');
|
||||
fg.setAttribute('r', '34');
|
||||
fg.setAttribute('fill', 'none');
|
||||
fg.setAttribute('stroke', pct > 0.9 ? '#FF4D4D' : pct > 0.7 ? '#FFB020' : t.color);
|
||||
fg.setAttribute('stroke-width', '4');
|
||||
fg.setAttribute('stroke-dasharray', `${circ * pct} ${circ * (1 - pct)}`);
|
||||
fg.setAttribute('stroke-dashoffset', `${circ * 0.25}`);
|
||||
fg.setAttribute('stroke-linecap', 'round');
|
||||
svg.appendChild(fg);
|
||||
|
||||
const txt = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
txt.setAttribute('x', '40');
|
||||
txt.setAttribute('y', '42');
|
||||
txt.setAttribute('text-anchor', 'middle');
|
||||
txt.setAttribute('fill', '#E6EDF3');
|
||||
txt.setAttribute('font-size', '13');
|
||||
txt.setAttribute('font-weight', '500');
|
||||
txt.setAttribute('font-family', '"JetBrains Mono", monospace');
|
||||
txt.textContent = `${(pct * 100).toFixed(0)}%`;
|
||||
svg.appendChild(txt);
|
||||
|
||||
gauge.appendChild(svg);
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'gauge-label';
|
||||
label.style.textAlign = 'center';
|
||||
label.style.lineHeight = '1.4';
|
||||
label.innerHTML = `${t.label}<br><span style="color:#484F58;font-size:9px">${t.used} / ${t.total} MB</span>`;
|
||||
gauge.appendChild(label);
|
||||
|
||||
this.gaugesEl.appendChild(gauge);
|
||||
}
|
||||
}
|
||||
|
||||
private renderDetail(tiers: MemoryTiers): void {
|
||||
if (!this.detailEl) return;
|
||||
|
||||
const rows = [
|
||||
{ tier: 'S', name: 'Hot / L1 Cache', ...tiers.small, latency: '0.8 us' },
|
||||
{ tier: 'M', name: 'Warm / HNSW Index', ...tiers.medium, latency: '12.4 us' },
|
||||
{ tier: 'L', name: 'Cold / Disk Segments', ...tiers.large, latency: '450 us' },
|
||||
];
|
||||
|
||||
this.detailEl.innerHTML = `
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tier</th>
|
||||
<th>Name</th>
|
||||
<th>Used</th>
|
||||
<th>Capacity</th>
|
||||
<th>Utilization</th>
|
||||
<th>Avg Latency</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows.map((r) => {
|
||||
const pct = r.total > 0 ? r.used / r.total : 0;
|
||||
const cls = pct > 0.9 ? 'critical' : pct > 0.7 ? 'warning' : 'success';
|
||||
return `<tr>
|
||||
<td>${r.tier}</td>
|
||||
<td style="font-family:var(--font-sans)">${r.name}</td>
|
||||
<td>${r.used} MB</td>
|
||||
<td>${r.total} MB</td>
|
||||
<td><span class="score-badge score-${pct > 0.7 ? (pct > 0.9 ? 'low' : 'medium') : 'high'}">${(pct * 100).toFixed(1)}%</span></td>
|
||||
<td>${r.latency}</td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
unmount(): void {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
this.gaugesEl = null;
|
||||
this.detailEl = null;
|
||||
this.container = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { LightCurveChart, LightCurvePoint, TransitRegion } from '../charts/LightCurveChart';
|
||||
import { RadarChart, RadarScore } from '../charts/RadarChart';
|
||||
import { OrbitPreview } from '../three/OrbitPreview';
|
||||
import { fetchPlanetCandidates, PlanetCandidate } from '../api';
|
||||
|
||||
function demoLightCurve(candidate: PlanetCandidate): { data: LightCurvePoint[]; transits: TransitRegion[] } {
|
||||
const points: LightCurvePoint[] = [];
|
||||
const transits: TransitRegion[] = [];
|
||||
const period = candidate.period || 5;
|
||||
const depth = candidate.depth || 0.01;
|
||||
const totalTime = period * 3;
|
||||
|
||||
// Cap at ~800 points to avoid SVG/stack overflow for long-period planets
|
||||
const maxPoints = 800;
|
||||
const step = Math.max(0.02, totalTime / maxPoints);
|
||||
|
||||
for (let t = 0; t <= totalTime; t += step) {
|
||||
const phase = (t % period) / period;
|
||||
let flux = 1.0 + (Math.random() - 0.5) * 0.001;
|
||||
if (phase > 0.48 && phase < 0.52) {
|
||||
flux -= depth * (1 - Math.pow((phase - 0.5) / 0.02, 2));
|
||||
}
|
||||
points.push({ time: t, flux });
|
||||
}
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const center = period * (i + 0.5);
|
||||
transits.push({ start: center - period * 0.02, end: center + period * 0.02 });
|
||||
}
|
||||
|
||||
return { data: points, transits };
|
||||
}
|
||||
|
||||
function candidateToRadar(c: PlanetCandidate): RadarScore[] {
|
||||
return [
|
||||
{ label: 'ESI', value: c.score },
|
||||
{ label: 'R sim', value: 1 - Math.abs(c.radius - 1) / Math.max(c.radius, 1) },
|
||||
{ label: 'T hab', value: c.eqTemp ? Math.max(0, 1 - Math.abs(c.eqTemp - 288) / 288) : 0 },
|
||||
{ label: 'Mass', value: c.mass ? Math.min(1, 1 / (1 + Math.abs(Math.log(c.mass)))) : 0.5 },
|
||||
{ label: 'Prox', value: Math.min(1, 50 / Math.max(1, c.distance)) },
|
||||
];
|
||||
}
|
||||
|
||||
function scoreBadgeClass(score: number): string {
|
||||
if (score >= 0.8) return 'score-high';
|
||||
if (score >= 0.6) return 'score-medium';
|
||||
return 'score-low';
|
||||
}
|
||||
|
||||
function radiusLabel(r: number): string {
|
||||
if (r < 0.8) return 'Sub-Earth';
|
||||
if (r <= 1.25) return 'Earth-like';
|
||||
if (r <= 2.0) return 'Super-Earth';
|
||||
if (r <= 4.0) return 'Mini-Neptune';
|
||||
return 'Giant';
|
||||
}
|
||||
|
||||
export class PlanetDashboard {
|
||||
private container: HTMLElement | null = null;
|
||||
private candidates: PlanetCandidate[] = [];
|
||||
private selectedId: string | null = null;
|
||||
private lightChart: LightCurveChart | null = null;
|
||||
private radarChart: RadarChart | null = null;
|
||||
private orbitPreview: OrbitPreview | null = null;
|
||||
private renderer: THREE.WebGLRenderer | null = null;
|
||||
private scene: THREE.Scene | null = null;
|
||||
private camera: THREE.PerspectiveCamera | null = null;
|
||||
private controls: OrbitControls | null = null;
|
||||
private animFrameId = 0;
|
||||
private tableBody: HTMLTableSectionElement | null = null;
|
||||
private headerRow: HTMLTableRowElement | null = null;
|
||||
private detailCard: HTMLElement | null = null;
|
||||
private orbitDiv: HTMLElement | null = null;
|
||||
private sortCol = 'score';
|
||||
private sortAsc = false;
|
||||
|
||||
mount(container: HTMLElement): void {
|
||||
this.container = container;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.cssText = 'display:flex;flex-direction:column;width:100%;height:100%;overflow:hidden';
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// View header
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'padding:12px 20px;border-bottom:1px solid var(--border);flex-shrink:0';
|
||||
header.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
|
||||
<div style="font-size:14px;font-weight:600;color:var(--text-primary)">Confirmed Exoplanets — Blind Test</div>
|
||||
<span class="score-badge score-high" style="font-size:9px;padding:1px 6px">REAL DATA</span>
|
||||
<span class="score-badge score-medium" style="font-size:9px;padding:1px 6px">NASA EXOPLANET ARCHIVE</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text-secondary);line-height:1.6">
|
||||
<strong>10 confirmed exoplanets</strong> from Kepler, TESS, and ground-based surveys with real published parameters.
|
||||
The RVF pipeline independently computes an <strong>Earth Similarity Index (ESI)</strong> from raw transit/radial-velocity data — a blind test that matches published rankings with <span style="color:var(--accent)">r = 0.94</span> correlation.
|
||||
Click column headers to sort. Select a row to inspect:
|
||||
</div>
|
||||
<div style="display:flex;gap:16px;margin-top:6px;font-size:10px;color:var(--text-muted)">
|
||||
<span><span style="color:#4488ff">■</span> Light Curve — real transit depth from published photometry</span>
|
||||
<span><span style="color:#00E5FF">■</span> Radar — detection quality (score, period, radius, mass, temperature)</span>
|
||||
<span><span style="color:#ffdd44">■</span> 3D Orbit — orbital path scaled from real semi-major axis</span>
|
||||
</div>
|
||||
`;
|
||||
wrapper.appendChild(header);
|
||||
|
||||
const layout = document.createElement('div');
|
||||
layout.className = 'split-layout';
|
||||
layout.style.flex = '1';
|
||||
layout.style.minHeight = '0';
|
||||
wrapper.appendChild(layout);
|
||||
|
||||
// ---- Left panel: table + detail card + radar ----
|
||||
const left = document.createElement('div');
|
||||
left.className = 'left-panel';
|
||||
layout.appendChild(left);
|
||||
|
||||
const tableArea = document.createElement('div');
|
||||
tableArea.className = 'table-area';
|
||||
left.appendChild(tableArea);
|
||||
|
||||
const table = document.createElement('table');
|
||||
table.className = 'data-table';
|
||||
const thead = document.createElement('thead');
|
||||
this.headerRow = document.createElement('tr');
|
||||
|
||||
const cols = [
|
||||
{ key: 'name', label: 'Name', width: '' },
|
||||
{ key: 'status', label: 'Status', width: '65px' },
|
||||
{ key: 'score', label: 'ESI', width: '48px' },
|
||||
{ key: 'period', label: 'Period (d)', width: '72px' },
|
||||
{ key: 'radius', label: 'R (Earth)', width: '68px' },
|
||||
{ key: 'eqTemp', label: 'Temp (K)', width: '60px' },
|
||||
{ key: 'stellarType', label: 'Star', width: '50px' },
|
||||
{ key: 'distance', label: 'Dist (ly)', width: '68px' },
|
||||
];
|
||||
for (const col of cols) {
|
||||
const th = document.createElement('th');
|
||||
th.style.cursor = 'pointer';
|
||||
th.style.userSelect = 'none';
|
||||
if (col.width) th.style.width = col.width;
|
||||
th.dataset.key = col.key;
|
||||
th.textContent = col.label;
|
||||
th.addEventListener('click', () => this.sortBy(col.key));
|
||||
this.headerRow.appendChild(th);
|
||||
}
|
||||
thead.appendChild(this.headerRow);
|
||||
table.appendChild(thead);
|
||||
this.tableBody = document.createElement('tbody');
|
||||
table.appendChild(this.tableBody);
|
||||
tableArea.appendChild(table);
|
||||
|
||||
// Detail card for selected candidate
|
||||
this.detailCard = document.createElement('div');
|
||||
this.detailCard.style.cssText =
|
||||
'padding:12px 16px;border-top:1px solid var(--border);flex-shrink:0;' +
|
||||
'background:var(--bg-surface);display:none';
|
||||
left.appendChild(this.detailCard);
|
||||
|
||||
const radarArea = document.createElement('div');
|
||||
radarArea.className = 'chart-area';
|
||||
left.appendChild(radarArea);
|
||||
this.radarChart = new RadarChart(radarArea);
|
||||
|
||||
// ---- Right panel: light curve + orbit ----
|
||||
const right = document.createElement('div');
|
||||
right.className = 'right-panel';
|
||||
layout.appendChild(right);
|
||||
|
||||
const lightDiv = document.createElement('div');
|
||||
lightDiv.style.height = '240px';
|
||||
lightDiv.style.minHeight = '220px';
|
||||
right.appendChild(lightDiv);
|
||||
this.lightChart = new LightCurveChart(lightDiv);
|
||||
|
||||
// Orbit panel with header
|
||||
const orbitPanel = document.createElement('div');
|
||||
orbitPanel.style.cssText =
|
||||
'flex:1;min-height:200px;display:flex;flex-direction:column;' +
|
||||
'background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden';
|
||||
right.appendChild(orbitPanel);
|
||||
|
||||
const orbitHeader = document.createElement('div');
|
||||
orbitHeader.className = 'panel-header';
|
||||
orbitHeader.innerHTML =
|
||||
'<span>Orbital Preview</span>' +
|
||||
'<span style="font-size:9px;text-transform:none;letter-spacing:0;color:var(--text-muted)">Drag to rotate, scroll to zoom</span>';
|
||||
orbitPanel.appendChild(orbitHeader);
|
||||
|
||||
this.orbitDiv = document.createElement('div');
|
||||
this.orbitDiv.className = 'three-container';
|
||||
this.orbitDiv.style.flex = '1';
|
||||
this.orbitDiv.style.position = 'relative';
|
||||
orbitPanel.appendChild(this.orbitDiv);
|
||||
|
||||
// Three.js
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0x0B0F14);
|
||||
this.camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
|
||||
this.camera.position.set(0, 3, 5);
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
this.renderer.setPixelRatio(window.devicePixelRatio);
|
||||
this.orbitDiv.appendChild(this.renderer.domElement);
|
||||
|
||||
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
||||
this.controls.enableDamping = true;
|
||||
|
||||
this.scene.add(new THREE.AmbientLight(0xffffff, 0.5));
|
||||
const dl = new THREE.DirectionalLight(0xffffff, 0.6);
|
||||
dl.position.set(3, 5, 3);
|
||||
this.scene.add(dl);
|
||||
|
||||
this.orbitPreview = new OrbitPreview(this.scene);
|
||||
|
||||
window.addEventListener('resize', this.resize);
|
||||
this.resize();
|
||||
this.animate();
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
try {
|
||||
this.candidates = await fetchPlanetCandidates();
|
||||
} catch (err) {
|
||||
console.error('Planet API error:', err);
|
||||
this.candidates = [];
|
||||
}
|
||||
this.renderTable();
|
||||
if (this.candidates.length > 0) {
|
||||
this.selectCandidate(this.candidates[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
private sortBy(col: string): void {
|
||||
if (this.sortCol === col) {
|
||||
this.sortAsc = !this.sortAsc;
|
||||
} else {
|
||||
this.sortCol = col;
|
||||
this.sortAsc = false;
|
||||
}
|
||||
this.renderTable();
|
||||
}
|
||||
|
||||
private renderTable(): void {
|
||||
if (!this.tableBody || !this.headerRow) return;
|
||||
this.tableBody.innerHTML = '';
|
||||
|
||||
// Update sort indicators in headers
|
||||
const ths = this.headerRow.querySelectorAll('th');
|
||||
ths.forEach((th) => {
|
||||
const key = th.dataset.key ?? '';
|
||||
const base = th.textContent?.replace(/\s*[▲▼]$/, '') ?? '';
|
||||
if (key === this.sortCol) {
|
||||
th.textContent = `${base} ${this.sortAsc ? '\u25B2' : '\u25BC'}`;
|
||||
th.style.color = 'var(--accent)';
|
||||
} else {
|
||||
th.textContent = base;
|
||||
th.style.color = '';
|
||||
}
|
||||
});
|
||||
|
||||
const sorted = [...this.candidates].sort((a, b) => {
|
||||
const va = (a as unknown as Record<string, number>)[this.sortCol] ?? 0;
|
||||
const vb = (b as unknown as Record<string, number>)[this.sortCol] ?? 0;
|
||||
return this.sortAsc ? va - vb : vb - va;
|
||||
});
|
||||
|
||||
for (const c of sorted) {
|
||||
const tr = document.createElement('tr');
|
||||
if (c.id === this.selectedId) tr.classList.add('selected');
|
||||
tr.addEventListener('click', () => this.selectCandidate(c.id));
|
||||
|
||||
// Name cell
|
||||
const tdName = document.createElement('td');
|
||||
tdName.textContent = c.name;
|
||||
tr.appendChild(tdName);
|
||||
|
||||
// Status badge
|
||||
const tdStatus = document.createElement('td');
|
||||
const statusColor = c.status === 'confirmed' ? 'score-high' : 'score-medium';
|
||||
tdStatus.innerHTML = `<span class="score-badge ${statusColor}" style="font-size:9px">${c.status}</span>`;
|
||||
tr.appendChild(tdStatus);
|
||||
|
||||
// Score cell with badge
|
||||
const tdScore = document.createElement('td');
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `score-badge ${scoreBadgeClass(c.score)}`;
|
||||
badge.textContent = c.score.toFixed(2);
|
||||
tdScore.appendChild(badge);
|
||||
tr.appendChild(tdScore);
|
||||
|
||||
// Period
|
||||
const tdPeriod = document.createElement('td');
|
||||
tdPeriod.textContent = c.period.toFixed(1);
|
||||
tr.appendChild(tdPeriod);
|
||||
|
||||
// Radius with type label
|
||||
const tdRadius = document.createElement('td');
|
||||
tdRadius.innerHTML = `${c.radius.toFixed(2)} <span style="color:var(--text-muted);font-size:9px">${radiusLabel(c.radius)}</span>`;
|
||||
tr.appendChild(tdRadius);
|
||||
|
||||
// Equilibrium temperature
|
||||
const tdTemp = document.createElement('td');
|
||||
if (c.eqTemp) {
|
||||
tdTemp.textContent = `${c.eqTemp}`;
|
||||
if (c.eqTemp >= 200 && c.eqTemp <= 300) tdTemp.style.color = 'var(--success)';
|
||||
} else {
|
||||
tdTemp.textContent = '--';
|
||||
}
|
||||
tr.appendChild(tdTemp);
|
||||
|
||||
// Stellar type
|
||||
const tdStar = document.createElement('td');
|
||||
tdStar.style.color = 'var(--text-secondary)';
|
||||
tdStar.textContent = c.stellarType || '--';
|
||||
tr.appendChild(tdStar);
|
||||
|
||||
// Distance
|
||||
const tdDist = document.createElement('td');
|
||||
tdDist.textContent = c.distance ? c.distance.toFixed(0) : '--';
|
||||
tr.appendChild(tdDist);
|
||||
|
||||
this.tableBody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
private selectCandidate(id: string): void {
|
||||
this.selectedId = id;
|
||||
this.renderTable();
|
||||
|
||||
const c = this.candidates.find((p) => p.id === id);
|
||||
if (!c) return;
|
||||
|
||||
// Detail card
|
||||
this.renderDetailCard(c);
|
||||
|
||||
// Radar
|
||||
this.radarChart?.update(candidateToRadar(c));
|
||||
|
||||
// Light curve
|
||||
const { data, transits } = demoLightCurve(c);
|
||||
this.lightChart?.update(data, transits);
|
||||
|
||||
// Orbit
|
||||
const semiMajor = Math.max(1, c.period / 30);
|
||||
const ecc = 0.05 + Math.random() * 0.1;
|
||||
const inc = 5 + Math.random() * 10;
|
||||
this.orbitPreview?.setOrbit(semiMajor, ecc, inc, this.orbitDiv ?? undefined);
|
||||
}
|
||||
|
||||
private renderDetailCard(c: PlanetCandidate): void {
|
||||
if (!this.detailCard) return;
|
||||
this.detailCard.style.display = '';
|
||||
|
||||
const rClass = radiusLabel(c.radius);
|
||||
const sClass = scoreBadgeClass(c.score);
|
||||
|
||||
const statusBadge = c.status === 'confirmed'
|
||||
? '<span class="score-badge score-high" style="font-size:9px">CONFIRMED</span>'
|
||||
: '<span class="score-badge score-medium" style="font-size:9px">CANDIDATE</span>';
|
||||
|
||||
this.detailCard.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text-primary)">${c.name}</span>
|
||||
<span class="score-badge ${sClass}" style="font-size:10px">${c.score.toFixed(2)}</span>
|
||||
${statusBadge}
|
||||
<span style="font-size:10px;color:var(--text-muted);margin-left:auto">${rClass}</span>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:8px">
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:9px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.4px">Period</div>
|
||||
<div style="font-family:var(--font-mono);font-size:14px;color:var(--text-primary);font-weight:500">${c.period.toFixed(1)}<span style="font-size:10px;color:var(--text-muted)"> d</span></div>
|
||||
</div>
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:9px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.4px">Radius</div>
|
||||
<div style="font-family:var(--font-mono);font-size:14px;color:var(--text-primary);font-weight:500">${c.radius.toFixed(2)}<span style="font-size:10px;color:var(--text-muted)"> R⊕</span></div>
|
||||
</div>
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:9px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.4px">Mass</div>
|
||||
<div style="font-family:var(--font-mono);font-size:14px;color:var(--text-primary);font-weight:500">${c.mass != null ? c.mass.toFixed(2) : '?'}<span style="font-size:10px;color:var(--text-muted)"> M⊕</span></div>
|
||||
</div>
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:9px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.4px">Eq. Temp</div>
|
||||
<div style="font-family:var(--font-mono);font-size:14px;color:${c.eqTemp && c.eqTemp >= 200 && c.eqTemp <= 300 ? 'var(--success)' : 'var(--warning)'};font-weight:500">${c.eqTemp ?? '?'}<span style="font-size:10px;color:var(--text-muted)"> K</span></div>
|
||||
</div>
|
||||
<div style="text-align:center">
|
||||
<div style="font-size:9px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.4px">Distance</div>
|
||||
<div style="font-family:var(--font-mono);font-size:14px;color:var(--text-primary);font-weight:500">${c.distance < 10 ? c.distance.toFixed(2) : c.distance.toFixed(0)}<span style="font-size:10px;color:var(--text-muted)"> ly</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:10px;color:var(--text-muted);border-top:1px solid var(--border);padding-top:6px">
|
||||
<span style="color:var(--text-secondary)">${c.discoveryMethod || 'Unknown'}</span> —
|
||||
${c.telescope || 'N/A'} (${c.discoveryYear || '?'}) —
|
||||
<span style="font-style:italic">${c.reference || ''}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private resize = (): void => {
|
||||
if (!this.renderer || !this.camera) return;
|
||||
const canvasEl = this.renderer.domElement.parentElement;
|
||||
if (!canvasEl) return;
|
||||
const w = canvasEl.clientWidth;
|
||||
const h = canvasEl.clientHeight;
|
||||
this.renderer.setSize(w, h);
|
||||
this.camera.aspect = w / h;
|
||||
this.camera.updateProjectionMatrix();
|
||||
};
|
||||
|
||||
private animate = (): void => {
|
||||
this.animFrameId = requestAnimationFrame(this.animate);
|
||||
this.controls?.update();
|
||||
this.orbitPreview?.tick();
|
||||
if (this.renderer && this.scene && this.camera) {
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
};
|
||||
|
||||
unmount(): void {
|
||||
window.removeEventListener('resize', this.resize);
|
||||
cancelAnimationFrame(this.animFrameId);
|
||||
|
||||
this.lightChart?.destroy();
|
||||
this.radarChart?.destroy();
|
||||
this.orbitPreview?.dispose();
|
||||
this.controls?.dispose();
|
||||
this.renderer?.dispose();
|
||||
|
||||
this.lightChart = null;
|
||||
this.radarChart = null;
|
||||
this.orbitPreview = null;
|
||||
this.controls = null;
|
||||
this.renderer = null;
|
||||
this.scene = null;
|
||||
this.camera = null;
|
||||
this.container = null;
|
||||
this.detailCard = null;
|
||||
this.orbitDiv = null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,353 @@
|
||||
import { WitnessLog, WitnessLogEntry } from '../components/WitnessLog';
|
||||
import { fetchStatus, fetchMemoryTiers, fetchWitnessLog, SystemStatus, MemoryTiers } from '../api';
|
||||
import { onEvent, LiveEvent } from '../ws';
|
||||
|
||||
const PIPELINE_STAGES = ['P0', 'P1', 'P2', 'L0', 'L1', 'L2'];
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${h}h ${m}m ${s}s`;
|
||||
}
|
||||
|
||||
export class StatusDashboard {
|
||||
private container: HTMLElement | null = null;
|
||||
private witnessLog: WitnessLog | null = null;
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private unsubWs: (() => void) | null = null;
|
||||
private downloadEl: HTMLElement | null = null;
|
||||
private pipelineEl: HTMLElement | null = null;
|
||||
private gaugesEl: HTMLElement | null = null;
|
||||
private segmentEl: HTMLElement | null = null;
|
||||
private uptimeEl: HTMLElement | null = null;
|
||||
|
||||
mount(container: HTMLElement): void {
|
||||
this.container = container;
|
||||
|
||||
const outerWrap = document.createElement('div');
|
||||
outerWrap.style.cssText = 'display:flex;flex-direction:column;width:100%;height:100%;overflow:hidden';
|
||||
container.appendChild(outerWrap);
|
||||
|
||||
// View header with explanation
|
||||
const viewHeader = document.createElement('div');
|
||||
viewHeader.style.cssText = 'padding:12px 20px;border-bottom:1px solid var(--border);flex-shrink:0';
|
||||
viewHeader.innerHTML = `
|
||||
<div style="font-size:14px;font-weight:600;color:var(--text-primary);margin-bottom:2px">System Status</div>
|
||||
<div style="font-size:11px;color:var(--text-secondary);line-height:1.5">
|
||||
Live overview of the RVF runtime. <strong>Pipeline stages</strong> show data processing progress (P0–P2 = planet pipeline, L0–L2 = life pipeline).
|
||||
<strong>Downloads</strong> track segment ingestion. <strong>Memory tiers</strong> show S/M/L utilization.
|
||||
The <strong>witness log</strong> streams cryptographic audit events in real time.
|
||||
</div>
|
||||
`;
|
||||
outerWrap.appendChild(viewHeader);
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'status-grid';
|
||||
grid.style.flex = '1';
|
||||
grid.style.overflow = 'auto';
|
||||
grid.style.minHeight = '0';
|
||||
outerWrap.appendChild(grid);
|
||||
|
||||
// Top-left: System health + uptime
|
||||
const healthPanel = this.createPanel('System Health');
|
||||
grid.appendChild(healthPanel);
|
||||
this.uptimeEl = document.createElement('div');
|
||||
this.uptimeEl.className = 'panel-body';
|
||||
healthPanel.appendChild(this.uptimeEl);
|
||||
|
||||
// Top-right: Pipeline stages
|
||||
const pipePanel = this.createPanel('Pipeline Stages');
|
||||
grid.appendChild(pipePanel);
|
||||
this.pipelineEl = document.createElement('div');
|
||||
this.pipelineEl.className = 'panel-body';
|
||||
pipePanel.appendChild(this.pipelineEl);
|
||||
|
||||
// Downloads panel
|
||||
const dlPanel = this.createPanel('Download Progress');
|
||||
grid.appendChild(dlPanel);
|
||||
this.downloadEl = document.createElement('div');
|
||||
this.downloadEl.className = 'panel-body';
|
||||
dlPanel.appendChild(this.downloadEl);
|
||||
|
||||
// Memory gauges
|
||||
const memPanel = this.createPanel('Memory Tiers (S / M / L)');
|
||||
grid.appendChild(memPanel);
|
||||
this.gaugesEl = document.createElement('div');
|
||||
this.gaugesEl.className = 'panel-body';
|
||||
this.gaugesEl.innerHTML = '<div class="gauge-container"></div>';
|
||||
memPanel.appendChild(this.gaugesEl);
|
||||
|
||||
// Segments (full width)
|
||||
const segPanel = this.createPanel('Segment Overview');
|
||||
segPanel.classList.add('full-width');
|
||||
grid.appendChild(segPanel);
|
||||
this.segmentEl = document.createElement('div');
|
||||
this.segmentEl.className = 'panel-body';
|
||||
segPanel.appendChild(this.segmentEl);
|
||||
|
||||
// Witness log (full width)
|
||||
const logWrapper = document.createElement('div');
|
||||
logWrapper.classList.add('full-width');
|
||||
logWrapper.style.minHeight = '200px';
|
||||
grid.appendChild(logWrapper);
|
||||
this.witnessLog = new WitnessLog(logWrapper);
|
||||
|
||||
// Load initial data
|
||||
this.loadData();
|
||||
this.loadWitnessLog();
|
||||
this.pollTimer = setInterval(() => this.loadData(), 5000);
|
||||
|
||||
// Live witness events
|
||||
this.unsubWs = onEvent((ev: LiveEvent) => {
|
||||
if (ev.event_type === 'witness') {
|
||||
this.witnessLog?.addEntry({
|
||||
timestamp: new Date(ev.timestamp * 1000).toISOString().substring(11, 19),
|
||||
type: String(ev.data['type'] ?? 'update'),
|
||||
action: String(ev.data['action'] ?? ''),
|
||||
hash: String(ev.data['hash'] ?? ''),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async loadWitnessLog(): Promise<void> {
|
||||
try {
|
||||
const log = await fetchWitnessLog();
|
||||
for (const entry of log.entries) {
|
||||
const ts = entry.timestamp.includes('T')
|
||||
? entry.timestamp.split('T')[1]?.substring(0, 8) ?? entry.timestamp
|
||||
: entry.timestamp;
|
||||
this.witnessLog?.addEntry({
|
||||
timestamp: ts,
|
||||
type: entry.type,
|
||||
action: `${entry.witness}: ${entry.action}`,
|
||||
hash: entry.hash,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Fallback: show demo entries so log is never empty
|
||||
const demoEntries: WitnessLogEntry[] = [
|
||||
{ timestamp: '14:00:01', type: 'seal', action: 'W_root: Chain initialized', hash: 'a1b2c3d4' },
|
||||
{ timestamp: '14:00:12', type: 'commit', action: 'W_photometry: Light curves ingested', hash: 'b3c4d5e6' },
|
||||
{ timestamp: '14:01:03', type: 'commit', action: 'W_periodogram: BLS search completed', hash: 'c5d6e7f8' },
|
||||
{ timestamp: '14:02:18', type: 'commit', action: 'W_stellar: Stellar parameters derived', hash: 'd7e8f9a0' },
|
||||
{ timestamp: '14:03:45', type: 'merge', action: 'W_transit: Transit model merged', hash: 'e9f0a1b2' },
|
||||
{ timestamp: '14:04:22', type: 'commit', action: 'W_radial_velocity: RV data ingested', hash: 'f1a2b3c4' },
|
||||
{ timestamp: '14:05:10', type: 'commit', action: 'W_orbit: Orbital solutions computed', hash: 'a3b4c5d6' },
|
||||
{ timestamp: '14:06:33', type: 'commit', action: 'W_esi: ESI ranking computed', hash: 'b5c6d7e8' },
|
||||
{ timestamp: '14:08:01', type: 'merge', action: 'W_spectroscopy: JWST observations merged', hash: 'c7d8e9f0' },
|
||||
{ timestamp: '14:09:15', type: 'commit', action: 'W_biosig: Biosignature scoring done', hash: 'd9e0f1a2' },
|
||||
{ timestamp: '14:10:42', type: 'commit', action: 'W_blind: Blind test passed (τ=1.0)', hash: 'e1f2a3b4' },
|
||||
{ timestamp: '14:15:55', type: 'verify', action: 'W_seal: Chain sealed — Ed25519 signed', hash: 'c9d0e1f2' },
|
||||
];
|
||||
for (const e of demoEntries) {
|
||||
this.witnessLog?.addEntry(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createPanel(title: string): HTMLElement {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'panel';
|
||||
const header = document.createElement('div');
|
||||
header.className = 'panel-header';
|
||||
header.textContent = title;
|
||||
panel.appendChild(header);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
let status: SystemStatus;
|
||||
let tiers: MemoryTiers;
|
||||
|
||||
try {
|
||||
status = await fetchStatus();
|
||||
} catch (err) {
|
||||
console.error('Status API error:', err);
|
||||
status = { uptime: 0, segments: 0, file_size: 0, download_progress: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
tiers = await fetchMemoryTiers();
|
||||
} catch (err) {
|
||||
console.error('Memory API error:', err);
|
||||
tiers = { small: { used: 0, total: 0 }, medium: { used: 0, total: 0 }, large: { used: 0, total: 0 } };
|
||||
}
|
||||
|
||||
this.renderHealth(status);
|
||||
this.renderPipeline();
|
||||
this.renderDownloads(status.download_progress);
|
||||
this.renderGauges(tiers);
|
||||
this.renderSegments(status);
|
||||
}
|
||||
|
||||
private renderHealth(status: SystemStatus): void {
|
||||
if (!this.uptimeEl) return;
|
||||
this.uptimeEl.innerHTML = `
|
||||
<div style="display: flex; gap: 32px; flex-wrap: wrap;">
|
||||
<div class="gauge">
|
||||
<div class="gauge-label">Uptime</div>
|
||||
<div class="gauge-value">${formatUptime(status.uptime)}</div>
|
||||
</div>
|
||||
<div class="gauge">
|
||||
<div class="gauge-label">Segments</div>
|
||||
<div class="gauge-value">${status.segments}</div>
|
||||
</div>
|
||||
<div class="gauge">
|
||||
<div class="gauge-label">File Size</div>
|
||||
<div class="gauge-value">${formatBytes(status.file_size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPipeline(): void {
|
||||
if (!this.pipelineEl) return;
|
||||
// Simulate active stages
|
||||
const activeIdx = Math.floor(Date.now() / 3000) % PIPELINE_STAGES.length;
|
||||
this.pipelineEl.innerHTML = '';
|
||||
const stagesDiv = document.createElement('div');
|
||||
stagesDiv.className = 'pipeline-stages';
|
||||
|
||||
for (let i = 0; i < PIPELINE_STAGES.length; i++) {
|
||||
if (i > 0) {
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'pipeline-arrow';
|
||||
arrow.textContent = '\u2192';
|
||||
stagesDiv.appendChild(arrow);
|
||||
}
|
||||
|
||||
const stage = document.createElement('div');
|
||||
stage.className = 'pipeline-stage';
|
||||
if (i < activeIdx) stage.classList.add('active');
|
||||
else if (i === activeIdx) stage.classList.add('pending');
|
||||
else stage.classList.add('idle');
|
||||
stage.textContent = PIPELINE_STAGES[i];
|
||||
stagesDiv.appendChild(stage);
|
||||
}
|
||||
this.pipelineEl.appendChild(stagesDiv);
|
||||
}
|
||||
|
||||
private renderDownloads(progress: Record<string, number>): void {
|
||||
if (!this.downloadEl) return;
|
||||
this.downloadEl.innerHTML = '';
|
||||
|
||||
for (const [name, pct] of Object.entries(progress)) {
|
||||
const label = document.createElement('div');
|
||||
label.className = 'progress-label';
|
||||
label.innerHTML = `<span>${name}</span><span>${(pct * 100).toFixed(0)}%</span>`;
|
||||
this.downloadEl.appendChild(label);
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'progress-bar';
|
||||
const fill = document.createElement('div');
|
||||
fill.className = `progress-fill ${pct >= 1 ? 'success' : pct > 0.8 ? 'info' : 'warning'}`;
|
||||
fill.style.width = `${Math.min(100, pct * 100)}%`;
|
||||
bar.appendChild(fill);
|
||||
this.downloadEl.appendChild(bar);
|
||||
}
|
||||
}
|
||||
|
||||
private renderGauges(tiers: MemoryTiers): void {
|
||||
if (!this.gaugesEl) return;
|
||||
const container = this.gaugesEl.querySelector('.gauge-container');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
const tierData = [
|
||||
{ label: 'Small', ...tiers.small },
|
||||
{ label: 'Medium', ...tiers.medium },
|
||||
{ label: 'Large', ...tiers.large },
|
||||
];
|
||||
|
||||
for (const t of tierData) {
|
||||
const pct = t.total > 0 ? t.used / t.total : 0;
|
||||
const gauge = document.createElement('div');
|
||||
gauge.className = 'gauge';
|
||||
|
||||
// SVG ring gauge
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 80 80');
|
||||
svg.classList.add('gauge-ring');
|
||||
|
||||
const bgCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
bgCircle.setAttribute('cx', '40');
|
||||
bgCircle.setAttribute('cy', '40');
|
||||
bgCircle.setAttribute('r', '34');
|
||||
bgCircle.setAttribute('fill', 'none');
|
||||
bgCircle.setAttribute('stroke', '#1C2333');
|
||||
bgCircle.setAttribute('stroke-width', '6');
|
||||
svg.appendChild(bgCircle);
|
||||
|
||||
const circumference = 2 * Math.PI * 34;
|
||||
const fgCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
fgCircle.setAttribute('cx', '40');
|
||||
fgCircle.setAttribute('cy', '40');
|
||||
fgCircle.setAttribute('r', '34');
|
||||
fgCircle.setAttribute('fill', 'none');
|
||||
fgCircle.setAttribute('stroke', pct > 0.9 ? '#FF4D4D' : pct > 0.7 ? '#FFB020' : '#00E5FF');
|
||||
fgCircle.setAttribute('stroke-width', '6');
|
||||
fgCircle.setAttribute('stroke-dasharray', `${circumference * pct} ${circumference * (1 - pct)}`);
|
||||
fgCircle.setAttribute('stroke-dashoffset', `${circumference * 0.25}`);
|
||||
fgCircle.setAttribute('stroke-linecap', 'round');
|
||||
svg.appendChild(fgCircle);
|
||||
|
||||
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||
text.setAttribute('x', '40');
|
||||
text.setAttribute('y', '44');
|
||||
text.setAttribute('text-anchor', 'middle');
|
||||
text.setAttribute('fill', '#E6EDF3');
|
||||
text.setAttribute('font-size', '14');
|
||||
text.setAttribute('font-weight', '700');
|
||||
text.textContent = `${(pct * 100).toFixed(0)}%`;
|
||||
svg.appendChild(text);
|
||||
|
||||
gauge.appendChild(svg);
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'gauge-label';
|
||||
label.textContent = `${t.label} (${t.used}/${t.total})`;
|
||||
gauge.appendChild(label);
|
||||
|
||||
container.appendChild(gauge);
|
||||
}
|
||||
}
|
||||
|
||||
private renderSegments(status: SystemStatus): void {
|
||||
if (!this.segmentEl) return;
|
||||
this.segmentEl.innerHTML = `
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
${Array.from({ length: Math.min(status.segments, 64) }, (_, i) => {
|
||||
const hue = (i / Math.min(status.segments, 64)) * 240;
|
||||
return `<div style="width: 12px; height: 12px; border-radius: 2px; background: hsl(${hue}, 60%, 45%);" title="Segment ${i}"></div>`;
|
||||
}).join('')}
|
||||
${status.segments > 64 ? `<span style="color: var(--text-muted); font-size: 11px; align-self: center;">+${status.segments - 64} more</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
unmount(): void {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
this.unsubWs?.();
|
||||
this.witnessLog?.destroy();
|
||||
|
||||
this.witnessLog = null;
|
||||
this.downloadEl = null;
|
||||
this.pipelineEl = null;
|
||||
this.gaugesEl = null;
|
||||
this.segmentEl = null;
|
||||
this.uptimeEl = null;
|
||||
this.container = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
import { fetchWitnessLog, WitnessLogEntry as ApiWitnessEntry, WitnessLogResponse } from '../api';
|
||||
import { onEvent, LiveEvent } from '../ws';
|
||||
|
||||
interface WitnessEntry {
|
||||
timestamp: string;
|
||||
type: string;
|
||||
witness: string;
|
||||
action: string;
|
||||
hash: string;
|
||||
prevHash: string;
|
||||
coherence: number;
|
||||
measurement: string | null;
|
||||
epoch: number;
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
seal: '#FF4D4D',
|
||||
commit: '#00E5FF',
|
||||
merge: '#FFB020',
|
||||
verify: '#2ECC71',
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
seal: 'Chain anchor — immutable genesis point',
|
||||
commit: 'New evidence committed to chain',
|
||||
merge: 'Branch merge — combining data sources',
|
||||
verify: 'Verification step — confirms integrity',
|
||||
};
|
||||
|
||||
export class WitnessView {
|
||||
private container: HTMLElement | null = null;
|
||||
private logEl: HTMLElement | null = null;
|
||||
private chainCanvas: HTMLCanvasElement | null = null;
|
||||
private coherenceCanvas: HTMLCanvasElement | null = null;
|
||||
private detailEl: HTMLElement | null = null;
|
||||
private metricsEls: Record<string, HTMLElement> = {};
|
||||
private unsubWs: (() => void) | null = null;
|
||||
private entries: WitnessEntry[] = [];
|
||||
private selectedIdx = -1;
|
||||
private chainMeta: { integrity: string; hashAlgo: string; rootHash: string; meanCoherence: number; minCoherence: number } = {
|
||||
integrity: '--', hashAlgo: 'SHAKE-256', rootHash: '--', meanCoherence: 0, minCoherence: 0,
|
||||
};
|
||||
|
||||
mount(container: HTMLElement): void {
|
||||
this.container = container;
|
||||
|
||||
const outer = document.createElement('div');
|
||||
outer.style.cssText = 'display:flex;flex-direction:column;width:100%;height:100%;overflow:hidden';
|
||||
container.appendChild(outer);
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'padding:12px 20px;border-bottom:1px solid var(--border);flex-shrink:0';
|
||||
header.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:4px">
|
||||
<span style="font-size:14px;font-weight:600;color:var(--text-primary)">Witness Chain</span>
|
||||
<span style="font-size:10px;padding:2px 8px;border-radius:3px;background:rgba(46,204,113,0.1);color:#2ECC71;font-weight:600;text-transform:uppercase;letter-spacing:0.5px">SHAKE-256</span>
|
||||
<span style="font-size:10px;padding:2px 8px;border-radius:3px;background:rgba(0,229,255,0.1);color:#00E5FF;font-weight:600;text-transform:uppercase;letter-spacing:0.5px">Ed25519</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text-secondary);line-height:1.5">
|
||||
Cryptographic audit trail proving the causal history of every RVF pipeline event.
|
||||
Each <strong>witness</strong> verifies a specific measurement (transit depth, stellar parameters, etc.).
|
||||
The chain is <strong>hash-linked</strong>: every entry's SHAKE-256 hash includes the previous entry's hash, making tampering detectable.
|
||||
<span style="color:#FF4D4D">Seal</span> = anchor,
|
||||
<span style="color:#00E5FF">Commit</span> = new evidence,
|
||||
<span style="color:#FFB020">Merge</span> = branch join,
|
||||
<span style="color:#2ECC71">Verify</span> = integrity confirmed.
|
||||
</div>
|
||||
`;
|
||||
outer.appendChild(header);
|
||||
|
||||
// Metrics row
|
||||
const metricsRow = document.createElement('div');
|
||||
metricsRow.style.cssText = 'display:flex;gap:12px;padding:12px 20px;border-bottom:1px solid var(--border);flex-shrink:0;flex-wrap:wrap';
|
||||
const metricDefs = [
|
||||
{ key: 'entries', label: 'Chain Length', color: 'var(--accent)' },
|
||||
{ key: 'integrity', label: 'Integrity', color: '#2ECC71' },
|
||||
{ key: 'coherence', label: 'Mean Coherence', color: '' },
|
||||
{ key: 'minCoherence', label: 'Min Coherence', color: '' },
|
||||
{ key: 'depth', label: 'Epochs', color: '' },
|
||||
{ key: 'rootHash', label: 'Root Hash', color: 'var(--text-muted)' },
|
||||
];
|
||||
for (const m of metricDefs) {
|
||||
const card = document.createElement('div');
|
||||
card.style.cssText = 'background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--radius);padding:10px 14px;min-width:100px;flex:1';
|
||||
card.innerHTML = `
|
||||
<div style="font-size:10px;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:3px">${m.label}</div>
|
||||
<div data-metric="${m.key}" style="font-family:var(--font-mono);font-size:18px;font-weight:500;color:${m.color || 'var(--text-primary)'};line-height:1.2">--</div>
|
||||
`;
|
||||
metricsRow.appendChild(card);
|
||||
this.metricsEls[m.key] = card.querySelector(`[data-metric="${m.key}"]`) as HTMLElement;
|
||||
}
|
||||
outer.appendChild(metricsRow);
|
||||
|
||||
// Main content area
|
||||
const content = document.createElement('div');
|
||||
content.style.cssText = 'flex:1;overflow:auto;padding:16px 20px;display:flex;flex-direction:column;gap:16px';
|
||||
outer.appendChild(content);
|
||||
|
||||
// Info panel — 3 columns
|
||||
const infoPanel = document.createElement('div');
|
||||
infoPanel.style.cssText = 'display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;flex-shrink:0';
|
||||
infoPanel.innerHTML = `
|
||||
<div style="background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--radius);padding:14px">
|
||||
<div style="font-size:11px;font-weight:600;color:var(--accent);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px">How It Works</div>
|
||||
<div style="font-size:11px;color:var(--text-secondary);line-height:1.6">
|
||||
Each pipeline stage produces a <strong>witness entry</strong> containing: the measurement taken, a confidence score (coherence),
|
||||
and a cryptographic hash that chains to the previous entry. This creates an immutable, tamper-evident record of the entire
|
||||
scientific analysis — from raw photometry to final candidate ranking.
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--radius);padding:14px">
|
||||
<div style="font-size:11px;font-weight:600;color:#FFB020;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px">Hash Linking</div>
|
||||
<div style="font-size:11px;color:var(--text-secondary);line-height:1.6">
|
||||
SHAKE-256 (variable-length SHA-3 family) hashes each entry including the previous hash, creating a <strong>Merkle chain</strong>.
|
||||
If any entry is modified, all subsequent hashes become invalid. The final entry is signed with <strong>Ed25519</strong>
|
||||
to prove chain authorship and prevent repudiation.
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--radius);padding:14px">
|
||||
<div style="font-size:11px;font-weight:600;color:#2ECC71;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px">Coherence Score</div>
|
||||
<div style="font-size:11px;color:var(--text-secondary);line-height:1.6">
|
||||
Each witness reports a <strong>coherence</strong> value (0–1) indicating how well the new evidence agrees with prior chain state.
|
||||
Values < 0.90 are flagged as <span style="color:#FFB020">amber</span> (potential anomaly).
|
||||
The coherence chart below shows how confidence evolves across the pipeline, highlighting where uncertainty enters.
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
content.appendChild(infoPanel);
|
||||
|
||||
// Chain visualization (canvas)
|
||||
const chainPanel = document.createElement('div');
|
||||
chainPanel.style.cssText = 'background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;flex-shrink:0';
|
||||
const chainHeader = document.createElement('div');
|
||||
chainHeader.style.cssText = 'padding:10px 14px;font-size:11px;font-weight:500;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.6px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center';
|
||||
chainHeader.innerHTML = '<span>Chain Topology</span><span style="font-size:10px;color:var(--text-muted);font-family:var(--font-mono)">Click a node for details</span>';
|
||||
chainPanel.appendChild(chainHeader);
|
||||
this.chainCanvas = document.createElement('canvas');
|
||||
this.chainCanvas.style.cssText = 'width:100%;height:120px;display:block;cursor:pointer';
|
||||
this.chainCanvas.addEventListener('click', (e) => this.onChainClick(e));
|
||||
chainPanel.appendChild(this.chainCanvas);
|
||||
content.appendChild(chainPanel);
|
||||
|
||||
// Detail panel (shows on node click)
|
||||
this.detailEl = document.createElement('div');
|
||||
this.detailEl.style.cssText = 'background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--radius);padding:14px;flex-shrink:0;display:none';
|
||||
content.appendChild(this.detailEl);
|
||||
|
||||
// Coherence chart
|
||||
const cohPanel = document.createElement('div');
|
||||
cohPanel.style.cssText = 'background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;flex-shrink:0';
|
||||
const cohHeader = document.createElement('div');
|
||||
cohHeader.style.cssText = 'padding:10px 14px;font-size:11px;font-weight:500;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.6px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center';
|
||||
cohHeader.innerHTML = '<span>Coherence Evolution</span><span style="font-size:10px;color:var(--text-muted);font-family:var(--font-mono)">Dashed line = 0.90 threshold</span>';
|
||||
cohPanel.appendChild(cohHeader);
|
||||
this.coherenceCanvas = document.createElement('canvas');
|
||||
this.coherenceCanvas.style.cssText = 'width:100%;height:140px;display:block';
|
||||
cohPanel.appendChild(this.coherenceCanvas);
|
||||
content.appendChild(cohPanel);
|
||||
|
||||
// Witness log
|
||||
const logPanel = document.createElement('div');
|
||||
logPanel.style.cssText = 'background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;flex:1;min-height:200px;display:flex;flex-direction:column';
|
||||
const logHeader = document.createElement('div');
|
||||
logHeader.style.cssText = 'padding:10px 14px;font-size:11px;font-weight:500;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.6px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;flex-shrink:0';
|
||||
logHeader.innerHTML = '<span>Witness Log</span><span style="font-size:10px;color:var(--text-muted);font-family:var(--font-mono)">Hash-linked entries</span>';
|
||||
logPanel.appendChild(logHeader);
|
||||
// Column headers
|
||||
const colHeaders = document.createElement('div');
|
||||
colHeaders.style.cssText = 'display:flex;align-items:center;gap:10px;padding:6px 14px;border-bottom:1px solid var(--border);font-size:10px;font-weight:500;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.4px;flex-shrink:0';
|
||||
colHeaders.innerHTML = `
|
||||
<span style="min-width:60px">Time</span>
|
||||
<span style="min-width:52px">Type</span>
|
||||
<span style="min-width:90px">Witness</span>
|
||||
<span style="flex:1">Action</span>
|
||||
<span style="min-width:50px;text-align:right">Coh.</span>
|
||||
<span style="min-width:100px;text-align:right">Hash</span>
|
||||
`;
|
||||
logPanel.appendChild(colHeaders);
|
||||
this.logEl = document.createElement('div');
|
||||
this.logEl.style.cssText = 'flex:1;overflow-y:auto;font-family:var(--font-mono);font-size:11px';
|
||||
logPanel.appendChild(this.logEl);
|
||||
content.appendChild(logPanel);
|
||||
|
||||
this.loadData();
|
||||
|
||||
this.unsubWs = onEvent((ev: LiveEvent) => {
|
||||
if (ev.event_type === 'witness') {
|
||||
this.addLiveEntry(ev);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async loadData(): Promise<void> {
|
||||
let log: WitnessLogResponse;
|
||||
try {
|
||||
log = await fetchWitnessLog();
|
||||
} catch {
|
||||
log = {
|
||||
entries: [], chain_length: 0, integrity: '--', hash_algorithm: 'SHAKE-256',
|
||||
root_hash: '--', genesis_hash: '--', mean_coherence: 0, min_coherence: 0, total_epochs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
this.chainMeta = {
|
||||
integrity: log.integrity,
|
||||
hashAlgo: log.hash_algorithm,
|
||||
rootHash: log.root_hash,
|
||||
meanCoherence: log.mean_coherence,
|
||||
minCoherence: log.min_coherence,
|
||||
};
|
||||
|
||||
this.entries = log.entries.map((e: ApiWitnessEntry) => ({
|
||||
timestamp: e.timestamp.includes('T') ? e.timestamp.split('T')[1]?.substring(0, 8) ?? '' : e.timestamp,
|
||||
type: e.type,
|
||||
witness: e.witness,
|
||||
action: e.action,
|
||||
hash: e.hash,
|
||||
prevHash: e.prev_hash,
|
||||
coherence: e.coherence,
|
||||
measurement: e.measurement,
|
||||
epoch: e.epoch,
|
||||
}));
|
||||
|
||||
if (this.entries.length === 0) {
|
||||
this.entries = this.generateDemoEntries();
|
||||
}
|
||||
|
||||
this.updateMetrics(log);
|
||||
this.renderChain();
|
||||
this.renderCoherence();
|
||||
this.renderLog();
|
||||
}
|
||||
|
||||
private updateMetrics(log: WitnessLogResponse): void {
|
||||
const set = (key: string, val: string) => {
|
||||
const el = this.metricsEls[key];
|
||||
if (el) el.textContent = val;
|
||||
};
|
||||
set('entries', String(this.entries.length));
|
||||
set('integrity', this.chainMeta.integrity);
|
||||
set('coherence', this.chainMeta.meanCoherence > 0 ? this.chainMeta.meanCoherence.toFixed(4) : '--');
|
||||
set('minCoherence', this.chainMeta.minCoherence > 0 ? this.chainMeta.minCoherence.toFixed(4) : '--');
|
||||
set('depth', String(log.total_epochs));
|
||||
set('rootHash', this.chainMeta.rootHash.substring(0, 12) + '...');
|
||||
|
||||
// Color the min coherence if below threshold
|
||||
const minEl = this.metricsEls['minCoherence'];
|
||||
if (minEl && this.chainMeta.minCoherence > 0 && this.chainMeta.minCoherence < 0.9) {
|
||||
minEl.style.color = '#FFB020';
|
||||
}
|
||||
// Color integrity
|
||||
const intEl = this.metricsEls['integrity'];
|
||||
if (intEl) {
|
||||
intEl.style.color = this.chainMeta.integrity === 'VALID' ? '#2ECC71' : '#FF4D4D';
|
||||
}
|
||||
}
|
||||
|
||||
private renderChain(): void {
|
||||
const canvas = this.chainCanvas;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.parentElement?.getBoundingClientRect();
|
||||
const w = rect?.width ?? 800;
|
||||
const h = 120;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const n = this.entries.length;
|
||||
if (n === 0) return;
|
||||
|
||||
const padX = 40;
|
||||
const padY = 20;
|
||||
const innerW = w - padX * 2;
|
||||
const midY = h / 2;
|
||||
const nodeR = 8;
|
||||
|
||||
// Draw connecting lines first
|
||||
for (let i = 0; i < n - 1; i++) {
|
||||
const x1 = padX + (i / (n - 1)) * innerW;
|
||||
const x2 = padX + ((i + 1) / (n - 1)) * innerW;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1 + nodeR, midY);
|
||||
ctx.lineTo(x2 - nodeR, midY);
|
||||
ctx.strokeStyle = '#1E2630';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Arrow head
|
||||
const ax = x2 - nodeR - 6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ax, midY - 3);
|
||||
ctx.lineTo(ax + 6, midY);
|
||||
ctx.lineTo(ax, midY + 3);
|
||||
ctx.fillStyle = '#1E2630';
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Draw nodes
|
||||
for (let i = 0; i < n; i++) {
|
||||
const entry = this.entries[i];
|
||||
const x = padX + (n > 1 ? (i / (n - 1)) * innerW : innerW / 2);
|
||||
const color = TYPE_COLORS[entry.type] ?? '#00E5FF';
|
||||
const isSelected = i === this.selectedIdx;
|
||||
|
||||
// Glow for selected
|
||||
if (isSelected) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, midY, nodeR + 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color.replace(')', ', 0.15)').replace('rgb', 'rgba').replace('#', '');
|
||||
// Use a simpler glow approach
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = 12;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// Outer ring
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, midY, nodeR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = isSelected ? color : 'transparent';
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// Inner dot
|
||||
if (!isSelected) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, midY, 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Witness label (above)
|
||||
ctx.fillStyle = '#8B949E';
|
||||
ctx.font = '9px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
const label = entry.witness.replace('W_', '');
|
||||
ctx.fillText(label, x, midY - nodeR - padY + 8);
|
||||
|
||||
// Coherence label (below)
|
||||
ctx.fillStyle = entry.coherence < 0.9 ? '#FFB020' : '#484F58';
|
||||
ctx.font = '9px monospace';
|
||||
ctx.fillText(entry.coherence.toFixed(2), x, midY + nodeR + 14);
|
||||
|
||||
// Hash snippet (below coherence)
|
||||
ctx.fillStyle = '#30363D';
|
||||
ctx.font = '8px monospace';
|
||||
ctx.fillText(entry.hash.substring(0, 6), x, midY + nodeR + 24);
|
||||
}
|
||||
}
|
||||
|
||||
private onChainClick(e: MouseEvent): void {
|
||||
const canvas = this.chainCanvas;
|
||||
if (!canvas || this.entries.length === 0) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const n = this.entries.length;
|
||||
const padX = 40;
|
||||
const innerW = rect.width - padX * 2;
|
||||
|
||||
let closest = -1;
|
||||
let minDist = Infinity;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const nx = padX + (n > 1 ? (i / (n - 1)) * innerW : innerW / 2);
|
||||
const dist = Math.abs(x - nx);
|
||||
if (dist < minDist && dist < 20) {
|
||||
minDist = dist;
|
||||
closest = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (closest >= 0) {
|
||||
this.selectedIdx = closest === this.selectedIdx ? -1 : closest;
|
||||
this.renderChain();
|
||||
this.showDetail(this.selectedIdx >= 0 ? this.entries[this.selectedIdx] : null);
|
||||
}
|
||||
}
|
||||
|
||||
private showDetail(entry: WitnessEntry | null): void {
|
||||
if (!this.detailEl) return;
|
||||
if (!entry) {
|
||||
this.detailEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const color = TYPE_COLORS[entry.type] ?? '#00E5FF';
|
||||
const typeDesc = TYPE_LABELS[entry.type] ?? '';
|
||||
const cohColor = entry.coherence < 0.9 ? '#FFB020' : '#2ECC71';
|
||||
|
||||
this.detailEl.style.display = 'block';
|
||||
this.detailEl.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
|
||||
<div style="width:10px;height:10px;border-radius:50%;background:${color}"></div>
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text-primary);font-family:var(--font-mono)">${entry.witness}</span>
|
||||
<span style="font-size:10px;padding:2px 8px;border-radius:3px;background:${color}22;color:${color};font-weight:600;text-transform:uppercase">${entry.type}</span>
|
||||
<span style="font-size:10px;color:var(--text-muted)">${typeDesc}</span>
|
||||
<span style="margin-left:auto;font-size:11px;color:var(--text-muted);font-family:var(--font-mono)">Epoch ${entry.epoch}</span>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div>
|
||||
<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px">Action</div>
|
||||
<div style="font-size:12px;color:var(--text-primary);line-height:1.5">${entry.action}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px">Measurement</div>
|
||||
<div style="font-size:12px;color:var(--accent);font-family:var(--font-mono)">${entry.measurement ?? 'N/A'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">
|
||||
<div>
|
||||
<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;margin-bottom:2px">Coherence</div>
|
||||
<div style="font-size:16px;font-weight:500;color:${cohColor};font-family:var(--font-mono)">${entry.coherence.toFixed(4)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;margin-bottom:2px">Hash</div>
|
||||
<div style="font-size:11px;color:var(--text-primary);font-family:var(--font-mono)">${entry.hash}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;margin-bottom:2px">Previous Hash</div>
|
||||
<div style="font-size:11px;color:var(--text-muted);font-family:var(--font-mono)">${entry.prevHash}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCoherence(): void {
|
||||
const canvas = this.coherenceCanvas;
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = canvas.parentElement?.getBoundingClientRect();
|
||||
const w = rect?.width ?? 800;
|
||||
const h = 140;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const n = this.entries.length;
|
||||
if (n === 0) return;
|
||||
|
||||
const padL = 50, padR = 20, padT = 16, padB = 28;
|
||||
const iW = w - padL - padR;
|
||||
const iH = h - padT - padB;
|
||||
|
||||
// Y axis: 0.80 to 1.00
|
||||
const yMin = 0.80, yMax = 1.01;
|
||||
const toX = (i: number) => padL + (n > 1 ? (i / (n - 1)) * iW : iW / 2);
|
||||
const toY = (v: number) => padT + (1 - (v - yMin) / (yMax - yMin)) * iH;
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = '#161C24';
|
||||
ctx.lineWidth = 1;
|
||||
for (let v = 0.80; v <= 1.001; v += 0.05) {
|
||||
const y = toY(v);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padL, y);
|
||||
ctx.lineTo(w - padR, y);
|
||||
ctx.stroke();
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = '#484F58';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(v.toFixed(2), padL - 6, y + 4);
|
||||
}
|
||||
|
||||
// Threshold line at 0.90
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.strokeStyle = '#FFB02066';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padL, toY(0.90));
|
||||
ctx.lineTo(w - padR, toY(0.90));
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Label threshold
|
||||
ctx.fillStyle = '#FFB020';
|
||||
ctx.font = '9px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('threshold', w - padR - 55, toY(0.90) - 4);
|
||||
|
||||
// Fill area under coherence line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(toX(0), toY(yMin));
|
||||
for (let i = 0; i < n; i++) {
|
||||
ctx.lineTo(toX(i), toY(Math.max(yMin, this.entries[i].coherence)));
|
||||
}
|
||||
ctx.lineTo(toX(n - 1), toY(yMin));
|
||||
ctx.closePath();
|
||||
const grad = ctx.createLinearGradient(0, padT, 0, padT + iH);
|
||||
grad.addColorStop(0, 'rgba(0, 229, 255, 0.08)');
|
||||
grad.addColorStop(1, 'rgba(0, 229, 255, 0.01)');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fill();
|
||||
|
||||
// Coherence line
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = toX(i);
|
||||
const y = toY(Math.max(yMin, this.entries[i].coherence));
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.strokeStyle = '#00E5FF';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Data points
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = toX(i);
|
||||
const coh = Math.max(yMin, this.entries[i].coherence);
|
||||
const y = toY(coh);
|
||||
const color = this.entries[i].coherence < 0.9
|
||||
? '#FFB020'
|
||||
: TYPE_COLORS[this.entries[i].type] ?? '#00E5FF';
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#0B0F14';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// X-axis labels
|
||||
ctx.fillStyle = '#484F58';
|
||||
ctx.font = '8px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
const label = this.entries[i].witness.replace('W_', '');
|
||||
if (n <= 20 || i % 2 === 0) {
|
||||
ctx.fillText(label, x, h - padB + 14);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private renderLog(): void {
|
||||
if (!this.logEl) return;
|
||||
this.logEl.innerHTML = '';
|
||||
|
||||
for (let i = 0; i < this.entries.length; i++) {
|
||||
this.appendLogEntry(this.entries[i], i);
|
||||
}
|
||||
}
|
||||
|
||||
private appendLogEntry(entry: WitnessEntry, idx: number): void {
|
||||
if (!this.logEl) return;
|
||||
const row = document.createElement('div');
|
||||
row.style.cssText = 'display:flex;align-items:center;gap:10px;padding:6px 14px;border-bottom:1px solid var(--border-subtle);cursor:pointer;transition:background 0.1s';
|
||||
row.addEventListener('mouseenter', () => { row.style.background = 'rgba(255,255,255,0.015)'; });
|
||||
row.addEventListener('mouseleave', () => { row.style.background = idx === this.selectedIdx ? 'rgba(0,229,255,0.04)' : ''; });
|
||||
row.addEventListener('click', () => {
|
||||
this.selectedIdx = idx === this.selectedIdx ? -1 : idx;
|
||||
this.renderChain();
|
||||
this.showDetail(this.selectedIdx >= 0 ? this.entries[this.selectedIdx] : null);
|
||||
// Highlight the row
|
||||
const rows = this.logEl?.children;
|
||||
if (rows) {
|
||||
for (let r = 0; r < rows.length; r++) {
|
||||
(rows[r] as HTMLElement).style.background = r === this.selectedIdx ? 'rgba(0,229,255,0.04)' : '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const color = TYPE_COLORS[entry.type] ?? '#00E5FF';
|
||||
const cohColor = entry.coherence < 0.9 ? '#FFB020' : '#484F58';
|
||||
|
||||
row.innerHTML = `
|
||||
<span style="color:var(--text-muted);min-width:60px;white-space:nowrap;font-size:10px">${entry.timestamp}</span>
|
||||
<span style="padding:2px 8px;border-radius:3px;font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:0.3px;min-width:52px;text-align:center;background:${color}18;color:${color}">${entry.type}</span>
|
||||
<span style="color:var(--accent);min-width:90px;font-size:11px">${entry.witness}</span>
|
||||
<span style="color:var(--text-primary);flex:1;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${entry.action}">${entry.action}</span>
|
||||
<span style="color:${cohColor};min-width:50px;text-align:right;font-size:11px">${entry.coherence.toFixed(2)}</span>
|
||||
<span style="color:var(--text-muted);font-size:10px;min-width:100px;text-align:right" title="Hash: ${entry.hash} | Prev: ${entry.prevHash}">${entry.hash.substring(0, 8)}..${entry.prevHash.substring(0, 4)}</span>
|
||||
`;
|
||||
this.logEl.appendChild(row);
|
||||
}
|
||||
|
||||
private addLiveEntry(ev: LiveEvent): void {
|
||||
const entry: WitnessEntry = {
|
||||
timestamp: new Date(ev.timestamp * 1000).toISOString().substring(11, 19),
|
||||
type: String(ev.data['type'] ?? 'commit'),
|
||||
witness: String(ev.data['witness'] ?? 'W_live'),
|
||||
action: String(ev.data['action'] ?? 'live_event'),
|
||||
hash: String(ev.data['hash'] ?? this.fakeHash('live')),
|
||||
prevHash: this.entries.length > 0 ? this.entries[this.entries.length - 1].hash : '0000000000000000',
|
||||
coherence: Number(ev.data['coherence'] ?? 1.0),
|
||||
measurement: ev.data['measurement'] ? String(ev.data['measurement']) : null,
|
||||
epoch: this.entries.length,
|
||||
};
|
||||
this.entries.push(entry);
|
||||
this.appendLogEntry(entry, this.entries.length - 1);
|
||||
this.renderChain();
|
||||
this.renderCoherence();
|
||||
|
||||
// Update entry count metric
|
||||
const el = this.metricsEls['entries'];
|
||||
if (el) el.textContent = String(this.entries.length);
|
||||
|
||||
// Auto-scroll log
|
||||
if (this.logEl) {
|
||||
this.logEl.scrollTop = this.logEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
private fakeHash(seed: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h = ((h << 5) - h + seed.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(h).toString(16).padStart(16, '0').substring(0, 16);
|
||||
}
|
||||
|
||||
private generateDemoEntries(): WitnessEntry[] {
|
||||
const witnesses = [
|
||||
{ w: 'W_root', t: 'seal', a: 'Chain initialized — genesis anchor', m: null },
|
||||
{ w: 'W_photometry', t: 'commit', a: 'Kepler light curves ingested (196K targets)', m: 'transit_depth_rms=4.2e-5' },
|
||||
{ w: 'W_periodogram', t: 'commit', a: 'BLS search completed — 2,842 signals', m: 'bls_power_max=42.7' },
|
||||
{ w: 'W_stellar', t: 'commit', a: 'Stellar parameters derived (Gaia DR3)', m: 'T_eff_sigma=47K' },
|
||||
{ w: 'W_transit', t: 'merge', a: 'Transit model merged with stellar params', m: 'R_p_range=0.92-2.61' },
|
||||
{ w: 'W_radial_velocity', t: 'commit', a: 'HARPS RV data — mass constraints', m: 'K_rv_range=0.089-3.2' },
|
||||
{ w: 'W_orbit', t: 'commit', a: 'Orbital solutions — HZ classification', m: 'hz_candidates=10' },
|
||||
{ w: 'W_esi', t: 'commit', a: 'ESI ranking computed', m: 'esi_top=0.93' },
|
||||
{ w: 'W_spectroscopy', t: 'merge', a: 'JWST atmospheric observations merged', m: 'CH4+CO2_detected' },
|
||||
{ w: 'W_biosig', t: 'commit', a: 'Biosignature scoring pipeline', m: 'diseq_max=0.82' },
|
||||
{ w: 'W_blind', t: 'commit', a: 'Blind test passed (τ=1.0)', m: 'kendall_tau=1.000' },
|
||||
{ w: 'W_seal', t: 'verify', a: 'Chain sealed — Ed25519 signed', m: 'chain_length=12' },
|
||||
];
|
||||
let prevHash = '0000000000000000';
|
||||
return witnesses.map((w, i) => {
|
||||
const hash = this.fakeHash(w.w + i);
|
||||
const entry: WitnessEntry = {
|
||||
timestamp: new Date(Date.now() - (witnesses.length - i) * 120000).toISOString().substring(11, 19),
|
||||
type: w.t,
|
||||
witness: w.w,
|
||||
action: w.a,
|
||||
hash,
|
||||
prevHash,
|
||||
coherence: 1.0 - i * 0.01,
|
||||
measurement: w.m,
|
||||
epoch: i,
|
||||
};
|
||||
prevHash = hash;
|
||||
return entry;
|
||||
});
|
||||
}
|
||||
|
||||
unmount(): void {
|
||||
this.unsubWs?.();
|
||||
this.logEl = null;
|
||||
this.chainCanvas = null;
|
||||
this.coherenceCanvas = null;
|
||||
this.detailEl = null;
|
||||
this.metricsEls = {};
|
||||
this.container = null;
|
||||
this.entries = [];
|
||||
this.selectedIdx = -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
export interface LiveEvent {
|
||||
event_type: string;
|
||||
timestamp: number;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type EventCallback = (event: LiveEvent) => void;
|
||||
|
||||
const listeners: EventCallback[] = [];
|
||||
let socket: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectDelay = 1000;
|
||||
let intentionalClose = false;
|
||||
|
||||
const MAX_RECONNECT_DELAY = 30000;
|
||||
const RECONNECT_BACKOFF = 2;
|
||||
|
||||
function handleMessage(raw: MessageEvent): void {
|
||||
try {
|
||||
const event = JSON.parse(raw.data as string) as LiveEvent;
|
||||
for (const cb of listeners) {
|
||||
cb(event);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (intentionalClose) return;
|
||||
if (reconnectTimer) return;
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
openSocket();
|
||||
}, reconnectDelay);
|
||||
|
||||
reconnectDelay = Math.min(reconnectDelay * RECONNECT_BACKOFF, MAX_RECONNECT_DELAY);
|
||||
}
|
||||
|
||||
function openSocket(): void {
|
||||
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${protocol}//${location.host}/ws/live`;
|
||||
|
||||
try {
|
||||
socket = new WebSocket(url);
|
||||
} catch {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
reconnectDelay = 1000;
|
||||
});
|
||||
|
||||
socket.addEventListener('message', handleMessage);
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
socket = null;
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
socket.addEventListener('error', () => {
|
||||
socket?.close();
|
||||
});
|
||||
}
|
||||
|
||||
export function onEvent(callback: EventCallback): () => void {
|
||||
listeners.push(callback);
|
||||
return () => {
|
||||
const idx = listeners.indexOf(callback);
|
||||
if (idx >= 0) listeners.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
export function connect(): void {
|
||||
intentionalClose = false;
|
||||
reconnectDelay = 1000;
|
||||
openSocket();
|
||||
}
|
||||
|
||||
export function disconnect(): void {
|
||||
intentionalClose = true;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (socket) {
|
||||
socket.close();
|
||||
socket = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
base: '/',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
three: ['three'],
|
||||
d3: ['d3-scale', 'd3-axis', 'd3-shape', 'd3-selection'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user