github-actions[bot]
Sync: publish Space API fix
5e0b58b
Raw History Blame Contribute Delete
8.62 kB
/**
* Brain-5D Dashboard — Heatmap & 5D Projection Visualization
*
* Polls scientific endpoints directly; large data remains lazy-loaded.
*
* @version 1.0.0
* @license MIT
*/
"use strict";
const $ = (id) => document.getElementById(id);
export let heatmapKind = 'activity';
export let liveSource = true;
function formatFloat(value, decimals = 3) {
if (value === null || value === undefined) return '—';
return Number(value).toFixed(decimals);
}
function formatNumber(value) {
if (value === null || value === undefined) return '—';
return Number(value).toLocaleString();
}
function heatmapColor(value, min, max) {
const scale = max > min ? (value - min) / (max - min) : 0;
const t = Math.max(0, Math.min(1, scale));
const hue = 210 - (195 * t);
const light = 16 + (46 * t);
return `hsl(${hue}, 88%, ${light}%)`;
}
function setText(id, value) {
const el = $(id);
if (el) el.textContent = String(value);
}
export function setHeatmapKind(kind) {
heatmapKind = kind;
}
export function toggleLiveSource() {
liveSource = !liveSource;
return liveSource;
}
export function updateSourceBadge(telemetryStatus) {
const badge = $('source-badge');
if (!badge) return;
if (!liveSource) {
badge.textContent = 'SNAPSHOT';
badge.className = 'badge badge-snapshot';
return;
}
const status = telemetryStatus || (document.getElementById('heatmap-meta')?.dataset?.telemetryStatus);
if (status === 'stale') {
badge.textContent = 'STALE';
badge.className = 'badge badge-stale';
return;
}
if (status === 'unavailable') {
badge.textContent = 'UNAVAILABLE';
badge.className = 'badge badge-unavailable';
return;
}
badge.textContent = 'LIVE';
badge.className = 'badge badge-live';
}
export function drawHeatmap(payload) {
const canvas = $('heatmap');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const rows = payload.values || [];
if (!rows.length || !rows[0]?.length) {
setText('heatmap-meta', payload.error || 'Keine Heatmap-Daten verfügbar.');
return;
}
const valid = rows.flat().filter(v => v !== null && v !== undefined && Number.isFinite(v));
const min = valid.length > 0 ? Math.min(...valid) : 0;
const max = valid.length > 0 ? Math.max(...valid) : 0;
const width = rows[0].length;
const height = rows.length;
const cellW = canvas.width / width;
const cellH = canvas.height / height;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const val = rows[y][x];
if (val === null || val === undefined) {
ctx.fillStyle = 'rgba(40, 50, 65, 0.4)';
ctx.fillRect(x * cellW, y * cellH, Math.ceil(cellW), Math.ceil(cellH));
ctx.strokeStyle = 'rgba(60, 70, 85, 0.3)';
ctx.lineWidth = 0.5;
const cx = x * cellW;
const cy = y * cellH;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + cellW, cy + cellH);
ctx.moveTo(cx + cellW, cy);
ctx.lineTo(cx, cy + cellH);
ctx.stroke();
} else {
ctx.fillStyle = heatmapColor(val, min, max);
ctx.fillRect(x * cellW, y * cellH, Math.ceil(cellW), Math.ceil(cellH));
}
}
}
setText(
'heatmap-meta',
`${payload.source === 'live_runtime' ? 'LIVE' : 'SNAPSHOT'} · ${payload.kind || heatmapKind} · Tick ${payload.tick || 0} · ${payload.sample_count || 0} Samples · ${formatFloat(min, 4)}…${formatFloat(max, 4)}`
);
const metaEl = $('heatmap-meta');
if (metaEl && payload.telemetry && payload.telemetry.status) {
metaEl.dataset.telemetryStatus = payload.telemetry.status;
}
draw5DProjection(payload);
}
export async function refreshHeatmap() {
const canvas = $('heatmap');
if (!canvas) return;
try {
const response = await fetch(
`/api/heatmap?kind=${encodeURIComponent(heatmapKind)}`,
{ cache: 'no-store' }
);
if (!response.ok) {
const data = await response.json();
setText('heatmap-meta', data.error || `Heatmap HTTP ${response.status}`);
return;
}
drawHeatmap(await response.json());
} catch (error) {
setText('heatmap-meta', '⚠️ Heatmap nicht erreichbar.');
}
}
export async function refreshLiveProjection() {
const canvas = $('heatmap');
if (!canvas) return;
if (!liveSource) return;
try {
const response = await fetch(
`/api/live/projection?kind=${encodeURIComponent(heatmapKind)}&resolution=50`,
{ cache: 'no-store' }
);
if (!response.ok) {
if (liveSource) {
liveSource = false;
updateSourceBadge();
}
return;
}
const data = await response.json();
drawHeatmap(data);
updateSourceBadge(data.telemetry?.status);
} catch {
// Silently fall back — snapshot will be used on next interval
}
}
function draw5DProjection(payload) {
const canvas = $('projection-5d');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const values = payload.values || [];
if (!values.length) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const rows = values.length;
const cols = values[0]?.length || 1;
const cx = canvas.width / 2;
const cy = canvas.height / 2 + 20;
const scale = Math.min(canvas.width, canvas.height) * 0.35 / Math.max(rows, cols);
const flat = values.flat().filter(v => v !== null && v !== undefined && Number.isFinite(v));
if (flat.length === 0) {
ctx.fillStyle = '#4a6a8a';
ctx.font = '14px monospace';
ctx.textAlign = 'center';
ctx.fillText('Keine Daten', cx, cy);
return;
}
const min = Math.min(...flat);
const max = Math.max(...flat);
const range = max - min || 1;
ctx.strokeStyle = 'rgba(64, 224, 208, 0.06)';
ctx.lineWidth = 0.5;
const gridStep = 4;
for (let y = 0; y < rows; y += gridStep) {
for (let x = 0; x < cols; x += gridStep) {
const gx1 = cx + (x - y) * scale * 0.7;
const gy1 = cy + (x + y) * scale * 0.35;
ctx.beginPath();
ctx.arc(gx1, gy1, 1.5, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(64, 224, 208, 0.15)';
ctx.fill();
}
}
for (let y = 0; y < rows; y += 2) {
for (let x = 0; x < cols; x += 2) {
const val = values[y]?.[x];
if (val === null || val === undefined) continue;
const normalized = (val - min) / range;
const isoX = (x - y) * scale * 0.7;
const isoY = (x + y) * scale * 0.35 - normalized * scale * 2;
const size = 2 + normalized * 5;
const hue = 210 - normalized * 195;
const light = 20 + normalized * 55;
ctx.beginPath();
ctx.arc(cx + isoX, cy + isoY, size, 0, Math.PI * 2);
ctx.fillStyle = `hsl(${hue}, 85%, ${light}%)`;
ctx.fill();
if (normalized > 0.6) {
ctx.beginPath();
ctx.arc(cx + isoX, cy + isoY, size * 2.5, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${hue}, 85%, ${light}%, 0.15)`;
ctx.fill();
}
}
}
ctx.strokeStyle = 'rgba(64, 224, 208, 0.08)';
ctx.lineWidth = 0.5;
for (let y = 0; y < rows - 2; y += 3) {
for (let x = 0; x < cols - 2; x += 3) {
const v1 = values[y]?.[x];
const v2 = values[y]?.[x + 2];
const v3 = values[y + 2]?.[x];
if (v1 === null || v1 === undefined || v2 === null || v2 === undefined || v3 === null || v3 === undefined) continue;
const n1 = (v1 - min) / range;
const n2 = (v2 - min) / range;
const n3 = (v3 - min) / range;
const x1 = cx + (x - y) * scale * 0.7;
const y1 = cy + (x + y) * scale * 0.35 - n1 * scale * 2;
const x2 = cx + ((x + 2) - y) * scale * 0.7;
const y2 = cy + ((x + 2) + y) * scale * 0.35 - n2 * scale * 2;
const x3 = cx + (x - (y + 2)) * scale * 0.7;
const y3 = cy + (x + (y + 2)) * scale * 0.35 - n3 * scale * 2;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x3, y3);
ctx.stroke();
}
}
ctx.fillStyle = 'rgba(142, 178, 193, 0.5)';
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.fillText('X →', cx + cols * scale * 0.35, cy + cols * scale * 0.17 + 12);
ctx.fillText('Y →', cx - rows * scale * 0.35, cy + rows * scale * 0.17 + 12);
ctx.fillText(`Z: ${min.toFixed(2)} … ${max.toFixed(2)}`, cx, 14);
const dims = payload.dimensions || [];
if (dims.length >= 5) {
ctx.fillStyle = 'rgba(142, 178, 193, 0.35)';
ctx.font = '9px monospace';
ctx.textAlign = 'left';
ctx.fillText(`5D: ${dims.join('×')}`, 8, canvas.height - 8);
}
}