333 lines
11 KiB
JavaScript
333 lines
11 KiB
JavaScript
const APP_VERSION = "3.0.0";
|
|
|
|
const wall = document.querySelector("#wall");
|
|
const canvas = document.querySelector("#motionCanvas");
|
|
const ctx = canvas.getContext("2d", { alpha: true });
|
|
const hud = document.querySelector("#hud");
|
|
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 sceneTitles = {
|
|
overview: "LED 大屏投放平台",
|
|
energy: "能源驾驶舱",
|
|
security: "安防态势",
|
|
};
|
|
|
|
let tile = null;
|
|
let state = null;
|
|
let ws = null;
|
|
let nodeId = sessionStorage.getItem("led-platform-node-id");
|
|
let serverOffsetFromPerfMs = Date.now() - performance.now();
|
|
let bestRttMs = Number.POSITIVE_INFINITY;
|
|
let clockSamples = [];
|
|
let scheduledJobs = new Map();
|
|
let frameCount = 0;
|
|
let fps = 0;
|
|
let frameTimeMs = 0;
|
|
let droppedFrames = 0;
|
|
let lastFrameAt = performance.now();
|
|
let lastFpsAt = performance.now();
|
|
let pageIndex = 0;
|
|
let paused = false;
|
|
|
|
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 serverNowMs() {
|
|
return performance.now() + serverOffsetFromPerfMs;
|
|
}
|
|
|
|
function clientNowMs() {
|
|
return Date.now();
|
|
}
|
|
|
|
async function bootstrap() {
|
|
const response = await fetch(`/api/bootstrap/${tileId}`);
|
|
const data = await response.json();
|
|
tile = data.tile;
|
|
applyState(data.state);
|
|
buildStaticContent();
|
|
layout();
|
|
connectWs();
|
|
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() {
|
|
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;
|
|
}
|
|
|
|
function applyState(nextState) {
|
|
state = nextState;
|
|
const view = state.active_view || "overview";
|
|
document.querySelectorAll(".scene").forEach((scene) => {
|
|
scene.classList.toggle("active", scene.dataset.view === view);
|
|
});
|
|
sceneTitle.textContent = sceneTitles[view] || sceneTitles.overview;
|
|
versionText.textContent = `v${state.version || 0}`;
|
|
pulse();
|
|
}
|
|
|
|
function scheduleJob(message, kind, callback) {
|
|
const applyAtMs = Number(message.payload.apply_at_ms);
|
|
scheduledJobs.set(message.command_id, {
|
|
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) {
|
|
sendAck(message, "prepared");
|
|
}
|
|
|
|
function commitScene(message) {
|
|
scheduleJob(message, "scene", (late) => {
|
|
applyState(message.payload.state);
|
|
sendAck(message, late ? "late" : "committed");
|
|
});
|
|
}
|
|
|
|
function runComponentAction(message) {
|
|
scheduleJob(message, "action", (late) => {
|
|
applyAction(message.payload.action, message.payload.args || {});
|
|
sendAck(message, late ? "late" : "action_committed");
|
|
});
|
|
}
|
|
|
|
function applyAction(action, args) {
|
|
if (action === "page.next") {
|
|
pageIndex += 1;
|
|
const views = ["overview", "energy", "security"];
|
|
applyState({ ...state, active_view: views[pageIndex % views.length], version: (state?.version || 0) + 1 });
|
|
} else if (action === "page.prev") {
|
|
pageIndex = Math.max(0, pageIndex - 1);
|
|
const views = ["overview", "energy", "security"];
|
|
applyState({ ...state, active_view: views[pageIndex % views.length], version: (state?.version || 0) + 1 });
|
|
} 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-card 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 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;
|
|
|
|
drainScheduledJobs();
|
|
drawMotion(paused ? 0 : timelineMs());
|
|
animateBars(paused ? 0 : timelineMs());
|
|
updateHud();
|
|
requestAnimationFrame(renderLoop);
|
|
}
|
|
|
|
function drawMotion(t) {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.globalAlpha = 0.75;
|
|
for (let i = 0; i < 28; i += 1) {
|
|
const phase = t / 1200 + i * 0.43;
|
|
const x = (Math.sin(phase * 0.46) * 0.5 + 0.5) * canvas.width;
|
|
const y = (Math.cos(phase * 0.37) * 0.5 + 0.5) * canvas.height;
|
|
const radius = 260 + (i % 6) * 70;
|
|
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius * 3.4);
|
|
gradient.addColorStop(0, i % 2 ? "rgba(249,115,22,0.18)" : "rgba(20,184,166,0.22)");
|
|
gradient.addColorStop(1, "rgba(0,0,0,0)");
|
|
ctx.fillStyle = gradient;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, radius * 3.4, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
function animateBars(t) {
|
|
document.querySelectorAll("#timelineBars i").forEach((bar, index) => {
|
|
const scale = 0.82 + (Math.sin(t / 720 + index * 0.38) + 1) * 0.16;
|
|
bar.style.transform = `scaleY(${scale.toFixed(3)})`;
|
|
});
|
|
}
|
|
|
|
function pulse() {
|
|
wall.classList.remove("pulse");
|
|
requestAnimationFrame(() => wall.classList.add("pulse"));
|
|
}
|
|
|
|
function syncClock() {
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
ws.send(JSON.stringify({
|
|
type: "clock_ping",
|
|
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) {
|
|
if (!ws || ws.readyState !== WebSocket.OPEN || !tile) 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: serverOffsetFromPerfMs - (Date.now() - performance.now()),
|
|
rtt_ms: Number.isFinite(bestRttMs) ? bestRttMs : null,
|
|
}));
|
|
}
|
|
|
|
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,
|
|
}));
|
|
for (let i = 0; i < 5; i += 1) setTimeout(syncClock, i * 120);
|
|
});
|
|
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") 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 === "commit_scene") commitScene(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() {
|
|
clockText.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, 2000);
|
|
setInterval(sendTelemetry, 1000);
|