Refactor LED control UI to Vue3

This commit is contained in:
sealks
2026-07-17 02:10:08 +08:00
parent fe906e028b
commit ba9f0044f3
16 changed files with 1906 additions and 752 deletions
+549 -298
View File
@@ -1,332 +1,583 @@
const APP_VERSION = "3.0.0";
const { createApp } = Vue;
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 APP_VERSION = "4.0.0-vue3";
const tileId = location.pathname.startsWith("/output/") ? location.pathname.split("/").pop() : "left";
const isPreview = new URLSearchParams(location.search).get("preview") === "1";
const nodeStorageKey = `led-platform-node-id-${tileId}`;
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 createSceneDefinitions() {
return [
{
id: "overview",
title: "LED 大屏投放平台",
kicker: "GLOBAL WALL OVERVIEW",
headline: "双 GPU 本地渲染,统一时间轴同步",
copy: "左右输出端各自渲染半屏,场景、动作、地图相机和动画由同一个服务端时间轴驱动。",
metrics: [
{ label: "逻辑宽度", value: "14880", trend: "px" },
{ label: "逻辑高度", value: "3510", trend: "px" },
{ label: "输出节点", value: "2", trend: "GPU servers" },
{ label: "帧同步", value: "ACK", trend: "barrier" },
],
lines: ["line-a", "line-b", "line-c"],
nodes: [
{ id: "control", label: "控制服务", value: "ACTIVE", x: 720, y: 620, tone: "cyan" },
{ id: "left", label: "左侧 tile", value: "7440px", x: 3400, y: 1100, tone: "blue" },
{ id: "right", label: "右侧 tile", value: "7440px", x: 7100, y: 880, tone: "orange" },
{ id: "wall", label: "LED 控制器", value: "8 INPUT", x: 10300, y: 1260, tone: "cyan" },
],
events: [
{ time: "T+00", title: "场景统一", detail: "prepare / commit 协议" },
{ time: "T+02", title: "时间校准", detail: "RTT 采样与 serverNow" },
{ time: "T+04", title: "本地渲染", detail: "两端分别绘制 tile" },
],
},
{
id: "energy",
title: "能源驾驶舱",
kicker: "ENERGY GRID COMMAND",
headline: "源网荷储一体化调度",
copy: "实时监控园区供电、储能、负荷趋势和削峰策略,支持模式切换与调度策略下发。",
metrics: [
{ label: "实时功率", value: "8.42", trend: "MW" },
{ label: "储能 SOC", value: "72", trend: "%" },
{ label: "负载趋势", value: "+3.8", trend: "%" },
{ label: "调度策略", value: "均衡", trend: "auto" },
],
lines: ["line-a", "line-d", "line-e"],
nodes: [
{ id: "grid", label: "园区供电", value: "稳定", x: 850, y: 760, tone: "cyan" },
{ id: "storage", label: "储能系统", value: "72%", x: 3900, y: 520, tone: "blue" },
{ id: "load", label: "业务负载", value: "8.42MW", x: 7600, y: 980, tone: "orange" },
{ id: "strategy", label: "调度策略", value: "均衡", x: 10800, y: 620, tone: "cyan" },
],
events: [
{ time: "09:12", title: "负载预测上调", detail: "未来 15 分钟 +3.8%" },
{ time: "09:18", title: "储能响应", detail: "2 组电池进入待命" },
{ time: "09:23", title: "策略校验", detail: "削峰阈值未触发" },
],
},
{
id: "security",
title: "安防态势",
kicker: "SECURITY SITUATION",
headline: "视频墙、告警与区域联动",
copy: "多路视频、门禁、消防与巡检事件按统一时间轴联动,支持局部告警动作同步触发。",
metrics: [
{ label: "在线摄像", value: "128", trend: "channels" },
{ label: "风险指数", value: "17", trend: "low" },
{ label: "巡检任务", value: "42", trend: "running" },
{ label: "联动告警", value: "4", trend: "active" },
],
lines: ["line-b", "line-c", "line-f"],
nodes: [
{ id: "north", label: "北区通道", value: "关注", x: 980, y: 880, tone: "orange" },
{ id: "center", label: "中心机房", value: "正常", x: 4200, y: 530, tone: "cyan" },
{ id: "gate", label: "门禁系统", value: "1 异常", x: 7550, y: 1200, tone: "orange" },
{ id: "fire", label: "消防联动", value: "待命", x: 10900, y: 760, tone: "blue" },
],
events: [
{ time: "10:31", title: "门禁异常联动", detail: "B2 机房侧门" },
{ time: "10:34", title: "人员聚集", detail: "北侧通道" },
{ time: "10:40", title: "巡检完成", detail: "A 区 12 个点位" },
],
},
{
id: "command",
title: "指挥调度",
kicker: "MISSION CONTROL",
headline: "跨区域任务派发与执行闭环",
copy: "事件、队伍、资源和任务状态聚合在同一个指挥视图,适合现场大屏调度与演练展示。",
metrics: [
{ label: "事件队列", value: "23", trend: "items" },
{ label: "响应队伍", value: "11", trend: "teams" },
{ label: "闭环率", value: "96", trend: "%" },
{ label: "平均响应", value: "4.6", trend: "min" },
],
lines: ["line-a", "line-e", "line-f"],
nodes: [
{ id: "hq", label: "指挥中心", value: "ONLINE", x: 920, y: 620, tone: "cyan" },
{ id: "team", label: "响应队伍", value: "11", x: 4100, y: 1080, tone: "blue" },
{ id: "resource", label: "资源池", value: "82%", x: 7500, y: 620, tone: "orange" },
{ id: "task", label: "任务闭环", value: "96%", x: 10800, y: 1050, tone: "cyan" },
],
events: [
{ time: "11:05", title: "任务派发", detail: "3 支队伍已接收" },
{ time: "11:08", title: "资源锁定", detail: "移动电源与无人机" },
{ time: "11:16", title: "阶段回执", detail: "现场反馈正常" },
],
},
{
id: "transport",
title: "交通运行",
kicker: "TRANSPORT NETWORK",
headline: "路网运行与运力调度",
copy: "车流、站点、路段拥堵与运力资源在大屏上联动呈现,支持重点区域快速聚焦。",
metrics: [
{ label: "车流指数", value: "68", trend: "stable" },
{ label: "拥堵路段", value: "7", trend: "segments" },
{ label: "准点率", value: "91", trend: "%" },
{ label: "可用运力", value: "84", trend: "%" },
],
lines: ["line-b", "line-d", "line-f"],
nodes: [
{ id: "hub", label: "枢纽站", value: "91%", x: 760, y: 980, tone: "cyan" },
{ id: "road", label: "主干路", value: "68", x: 3900, y: 540, tone: "orange" },
{ id: "fleet", label: "运力池", value: "84%", x: 7600, y: 1150, tone: "blue" },
{ id: "signal", label: "信号优化", value: "运行", x: 10900, y: 700, tone: "cyan" },
],
events: [
{ time: "12:20", title: "车流高峰", detail: "东向主线压力上升" },
{ time: "12:26", title: "信号配时", detail: "绿波方案已下发" },
{ time: "12:31", title: "运力补偿", detail: "3 条线路加车" },
],
},
{
id: "dataflow",
title: "数据中枢",
kicker: "AI DATA FABRIC",
headline: "数据流入、计算与智能分析",
copy: "多源数据经过实时管道进入计算集群,AI 推理结果以指标、事件和预测态势输出。",
metrics: [
{ label: "接入数据", value: "2.8", trend: "TB/h" },
{ label: "计算任务", value: "418", trend: "jobs" },
{ label: "AI 推理", value: "36", trend: "models" },
{ label: "延迟 P95", value: "42", trend: "ms" },
],
lines: ["line-a", "line-c", "line-d"],
nodes: [
{ id: "ingest", label: "数据接入", value: "2.8TB/h", x: 780, y: 650, tone: "cyan" },
{ id: "compute", label: "计算集群", value: "418", x: 4100, y: 1050, tone: "blue" },
{ id: "ai", label: "AI 推理", value: "36", x: 7700, y: 580, tone: "orange" },
{ id: "output", label: "指标输出", value: "42ms", x: 10800, y: 1160, tone: "cyan" },
],
events: [
{ time: "13:02", title: "数据流入峰值", detail: "北区视频数据增加" },
{ time: "13:07", title: "模型推理完成", detail: "风险识别批次 19" },
{ time: "13:10", title: "指标写入", detail: "P95 延迟 42ms" },
],
},
];
}
function serverNowMs() {
return performance.now() + serverOffsetFromPerfMs;
}
createApp({
data() {
return {
tile: null,
state: null,
activeView: "overview",
sceneDefinitions: createSceneDefinitions(),
ws: null,
nodeId: sessionStorage.getItem(nodeStorageKey),
serverOffsetFromDateMs: 0,
bestRttMs: Number.POSITIVE_INFINITY,
clockSamples: [],
scheduledJobs: new Map(),
frameCount: 0,
fps: 0,
frameTimeMs: 0,
droppedFrames: 0,
lastFrameAt: performance.now(),
lastFpsAt: performance.now(),
pageIndex: 0,
paused: false,
motionMode: "flow",
focusTarget: "core",
pulseActive: false,
clockText: "--:--:--",
hudText: "",
ctx: null,
isPreview,
};
},
function clientNowMs() {
return Date.now();
}
computed: {
sceneTitle() {
return this.sceneDefinitions.find((scene) => scene.id === this.activeView)?.title || "LED 大屏投放平台";
},
},
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);
}
async mounted() {
if (!this.nodeId) {
const randomId = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(16).slice(2);
this.nodeId = `${tileId}-${randomId}`;
sessionStorage.setItem(nodeStorageKey, this.nodeId);
}
this.ctx = this.$refs.motionCanvas.getContext("2d", { alpha: true });
await this.bootstrap();
this.tickClock();
setInterval(() => this.tickClock(), 1000);
setInterval(() => this.syncClock(), 2000);
if (!isPreview) setInterval(() => this.sendTelemetry(), 1000);
requestAnimationFrame((now) => this.renderLoop(now));
},
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("");
methods: {
async bootstrap() {
const response = await fetch(`/api/bootstrap/${tileId}`);
const data = await response.json();
this.tile = data.tile;
this.applyState(data.state);
this.layout();
if (isPreview) {
this.connectPreviewWs();
} else {
this.connectWs();
}
window.addEventListener("resize", () => this.layout());
window.addEventListener("keydown", (event) => {
if (event.key.toLowerCase() === "h") this.$refs.hud.hidden = !this.$refs.hud.hidden;
});
},
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);
}
layout() {
if (!this.tile) return;
const scale = Math.min(window.innerWidth / this.tile.width, window.innerHeight / this.tile.height);
const tileRenderWidth = this.tile.width * scale;
const tileRenderHeight = this.tile.height * scale;
const root = document.documentElement;
root.style.setProperty("--wall-width", `${this.tile.wall_width}px`);
root.style.setProperty("--wall-height", `${this.tile.wall_height}px`);
root.style.setProperty("--tile-render-width", `${tileRenderWidth}px`);
root.style.setProperty("--tile-render-height", `${tileRenderHeight}px`);
root.style.setProperty("--scale", `${scale}`);
root.style.setProperty("--offset-x", `${-this.tile.x * scale}px`);
root.style.setProperty("--offset-y", `${-this.tile.y * scale}px`);
const appEl = document.querySelector("#outputApp") || (this.$el?.style ? this.$el : null);
const canvas = document.querySelector("#motionCanvas") || this.$refs.motionCanvas;
if (appEl) {
appEl.style.width = `${tileRenderWidth}px`;
appEl.style.height = `${tileRenderHeight}px`;
}
if (canvas) {
canvas.width = this.tile.wall_width;
canvas.height = this.tile.wall_height;
if (!this.ctx) this.ctx = canvas.getContext("2d", { alpha: true });
}
},
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("");
applyState(nextState) {
this.state = nextState;
this.activeView = nextState.active_view || "overview";
this.pulse();
},
document.querySelector("#alertRail").innerHTML = [
"北侧通道人员聚集",
"机房门禁异常",
"消防通道占用",
"视频质量波动",
].map((text) => `<article class="alert-card">${text}</article>`).join("");
}
scheduleJob(message, kind, callback) {
const applyAtMs = Number(message.payload.apply_at_ms);
this.scheduledJobs.set(message.command_id, {
id: message.command_id,
kind,
applyAtMs,
message,
callback,
});
},
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;
}
drainScheduledJobs() {
if (!this.scheduledJobs.size) return;
const now = this.serverNowMs();
const ready = Array.from(this.scheduledJobs.values())
.filter((job) => now >= job.applyAtMs)
.sort((a, b) => a.applyAtMs - b.applyAtMs);
for (const job of ready) {
this.scheduledJobs.delete(job.id);
const late = now - job.applyAtMs > 50;
job.callback(late);
}
},
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();
}
async prepareScene(message) {
const startedAtMs = this.serverNowMs();
try {
await this.prepareSceneFrame(message);
this.sendAck(message, "prepared", {
prepare_duration_ms: Math.max(0, this.serverNowMs() - startedAtMs),
});
} catch (error) {
this.sendAck(message, "error", {
error: error instanceof Error ? error.message : String(error),
});
}
},
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(),
});
}
async prepareSceneFrame(message) {
if (typeof window.ledPlatformPrepareScene === "function") {
await window.ledPlatformPrepareScene({
commandId: message.command_id,
tile: this.tile,
state: message.payload.state,
scene: message.payload.scene,
});
return;
}
await Promise.all((message.payload.scene?.preload || []).map((url) => this.preloadUrl(url)));
await this.waitForFrames(2);
},
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);
}
}
preloadUrl(url) {
return new Promise((resolve) => {
const image = new Image();
image.onload = resolve;
image.onerror = resolve;
image.src = url;
});
},
function prepareScene(message) {
sendAck(message, "prepared");
}
waitForFrames(count = 2) {
return new Promise((resolve) => {
const step = (remaining) => {
if (remaining <= 0) {
resolve();
return;
}
requestAnimationFrame(() => step(remaining - 1));
};
step(count);
});
},
function commitScene(message) {
scheduleJob(message, "scene", (late) => {
applyState(message.payload.state);
sendAck(message, late ? "late" : "committed");
});
}
commitScene(message) {
this.scheduleJob(message, "scene", (late) => {
this.applyState(message.payload.state);
this.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");
});
}
commitScenePreview(message) {
this.scheduleJob(message, "scene-preview", () => {
this.applyState(message.payload.state);
});
},
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();
}
runComponentAction(message) {
this.scheduleJob(message, "action", (late) => {
this.applyAction(message.payload.action, message.payload.args || {});
this.sendAck(message, late ? "late" : "action_committed");
});
},
function timelineMs() {
if (!state) return 0;
return Math.max(0, serverNowMs() - state.scene_started_at_ms);
}
runComponentActionPreview(message) {
this.scheduleJob(message, "action-preview", () => {
this.applyAction(message.payload.action, message.payload.args || {});
});
},
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;
applyAction(action, args) {
if (action === "page.next" || action === "page.prev") {
const views = this.sceneDefinitions.map((scene) => scene.id);
const direction = action === "page.next" ? 1 : -1;
this.pageIndex = (views.indexOf(this.activeView) + direction + views.length) % views.length;
this.applyState({
...this.state,
active_view: views[this.pageIndex],
version: (this.state?.version || 0) + 1,
});
} else if (action === "energy.mode") {
this.updateMetric("energy", "调度策略", args.mode || "削峰", "manual");
} else if (action === "security.alert") {
this.prependEvent("security", {
time: this.clockText,
title: args.text || "新增联动告警",
detail: "结构化动作同步触发",
});
} else if (action === "motion.mode") {
this.motionMode = args.mode || "flow";
} else if (action === "scenario.focus") {
this.focusTarget = args.target || "core";
} else if (action === "timeline.pause") {
this.paused = true;
} else if (action === "timeline.resume") {
this.paused = false;
}
this.pulse();
},
drainScheduledJobs();
drawMotion(paused ? 0 : timelineMs());
animateBars(paused ? 0 : timelineMs());
updateHud();
requestAnimationFrame(renderLoop);
}
updateMetric(sceneId, label, value, trend) {
const scene = this.sceneDefinitions.find((item) => item.id === sceneId);
const metric = scene?.metrics.find((item) => item.label === label);
if (!metric) return;
metric.value = value;
metric.trend = trend;
},
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;
}
prependEvent(sceneId, event) {
const scene = this.sceneDefinitions.find((item) => item.id === sceneId);
if (!scene) return;
scene.events.unshift(event);
scene.events = scene.events.slice(0, 5);
},
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)})`;
});
}
timelineMs() {
if (!this.state) return 0;
return Math.max(0, this.serverNowMs() - this.state.scene_started_at_ms);
},
function pulse() {
wall.classList.remove("pulse");
requestAnimationFrame(() => wall.classList.add("pulse"));
}
renderLoop(now) {
this.frameTimeMs = now - this.lastFrameAt;
if (this.frameTimeMs > 40) this.droppedFrames += 1;
this.frameCount += 1;
if (now - this.lastFpsAt >= 1000) {
this.fps = this.frameCount * 1000 / (now - this.lastFpsAt);
this.frameCount = 0;
this.lastFpsAt = now;
}
this.lastFrameAt = now;
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(),
}));
}
this.drainScheduledJobs();
this.drawMotion(this.paused ? 0 : this.timelineMs());
this.updateHud();
requestAnimationFrame((nextNow) => this.renderLoop(nextNow));
},
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);
}
drawMotion(t) {
const ctx = this.ctx;
if (!ctx || !this.tile) return;
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
const count = this.motionMode === "orbit" ? 46 : this.motionMode === "pulse" ? 34 : 28;
ctx.globalAlpha = 0.72;
for (let i = 0; i < count; i += 1) {
const modeFactor = this.motionMode === "pulse" ? 0.84 : this.motionMode === "orbit" ? 1.24 : 1;
const phase = t / (980 / modeFactor) + i * 0.39;
const x = (Math.sin(phase * 0.41 + i) * 0.5 + 0.5) * ctx.canvas.width;
const y = (Math.cos(phase * 0.33 + i * 0.2) * 0.5 + 0.5) * ctx.canvas.height;
const radius = 240 + (i % 7) * 74;
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius * 3.2);
gradient.addColorStop(0, i % 3 ? "rgba(56,189,248,0.22)" : "rgba(20,184,166,0.24)");
gradient.addColorStop(0.42, "rgba(37,99,235,0.08)");
gradient.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(x, y, radius * 3.2, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
},
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,
}));
}
pulse() {
this.pulseActive = false;
requestAnimationFrame(() => {
this.pulseActive = true;
setTimeout(() => {
this.pulseActive = false;
}, 560);
});
},
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,
}));
}
syncClock() {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
this.ws.send(JSON.stringify({
type: "clock_ping",
node_id: this.nodeId,
client_send_ms: Date.now(),
client_send_perf_ms: performance.now(),
}));
},
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);
});
}
handleClockPong(payload) {
const receivePerf = performance.now();
const sendPerf = Number(payload.client_send_perf_ms);
const sendWall = Number(payload.client_send_ms);
const serverTime = Number(payload.server_time_ms);
if (!Number.isFinite(sendWall) || !Number.isFinite(serverTime)) return;
const perfRtt = Number.isFinite(sendPerf) ? receivePerf - sendPerf : Number.NaN;
const wallRtt = Date.now() - sendWall;
const rtt = Number.isFinite(perfRtt) && perfRtt >= 0 ? perfRtt : wallRtt;
const midpointWall = sendWall + rtt / 2;
const offset = serverTime - midpointWall;
this.clockSamples.push({ rtt, offset });
this.clockSamples = this.clockSamples.sort((a, b) => a.rtt - b.rtt).slice(0, 8);
this.bestRttMs = this.clockSamples[0].rtt;
this.serverOffsetFromDateMs = this.clockSamples
.slice(0, 4)
.reduce((sum, item) => sum + item.offset, 0) / Math.min(4, this.clockSamples.length);
},
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>`;
}
serverNowMs() {
return Date.now() + this.serverOffsetFromDateMs;
},
function tickClock() {
clockText.textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false });
}
sendAck(message, status, extra = {}) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.tile) return;
this.ws.send(JSON.stringify({
type: "ack",
command_id: message.command_id,
node_id: this.nodeId,
tile_id: this.tile.id,
status,
client_time_ms: Date.now(),
server_estimated_ms: this.serverNowMs(),
clock_offset_ms: this.serverOffsetFromDateMs,
rtt_ms: Number.isFinite(this.bestRttMs) ? this.bestRttMs : null,
...extra,
}));
},
window.addEventListener("resize", layout);
window.addEventListener("keydown", (event) => {
if (event.key.toLowerCase() === "h") hud.hidden = !hud.hidden;
});
sendTelemetry() {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.tile) return;
this.ws.send(JSON.stringify({
type: "telemetry",
node_id: this.nodeId,
tile_id: this.tile.id,
fps: this.fps,
frame_time_ms: this.frameTimeMs,
dropped_frames: this.droppedFrames,
}));
},
bootstrap();
tickClock();
setInterval(tickClock, 1000);
setInterval(syncClock, 2000);
setInterval(sendTelemetry, 1000);
connectWs() {
const protocol = location.protocol === "https:" ? "wss" : "ws";
this.ws = new WebSocket(`${protocol}://${location.host}/ws/output/${this.tile.id}`);
this.ws.addEventListener("open", () => {
this.ws.send(JSON.stringify({
type: "hello",
node_id: this.nodeId,
tile_id: this.tile.id,
app_version: APP_VERSION,
user_agent: navigator.userAgent,
}));
for (let i = 0; i < 5; i += 1) setTimeout(() => this.syncClock(), i * 120);
});
this.ws.addEventListener("close", () => setTimeout(() => this.connectWs(), 1200));
this.ws.addEventListener("message", (event) => this.handleOutputMessage(JSON.parse(event.data)));
},
connectPreviewWs() {
const protocol = location.protocol === "https:" ? "wss" : "ws";
this.ws = new WebSocket(`${protocol}://${location.host}/ws/admin`);
this.ws.addEventListener("open", () => {
for (let i = 0; i < 5; i += 1) setTimeout(() => this.syncClock(), i * 120);
});
this.ws.addEventListener("close", () => setTimeout(() => this.connectPreviewWs(), 1200));
this.ws.addEventListener("message", (event) => this.handlePreviewMessage(JSON.parse(event.data)));
},
handleOutputMessage(message) {
if (message.type === "clock_pong") this.handleClockPong(message.payload);
if (message.type === "state") this.scheduleJob({ command_id: message.command_id, payload: message.payload }, "state", () => this.applyState(message.payload.state || message.payload));
if (message.type === "prepare_scene") void this.prepareScene(message);
if (message.type === "commit_scene") this.commitScene(message);
if (message.type === "component_action") this.runComponentAction(message);
},
handlePreviewMessage(message) {
if (message.type === "clock_pong") this.handleClockPong(message.payload);
if (message.type === "state") this.scheduleJob({ command_id: message.command_id, payload: message.payload }, "state", () => this.applyState(message.payload.state || message.payload));
if (message.type === "prepare_scene") this.commitScenePreview(message);
if (message.type === "commit_scene") this.commitScenePreview(message);
if (message.type === "component_action") this.runComponentActionPreview(message);
},
updateHud() {
if (!this.tile) return;
const rtt = Number.isFinite(this.bestRttMs) ? `${Math.round(this.bestRttMs)}ms` : "--";
const scale = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--scale"));
const previewNote = scale < 0.75 ? " | 本地预览已缩小,生产 4K 拼接桌面会更清晰" : "";
const mode = isPreview ? "PREVIEW" : this.nodeId.slice(0, 18);
this.hudText = `${this.tile.id} | ${mode} | ${this.motionMode} | ${this.fps.toFixed(1)}fps | ${this.frameTimeMs.toFixed(1)}ms | rtt ${rtt} | jobs ${this.scheduledJobs.size}${previewNote}`;
},
tickClock() {
this.clockText = new Date().toLocaleTimeString("zh-CN", { hour12: false });
},
},
}).mount("#outputApp");