Implement synchronized LED wall output
This commit is contained in:
@@ -34,7 +34,7 @@ python -m led_platform.cli serve
|
|||||||
或者直接用 uvicorn:
|
或者直接用 uvicorn:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uvicorn led_platform.main:app --host 127.0.0.1 --port 8000
|
uvicorn led_platform.main:app --host 0.0.0.0 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
查看地址:
|
查看地址:
|
||||||
|
|||||||
+6
-6
@@ -3,26 +3,26 @@
|
|||||||
"scenes": [
|
"scenes": [
|
||||||
{
|
{
|
||||||
"id": "overview",
|
"id": "overview",
|
||||||
"name": "总览大屏",
|
"name": "\u603b\u89c8\u5927\u5c4f",
|
||||||
"url": "/wall-app",
|
"url": "/wall-app",
|
||||||
"view": "overview",
|
"view": "overview",
|
||||||
"description": "双 GPU 本地渲染、统一时间轴、左右 tile 裁剪的默认场景",
|
"description": "\u53cc GPU \u672c\u5730\u6e32\u67d3\u3001\u7edf\u4e00\u65f6\u95f4\u8f74\u3001\u5de6\u53f3 tile \u88c1\u526a\u7684\u9ed8\u8ba4\u573a\u666f",
|
||||||
"preload": []
|
"preload": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "energy",
|
"id": "energy",
|
||||||
"name": "能源驾驶舱",
|
"name": "\u80fd\u6e90\u9a7e\u9a76\u8231",
|
||||||
"url": "/wall-app",
|
"url": "/wall-app",
|
||||||
"view": "energy",
|
"view": "energy",
|
||||||
"description": "能源、功率、储能和调度策略场景",
|
"description": "\u80fd\u6e90\u3001\u529f\u7387\u3001\u50a8\u80fd\u548c\u8c03\u5ea6\u7b56\u7565\u573a\u666f",
|
||||||
"preload": ["energy-models", "energy-textures"]
|
"preload": ["energy-models", "energy-textures"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "security",
|
"id": "security",
|
||||||
"name": "安防态势",
|
"name": "\u5b89\u9632\u6001\u52bf",
|
||||||
"url": "/wall-app",
|
"url": "/wall-app",
|
||||||
"view": "security",
|
"view": "security",
|
||||||
"description": "视频墙、告警、地图联动场景",
|
"description": "\u89c6\u9891\u5899\u3001\u544a\u8b66\u3001\u5730\u56fe\u8054\u52a8\u573a\u666f",
|
||||||
"preload": ["camera-grid", "security-map"]
|
"preload": ["camera-grid", "security-map"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ async def admin_ws(websocket: WebSocket) -> None:
|
|||||||
type=CommandType.CLOCK_PONG,
|
type=CommandType.CLOCK_PONG,
|
||||||
payload={
|
payload={
|
||||||
"client_send_ms": raw.get("client_send_ms"),
|
"client_send_ms": raw.get("client_send_ms"),
|
||||||
|
"client_send_perf_ms": raw.get("client_send_perf_ms"),
|
||||||
"server_time_ms": epoch_ms(),
|
"server_time_ms": epoch_ms(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -79,6 +80,7 @@ async def output_ws(websocket: WebSocket, tile_id: str) -> None:
|
|||||||
payload={
|
payload={
|
||||||
"node_id": raw.get("node_id") or node_id,
|
"node_id": raw.get("node_id") or node_id,
|
||||||
"client_send_ms": raw.get("client_send_ms"),
|
"client_send_ms": raw.get("client_send_ms"),
|
||||||
|
"client_send_perf_ms": raw.get("client_send_perf_ms"),
|
||||||
"server_time_ms": epoch_ms(),
|
"server_time_ms": epoch_ms(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -111,4 +113,3 @@ async def output_ws(websocket: WebSocket, tile_id: str) -> None:
|
|||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
sync.disconnect_node(node_id)
|
sync.disconnect_node(node_id)
|
||||||
await hub.disconnect("outputs", websocket)
|
await hub.disconnect("outputs", websocket)
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -1,4 +1,5 @@
|
|||||||
import webbrowser
|
import webbrowser
|
||||||
|
import socket
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
import uvicorn
|
import uvicorn
|
||||||
@@ -8,6 +9,16 @@ from led_platform.core.config import get_settings
|
|||||||
app = typer.Typer(help="LED wall projection platform CLI.")
|
app = typer.Typer(help="LED wall projection platform CLI.")
|
||||||
|
|
||||||
|
|
||||||
|
def _lan_base_url(port: int = 8000) -> str:
|
||||||
|
try:
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||||
|
sock.connect(("8.8.8.8", 80))
|
||||||
|
host = sock.getsockname()[0]
|
||||||
|
except OSError:
|
||||||
|
host = "127.0.0.1"
|
||||||
|
return f"http://{host}:{port}"
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def serve(
|
def serve(
|
||||||
host: str = typer.Option(None, help="Bind host."),
|
host: str = typer.Option(None, help="Bind host."),
|
||||||
@@ -25,8 +36,9 @@ def serve(
|
|||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def urls(
|
def urls(
|
||||||
base: str = typer.Option("http://127.0.0.1:8000", help="Controller base URL."),
|
base: str = typer.Option(None, help="Controller base URL."),
|
||||||
) -> None:
|
) -> None:
|
||||||
|
base = base or _lan_base_url()
|
||||||
typer.echo(f"Admin: {base}/")
|
typer.echo(f"Admin: {base}/")
|
||||||
typer.echo(f"Left output: {base}/output/left")
|
typer.echo(f"Left output: {base}/output/left")
|
||||||
typer.echo(f"Right output:{base}/output/right")
|
typer.echo(f"Right output:{base}/output/right")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
app_host: str = "127.0.0.1"
|
app_host: str = "0.0.0.0"
|
||||||
app_port: int = 8000
|
app_port: int = 8000
|
||||||
public_base_url: str = "http://127.0.0.1:8000"
|
public_base_url: str = "http://127.0.0.1:8000"
|
||||||
|
|
||||||
@@ -13,8 +13,8 @@ class Settings(BaseSettings):
|
|||||||
wall_height: int = 3510
|
wall_height: int = 3510
|
||||||
target_fps: int = 60
|
target_fps: int = 60
|
||||||
|
|
||||||
switch_prepare_delay_ms: int = 1400
|
switch_prepare_delay_ms: int = 2000
|
||||||
action_prepare_delay_ms: int = 650
|
action_prepare_delay_ms: int = 900
|
||||||
initial_state_delay_ms: int = 160
|
initial_state_delay_ms: int = 160
|
||||||
command_ttl_seconds: int = 45
|
command_ttl_seconds: int = 45
|
||||||
|
|
||||||
@@ -27,4 +27,3 @@ class Settings(BaseSettings):
|
|||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
return Settings()
|
return Settings()
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>LED Wall Control</title>
|
<title>LED 大屏控制台</title>
|
||||||
<link rel="stylesheet" href="/static/admin/styles.css" />
|
<link rel="stylesheet" href="/static/admin/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<section class="grid">
|
<section class="grid">
|
||||||
<section class="panel scenes">
|
<section class="panel scenes">
|
||||||
<div class="panelHead">
|
<div class="panelHead">
|
||||||
<h2>场景</h2>
|
<h2>场景切换</h2>
|
||||||
<button id="refreshBtn">刷新</button>
|
<button id="refreshBtn">刷新</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="sceneList" class="sceneList"></div>
|
<div id="sceneList" class="sceneList"></div>
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
<div class="linkGrid">
|
<div class="linkGrid">
|
||||||
<a href="/output/left" target="_blank">打开左输出</a>
|
<a href="/output/left" target="_blank">打开左输出</a>
|
||||||
<a href="/output/right" target="_blank">打开右输出</a>
|
<a href="/output/right" target="_blank">打开右输出</a>
|
||||||
|
<a href="/api/sync/status" target="_blank">同步状态</a>
|
||||||
<a href="/docs" target="_blank">API 文档</a>
|
<a href="/docs" target="_blank">API 文档</a>
|
||||||
</div>
|
</div>
|
||||||
<div id="syncStatus" class="syncStatus"></div>
|
<div id="syncStatus" class="syncStatus"></div>
|
||||||
|
|||||||
@@ -48,9 +48,10 @@ function renderScenes() {
|
|||||||
function renderSyncStatus() {
|
function renderSyncStatus() {
|
||||||
if (!syncSnapshot) return;
|
if (!syncSnapshot) return;
|
||||||
const nodes = syncSnapshot.nodes || [];
|
const nodes = syncSnapshot.nodes || [];
|
||||||
const commands = (syncSnapshot.commands || []).slice(-4).reverse();
|
const commands = (syncSnapshot.commands || []).slice(-5).reverse();
|
||||||
const rows = [];
|
const rows = [];
|
||||||
rows.push(`<div class="syncRow"><span>输出节点</span><strong>${nodes.length}/${syncSnapshot.target_tiles.length}</strong></div>`);
|
rows.push(`<div class="syncRow"><span>输出节点</span><strong>${nodes.length}/${syncSnapshot.target_tiles.length}</strong></div>`);
|
||||||
|
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
const fps = node.fps == null ? "--" : node.fps.toFixed(1);
|
const fps = node.fps == null ? "--" : node.fps.toFixed(1);
|
||||||
const frame = node.frame_time_ms == null ? "--" : `${node.frame_time_ms.toFixed(1)}ms`;
|
const frame = node.frame_time_ms == null ? "--" : `${node.frame_time_ms.toFixed(1)}ms`;
|
||||||
@@ -58,10 +59,12 @@ function renderSyncStatus() {
|
|||||||
const offset = node.clock_offset_ms == null ? "--" : `${Math.round(node.clock_offset_ms)}ms`;
|
const offset = node.clock_offset_ms == null ? "--" : `${Math.round(node.clock_offset_ms)}ms`;
|
||||||
rows.push(`<div class="syncRow"><span>${node.tile_id} ${node.node_id.slice(0, 16)}</span><span>${fps}fps / ${frame} / rtt ${rtt} / offset ${offset}</span></div>`);
|
rows.push(`<div class="syncRow"><span>${node.tile_id} ${node.node_id.slice(0, 16)}</span><span>${fps}fps / ${frame} / rtt ${rtt} / offset ${offset}</span></div>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const command of commands) {
|
for (const command of commands) {
|
||||||
const ackText = command.acks.map((ack) => `${ack.tile_id}:${ack.status}`).join(", ") || "等待 ACK";
|
const ackText = command.acks.map((ack) => `${ack.tile_id}:${ack.status}`).join(", ") || "等待 ACK";
|
||||||
rows.push(`<div class="syncRow"><span>${command.command_type} ${command.command_id.slice(0, 8)}</span><span>${command.complete ? "完成" : "进行中"} | ${ackText}</span></div>`);
|
rows.push(`<div class="syncRow"><span>${command.command_type} ${command.command_id.slice(0, 8)}</span><span>${command.complete ? "完成" : "进行中"} | ${ackText}</span></div>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
syncStatus.innerHTML = rows.join("");
|
syncStatus.innerHTML = rows.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,10 +130,11 @@ function connectWs() {
|
|||||||
renderScenes();
|
renderScenes();
|
||||||
loadSyncStatus();
|
loadSyncStatus();
|
||||||
}
|
}
|
||||||
if (["ack", "heartbeat"].includes(message.type)) {
|
if (message.type === "ack") {
|
||||||
if (message.type === "ack") {
|
wsText.textContent = `${message.payload.tile_id || "node"} ${message.payload.status}`;
|
||||||
wsText.textContent = `${message.payload.tile_id || "node"} ${message.payload.status}`;
|
loadSyncStatus();
|
||||||
}
|
}
|
||||||
|
if (message.type === "heartbeat") {
|
||||||
loadSyncStatus();
|
loadSyncStatus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -148,4 +152,4 @@ document.querySelectorAll("[data-action]").forEach((button) => {
|
|||||||
loadScenes();
|
loadScenes();
|
||||||
loadSyncStatus();
|
loadSyncStatus();
|
||||||
connectWs();
|
connectWs();
|
||||||
setInterval(loadSyncStatus, 3000);
|
setInterval(loadSyncStatus, 2500);
|
||||||
|
|||||||
@@ -3,48 +3,48 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>LED Wall Output</title>
|
<title>LED Output</title>
|
||||||
<link rel="stylesheet" href="/static/output/styles.css" />
|
<link rel="stylesheet" href="/static/output/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main id="viewport" class="viewport">
|
<main class="viewport">
|
||||||
<section id="wall" class="wall" aria-label="LED wall output">
|
<section id="wall" class="wall">
|
||||||
<canvas id="motionCanvas" class="motionCanvas"></canvas>
|
<canvas id="motionCanvas" class="motion-canvas"></canvas>
|
||||||
<div class="gridLayer"></div>
|
<div class="grid-layer"></div>
|
||||||
|
|
||||||
<header class="wallHeader">
|
<header class="screen-header">
|
||||||
<div>
|
<div>
|
||||||
<p id="eyebrow" class="eyebrow">Unified Timeline Rendering</p>
|
<p class="eyebrow">Tile-aware timeline rendering</p>
|
||||||
<h1 id="sceneTitle">LED 大屏投放平台</h1>
|
<h1 id="sceneTitle">LED 大屏投放平台</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="metrics">
|
<div class="header-metrics">
|
||||||
<strong id="clock">--:--:--</strong>
|
<strong id="clockText">--:--:--</strong>
|
||||||
<span id="version">v0</span>
|
<span id="versionText">v0</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="scene active" data-view="overview">
|
<section class="scene active" data-view="overview">
|
||||||
<div class="headline">
|
<div class="hero-copy">
|
||||||
<h2>双 GPU 输出,统一时间轴</h2>
|
<h2>双 GPU 本地渲染,统一时间轴同步</h2>
|
||||||
<p>左右服务器各自渲染半屏,但所有场景、动画、地图相机、视频时间都由同一个服务端时间轴驱动。</p>
|
<p>左右输出端各自渲染半屏,但场景、动画、地图相机、视频时间由同一个服务端时间轴驱动。</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="overviewKpis" class="kpiGrid"></div>
|
<div id="overviewCards" class="card-grid"></div>
|
||||||
<div id="overviewBars" class="bars"></div>
|
<div id="timelineBars" class="timeline-bars"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="scene" data-view="energy">
|
<section class="scene" data-view="energy">
|
||||||
<div class="headline">
|
<div class="hero-copy">
|
||||||
<h2>能源驾驶舱</h2>
|
<h2>能源驾驶舱</h2>
|
||||||
<p>适合 ECharts、Canvas、WebGL 地图和 Three.js 数字孪生。真实项目中应使用 camera.setViewOffset 做 tile-aware 渲染。</p>
|
<p>适合 WebGL、Three.js、地图和视频组件。生产接入时使用同一 camera state 和 serverTime 渲染。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="energyMap">
|
<div class="flow-map">
|
||||||
<div class="flowLine lineA"></div>
|
<div class="flow-line line-a"></div>
|
||||||
<div class="flowLine lineB"></div>
|
<div class="flow-line line-b"></div>
|
||||||
<div class="flowNode nodeA">园区供电</div>
|
<div class="flow-node node-a">园区供电</div>
|
||||||
<div class="flowNode nodeB">储能系统</div>
|
<div class="flow-node node-b">储能系统</div>
|
||||||
<div class="flowNode nodeC">负载中心</div>
|
<div class="flow-node node-c">业务负载</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sideStats">
|
<div class="side-stats">
|
||||||
<article><span>实时功率</span><strong id="powerValue">8.42 MW</strong></article>
|
<article><span>实时功率</span><strong id="powerValue">8.42 MW</strong></article>
|
||||||
<article><span>负载趋势</span><strong id="trendValue">+3.8%</strong></article>
|
<article><span>负载趋势</span><strong id="trendValue">+3.8%</strong></article>
|
||||||
<article><span>调度策略</span><strong id="modeValue">均衡</strong></article>
|
<article><span>调度策略</span><strong id="modeValue">均衡</strong></article>
|
||||||
@@ -52,12 +52,12 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="scene" data-view="security">
|
<section class="scene" data-view="security">
|
||||||
<div class="headline">
|
<div class="hero-copy">
|
||||||
<h2>安防态势</h2>
|
<h2>安防态势</h2>
|
||||||
<p>视频墙、告警卡片和地图联动都通过结构化动作同步触发,不在输出端执行任意远程脚本。</p>
|
<p>视频墙、地图联动和告警卡片通过结构化动作同步触发,左右节点只按计划时间提交状态。</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="cameraGrid" class="cameraGrid"></div>
|
<div id="cameraGrid" class="camera-grid"></div>
|
||||||
<div id="alertRail" class="alertRail"></div>
|
<div id="alertRail" class="alert-rail"></div>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
<aside id="hud" class="hud"></aside>
|
<aside id="hud" class="hud"></aside>
|
||||||
|
|||||||
@@ -1,34 +1,36 @@
|
|||||||
const APP_VERSION = "1.0.0";
|
const APP_VERSION = "3.0.0";
|
||||||
const hud = document.querySelector("#hud");
|
|
||||||
const wall = document.querySelector("#wall");
|
const wall = document.querySelector("#wall");
|
||||||
const canvas = document.querySelector("#motionCanvas");
|
const canvas = document.querySelector("#motionCanvas");
|
||||||
const ctx = canvas.getContext("2d", { alpha: true });
|
const ctx = canvas.getContext("2d", { alpha: true });
|
||||||
const clock = document.querySelector("#clock");
|
const hud = document.querySelector("#hud");
|
||||||
const version = document.querySelector("#version");
|
|
||||||
const sceneTitle = document.querySelector("#sceneTitle");
|
const sceneTitle = document.querySelector("#sceneTitle");
|
||||||
|
const versionText = document.querySelector("#versionText");
|
||||||
|
const clockText = document.querySelector("#clockText");
|
||||||
const tileId = location.pathname.startsWith("/output/") ? location.pathname.split("/").pop() : "left";
|
const tileId = location.pathname.startsWith("/output/") ? location.pathname.split("/").pop() : "left";
|
||||||
|
|
||||||
const sceneNames = {
|
const sceneTitles = {
|
||||||
overview: "LED 大屏投放平台",
|
overview: "LED 大屏投放平台",
|
||||||
energy: "能源驾驶舱",
|
energy: "能源驾驶舱",
|
||||||
security: "安防态势",
|
security: "安防态势",
|
||||||
};
|
};
|
||||||
|
|
||||||
let ws = null;
|
|
||||||
let tile = null;
|
let tile = null;
|
||||||
let state = null;
|
let state = null;
|
||||||
let paused = false;
|
let ws = null;
|
||||||
let pageIndex = 0;
|
let nodeId = sessionStorage.getItem("led-platform-node-id");
|
||||||
let clockOffsetMs = 0;
|
let serverOffsetFromPerfMs = Date.now() - performance.now();
|
||||||
let bestRttMs = Number.POSITIVE_INFINITY;
|
let bestRttMs = Number.POSITIVE_INFINITY;
|
||||||
let pendingCommands = new Map();
|
let clockSamples = [];
|
||||||
|
let scheduledJobs = new Map();
|
||||||
let frameCount = 0;
|
let frameCount = 0;
|
||||||
|
let fps = 0;
|
||||||
|
let frameTimeMs = 0;
|
||||||
let droppedFrames = 0;
|
let droppedFrames = 0;
|
||||||
let lastFrameAt = performance.now();
|
let lastFrameAt = performance.now();
|
||||||
let lastFpsAt = performance.now();
|
let lastFpsAt = performance.now();
|
||||||
let fps = 0;
|
let pageIndex = 0;
|
||||||
let frameTimeMs = 0;
|
let paused = false;
|
||||||
let nodeId = sessionStorage.getItem("led-platform-node-id");
|
|
||||||
|
|
||||||
if (!nodeId) {
|
if (!nodeId) {
|
||||||
const randomId = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(16).slice(2);
|
const randomId = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(16).slice(2);
|
||||||
@@ -36,17 +38,17 @@ if (!nodeId) {
|
|||||||
sessionStorage.setItem("led-platform-node-id", nodeId);
|
sessionStorage.setItem("led-platform-node-id", nodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serverNowMs() {
|
||||||
|
return performance.now() + serverOffsetFromPerfMs;
|
||||||
|
}
|
||||||
|
|
||||||
function clientNowMs() {
|
function clientNowMs() {
|
||||||
return Date.now();
|
return Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
function serverNowMs() {
|
|
||||||
return clientNowMs() + clockOffsetMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const res = await fetch(`/api/bootstrap/${tileId}`);
|
const response = await fetch(`/api/bootstrap/${tileId}`);
|
||||||
const data = await res.json();
|
const data = await response.json();
|
||||||
tile = data.tile;
|
tile = data.tile;
|
||||||
applyState(data.state);
|
applyState(data.state);
|
||||||
buildStaticContent();
|
buildStaticContent();
|
||||||
@@ -55,6 +57,37 @@ async function bootstrap() {
|
|||||||
requestAnimationFrame(renderLoop);
|
requestAnimationFrame(renderLoop);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildStaticContent() {
|
||||||
|
document.querySelector("#overviewCards").innerHTML = [
|
||||||
|
["同步模式", "时间轴"],
|
||||||
|
["渲染节点", "双 GPU"],
|
||||||
|
["逻辑宽度", "14880"],
|
||||||
|
["逻辑高度", "3510"],
|
||||||
|
["半屏宽度", "7440"],
|
||||||
|
["目标帧率", "60"],
|
||||||
|
].map(([label, value]) => `<article class="metric-card"><span>${label}</span><strong>${value}</strong></article>`).join("");
|
||||||
|
|
||||||
|
const bars = document.querySelector("#timelineBars");
|
||||||
|
bars.innerHTML = "";
|
||||||
|
for (let index = 0; index < 32; index += 1) {
|
||||||
|
const bar = document.createElement("i");
|
||||||
|
bar.style.height = `${180 + ((index * 137) % 760)}px`;
|
||||||
|
bars.append(bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector("#cameraGrid").innerHTML = Array.from({ length: 8 }, (_, index) => {
|
||||||
|
const id = String(index + 1).padStart(2, "0");
|
||||||
|
return `<article class="camera-card"><strong>CAM-${id}</strong><span>在线 | 1080P | 低延迟</span></article>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
document.querySelector("#alertRail").innerHTML = [
|
||||||
|
"北侧通道人员聚集",
|
||||||
|
"机房门禁异常",
|
||||||
|
"消防通道占用",
|
||||||
|
"视频质量波动",
|
||||||
|
].map((text) => `<article class="alert-card">${text}</article>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
function layout() {
|
function layout() {
|
||||||
if (!tile) return;
|
if (!tile) return;
|
||||||
const scale = Math.min(window.innerWidth / tile.width, window.innerHeight / tile.height);
|
const scale = Math.min(window.innerWidth / tile.width, window.innerHeight / tile.height);
|
||||||
@@ -65,80 +98,58 @@ function layout() {
|
|||||||
document.documentElement.style.setProperty("--offset-y", `${-tile.y * scale}px`);
|
document.documentElement.style.setProperty("--offset-y", `${-tile.y * scale}px`);
|
||||||
canvas.width = tile.wall_width;
|
canvas.width = tile.wall_width;
|
||||||
canvas.height = tile.wall_height;
|
canvas.height = tile.wall_height;
|
||||||
updateHud();
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStaticContent() {
|
|
||||||
document.querySelector("#overviewKpis").innerHTML = [
|
|
||||||
["渲染模式", "Local GPU"],
|
|
||||||
["同步方式", "Timeline"],
|
|
||||||
["逻辑宽度", "14,880"],
|
|
||||||
["逻辑高度", "3,510"],
|
|
||||||
["左/右分区", "7,440"],
|
|
||||||
["目标帧率", "60 FPS"],
|
|
||||||
].map(([label, value]) => `<article class="kpi"><span>${label}</span><strong>${value}</strong></article>`).join("");
|
|
||||||
|
|
||||||
const bars = document.querySelector("#overviewBars");
|
|
||||||
bars.innerHTML = "";
|
|
||||||
for (let i = 0; i < 32; i += 1) {
|
|
||||||
const bar = document.createElement("div");
|
|
||||||
bar.className = "bar";
|
|
||||||
bar.style.height = `${180 + ((i * 137) % 760)}px`;
|
|
||||||
bars.append(bar);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelector("#cameraGrid").innerHTML = Array.from({ length: 8 }, (_, index) => {
|
|
||||||
const id = String(index + 1).padStart(2, "0");
|
|
||||||
return `<article class="camera"><strong>CAM-${id}</strong><span>在线 | 1080P | 低延迟</span></article>`;
|
|
||||||
}).join("");
|
|
||||||
|
|
||||||
document.querySelector("#alertRail").innerHTML = [
|
|
||||||
"北侧通道人员聚集",
|
|
||||||
"机房门禁异常",
|
|
||||||
"消防通道占用",
|
|
||||||
"视频质量波动",
|
|
||||||
].map((item) => `<article class="alert">${item}</article>`).join("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyState(nextState) {
|
function applyState(nextState) {
|
||||||
state = nextState;
|
state = nextState;
|
||||||
version.textContent = `v${state.version}`;
|
const view = state.active_view || "overview";
|
||||||
setScene(state.active_view);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setScene(view) {
|
|
||||||
const normalized = view || "overview";
|
|
||||||
document.querySelectorAll(".scene").forEach((scene) => {
|
document.querySelectorAll(".scene").forEach((scene) => {
|
||||||
scene.classList.toggle("active", scene.dataset.view === normalized);
|
scene.classList.toggle("active", scene.dataset.view === view);
|
||||||
});
|
});
|
||||||
sceneTitle.textContent = sceneNames[normalized] || sceneNames.overview;
|
sceneTitle.textContent = sceneTitles[view] || sceneTitles.overview;
|
||||||
|
versionText.textContent = `v${state.version || 0}`;
|
||||||
pulse();
|
pulse();
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAt(applyAtMs, callback) {
|
function scheduleJob(message, kind, callback) {
|
||||||
const leadTimeMs = applyAtMs - serverNowMs();
|
const applyAtMs = Number(message.payload.apply_at_ms);
|
||||||
const late = leadTimeMs < 80;
|
scheduledJobs.set(message.command_id, {
|
||||||
setTimeout(() => callback(late), Math.max(0, leadTimeMs));
|
id: message.command_id,
|
||||||
|
kind,
|
||||||
|
applyAtMs,
|
||||||
|
message,
|
||||||
|
callback,
|
||||||
|
preparedAtMs: serverNowMs(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drainScheduledJobs() {
|
||||||
|
if (!scheduledJobs.size) return;
|
||||||
|
const now = serverNowMs();
|
||||||
|
const ready = Array.from(scheduledJobs.values())
|
||||||
|
.filter((job) => now >= job.applyAtMs)
|
||||||
|
.sort((a, b) => a.applyAtMs - b.applyAtMs);
|
||||||
|
for (const job of ready) {
|
||||||
|
scheduledJobs.delete(job.id);
|
||||||
|
const late = now - job.applyAtMs > 50;
|
||||||
|
job.callback(late);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareScene(message) {
|
function prepareScene(message) {
|
||||||
pendingCommands.set(message.command_id, message.payload);
|
|
||||||
sendAck(message, "prepared");
|
sendAck(message, "prepared");
|
||||||
}
|
}
|
||||||
|
|
||||||
function commitScene(message) {
|
function commitScene(message) {
|
||||||
const payload = pendingCommands.get(message.command_id) || message.payload;
|
scheduleJob(message, "scene", (late) => {
|
||||||
scheduleAt(payload.apply_at_ms, (late) => {
|
applyState(message.payload.state);
|
||||||
applyState(payload.state);
|
|
||||||
pendingCommands.delete(message.command_id);
|
|
||||||
sendAck(message, late ? "late" : "committed");
|
sendAck(message, late ? "late" : "committed");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function runComponentAction(message) {
|
function runComponentAction(message) {
|
||||||
const { action, args, apply_at_ms: applyAtMs } = message.payload;
|
scheduleJob(message, "action", (late) => {
|
||||||
scheduleAt(applyAtMs, (late) => {
|
applyAction(message.payload.action, message.payload.args || {});
|
||||||
applyAction(action, args || {});
|
|
||||||
sendAck(message, late ? "late" : "action_committed");
|
sendAck(message, late ? "late" : "action_committed");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -146,16 +157,18 @@ function runComponentAction(message) {
|
|||||||
function applyAction(action, args) {
|
function applyAction(action, args) {
|
||||||
if (action === "page.next") {
|
if (action === "page.next") {
|
||||||
pageIndex += 1;
|
pageIndex += 1;
|
||||||
rotateValues();
|
const views = ["overview", "energy", "security"];
|
||||||
|
applyState({ ...state, active_view: views[pageIndex % views.length], version: (state?.version || 0) + 1 });
|
||||||
} else if (action === "page.prev") {
|
} else if (action === "page.prev") {
|
||||||
pageIndex = Math.max(0, pageIndex - 1);
|
pageIndex = Math.max(0, pageIndex - 1);
|
||||||
rotateValues();
|
const views = ["overview", "energy", "security"];
|
||||||
|
applyState({ ...state, active_view: views[pageIndex % views.length], version: (state?.version || 0) + 1 });
|
||||||
} else if (action === "energy.mode") {
|
} else if (action === "energy.mode") {
|
||||||
document.querySelector("#modeValue").textContent = args.mode || "削峰";
|
document.querySelector("#modeValue").textContent = args.mode || "削峰";
|
||||||
} else if (action === "security.alert") {
|
} else if (action === "security.alert") {
|
||||||
const rail = document.querySelector("#alertRail");
|
const rail = document.querySelector("#alertRail");
|
||||||
const alert = document.createElement("article");
|
const alert = document.createElement("article");
|
||||||
alert.className = "alert pulse";
|
alert.className = "alert-card pulse";
|
||||||
alert.textContent = args.text || "新增联动告警";
|
alert.textContent = args.text || "新增联动告警";
|
||||||
rail.prepend(alert);
|
rail.prepend(alert);
|
||||||
while (rail.children.length > 5) rail.lastElementChild.remove();
|
while (rail.children.length > 5) rail.lastElementChild.remove();
|
||||||
@@ -167,17 +180,6 @@ function applyAction(action, args) {
|
|||||||
pulse();
|
pulse();
|
||||||
}
|
}
|
||||||
|
|
||||||
function rotateValues() {
|
|
||||||
const values = [
|
|
||||||
["8.42 MW", "+3.8%", "均衡"],
|
|
||||||
["7.91 MW", "-1.2%", "削峰"],
|
|
||||||
["9.08 MW", "+6.1%", "保供"],
|
|
||||||
][pageIndex % 3];
|
|
||||||
document.querySelector("#powerValue").textContent = values[0];
|
|
||||||
document.querySelector("#trendValue").textContent = values[1];
|
|
||||||
document.querySelector("#modeValue").textContent = values[2];
|
|
||||||
}
|
|
||||||
|
|
||||||
function timelineMs() {
|
function timelineMs() {
|
||||||
if (!state) return 0;
|
if (!state) return 0;
|
||||||
return Math.max(0, serverNowMs() - state.scene_started_at_ms);
|
return Math.max(0, serverNowMs() - state.scene_started_at_ms);
|
||||||
@@ -194,34 +196,35 @@ function renderLoop(now) {
|
|||||||
}
|
}
|
||||||
lastFrameAt = now;
|
lastFrameAt = now;
|
||||||
|
|
||||||
|
drainScheduledJobs();
|
||||||
drawMotion(paused ? 0 : timelineMs());
|
drawMotion(paused ? 0 : timelineMs());
|
||||||
updateAnimatedBars(paused ? 0 : timelineMs());
|
animateBars(paused ? 0 : timelineMs());
|
||||||
updateHud();
|
updateHud();
|
||||||
requestAnimationFrame(renderLoop);
|
requestAnimationFrame(renderLoop);
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawMotion(t) {
|
function drawMotion(t) {
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
ctx.globalAlpha = 0.72;
|
ctx.globalAlpha = 0.75;
|
||||||
for (let i = 0; i < 24; i += 1) {
|
for (let i = 0; i < 28; i += 1) {
|
||||||
const phase = (t / 1000) + i * 0.42;
|
const phase = t / 1200 + i * 0.43;
|
||||||
const x = (Math.sin(phase * 0.42) * 0.5 + 0.5) * canvas.width;
|
const x = (Math.sin(phase * 0.46) * 0.5 + 0.5) * canvas.width;
|
||||||
const y = (Math.cos(phase * 0.36) * 0.5 + 0.5) * canvas.height;
|
const y = (Math.cos(phase * 0.37) * 0.5 + 0.5) * canvas.height;
|
||||||
const r = 80 + (i % 5) * 28;
|
const radius = 260 + (i % 6) * 70;
|
||||||
const grad = ctx.createRadialGradient(x, y, 0, x, y, r * 5);
|
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius * 3.4);
|
||||||
grad.addColorStop(0, i % 2 ? "rgba(249,115,22,0.23)" : "rgba(20,184,166,0.25)");
|
gradient.addColorStop(0, i % 2 ? "rgba(249,115,22,0.18)" : "rgba(20,184,166,0.22)");
|
||||||
grad.addColorStop(1, "rgba(0,0,0,0)");
|
gradient.addColorStop(1, "rgba(0,0,0,0)");
|
||||||
ctx.fillStyle = grad;
|
ctx.fillStyle = gradient;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x, y, r * 5, 0, Math.PI * 2);
|
ctx.arc(x, y, radius * 3.4, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAnimatedBars(t) {
|
function animateBars(t) {
|
||||||
document.querySelectorAll(".bar").forEach((bar, index) => {
|
document.querySelectorAll("#timelineBars i").forEach((bar, index) => {
|
||||||
const scale = 0.82 + (Math.sin(t / 760 + index * 0.36) + 1) * 0.16;
|
const scale = 0.82 + (Math.sin(t / 720 + index * 0.38) + 1) * 0.16;
|
||||||
bar.style.transform = `scaleY(${scale.toFixed(3)})`;
|
bar.style.transform = `scaleY(${scale.toFixed(3)})`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -231,15 +234,31 @@ function pulse() {
|
|||||||
requestAnimationFrame(() => wall.classList.add("pulse"));
|
requestAnimationFrame(() => wall.classList.add("pulse"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateHud() {
|
function syncClock() {
|
||||||
if (!tile) return;
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
const rtt = Number.isFinite(bestRttMs) ? Math.round(bestRttMs) : "--";
|
ws.send(JSON.stringify({
|
||||||
const offset = `${clockOffsetMs >= 0 ? "+" : ""}${Math.round(clockOffsetMs)}ms`;
|
type: "clock_ping",
|
||||||
hud.textContent = `${tile.id} | ${nodeId.slice(0, 18)} | ${fps.toFixed(1)}fps | ${frameTimeMs.toFixed(1)}ms | rtt ${rtt}ms | offset ${offset}`;
|
node_id: nodeId,
|
||||||
|
client_send_ms: clientNowMs(),
|
||||||
|
client_send_perf_ms: performance.now(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClockPong(payload) {
|
||||||
|
const receivePerf = performance.now();
|
||||||
|
const sendPerf = Number(payload.client_send_perf_ms ?? payload.client_send_ms);
|
||||||
|
if (!Number.isFinite(sendPerf)) return;
|
||||||
|
const rtt = receivePerf - sendPerf;
|
||||||
|
const midpointPerf = sendPerf + rtt / 2;
|
||||||
|
const offset = Number(payload.server_time_ms) - midpointPerf;
|
||||||
|
clockSamples.push({ rtt, offset });
|
||||||
|
clockSamples = clockSamples.sort((a, b) => a.rtt - b.rtt).slice(0, 8);
|
||||||
|
bestRttMs = clockSamples[0].rtt;
|
||||||
|
serverOffsetFromPerfMs = clockSamples.slice(0, 4).reduce((sum, item) => sum + item.offset, 0) / Math.min(4, clockSamples.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendAck(message, status) {
|
function sendAck(message, status) {
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
if (!ws || ws.readyState !== WebSocket.OPEN || !tile) return;
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: "ack",
|
type: "ack",
|
||||||
command_id: message.command_id,
|
command_id: message.command_id,
|
||||||
@@ -248,31 +267,11 @@ function sendAck(message, status) {
|
|||||||
status,
|
status,
|
||||||
client_time_ms: clientNowMs(),
|
client_time_ms: clientNowMs(),
|
||||||
server_estimated_ms: serverNowMs(),
|
server_estimated_ms: serverNowMs(),
|
||||||
clock_offset_ms: clockOffsetMs,
|
clock_offset_ms: serverOffsetFromPerfMs - (Date.now() - performance.now()),
|
||||||
rtt_ms: Number.isFinite(bestRttMs) ? bestRttMs : null,
|
rtt_ms: Number.isFinite(bestRttMs) ? bestRttMs : null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncClock() {
|
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
||||||
ws.send(JSON.stringify({ type: "clock_ping", node_id: nodeId, client_send_ms: clientNowMs() }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClockPong(payload) {
|
|
||||||
const receiveMs = clientNowMs();
|
|
||||||
const sendMs = payload.client_send_ms;
|
|
||||||
if (typeof sendMs !== "number") return;
|
|
||||||
const rtt = receiveMs - sendMs;
|
|
||||||
const estimatedServerAtReceive = payload.server_time_ms + rtt / 2;
|
|
||||||
const nextOffset = estimatedServerAtReceive - receiveMs;
|
|
||||||
if (rtt < bestRttMs) {
|
|
||||||
bestRttMs = rtt;
|
|
||||||
clockOffsetMs = nextOffset;
|
|
||||||
} else {
|
|
||||||
clockOffsetMs = clockOffsetMs * 0.85 + nextOffset * 0.15;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendTelemetry() {
|
function sendTelemetry() {
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN || !tile) return;
|
if (!ws || ws.readyState !== WebSocket.OPEN || !tile) return;
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
@@ -296,21 +295,29 @@ function connectWs() {
|
|||||||
app_version: APP_VERSION,
|
app_version: APP_VERSION,
|
||||||
user_agent: navigator.userAgent,
|
user_agent: navigator.userAgent,
|
||||||
}));
|
}));
|
||||||
syncClock();
|
for (let i = 0; i < 5; i += 1) setTimeout(syncClock, i * 120);
|
||||||
});
|
});
|
||||||
ws.addEventListener("close", () => setTimeout(connectWs, 1200));
|
ws.addEventListener("close", () => setTimeout(connectWs, 1200));
|
||||||
ws.addEventListener("message", (event) => {
|
ws.addEventListener("message", (event) => {
|
||||||
const message = JSON.parse(event.data);
|
const message = JSON.parse(event.data);
|
||||||
if (message.type === "clock_pong") handleClockPong(message.payload);
|
if (message.type === "clock_pong") handleClockPong(message.payload);
|
||||||
if (message.type === "state") commitScene({ command_id: message.command_id, payload: message.payload });
|
if (message.type === "state") scheduleJob({ command_id: message.command_id, payload: message.payload }, "state", () => applyState(message.payload.state || message.payload));
|
||||||
if (message.type === "prepare_scene") prepareScene(message);
|
if (message.type === "prepare_scene") prepareScene(message);
|
||||||
if (message.type === "commit_scene") commitScene(message);
|
if (message.type === "commit_scene") commitScene(message);
|
||||||
if (message.type === "component_action") runComponentAction(message);
|
if (message.type === "component_action") runComponentAction(message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateHud() {
|
||||||
|
if (!tile) return;
|
||||||
|
const rtt = Number.isFinite(bestRttMs) ? `${Math.round(bestRttMs)}ms` : "--";
|
||||||
|
const scale = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--scale"));
|
||||||
|
const preview = scale < 0.75 ? " | 本地预览已缩小,生产 4K 拼接桌面会更清晰" : "";
|
||||||
|
hud.innerHTML = `${tile.id} | ${nodeId.slice(0, 18)} | ${fps.toFixed(1)}fps | ${frameTimeMs.toFixed(1)}ms | rtt ${rtt} | jobs ${scheduledJobs.size}<span class="preview-note">${preview}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
function tickClock() {
|
function tickClock() {
|
||||||
clock.textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
clockText.textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("resize", layout);
|
window.addEventListener("resize", layout);
|
||||||
@@ -321,5 +328,5 @@ window.addEventListener("keydown", (event) => {
|
|||||||
bootstrap();
|
bootstrap();
|
||||||
tickClock();
|
tickClock();
|
||||||
setInterval(tickClock, 1000);
|
setInterval(tickClock, 1000);
|
||||||
setInterval(syncClock, 2500);
|
setInterval(syncClock, 2000);
|
||||||
setInterval(sendTelemetry, 1000);
|
setInterval(sendTelemetry, 1000);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
font-family: Inter, "Segoe UI", system-ui, sans-serif;
|
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif;
|
||||||
background: #000;
|
background: #030712;
|
||||||
color: #f8fafc;
|
color: #f8fbff;
|
||||||
--wall-width: 14880px;
|
--wall-width: 14880px;
|
||||||
--wall-height: 3510px;
|
--wall-height: 3510px;
|
||||||
--scale: 1;
|
--scale: 1;
|
||||||
@@ -23,10 +23,14 @@ body,
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #030712;
|
||||||
|
}
|
||||||
|
|
||||||
.viewport {
|
.viewport {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: #020407;
|
background: #030712;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wall {
|
.wall {
|
||||||
@@ -39,33 +43,34 @@ body,
|
|||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
transform: translate(var(--offset-x), var(--offset-y)) scale(var(--scale));
|
transform: translate(var(--offset-x), var(--offset-y)) scale(var(--scale));
|
||||||
background:
|
background:
|
||||||
linear-gradient(90deg, rgba(20, 184, 166, 0.14), transparent 34%, rgba(249, 115, 22, 0.12)),
|
radial-gradient(circle at 50% 40%, rgba(20, 184, 166, 0.28), transparent 36%),
|
||||||
#071018;
|
radial-gradient(circle at 82% 22%, rgba(249, 115, 22, 0.18), transparent 24%),
|
||||||
|
linear-gradient(135deg, #061528 0%, #0b1220 48%, #071018 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.motionCanvas,
|
.motion-canvas,
|
||||||
.gridLayer {
|
.grid-layer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gridLayer {
|
.grid-layer {
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba(255,255,255,0.045) 1px, transparent 1px),
|
linear-gradient(rgba(255,255,255,0.045) 2px, transparent 2px),
|
||||||
linear-gradient(90deg, rgba(255,255,255,0.045) 1px, transparent 1px);
|
linear-gradient(90deg, rgba(255,255,255,0.045) 2px, transparent 2px);
|
||||||
background-size: 240px 240px;
|
background-size: 240px 240px;
|
||||||
opacity: 0.72;
|
opacity: 0.74;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wallHeader {
|
.screen-header {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 3;
|
z-index: 4;
|
||||||
left: 360px;
|
left: 360px;
|
||||||
right: 360px;
|
right: 360px;
|
||||||
top: 180px;
|
top: 180px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
gap: 120px;
|
gap: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,41 +82,45 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
margin-bottom: 36px;
|
margin-bottom: 38px;
|
||||||
color: #5eead4;
|
color: #5eead4;
|
||||||
font-size: 58px;
|
font-size: 58px;
|
||||||
|
line-height: 1;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 178px;
|
font-size: 178px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
font-weight: 820;
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: 0 0 36px rgba(20, 184, 166, 0.46);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metrics {
|
.header-metrics {
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-items: end;
|
justify-items: end;
|
||||||
gap: 24px;
|
gap: 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metrics strong {
|
.header-metrics strong {
|
||||||
font-size: 108px;
|
font-size: 112px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metrics span {
|
.header-metrics span {
|
||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
font-size: 48px;
|
font-size: 52px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene {
|
.scene {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 2;
|
z-index: 3;
|
||||||
inset: 620px 360px 240px;
|
inset: 620px 360px 250px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transform: translateY(50px);
|
transform: translateY(54px);
|
||||||
transition: opacity 360ms ease, transform 360ms ease;
|
transition: opacity 260ms linear, transform 260ms linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene.active {
|
.scene.active {
|
||||||
@@ -120,58 +129,61 @@ h1 {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.headline {
|
.hero-copy {
|
||||||
max-width: 5900px;
|
max-width: 6200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.headline h2 {
|
.hero-copy h2 {
|
||||||
font-size: 156px;
|
font-size: 158px;
|
||||||
line-height: 1.05;
|
line-height: 1.05;
|
||||||
|
font-weight: 820;
|
||||||
}
|
}
|
||||||
|
|
||||||
.headline p {
|
.hero-copy p {
|
||||||
margin-top: 42px;
|
margin-top: 44px;
|
||||||
color: #cbd5e1;
|
color: #dbeafe;
|
||||||
font-size: 62px;
|
font-size: 66px;
|
||||||
line-height: 1.35;
|
line-height: 1.34;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpiGrid {
|
.card-grid {
|
||||||
margin-top: 140px;
|
margin-top: 150px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, 1fr);
|
grid-template-columns: repeat(6, 1fr);
|
||||||
gap: 72px;
|
gap: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi,
|
.metric-card,
|
||||||
.sideStats article,
|
.side-stats article,
|
||||||
.camera {
|
.camera-card {
|
||||||
border: 3px solid rgba(255,255,255,0.12);
|
border: 4px solid rgba(94, 234, 212, 0.32);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(255,255,255,0.055);
|
background: rgba(15, 23, 42, 0.76);
|
||||||
|
box-shadow: inset 0 0 60px rgba(20, 184, 166, 0.10), 0 0 42px rgba(20, 184, 166, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi {
|
.metric-card {
|
||||||
min-height: 500px;
|
min-height: 520px;
|
||||||
padding: 70px;
|
padding: 74px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi span,
|
.metric-card span,
|
||||||
.sideStats span {
|
.side-stats span {
|
||||||
display: block;
|
display: block;
|
||||||
color: #aeb7c2;
|
color: #a7f3d0;
|
||||||
font-size: 58px;
|
font-size: 58px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi strong,
|
.metric-card strong,
|
||||||
.sideStats strong {
|
.side-stats strong {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 46px;
|
margin-top: 48px;
|
||||||
font-size: 130px;
|
color: #ffffff;
|
||||||
|
font-size: 136px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bars {
|
.timeline-bars {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -179,65 +191,65 @@ h1 {
|
|||||||
height: 960px;
|
height: 960px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(32, 1fr);
|
grid-template-columns: repeat(32, 1fr);
|
||||||
gap: 24px;
|
|
||||||
align-items: end;
|
align-items: end;
|
||||||
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar {
|
.timeline-bars i {
|
||||||
min-height: 100px;
|
min-height: 120px;
|
||||||
border-radius: 8px 8px 0 0;
|
border-radius: 8px 8px 0 0;
|
||||||
background: linear-gradient(180deg, #14b8a6, #f97316);
|
background: linear-gradient(180deg, #14b8a6, #f97316);
|
||||||
transform-origin: bottom;
|
transform-origin: bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
.energyMap {
|
.flow-map {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 780px;
|
top: 790px;
|
||||||
width: 8600px;
|
width: 8600px;
|
||||||
height: 1760px;
|
height: 1760px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flowLine {
|
.flow-line {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
height: 28px;
|
height: 30px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: linear-gradient(90deg, #14b8a6, #facc15, #f97316);
|
background: linear-gradient(90deg, #14b8a6, #facc15, #f97316);
|
||||||
box-shadow: 0 0 80px rgba(20,184,166,0.42);
|
box-shadow: 0 0 90px rgba(20, 184, 166, 0.46);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lineA {
|
.line-a {
|
||||||
left: 1100px;
|
left: 1100px;
|
||||||
top: 690px;
|
top: 690px;
|
||||||
width: 5200px;
|
width: 5200px;
|
||||||
transform: rotate(7deg);
|
transform: rotate(7deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lineB {
|
.line-b {
|
||||||
left: 2300px;
|
left: 2300px;
|
||||||
top: 1050px;
|
top: 1050px;
|
||||||
width: 4200px;
|
width: 4200px;
|
||||||
transform: rotate(-10deg);
|
transform: rotate(-10deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.flowNode {
|
.flow-node {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 980px;
|
width: 980px;
|
||||||
height: 420px;
|
height: 420px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 5px solid rgba(94,234,212,0.55);
|
border: 5px solid rgba(94, 234, 212, 0.62);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(8,20,28,0.92);
|
background: rgba(8, 20, 28, 0.92);
|
||||||
font-size: 76px;
|
font-size: 80px;
|
||||||
font-weight: 760;
|
font-weight: 780;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodeA { left: 220px; top: 520px; }
|
.node-a { left: 220px; top: 520px; }
|
||||||
.nodeB { left: 3600px; top: 220px; }
|
.node-b { left: 3600px; top: 220px; }
|
||||||
.nodeC { left: 7020px; top: 900px; }
|
.node-c { left: 7020px; top: 900px; }
|
||||||
|
|
||||||
.sideStats {
|
.side-stats {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 690px;
|
top: 690px;
|
||||||
@@ -247,12 +259,12 @@ h1 {
|
|||||||
gap: 64px;
|
gap: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sideStats article {
|
.side-stats article {
|
||||||
min-height: 610px;
|
min-height: 620px;
|
||||||
padding: 78px;
|
padding: 78px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cameraGrid {
|
.camera-grid {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 760px;
|
top: 760px;
|
||||||
@@ -262,26 +274,26 @@ h1 {
|
|||||||
gap: 52px;
|
gap: 52px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.camera {
|
.camera-card {
|
||||||
height: 700px;
|
height: 700px;
|
||||||
padding: 52px;
|
padding: 58px;
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: space-between;
|
align-content: space-between;
|
||||||
background:
|
background:
|
||||||
linear-gradient(135deg, rgba(20,184,166,0.22), rgba(249,115,22,0.14)),
|
linear-gradient(135deg, rgba(20,184,166,0.22), rgba(249,115,22,0.14)),
|
||||||
rgba(255,255,255,0.055);
|
rgba(15,23,42,0.78);
|
||||||
}
|
}
|
||||||
|
|
||||||
.camera strong {
|
.camera-card strong {
|
||||||
font-size: 72px;
|
font-size: 78px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.camera span {
|
.camera-card span {
|
||||||
color: #cbd5e1;
|
color: #dbeafe;
|
||||||
font-size: 48px;
|
font-size: 52px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alertRail {
|
.alert-rail {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 740px;
|
top: 740px;
|
||||||
@@ -290,21 +302,12 @@ h1 {
|
|||||||
gap: 44px;
|
gap: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert {
|
.alert-card {
|
||||||
padding: 54px 64px;
|
padding: 58px 68px;
|
||||||
border-left: 18px solid #f97316;
|
border-left: 20px solid #f97316;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(255,255,255,0.065);
|
background: rgba(15, 23, 42, 0.78);
|
||||||
font-size: 60px;
|
font-size: 64px;
|
||||||
}
|
|
||||||
|
|
||||||
.pulse {
|
|
||||||
animation: flash 650ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes flash {
|
|
||||||
0% { outline: 16px solid rgba(250,204,21,0.78); }
|
|
||||||
100% { outline: 0 solid rgba(250,204,21,0); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hud {
|
.hud {
|
||||||
@@ -312,11 +315,24 @@ h1 {
|
|||||||
right: 12px;
|
right: 12px;
|
||||||
bottom: 12px;
|
bottom: 12px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
max-width: min(860px, calc(100vw - 24px));
|
max-width: min(980px, calc(100vw - 24px));
|
||||||
padding: 8px 10px;
|
padding: 9px 11px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: rgba(0,0,0,0.68);
|
background: rgba(0,0,0,0.72);
|
||||||
color: rgba(255,255,255,0.78);
|
color: rgba(255,255,255,0.82);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-note {
|
||||||
|
color: #facc15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse {
|
||||||
|
animation: flash 520ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flash {
|
||||||
|
0% { outline: 18px solid rgba(94, 234, 212, 0.55); }
|
||||||
|
100% { outline: 0 solid rgba(94, 234, 212, 0); }
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user