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]) => `
${label}${value}
`).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 `
CAM-${id}在线 | 1080P | 低延迟
`; }).join(""); document.querySelector("#alertRail").innerHTML = [ "北侧通道人员聚集", "机房门禁异常", "消防通道占用", "视频质量波动", ].map((text) => `
${text}
`).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}${preview}`; } 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);