Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'

This commit is contained in:
ruv
2026-02-28 14:39:40 -05:00
7854 changed files with 3522914 additions and 0 deletions

View File

@@ -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 },
};
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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,
};
}

View File

@@ -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; }
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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)}&deg;</span></div>` +
`<div style="margin-top:4px;color:#2ECC71;font-size:9px">&#9679; 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;
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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;
}
}

View File

@@ -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">&Delta;${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: &lt;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;
}
}

View File

@@ -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> &mdash; 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;
}
}

View File

@@ -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 &lt;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 &lt;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 = {};
}
}

View File

@@ -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)">&#8594;</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">&#127760; View 3D</span>
<span style="margin-left:auto;font-size:10px;color:var(--text-muted)">
R=${c.pipeline_derived.radius_earth.toFixed(2)} R&#8853; | 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&#8853;</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&#9737;</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;
}
}

View File

@@ -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 &mdash; raw telescope data, analysis code,
results, cryptographic proofs, and this interactive dashboard &mdash; 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> &mdash;
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 &amp; 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>
&bull; <strong>Step name</strong> &mdash; what operation was performed<br>
&bull; <strong>Input hash</strong> &mdash; SHAKE-256 of data going in<br>
&bull; <strong>Output hash</strong> &mdash; SHAKE-256 of data coming out<br>
&bull; <strong>Parent hash</strong> &mdash; links to previous entry (chain)<br>
&bull; <strong>Ed25519 signature</strong> &mdash; 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" &mdash; 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> &mdash; Can the solver achieve basic accuracy?<br>
<span style="color:#FFB020;font-weight:600">Mode B (Compiler)</span> &mdash; Accuracy + computational cost reduction?<br>
<span style="color:#2ECC71;font-weight:600">Mode C (Learned)</span> &mdash; 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 &mdash; 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 &mdash; 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> &mdash; Trying uncertain arms to gather data<br>
<span style="${S.success}">Exploitation</span> &mdash; 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 &mdash; 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 &mdash; 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 &mdash; fast kernel programs')}
${this.glossaryRow('WASM', 'WebAssembly &mdash; 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 &mdash; possible artificial origin')}
${this.glossaryRow('SED', 'Spectral Energy Distribution &mdash; 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>`;
}
}

View File

@@ -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 &amp; 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

View File

@@ -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 &mdash; 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> &mdash; 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) &mdash; product of photosynthesis. <strong>CH<sub>4</sub></strong> (methane) &mdash; produced by methanogens. <strong>H<sub>2</sub>O</strong> (water) &mdash; essential solvent. <strong>CO<sub>2</sub></strong> &mdash; greenhouse gas. <strong>DMS</strong> &mdash; 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 &amp; 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> &mdash; 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)}% &mdash; ${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;
}
}

View File

@@ -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 (&lt;1&mu;s), recent data in CPU cache.
<strong>M (Warm/HNSW)</strong> = indexed vectors (~12&mu;s), approximate nearest-neighbor graph.
<strong>L (Cold/Disk)</strong> = archived segments (~450&mu;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;
}
}

View File

@@ -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 &mdash; 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 &mdash; 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">&#9632;</span> Light Curve &mdash; real transit depth from published photometry</span>
<span><span style="color:#00E5FF">&#9632;</span> Radar &mdash; detection quality (score, period, radius, mass, temperature)</span>
<span><span style="color:#ffdd44">&#9632;</span> 3D Orbit &mdash; 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&#8853;</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&#8853;</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> &mdash;
${c.telescope || 'N/A'} (${c.discoveryYear || '?'}) &mdash;
<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

View File

@@ -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&ndash;P2 = planet pipeline, L0&ndash;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;
}
}

View File

@@ -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 (01) 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;
}
}

View File

@@ -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;
}
}