Init
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>LED Wall Output</title>
|
||||
<link rel="stylesheet" href="/static/output/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="viewport" class="viewport">
|
||||
<section id="wall" class="wall" aria-label="LED wall output">
|
||||
<canvas id="motionCanvas" class="motionCanvas"></canvas>
|
||||
<div class="gridLayer"></div>
|
||||
|
||||
<header class="wallHeader">
|
||||
<div>
|
||||
<p id="eyebrow" class="eyebrow">Unified Timeline Rendering</p>
|
||||
<h1 id="sceneTitle">LED 大屏投放平台</h1>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<strong id="clock">--:--:--</strong>
|
||||
<span id="version">v0</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="scene active" data-view="overview">
|
||||
<div class="headline">
|
||||
<h2>双 GPU 输出,统一时间轴</h2>
|
||||
<p>左右服务器各自渲染半屏,但所有场景、动画、地图相机、视频时间都由同一个服务端时间轴驱动。</p>
|
||||
</div>
|
||||
<div id="overviewKpis" class="kpiGrid"></div>
|
||||
<div id="overviewBars" class="bars"></div>
|
||||
</section>
|
||||
|
||||
<section class="scene" data-view="energy">
|
||||
<div class="headline">
|
||||
<h2>能源驾驶舱</h2>
|
||||
<p>适合 ECharts、Canvas、WebGL 地图和 Three.js 数字孪生。真实项目中应使用 camera.setViewOffset 做 tile-aware 渲染。</p>
|
||||
</div>
|
||||
<div class="energyMap">
|
||||
<div class="flowLine lineA"></div>
|
||||
<div class="flowLine lineB"></div>
|
||||
<div class="flowNode nodeA">园区供电</div>
|
||||
<div class="flowNode nodeB">储能系统</div>
|
||||
<div class="flowNode nodeC">负载中心</div>
|
||||
</div>
|
||||
<div class="sideStats">
|
||||
<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="modeValue">均衡</strong></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="scene" data-view="security">
|
||||
<div class="headline">
|
||||
<h2>安防态势</h2>
|
||||
<p>视频墙、告警卡片和地图联动都通过结构化动作同步触发,不在输出端执行任意远程脚本。</p>
|
||||
</div>
|
||||
<div id="cameraGrid" class="cameraGrid"></div>
|
||||
<div id="alertRail" class="alertRail"></div>
|
||||
</section>
|
||||
</section>
|
||||
<aside id="hud" class="hud"></aside>
|
||||
</main>
|
||||
<script src="/static/output/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,325 @@
|
||||
const APP_VERSION = "1.0.0";
|
||||
const hud = document.querySelector("#hud");
|
||||
const wall = document.querySelector("#wall");
|
||||
const canvas = document.querySelector("#motionCanvas");
|
||||
const ctx = canvas.getContext("2d", { alpha: true });
|
||||
const clock = document.querySelector("#clock");
|
||||
const version = document.querySelector("#version");
|
||||
const sceneTitle = document.querySelector("#sceneTitle");
|
||||
const tileId = location.pathname.startsWith("/output/") ? location.pathname.split("/").pop() : "left";
|
||||
|
||||
const sceneNames = {
|
||||
overview: "LED 大屏投放平台",
|
||||
energy: "能源驾驶舱",
|
||||
security: "安防态势",
|
||||
};
|
||||
|
||||
let ws = null;
|
||||
let tile = null;
|
||||
let state = null;
|
||||
let paused = false;
|
||||
let pageIndex = 0;
|
||||
let clockOffsetMs = 0;
|
||||
let bestRttMs = Number.POSITIVE_INFINITY;
|
||||
let pendingCommands = new Map();
|
||||
let frameCount = 0;
|
||||
let droppedFrames = 0;
|
||||
let lastFrameAt = performance.now();
|
||||
let lastFpsAt = performance.now();
|
||||
let fps = 0;
|
||||
let frameTimeMs = 0;
|
||||
let nodeId = sessionStorage.getItem("led-platform-node-id");
|
||||
|
||||
if (!nodeId) {
|
||||
const randomId = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(16).slice(2);
|
||||
nodeId = `${tileId}-${randomId}`;
|
||||
sessionStorage.setItem("led-platform-node-id", nodeId);
|
||||
}
|
||||
|
||||
function clientNowMs() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function serverNowMs() {
|
||||
return clientNowMs() + clockOffsetMs;
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const res = await fetch(`/api/bootstrap/${tileId}`);
|
||||
const data = await res.json();
|
||||
tile = data.tile;
|
||||
applyState(data.state);
|
||||
buildStaticContent();
|
||||
layout();
|
||||
connectWs();
|
||||
requestAnimationFrame(renderLoop);
|
||||
}
|
||||
|
||||
function layout() {
|
||||
if (!tile) return;
|
||||
const scale = Math.min(window.innerWidth / tile.width, window.innerHeight / tile.height);
|
||||
document.documentElement.style.setProperty("--wall-width", `${tile.wall_width}px`);
|
||||
document.documentElement.style.setProperty("--wall-height", `${tile.wall_height}px`);
|
||||
document.documentElement.style.setProperty("--scale", `${scale}`);
|
||||
document.documentElement.style.setProperty("--offset-x", `${-tile.x * scale}px`);
|
||||
document.documentElement.style.setProperty("--offset-y", `${-tile.y * scale}px`);
|
||||
canvas.width = tile.wall_width;
|
||||
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) {
|
||||
state = nextState;
|
||||
version.textContent = `v${state.version}`;
|
||||
setScene(state.active_view);
|
||||
}
|
||||
|
||||
function setScene(view) {
|
||||
const normalized = view || "overview";
|
||||
document.querySelectorAll(".scene").forEach((scene) => {
|
||||
scene.classList.toggle("active", scene.dataset.view === normalized);
|
||||
});
|
||||
sceneTitle.textContent = sceneNames[normalized] || sceneNames.overview;
|
||||
pulse();
|
||||
}
|
||||
|
||||
function scheduleAt(applyAtMs, callback) {
|
||||
const leadTimeMs = applyAtMs - serverNowMs();
|
||||
const late = leadTimeMs < 80;
|
||||
setTimeout(() => callback(late), Math.max(0, leadTimeMs));
|
||||
}
|
||||
|
||||
function prepareScene(message) {
|
||||
pendingCommands.set(message.command_id, message.payload);
|
||||
sendAck(message, "prepared");
|
||||
}
|
||||
|
||||
function commitScene(message) {
|
||||
const payload = pendingCommands.get(message.command_id) || message.payload;
|
||||
scheduleAt(payload.apply_at_ms, (late) => {
|
||||
applyState(payload.state);
|
||||
pendingCommands.delete(message.command_id);
|
||||
sendAck(message, late ? "late" : "committed");
|
||||
});
|
||||
}
|
||||
|
||||
function runComponentAction(message) {
|
||||
const { action, args, apply_at_ms: applyAtMs } = message.payload;
|
||||
scheduleAt(applyAtMs, (late) => {
|
||||
applyAction(action, args || {});
|
||||
sendAck(message, late ? "late" : "action_committed");
|
||||
});
|
||||
}
|
||||
|
||||
function applyAction(action, args) {
|
||||
if (action === "page.next") {
|
||||
pageIndex += 1;
|
||||
rotateValues();
|
||||
} else if (action === "page.prev") {
|
||||
pageIndex = Math.max(0, pageIndex - 1);
|
||||
rotateValues();
|
||||
} else if (action === "energy.mode") {
|
||||
document.querySelector("#modeValue").textContent = args.mode || "削峰";
|
||||
} else if (action === "security.alert") {
|
||||
const rail = document.querySelector("#alertRail");
|
||||
const alert = document.createElement("article");
|
||||
alert.className = "alert pulse";
|
||||
alert.textContent = args.text || "新增联动告警";
|
||||
rail.prepend(alert);
|
||||
while (rail.children.length > 5) rail.lastElementChild.remove();
|
||||
} else if (action === "timeline.pause") {
|
||||
paused = true;
|
||||
} else if (action === "timeline.resume") {
|
||||
paused = false;
|
||||
}
|
||||
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() {
|
||||
if (!state) return 0;
|
||||
return Math.max(0, serverNowMs() - state.scene_started_at_ms);
|
||||
}
|
||||
|
||||
function renderLoop(now) {
|
||||
frameTimeMs = now - lastFrameAt;
|
||||
if (frameTimeMs > 40) droppedFrames += 1;
|
||||
frameCount += 1;
|
||||
if (now - lastFpsAt >= 1000) {
|
||||
fps = frameCount * 1000 / (now - lastFpsAt);
|
||||
frameCount = 0;
|
||||
lastFpsAt = now;
|
||||
}
|
||||
lastFrameAt = now;
|
||||
|
||||
drawMotion(paused ? 0 : timelineMs());
|
||||
updateAnimatedBars(paused ? 0 : timelineMs());
|
||||
updateHud();
|
||||
requestAnimationFrame(renderLoop);
|
||||
}
|
||||
|
||||
function drawMotion(t) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.globalAlpha = 0.72;
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
const phase = (t / 1000) + i * 0.42;
|
||||
const x = (Math.sin(phase * 0.42) * 0.5 + 0.5) * canvas.width;
|
||||
const y = (Math.cos(phase * 0.36) * 0.5 + 0.5) * canvas.height;
|
||||
const r = 80 + (i % 5) * 28;
|
||||
const grad = ctx.createRadialGradient(x, y, 0, x, y, r * 5);
|
||||
grad.addColorStop(0, i % 2 ? "rgba(249,115,22,0.23)" : "rgba(20,184,166,0.25)");
|
||||
grad.addColorStop(1, "rgba(0,0,0,0)");
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r * 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function updateAnimatedBars(t) {
|
||||
document.querySelectorAll(".bar").forEach((bar, index) => {
|
||||
const scale = 0.82 + (Math.sin(t / 760 + index * 0.36) + 1) * 0.16;
|
||||
bar.style.transform = `scaleY(${scale.toFixed(3)})`;
|
||||
});
|
||||
}
|
||||
|
||||
function pulse() {
|
||||
wall.classList.remove("pulse");
|
||||
requestAnimationFrame(() => wall.classList.add("pulse"));
|
||||
}
|
||||
|
||||
function updateHud() {
|
||||
if (!tile) return;
|
||||
const rtt = Number.isFinite(bestRttMs) ? Math.round(bestRttMs) : "--";
|
||||
const offset = `${clockOffsetMs >= 0 ? "+" : ""}${Math.round(clockOffsetMs)}ms`;
|
||||
hud.textContent = `${tile.id} | ${nodeId.slice(0, 18)} | ${fps.toFixed(1)}fps | ${frameTimeMs.toFixed(1)}ms | rtt ${rtt}ms | offset ${offset}`;
|
||||
}
|
||||
|
||||
function sendAck(message, status) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify({
|
||||
type: "ack",
|
||||
command_id: message.command_id,
|
||||
node_id: nodeId,
|
||||
tile_id: tile.id,
|
||||
status,
|
||||
client_time_ms: clientNowMs(),
|
||||
server_estimated_ms: serverNowMs(),
|
||||
clock_offset_ms: clockOffsetMs,
|
||||
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() {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN || !tile) return;
|
||||
ws.send(JSON.stringify({
|
||||
type: "telemetry",
|
||||
node_id: nodeId,
|
||||
tile_id: tile.id,
|
||||
fps,
|
||||
frame_time_ms: frameTimeMs,
|
||||
dropped_frames: droppedFrames,
|
||||
}));
|
||||
}
|
||||
|
||||
function connectWs() {
|
||||
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
||||
ws = new WebSocket(`${protocol}://${location.host}/ws/output/${tile.id}`);
|
||||
ws.addEventListener("open", () => {
|
||||
ws.send(JSON.stringify({
|
||||
type: "hello",
|
||||
node_id: nodeId,
|
||||
tile_id: tile.id,
|
||||
app_version: APP_VERSION,
|
||||
user_agent: navigator.userAgent,
|
||||
}));
|
||||
syncClock();
|
||||
});
|
||||
ws.addEventListener("close", () => setTimeout(connectWs, 1200));
|
||||
ws.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === "clock_pong") handleClockPong(message.payload);
|
||||
if (message.type === "state") commitScene({ command_id: message.command_id, payload: message.payload });
|
||||
if (message.type === "prepare_scene") prepareScene(message);
|
||||
if (message.type === "commit_scene") commitScene(message);
|
||||
if (message.type === "component_action") runComponentAction(message);
|
||||
});
|
||||
}
|
||||
|
||||
function tickClock() {
|
||||
clock.textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
window.addEventListener("resize", layout);
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (event.key.toLowerCase() === "h") hud.hidden = !hud.hidden;
|
||||
});
|
||||
|
||||
bootstrap();
|
||||
tickClock();
|
||||
setInterval(tickClock, 1000);
|
||||
setInterval(syncClock, 2500);
|
||||
setInterval(sendTelemetry, 1000);
|
||||
@@ -0,0 +1,322 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, "Segoe UI", system-ui, sans-serif;
|
||||
background: #000;
|
||||
color: #f8fafc;
|
||||
--wall-width: 14880px;
|
||||
--wall-height: 3510px;
|
||||
--scale: 1;
|
||||
--offset-x: 0px;
|
||||
--offset-y: 0px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
.viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #020407;
|
||||
}
|
||||
|
||||
.wall {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: var(--wall-width);
|
||||
height: var(--wall-height);
|
||||
overflow: hidden;
|
||||
transform-origin: 0 0;
|
||||
transform: translate(var(--offset-x), var(--offset-y)) scale(var(--scale));
|
||||
background:
|
||||
linear-gradient(90deg, rgba(20, 184, 166, 0.14), transparent 34%, rgba(249, 115, 22, 0.12)),
|
||||
#071018;
|
||||
}
|
||||
|
||||
.motionCanvas,
|
||||
.gridLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.gridLayer {
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.045) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.045) 1px, transparent 1px);
|
||||
background-size: 240px 240px;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.wallHeader {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: 360px;
|
||||
right: 360px;
|
||||
top: 180px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 120px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin-bottom: 36px;
|
||||
color: #5eead4;
|
||||
font-size: 58px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 178px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.metrics strong {
|
||||
font-size: 108px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.metrics span {
|
||||
color: #cbd5e1;
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.scene {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 620px 360px 240px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(50px);
|
||||
transition: opacity 360ms ease, transform 360ms ease;
|
||||
}
|
||||
|
||||
.scene.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.headline {
|
||||
max-width: 5900px;
|
||||
}
|
||||
|
||||
.headline h2 {
|
||||
font-size: 156px;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.headline p {
|
||||
margin-top: 42px;
|
||||
color: #cbd5e1;
|
||||
font-size: 62px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.kpiGrid {
|
||||
margin-top: 140px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 72px;
|
||||
}
|
||||
|
||||
.kpi,
|
||||
.sideStats article,
|
||||
.camera {
|
||||
border: 3px solid rgba(255,255,255,0.12);
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.055);
|
||||
}
|
||||
|
||||
.kpi {
|
||||
min-height: 500px;
|
||||
padding: 70px;
|
||||
}
|
||||
|
||||
.kpi span,
|
||||
.sideStats span {
|
||||
display: block;
|
||||
color: #aeb7c2;
|
||||
font-size: 58px;
|
||||
}
|
||||
|
||||
.kpi strong,
|
||||
.sideStats strong {
|
||||
display: block;
|
||||
margin-top: 46px;
|
||||
font-size: 130px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bars {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 960px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(32, 1fr);
|
||||
gap: 24px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.bar {
|
||||
min-height: 100px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
background: linear-gradient(180deg, #14b8a6, #f97316);
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
.energyMap {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 780px;
|
||||
width: 8600px;
|
||||
height: 1760px;
|
||||
}
|
||||
|
||||
.flowLine {
|
||||
position: absolute;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(90deg, #14b8a6, #facc15, #f97316);
|
||||
box-shadow: 0 0 80px rgba(20,184,166,0.42);
|
||||
}
|
||||
|
||||
.lineA {
|
||||
left: 1100px;
|
||||
top: 690px;
|
||||
width: 5200px;
|
||||
transform: rotate(7deg);
|
||||
}
|
||||
|
||||
.lineB {
|
||||
left: 2300px;
|
||||
top: 1050px;
|
||||
width: 4200px;
|
||||
transform: rotate(-10deg);
|
||||
}
|
||||
|
||||
.flowNode {
|
||||
position: absolute;
|
||||
width: 980px;
|
||||
height: 420px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 5px solid rgba(94,234,212,0.55);
|
||||
border-radius: 8px;
|
||||
background: rgba(8,20,28,0.92);
|
||||
font-size: 76px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.nodeA { left: 220px; top: 520px; }
|
||||
.nodeB { left: 3600px; top: 220px; }
|
||||
.nodeC { left: 7020px; top: 900px; }
|
||||
|
||||
.sideStats {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 690px;
|
||||
width: 4700px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 64px;
|
||||
}
|
||||
|
||||
.sideStats article {
|
||||
min-height: 610px;
|
||||
padding: 78px;
|
||||
}
|
||||
|
||||
.cameraGrid {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 760px;
|
||||
width: 9200px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 52px;
|
||||
}
|
||||
|
||||
.camera {
|
||||
height: 700px;
|
||||
padding: 52px;
|
||||
display: grid;
|
||||
align-content: space-between;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(20,184,166,0.22), rgba(249,115,22,0.14)),
|
||||
rgba(255,255,255,0.055);
|
||||
}
|
||||
|
||||
.camera strong {
|
||||
font-size: 72px;
|
||||
}
|
||||
|
||||
.camera span {
|
||||
color: #cbd5e1;
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.alertRail {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 740px;
|
||||
width: 4300px;
|
||||
display: grid;
|
||||
gap: 44px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 54px 64px;
|
||||
border-left: 18px solid #f97316;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.065);
|
||||
font-size: 60px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
position: fixed;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 10;
|
||||
max-width: min(860px, calc(100vw - 24px));
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0,0,0,0.68);
|
||||
color: rgba(255,255,255,0.78);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
Reference in New Issue
Block a user