const { createApp } = Vue; 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}`; 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" }, ], }, ]; } 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, }; }, computed: { sceneTitle() { return this.sceneDefinitions.find((scene) => scene.id === this.activeView)?.title || "LED 大屏投放平台"; }, }, 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)); }, 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; }); }, 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 }); } }, applyState(nextState) { this.state = nextState; this.activeView = nextState.active_view || "overview"; this.pulse(); }, 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, }); }, 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); } }, 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), }); } }, 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); }, preloadUrl(url) { return new Promise((resolve) => { const image = new Image(); image.onload = resolve; image.onerror = resolve; image.src = url; }); }, waitForFrames(count = 2) { return new Promise((resolve) => { const step = (remaining) => { if (remaining <= 0) { resolve(); return; } requestAnimationFrame(() => step(remaining - 1)); }; step(count); }); }, commitScene(message) { this.scheduleJob(message, "scene", (late) => { this.applyState(message.payload.state); this.sendAck(message, late ? "late" : "committed"); }); }, commitScenePreview(message) { this.scheduleJob(message, "scene-preview", () => { this.applyState(message.payload.state); }); }, runComponentAction(message) { this.scheduleJob(message, "action", (late) => { this.applyAction(message.payload.action, message.payload.args || {}); this.sendAck(message, late ? "late" : "action_committed"); }); }, runComponentActionPreview(message) { this.scheduleJob(message, "action-preview", () => { this.applyAction(message.payload.action, message.payload.args || {}); }); }, 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(); }, 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; }, 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); }, timelineMs() { if (!this.state) return 0; return Math.max(0, this.serverNowMs() - this.state.scene_started_at_ms); }, 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; this.drainScheduledJobs(); this.drawMotion(this.paused ? 0 : this.timelineMs()); this.updateHud(); requestAnimationFrame((nextNow) => this.renderLoop(nextNow)); }, 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; }, pulse() { this.pulseActive = false; requestAnimationFrame(() => { this.pulseActive = true; setTimeout(() => { this.pulseActive = false; }, 560); }); }, 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(), })); }, 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); }, serverNowMs() { return Date.now() + this.serverOffsetFromDateMs; }, 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, })); }, 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, })); }, 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");