Add browser Eve renderer parity surfaces
This commit is contained in:
parent
21b646f499
commit
e1f21cea87
7 changed files with 635 additions and 0 deletions
16
README.md
16
README.md
|
|
@ -86,6 +86,22 @@ Mimir dashboard broker, and surfaces timestamped touch/motion sensor data.
|
|||
accepted state, commands, and side effects.
|
||||
|
||||
See `docs/cultmesh-streaming-ui-framework.md` for the target architecture.
|
||||
See `docs/renderer-parity.md` for current browser/iOS/Android/Fensalir/Flutter
|
||||
renderer parity.
|
||||
|
||||
## Browser Reference
|
||||
|
||||
Start the browser reference surface runner:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\start-browser-reference.ps1
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:8891/`. The page can spawn:
|
||||
|
||||
- `VoidBot Live`, connected to Mimir's `/eve/deck` broker.
|
||||
- `Fensalir Direct2D`, a fixture surface for the planned
|
||||
CultMesh-to-`AquariumUiDocument` lowering.
|
||||
|
||||
## VoidBot CEF Stream
|
||||
|
||||
|
|
|
|||
36
docs/renderer-parity.md
Normal file
36
docs/renderer-parity.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Eve Renderer Parity
|
||||
|
||||
Parity is currently uneven by design. The shared surface contract is forming,
|
||||
but the browser reference is the first renderer that can be used as a practical
|
||||
comparison oracle.
|
||||
|
||||
## Current State
|
||||
|
||||
| Renderer | Status | Proof | Gap |
|
||||
| --- | --- | --- | --- |
|
||||
| Browser | First reference runner | `web/` renders live VoidBot and Fensalir fixture surfaces | Needs visual regression fixtures and command round-trip tests |
|
||||
| iOS / EveCanvas | Native proof | Renders Mimir provider trees and a custom VoidBot cockpit with avatar images | Uses provider-specific UIKit rendering for VoidBot |
|
||||
| Android / Periwinkle | Device-edge proof | APK builds and shows broker/sensor status | Install blocked until device allows ADB sideload; dashboard rendering is not implemented |
|
||||
| Fensalir Direct2D | Documented landing zone | Existing `AquariumUiDocument` and `DirectWriteOverlay` path | Needs adapter from Eve surface document to `AquariumUiDocument` |
|
||||
| Flutter | Candidate shared native path | Not installed locally | Needs toolchain and client scaffold |
|
||||
|
||||
## Spawned Surfaces
|
||||
|
||||
The browser reference runner spawns two surfaces:
|
||||
|
||||
- `VoidBot Live`: connects to Mimir's `/eve/deck` broker and opens
|
||||
`voidbot.swarm`.
|
||||
- `Fensalir Direct2D`: loads `web/fixtures/fensalir-client-surface.json`, a
|
||||
recorded surface describing the Direct2D client lowering path.
|
||||
|
||||
Start it with:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\start-browser-reference.ps1
|
||||
```
|
||||
|
||||
Then open:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8891/
|
||||
```
|
||||
56
scripts/start-browser-reference.ps1
Normal file
56
scripts/start-browser-reference.ps1
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
param(
|
||||
[int] $Port = 8891
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$webRoot = Join-Path $projectRoot "web"
|
||||
$logRoot = Join-Path $projectRoot "artifacts\web"
|
||||
New-Item -ItemType Directory -Force $logRoot | Out-Null
|
||||
|
||||
$pidPath = Join-Path $logRoot "eve-browser-reference.pid"
|
||||
$outLog = Join-Path $logRoot "eve-browser-reference.out.log"
|
||||
$errLog = Join-Path $logRoot "eve-browser-reference.err.log"
|
||||
|
||||
if (Test-Path $pidPath) {
|
||||
$oldPid = Get-Content $pidPath -ErrorAction SilentlyContinue
|
||||
if ($oldPid) {
|
||||
Stop-Process -Id ([int]$oldPid) -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
$nodeScript = @'
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const root = process.argv[2];
|
||||
const port = Number(process.argv[3]);
|
||||
const types = {'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json; charset=utf-8'};
|
||||
http.createServer((req, res) => {
|
||||
const urlPath = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
|
||||
const clean = urlPath === '/' ? '/index.html' : urlPath;
|
||||
const file = path.normalize(path.join(root, clean));
|
||||
if (!file.startsWith(root)) {
|
||||
res.writeHead(403);
|
||||
res.end('forbidden');
|
||||
return;
|
||||
}
|
||||
fs.readFile(file, (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(404);
|
||||
res.end('not found');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {'content-type': types[path.extname(file)] || 'application/octet-stream'});
|
||||
res.end(data);
|
||||
});
|
||||
}).listen(port, '127.0.0.1', () => console.log(`Eve browser reference listening on http://127.0.0.1:${port}`));
|
||||
'@
|
||||
|
||||
$scriptPath = Join-Path $logRoot "serve-eve-reference.cjs"
|
||||
Set-Content -LiteralPath $scriptPath -Value $nodeScript -Encoding UTF8
|
||||
$proc = Start-Process -FilePath "node" -ArgumentList @($scriptPath, $webRoot, $Port) -WorkingDirectory $projectRoot -RedirectStandardOutput $outLog -RedirectStandardError $errLog -PassThru -WindowStyle Hidden
|
||||
$proc.Id | Set-Content -LiteralPath $pidPath
|
||||
Start-Sleep -Milliseconds 500
|
||||
Get-Content $outLog -Tail 5
|
||||
Write-Host "PID $($proc.Id)"
|
||||
88
web/fixtures/fensalir-client-surface.json
Normal file
88
web/fixtures/fensalir-client-surface.json
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
{
|
||||
"type": "dashboard-state",
|
||||
"schema": "mimir.eve_dashboard_state.v1",
|
||||
"providerId": "fensalir.direct2d",
|
||||
"title": "Fensalir Direct2D Client Surface",
|
||||
"version": 1,
|
||||
"updatedAt": "2026-05-31T03:34:00.000Z",
|
||||
"selectedNodeId": "surface-contract",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "surface-contract",
|
||||
"label": "Eve Surface Contract\nCultMesh -> AquariumUiDocument",
|
||||
"kind": "state-detail",
|
||||
"x": -0.62,
|
||||
"y": -0.18,
|
||||
"width": 0.34,
|
||||
"height": 0.34,
|
||||
"health": "owner",
|
||||
"detail": "Browser Eve is the reference renderer. Fensalir consumes the same provider-owned surface document and lowers it into AquariumUiDocument. Provider repos own state and command semantics; Fensalir owns DirectWrite/Direct2D rendering, focus, keyboard, pointer dispatch, and overlay timing."
|
||||
},
|
||||
{
|
||||
"id": "direct2d-overlay",
|
||||
"label": "DirectWriteOverlay\nD3D11-on-12 Direct2D",
|
||||
"kind": "renderer",
|
||||
"x": -0.12,
|
||||
"y": -0.54,
|
||||
"width": 0.34,
|
||||
"height": 0.22,
|
||||
"health": "ready",
|
||||
"detail": "Draws Eve panels after the D3D12 scene. Existing Fensalir mechanism: DirectWriteOverlay.Render(frame, debugUi, clientUiPanels, performanceText)."
|
||||
},
|
||||
{
|
||||
"id": "aquarium-ui",
|
||||
"label": "AquariumUiDocument\npanels and controls",
|
||||
"kind": "surface",
|
||||
"x": 0.36,
|
||||
"y": -0.48,
|
||||
"width": 0.34,
|
||||
"height": 0.22,
|
||||
"health": "adapter target",
|
||||
"detail": "First lowering target for shared Eve documents: readouts, buttons, toggles, sliders, options, text boxes, tree items, and detail panes."
|
||||
},
|
||||
{
|
||||
"id": "field-reservoir",
|
||||
"label": "Field Reservoir\nengine timing witness",
|
||||
"kind": "telemetry",
|
||||
"x": -0.12,
|
||||
"y": 0.02,
|
||||
"width": 0.34,
|
||||
"height": 0.22,
|
||||
"health": "live",
|
||||
"detail": "Fensalir remains a field/evidence engine. Eve surface rendering must stay overlay/client UI; it must not become another field-truth path."
|
||||
},
|
||||
{
|
||||
"id": "program-output",
|
||||
"label": "Program Output\nshared D3D12 texture",
|
||||
"kind": "media",
|
||||
"x": 0.36,
|
||||
"y": 0.06,
|
||||
"width": 0.34,
|
||||
"height": 0.22,
|
||||
"health": "external surface",
|
||||
"detail": "The program-output texture is external publication. Eve UI controls it; the resolved HDR scene and shared texture publication stay renderer-owned."
|
||||
},
|
||||
{
|
||||
"id": "command-sink",
|
||||
"label": "Command Sink\nEve/CultNet outbound",
|
||||
"kind": "command",
|
||||
"x": -0.38,
|
||||
"y": 0.48,
|
||||
"width": 0.30,
|
||||
"height": 0.18,
|
||||
"health": "pending",
|
||||
"detail": "The first implementation cut needs a provider command sink so Direct2D control activation sends the same command messages as browser, iOS, and Android."
|
||||
},
|
||||
{
|
||||
"id": "parity-probe",
|
||||
"label": "Parity Probe\nbrowser reference fixture",
|
||||
"kind": "test",
|
||||
"x": 0.28,
|
||||
"y": 0.50,
|
||||
"width": 0.34,
|
||||
"height": 0.18,
|
||||
"health": "required",
|
||||
"detail": "Recorded surface fixtures should render in browser and Fensalir with comparable hierarchy, selection, controls, and detail text before provider-specific polish."
|
||||
}
|
||||
]
|
||||
}
|
||||
31
web/index.html
Normal file
31
web/index.html
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Eve Surface Reference</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Eve Surface Reference</h1>
|
||||
<p id="status">booting browser reference renderer</p>
|
||||
</div>
|
||||
<div class="hud">
|
||||
<strong>EVE</strong>
|
||||
<span id="surface-id">surface none</span>
|
||||
<span id="surface-version">v0</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<button id="voidbot-tab" class="active" type="button">VoidBot Live</button>
|
||||
<button id="fensalir-tab" type="button">Fensalir Direct2D</button>
|
||||
</nav>
|
||||
|
||||
<main id="app" class="surface"></main>
|
||||
|
||||
<script type="module" src="./surface.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
226
web/styles.css
Normal file
226
web/styles.css
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #020909;
|
||||
--panel: #071918;
|
||||
--panel-2: #0a2423;
|
||||
--line: rgba(103, 240, 228, 0.28);
|
||||
--line-hot: rgba(142, 252, 255, 0.72);
|
||||
--text: #e7f1f1;
|
||||
--quiet: #8ba5a3;
|
||||
--accent: #ffb84f;
|
||||
--cyan: #8efcff;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Inter, "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 84px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #010707;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--quiet);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hud {
|
||||
min-width: 168px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line-hot);
|
||||
border-radius: 6px;
|
||||
background: rgba(7, 25, 24, 0.88);
|
||||
color: var(--accent);
|
||||
font-family: "Cascadia Mono", Consolas, monospace;
|
||||
font-size: 11px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 16px 0;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px 6px 0 0;
|
||||
background: var(--panel);
|
||||
color: var(--quiet);
|
||||
padding: 8px 12px;
|
||||
font: 600 13px "Cascadia Mono", Consolas, monospace;
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
color: var(--cyan);
|
||||
border-color: var(--line-hot);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.surface {
|
||||
padding: 10px 16px 16px;
|
||||
}
|
||||
|
||||
.cockpit {
|
||||
display: grid;
|
||||
grid-template-columns: 0.78fr 1.08fr 1.34fr;
|
||||
gap: 10px;
|
||||
min-height: calc(100vh - 138px);
|
||||
}
|
||||
|
||||
.ctb {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 88px;
|
||||
overflow-x: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.pane {
|
||||
min-height: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: rgba(7, 25, 24, 0.92);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.pane h2 {
|
||||
margin: 0 0 10px;
|
||||
color: var(--quiet);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: rgba(10, 36, 35, 0.96);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.ctb .card {
|
||||
flex: 0 0 96px;
|
||||
min-height: 76px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--line-hot);
|
||||
object-fit: cover;
|
||||
background: #123;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
white-space: pre-line;
|
||||
margin-top: 4px;
|
||||
font: 700 11px "Cascadia Mono", Consolas, monospace;
|
||||
}
|
||||
|
||||
.health {
|
||||
color: var(--accent);
|
||||
font: 700 10px "Cascadia Mono", Consolas, monospace;
|
||||
}
|
||||
|
||||
.detail {
|
||||
white-space: pre-wrap;
|
||||
font: 600 12px "Cascadia Mono", Consolas, monospace;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.metric label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--quiet);
|
||||
font: 700 10px "Cascadia Mono", Consolas, monospace;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 7px;
|
||||
border-radius: 7px;
|
||||
background: #010707;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #bca8ff, #8efcff);
|
||||
}
|
||||
|
||||
.graph {
|
||||
position: relative;
|
||||
min-height: calc(100vh - 166px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background:
|
||||
linear-gradient(rgba(142, 252, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(142, 252, 255, 0.04) 1px, transparent 1px),
|
||||
rgba(3, 15, 17, 0.94);
|
||||
background-size: 44px 44px;
|
||||
}
|
||||
|
||||
.node {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: rgba(10, 36, 35, 0.95);
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.node.selected {
|
||||
border-color: var(--line-hot);
|
||||
box-shadow: 0 0 0 1px rgba(142, 252, 255, 0.38);
|
||||
}
|
||||
|
||||
.node .detail {
|
||||
margin-top: 6px;
|
||||
color: var(--quiet);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.cockpit {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
182
web/surface.js
Normal file
182
web/surface.js
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
const statusEl = document.querySelector("#status");
|
||||
const app = document.querySelector("#app");
|
||||
const surfaceId = document.querySelector("#surface-id");
|
||||
const surfaceVersion = document.querySelector("#surface-version");
|
||||
const voidBotTab = document.querySelector("#voidbot-tab");
|
||||
const fensalirTab = document.querySelector("#fensalir-tab");
|
||||
|
||||
let socket;
|
||||
|
||||
voidBotTab.addEventListener("click", () => {
|
||||
setActiveTab(voidBotTab);
|
||||
openVoidBot();
|
||||
});
|
||||
|
||||
fensalirTab.addEventListener("click", async () => {
|
||||
setActiveTab(fensalirTab);
|
||||
closeSocket();
|
||||
const response = await fetch("./fixtures/fensalir-client-surface.json");
|
||||
renderSurface(await response.json(), "fixture");
|
||||
});
|
||||
|
||||
openVoidBot();
|
||||
|
||||
function setActiveTab(tab) {
|
||||
for (const button of [voidBotTab, fensalirTab]) {
|
||||
button.classList.toggle("active", button === tab);
|
||||
}
|
||||
}
|
||||
|
||||
function openVoidBot() {
|
||||
closeSocket();
|
||||
const url = "ws://127.0.0.1:8795/eve/deck";
|
||||
statusEl.textContent = `connecting ${url}`;
|
||||
socket = new WebSocket(url);
|
||||
socket.addEventListener("open", () => {
|
||||
statusEl.textContent = "connected to Mimir Eve broker";
|
||||
socket.send(JSON.stringify({ type: "open-provider", providerId: "voidbot.swarm" }));
|
||||
});
|
||||
socket.addEventListener("message", event => {
|
||||
const state = JSON.parse(event.data);
|
||||
if (state.providerId === "voidbot.swarm") {
|
||||
renderSurface(state, "live");
|
||||
}
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
statusEl.textContent = "Mimir broker disconnected";
|
||||
});
|
||||
socket.addEventListener("error", () => {
|
||||
statusEl.textContent = "Mimir broker error; is port 8795 running?";
|
||||
});
|
||||
}
|
||||
|
||||
function closeSocket() {
|
||||
if (socket) {
|
||||
socket.close();
|
||||
socket = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSurface(state, source) {
|
||||
surfaceId.textContent = state.providerId || "surface unknown";
|
||||
surfaceVersion.textContent = `v${state.version ?? "?"}`;
|
||||
statusEl.textContent = `${state.title || "surface"} (${source})`;
|
||||
if (state.providerId === "voidbot.swarm") {
|
||||
renderVoidBot(state);
|
||||
} else {
|
||||
renderGraphSurface(state);
|
||||
}
|
||||
}
|
||||
|
||||
function renderVoidBot(state) {
|
||||
const nodes = state.nodes || [];
|
||||
const ctb = nodes.filter(n => n.kind === "ctb-turn");
|
||||
const leaves = nodes.filter(n => n.kind === "state-leaf");
|
||||
const summary = nodeById(nodes, "voidbot-summary");
|
||||
const agent = nodeById(nodes, "agent-detail");
|
||||
const detail = nodeById(nodes, state.selectedNodeId) || nodeById(nodes, "state-detail") || agent || summary;
|
||||
|
||||
app.innerHTML = "";
|
||||
const root = el("section", "cockpit");
|
||||
const rail = el("div", "ctb");
|
||||
for (const turn of ctb) {
|
||||
const card = el("button", "card");
|
||||
card.type = "button";
|
||||
if (turn.avatarUrl) {
|
||||
const img = el("img", "avatar");
|
||||
img.src = turn.avatarUrl;
|
||||
img.alt = "";
|
||||
card.append(img);
|
||||
}
|
||||
card.append(el("div", "card-title", turn.label || turn.id));
|
||||
card.append(el("div", "health", turn.health || ""));
|
||||
rail.append(card);
|
||||
}
|
||||
|
||||
const left = el("section", "pane");
|
||||
left.append(el("h2", "", "Controls / Selected Face"));
|
||||
left.append(el("div", "detail", `${summary?.label || "VoidBot Swarm"}\n${summary?.detail || ""}`));
|
||||
left.append(metricStack());
|
||||
left.append(el("div", "detail", agent?.detail || "No selected Face detail."));
|
||||
|
||||
const middle = el("section", "pane");
|
||||
middle.append(el("h2", "", "State Graph"));
|
||||
const list = el("div", "list");
|
||||
for (const leaf of leaves) {
|
||||
const card = el("div", `card ${leaf.id === state.selectedNodeId ? "selected" : ""}`);
|
||||
card.append(el("div", "card-title", leaf.label || leaf.id));
|
||||
card.append(el("div", "health", leaf.health || leaf.statePath || ""));
|
||||
list.append(card);
|
||||
}
|
||||
middle.append(list);
|
||||
|
||||
const right = el("section", "pane");
|
||||
right.append(el("h2", "", "State Detail"));
|
||||
right.append(el("div", "detail", `${detail?.label || "detail"}\n\n${detail?.detail || ""}`));
|
||||
|
||||
root.append(rail, left, middle, right);
|
||||
app.append(root);
|
||||
}
|
||||
|
||||
function renderGraphSurface(state) {
|
||||
app.innerHTML = "";
|
||||
const graph = el("section", "graph");
|
||||
for (const node of state.nodes || []) {
|
||||
const view = el("article", `node ${node.id === state.selectedNodeId ? "selected" : ""}`);
|
||||
const width = Math.max(160, graphWidth(node.width));
|
||||
const height = Math.max(92, graphHeight(node.height));
|
||||
view.style.width = `${width}px`;
|
||||
view.style.minHeight = `${height}px`;
|
||||
view.style.left = `${((node.x ?? 0) + 1) * 50}%`;
|
||||
view.style.top = `${((node.y ?? 0) + 1) * 50}%`;
|
||||
view.append(el("div", "card-title", node.label || node.id));
|
||||
view.append(el("div", "health", `${node.kind || "node"} ${node.health || ""}`));
|
||||
if (node.detail) {
|
||||
view.append(el("div", "detail", node.detail));
|
||||
}
|
||||
graph.append(view);
|
||||
}
|
||||
app.append(graph);
|
||||
}
|
||||
|
||||
function graphWidth(value) {
|
||||
return Math.round((value || 0.25) * Math.max(900, window.innerWidth - 32));
|
||||
}
|
||||
|
||||
function graphHeight(value) {
|
||||
return Math.round((value || 0.16) * Math.max(520, window.innerHeight - 150));
|
||||
}
|
||||
|
||||
function metricStack() {
|
||||
const wrapper = el("div", "metrics");
|
||||
const metrics = [
|
||||
["TURN", 0.96],
|
||||
["MEMORY", 0.70],
|
||||
["PRESSURE", 0.42],
|
||||
["HEAT", 0.34],
|
||||
["LOAD", 0.99],
|
||||
["SPEED", 0.88],
|
||||
];
|
||||
for (const [label, value] of metrics) {
|
||||
const metric = el("div", "metric");
|
||||
metric.append(el("label", "", `${label} ${Math.round(value * 100)}%`));
|
||||
const bar = el("div", "bar");
|
||||
const fill = el("span");
|
||||
fill.style.width = `${Math.round(value * 100)}%`;
|
||||
bar.append(fill);
|
||||
metric.append(bar);
|
||||
wrapper.append(metric);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function nodeById(nodes, id) {
|
||||
return nodes.find(node => node.id === id);
|
||||
}
|
||||
|
||||
function el(tag, className = "", text = "") {
|
||||
const element = document.createElement(tag);
|
||||
if (className) element.className = className;
|
||||
if (text) element.textContent = text;
|
||||
return element;
|
||||
}
|
||||
Loading…
Reference in a new issue