808 lines
42 KiB
JavaScript
808 lines
42 KiB
JavaScript
"use strict";
|
||
|
||
const $ = id => document.getElementById(id);
|
||
const elements = {
|
||
pages: $("pages"), pageTemplate: $("page-template"), cardTemplate: $("card-template"),
|
||
connection: $("connection-status"), filters: $("category-filter"), modeTabs: $("mode-tabs"),
|
||
streamToggle: $("toggle-streams"), pageCount: $("page-count"), caseCount: $("case-count"),
|
||
canvasCount: $("canvas-count"), apiCount: $("api-count"), modeDescription: $("mode-description"),
|
||
heroEyebrow: $("hero-eyebrow"), heroTitle: $("hero-title"),
|
||
menu: $("context-menu"), menuTitle: $("menu-title"), menuComponent: $("menu-component"),
|
||
menuDescription: $("menu-description"), menuBody: $("menu-body"), menuStatus: $("menu-status"),
|
||
menuClose: $("menu-close"), menuTabs: [...document.querySelectorAll(".menu-tabs button")], toast: $("toast")
|
||
};
|
||
|
||
const query = new URLSearchParams(location.search);
|
||
const hosted = location.protocol !== "file:" && ["/", "/index.html", "/gallery", "/gallery/"].includes(location.pathname);
|
||
const jetBrainsPreview = location.port === "63342";
|
||
const socketPort = query.get("port") || (hosted && !jetBrainsPreview ? location.port : "8848") || "8848";
|
||
const socketHost = query.get("host") || (hosted ? location.hostname : "127.0.0.1") || "127.0.0.1";
|
||
const socketUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${socketHost}:${socketPort}/renderive/gallery`;
|
||
|
||
const pages = new Map();
|
||
let definitions = [];
|
||
let modes = [];
|
||
let navigation = {};
|
||
let dashboard = {};
|
||
let activeMode = "";
|
||
let activeCategory = "";
|
||
let activeCard = null;
|
||
let activeTab = "controls";
|
||
let streamsPaused = false;
|
||
let toastTimer = 0;
|
||
let lastAnimationFrameAt = 0;
|
||
let displayIntervalMs = 0;
|
||
let displayIntervalLatestMs = 0;
|
||
let displayIntervalP95Ms = 0;
|
||
let displayJitterMs = 0;
|
||
const displayIntervalSamples = [];
|
||
|
||
const message = (type, payload = {}) => JSON.stringify({category: "event", type, ...payload});
|
||
const setConnection = (state, text) => {
|
||
elements.connection.dataset.state = state;
|
||
elements.connection.querySelector("span").textContent = text;
|
||
};
|
||
function toast(text, error = false) {
|
||
clearTimeout(toastTimer);
|
||
elements.toast.textContent = text;
|
||
elements.toast.dataset.error = String(error);
|
||
elements.toast.hidden = false;
|
||
toastTimer = setTimeout(() => { elements.toast.hidden = true; }, 2600);
|
||
}
|
||
function grouped(items) {
|
||
const groups = new Map();
|
||
for (const item of items || []) {
|
||
const group = item.group || "其他";
|
||
if (!groups.has(group)) groups.set(group, []);
|
||
groups.get(group).push(item);
|
||
}
|
||
return groups;
|
||
}
|
||
function adminiveFieldVisible(expression, data) {
|
||
if (!expression) return true;
|
||
const equality = expression.match(/^\$\{\$self\.([A-Za-z_][A-Za-z0-9_]*) == '([^']*)'\}$/);
|
||
return equality ? String(data?.[equality[1]]) === equality[2] : true;
|
||
}
|
||
function adminiveControls(resource) {
|
||
const resources = resource?.resources || (resource ? [resource] : []);
|
||
const controls = [];
|
||
const visit = (fields, data, target, group, path = [], labels = []) => {
|
||
for (const field of fields || []) {
|
||
const presentation = field.presentation || {};
|
||
if (!adminiveFieldVisible(presentation.visible_on, data)) continue;
|
||
const fieldPath = [...path, field.name];
|
||
const fieldLabels = [...labels, presentation.label || field.name];
|
||
if (field.children?.length) {
|
||
visit(field.children, data?.[field.name], target, group, fieldPath, fieldLabels);
|
||
continue;
|
||
}
|
||
if (!field.editable) continue;
|
||
controls.push({
|
||
id: fieldPath.join("."),
|
||
target,
|
||
path: fieldPath,
|
||
label: fieldLabels.join(" / "),
|
||
api: fieldPath.join("."),
|
||
description: presentation.description || "",
|
||
group,
|
||
input: presentation.control === "automatic" ? "text" : presentation.control || "text",
|
||
minimum: field.minimum,
|
||
maximum: field.maximum,
|
||
step: field.multiple_of,
|
||
options: presentation.options || [],
|
||
value: data?.[field.name]
|
||
});
|
||
}
|
||
};
|
||
for (const item of resources) {
|
||
const group = item?.view?.title || item?.descriptor?.label || "控件属性";
|
||
visit(item?.descriptor?.fields, item?.data || {}, item?.target, group);
|
||
}
|
||
return controls;
|
||
}
|
||
function nestedPatch(path, value) {
|
||
return [...path].reverse().reduce((result, key) => ({[key]: result}), value);
|
||
}
|
||
function flatten(value, prefix = "", result = []) {
|
||
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
||
for (const [key, child] of Object.entries(value)) flatten(child, prefix ? `${prefix}.${key}` : key, result);
|
||
} else result.push([prefix, typeof value === "object" ? JSON.stringify(value) : String(value)]);
|
||
return result;
|
||
}
|
||
function formatNanoseconds(value) {
|
||
const nanoseconds = Math.max(0, Number(value) || 0);
|
||
if (nanoseconds < 1_000) return `${Math.round(nanoseconds)} ns`;
|
||
if (nanoseconds < 1_000_000) return `${(nanoseconds / 1_000).toFixed(2)} µs`;
|
||
return `${(nanoseconds / 1_000_000).toFixed(3)} ms`;
|
||
}
|
||
function valueAtPath(root, path) {
|
||
return path.split(".").reduce((value, key) => value?.[key], root);
|
||
}
|
||
function formatDashboardField(field, telemetry) {
|
||
const format = field.format || "text";
|
||
const raw = valueAtPath(telemetry, field.source || "") ?? field.default ?? (format === "text" ? "" : 0);
|
||
const number = Number(raw) || 0;
|
||
const digits = field.digits ?? 0;
|
||
const valueMap = dashboard.value_maps?.[field.value_map] || {};
|
||
if (format === "fixed") return {text: number.toFixed(digits), title: String(number)};
|
||
if (format === "integer") return {text: number.toLocaleString(), title: String(number)};
|
||
if (format === "milliseconds") return {text: `${number.toFixed(digits)} ms`, title: `${number} ms`};
|
||
if (format === "fps") return {text: `${number.toFixed(digits)} FPS`, title: `${number} FPS`};
|
||
if (format === "bytes") return {text: `${number.toLocaleString()} B`, title: `${number} B`};
|
||
if (format === "nanoseconds") return {text: formatNanoseconds(number), title: `${number.toLocaleString()} ns`};
|
||
if (format === "frequency") {
|
||
if (valueAtPath(telemetry, field.enabled_source) === false)
|
||
return {text: field.disabled_label, title: field.disabled_label};
|
||
return {text: `${number.toLocaleString()} Hz`, title: `${number} Hz`};
|
||
}
|
||
if (format === "inverse_fps") {
|
||
const fps = number > 0 ? 1e9 / number : 0;
|
||
return {text: `${fps.toFixed(digits)} FPS`, title: `${fps} FPS`};
|
||
}
|
||
if (format === "pair") {
|
||
const values = field.sources.map(source => Number(valueAtPath(telemetry, source) || 0).toLocaleString());
|
||
return {text: values.join(field.separator || " / "), title: values.join(field.separator || " / ")};
|
||
}
|
||
if (format === "enum" || format === "duration_enum") {
|
||
const key = String(raw);
|
||
const label = valueMap[key] || key;
|
||
const durationSource = field.duration_sources?.[key];
|
||
const duration = durationSource === undefined ? undefined : valueAtPath(telemetry, durationSource);
|
||
return duration === undefined ? {text: label, title: key} :
|
||
{text: `${label} · ${formatNanoseconds(duration)}`, title: `${key} · ${Number(duration || 0).toLocaleString()} ns`};
|
||
}
|
||
if (format === "flags") {
|
||
const text = String(raw).split(field.separator || "+").map(value => valueMap[value] || value).join(field.joiner || " + ");
|
||
return {text, title: String(raw)};
|
||
}
|
||
return {text: String(raw), title: String(raw)};
|
||
}
|
||
function updateDisplayTiming(time) {
|
||
if (lastAnimationFrameAt > 0) {
|
||
displayIntervalLatestMs = Math.max(0, time - lastAnimationFrameAt);
|
||
displayIntervalSamples.push({time, interval: displayIntervalLatestMs});
|
||
}
|
||
lastAnimationFrameAt = time;
|
||
while (displayIntervalSamples.length && displayIntervalSamples[0].time < time - 1000) displayIntervalSamples.shift();
|
||
if (!displayIntervalSamples.length) {
|
||
displayIntervalMs = 0;
|
||
displayIntervalP95Ms = 0;
|
||
displayJitterMs = 0;
|
||
return;
|
||
}
|
||
const values = displayIntervalSamples.map(sample => sample.interval).sort((left, right) => left - right);
|
||
const percentile = ratio => values[Math.min(values.length - 1, Math.floor((values.length - 1) * ratio))];
|
||
displayIntervalMs = percentile(0.5);
|
||
displayIntervalP95Ms = percentile(0.95);
|
||
displayJitterMs = Math.max(0, displayIntervalP95Ms - displayIntervalMs);
|
||
}
|
||
|
||
class GalleryCard {
|
||
constructor(definition, mode) {
|
||
this.definition = definition;
|
||
this.mode = mode;
|
||
this.controls = [];
|
||
this.actions = [];
|
||
this.telemetry = {};
|
||
this.frameCount = 0;
|
||
this.framePending = false;
|
||
this.frameTimeout = null;
|
||
this.frameTimeoutCount = 0;
|
||
this.latestPixelBuffer = null;
|
||
this.transportTimes = [];
|
||
this.presentationTimes = [];
|
||
this.transportFps = 0;
|
||
this.presentationFps = 0;
|
||
this.lastPixelSignature = null;
|
||
this.changedPixelFrames = 0;
|
||
this.duplicatePixelFrames = 0;
|
||
this.lastPixelReceivedAt = 0;
|
||
this.lastPixelChangeAt = 0;
|
||
this.motionState = "waiting";
|
||
this.reconnectTimer = null;
|
||
this.disposed = false;
|
||
this.ready = false;
|
||
this.intersecting = false;
|
||
this.backendActive = null;
|
||
this.frameRequestStartedAt = 0;
|
||
this.frameRoundTripMs = 0;
|
||
this.overwrittenPixelFrames = 0;
|
||
this.lastObserveRequest = 0;
|
||
this.node = elements.cardTemplate.content.firstElementChild.cloneNode(true);
|
||
this.node.dataset.category = definition.category;
|
||
this.node.dataset.mode = mode.id;
|
||
this.canvas = this.node.querySelector("canvas");
|
||
this.shell = this.node.querySelector(".canvas-shell");
|
||
this.context = this.canvas.getContext("2d", {alpha: false});
|
||
this.socketState = this.node.querySelector(".card-socket");
|
||
this.motionStatus = this.node.querySelector(".motion-status");
|
||
this.dashboardBindings = [];
|
||
this.limitBindings = [];
|
||
this.frameLabel = this.node.querySelector(".card-frames");
|
||
this.node.dataset.observerVisible = String(mode.observer_visible);
|
||
this.node.style.setProperty("--mode-accent", mode.accent);
|
||
this.node.querySelector(".card-category").textContent = definition.category;
|
||
this.node.querySelector(".card-title").textContent = definition.title;
|
||
this.node.querySelector(".card-description").textContent = definition.description;
|
||
this.node.querySelector(".card-component").textContent = definition.component;
|
||
this.node.querySelector(".card-controls").textContent = definition.control_count_by_mode?.[mode.id] ?? "—";
|
||
this.node.querySelector(".card-actions").textContent = definition.action_count_by_mode?.[mode.id] ?? "—";
|
||
const frameButton = this.node.querySelector(".frame-button");
|
||
frameButton.textContent = mode.frame_button_label;
|
||
frameButton.addEventListener("click", () => this.requestFrame(performance.now(), true));
|
||
this.node.querySelector(".open-menu").addEventListener("click", event => {
|
||
const rect = event.currentTarget.getBoundingClientRect();
|
||
openMenu(this, rect.right, rect.bottom);
|
||
});
|
||
this.node.addEventListener("contextmenu", event => {
|
||
event.preventDefault();
|
||
openMenu(this, event.clientX, event.clientY);
|
||
});
|
||
this.installCanvasEvents();
|
||
this.resizeObserver = new ResizeObserver(() => this.resize());
|
||
this.resizeObserver.observe(this.shell);
|
||
this.intersectionObserver = new IntersectionObserver(entries => {
|
||
const intersecting = entries[0]?.isIntersecting === true;
|
||
if (this.intersecting === intersecting) return;
|
||
this.intersecting = intersecting;
|
||
this.syncActivity();
|
||
}, {rootMargin: "160px"});
|
||
this.intersectionObserver.observe(this.node);
|
||
this.installDashboard();
|
||
}
|
||
createDashboardCell(field, valueTag, labelTag, valueFirst) {
|
||
const cell = document.createElement("div");
|
||
if (field.cell_class) cell.className = field.cell_class;
|
||
cell.dataset.format = field.format;
|
||
const value = document.createElement(valueTag);
|
||
const label = document.createElement(labelTag);
|
||
label.textContent = field.label;
|
||
cell.append(...(valueFirst ? [value, label] : [label, value]));
|
||
this.dashboardBindings.push({field, node: value});
|
||
return cell;
|
||
}
|
||
createDashboardValue(field, tagName) {
|
||
const node = document.createElement(tagName);
|
||
this.dashboardBindings.push({field, node});
|
||
return node;
|
||
}
|
||
installDashboard() {
|
||
const performanceStrip = this.node.querySelector(".performance-strip");
|
||
performanceStrip.replaceChildren(...dashboard.performance.fields.map(field =>
|
||
this.createDashboardCell(field, "dt", "dd", true)));
|
||
const limitFlags = this.node.querySelector(".limit-flags");
|
||
limitFlags.ariaLabel = dashboard.limits.aria_label;
|
||
const limitTitle = document.createElement("strong");
|
||
limitTitle.textContent = dashboard.limits.title;
|
||
const limitNodes = dashboard.limits.fields.map(field => {
|
||
const node = document.createElement("span");
|
||
const value = document.createElement("b");
|
||
node.append(`${field.label}:`, value);
|
||
this.limitBindings.push({field, node, value});
|
||
return node;
|
||
});
|
||
limitFlags.replaceChildren(limitTitle, ...limitNodes);
|
||
const observerPanel = this.node.querySelector(".kernel-observer-panel");
|
||
observerPanel.ariaLabel = dashboard.observer.aria_label;
|
||
const headerContract = dashboard.observer.header;
|
||
const header = document.createElement("header");
|
||
const identity = document.createElement("div");
|
||
const name = document.createElement("span");
|
||
name.append(`${headerContract.prefix} `, this.createDashboardValue(headerContract.mode, "b"), ` ${headerContract.suffix}`);
|
||
identity.append(name, this.createDashboardValue(headerContract.limit, "strong"));
|
||
const event = document.createElement("div");
|
||
event.append(`${headerContract.event_label} `, this.createDashboardValue(headerContract.event, "b"));
|
||
header.append(identity, event);
|
||
const sections = dashboard.observer.sections.map(sectionContract => {
|
||
const section = document.createElement("div");
|
||
section.className = sectionContract.class_name;
|
||
if (sectionContract.aria_label) section.ariaLabel = sectionContract.aria_label;
|
||
section.replaceChildren(...sectionContract.fields.map(field =>
|
||
this.createDashboardCell(field, "b", "small", false)));
|
||
return section;
|
||
});
|
||
observerPanel.replaceChildren(header, ...sections);
|
||
}
|
||
setSocketState(state, text) {
|
||
this.socketState.dataset.state = state;
|
||
this.socketState.querySelector("span").textContent = text;
|
||
}
|
||
categoryVisible() {
|
||
return activeCategory === navigation.all_categories_label || this.definition.category === activeCategory;
|
||
}
|
||
displayVisible() {
|
||
return !document.hidden && this.mode.id === activeMode && this.categoryVisible() && this.intersecting;
|
||
}
|
||
streamActive() {
|
||
return !streamsPaused && this.displayVisible();
|
||
}
|
||
syncActivity(force = false) {
|
||
const visible = this.displayVisible();
|
||
if (visible && ![WebSocket.OPEN, WebSocket.CONNECTING].includes(this.socket?.readyState)) {
|
||
this.connect();
|
||
return;
|
||
}
|
||
const active = !streamsPaused && visible;
|
||
if (this.ready && (force || this.backendActive !== active)) {
|
||
this.send(active ? "show" : "hide");
|
||
this.backendActive = active;
|
||
}
|
||
if (active) this.requestFrame(performance.now());
|
||
}
|
||
connect() {
|
||
if (this.disposed || [WebSocket.OPEN, WebSocket.CONNECTING].includes(this.socket?.readyState)) return;
|
||
clearTimeout(this.reconnectTimer);
|
||
const socket = new WebSocket(socketUrl);
|
||
this.socket = socket;
|
||
socket.binaryType = "arraybuffer";
|
||
socket.addEventListener("open", () => {
|
||
if (this.socket !== socket) return;
|
||
this.setSocketState("ready", "WS 已连接");
|
||
this.send("gallery_open", {case: this.definition.id, frame_mode: this.mode.id});
|
||
this.resize();
|
||
});
|
||
socket.addEventListener("message", event => {
|
||
if (this.socket === socket)
|
||
typeof event.data === "string" ? this.receiveJson(event.data) : this.receivePixels(event.data);
|
||
});
|
||
socket.addEventListener("close", () => {
|
||
if (this.socket !== socket) return;
|
||
this.ready = false;
|
||
this.backendActive = null;
|
||
this.framePending = false;
|
||
clearTimeout(this.frameTimeout); this.frameTimeout = null;
|
||
this.setSocketState("error", this.displayVisible() ? "连接关闭 · 自动重连" : "连接关闭 · 等待可见");
|
||
this.updateMotionStatus();
|
||
if (!this.disposed && this.displayVisible()) this.reconnectTimer = setTimeout(() => this.connect(), 1000);
|
||
});
|
||
socket.addEventListener("error", () => {
|
||
if (this.socket === socket) this.setSocketState("error", "连接错误 · 自动重连");
|
||
});
|
||
}
|
||
send(type, payload = {}) {
|
||
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(message(type, payload));
|
||
}
|
||
receiveJson(raw) {
|
||
let data;
|
||
try { data = JSON.parse(raw); } catch { toast(`${this.definition.title} 返回无效 JSON`, true); return; }
|
||
if (data.type === "error") {
|
||
const detail = Object.values(data.field_errors || {})[0] || data.message;
|
||
toast(detail || "后端拒绝操作", true);
|
||
if (activeCard === this) elements.menuStatus.textContent = detail;
|
||
return;
|
||
}
|
||
if (data.type === "observer_state") {
|
||
this.telemetry = data.telemetry || {};
|
||
this.updateDashboard();
|
||
if (activeCard === this && ["observer", "performance"].includes(activeTab)) renderMenuBody();
|
||
return;
|
||
}
|
||
if (data.type !== "case_state") return;
|
||
this.controls = adminiveControls(data.controls);
|
||
this.actions = data.actions?.data || [];
|
||
this.telemetry = data.telemetry || {};
|
||
this.ready = true;
|
||
this.backendActive = null;
|
||
this.node.dataset.ready = "true";
|
||
this.syncActivity();
|
||
this.node.querySelector(".card-controls").textContent = this.controls.length;
|
||
this.node.querySelector(".card-actions").textContent = this.actions.length;
|
||
this.setSocketState("ready", `${data.frame_mode?.strategy || "Core2"} 在线`);
|
||
this.updateDashboard();
|
||
if (data.notice && !data.notice.includes("已创建")) toast(`${this.definition.title}:${data.notice}`);
|
||
if (activeCard === this) { elements.menuStatus.textContent = data.notice || "后端状态已回读"; renderMenuBody(); }
|
||
}
|
||
updateDashboard() {
|
||
for (const binding of this.dashboardBindings) {
|
||
const formatted = formatDashboardField(binding.field, this.telemetry);
|
||
binding.node.textContent = formatted.text;
|
||
binding.node.title = formatted.title;
|
||
}
|
||
const currentLimit = valueAtPath(this.telemetry, dashboard.limits.current_source);
|
||
for (const binding of this.limitBindings) {
|
||
const enabled = binding.field.enabled_source === undefined ||
|
||
Boolean(valueAtPath(this.telemetry, binding.field.enabled_source));
|
||
const active = currentLimit === binding.field.active_value;
|
||
const status = enabled ? (active ? dashboard.limits.active_label : dashboard.limits.inactive_label) :
|
||
dashboard.limits.disabled_label;
|
||
const duration = valueAtPath(this.telemetry, binding.field.duration_source) || 0;
|
||
binding.node.dataset.active = String(active);
|
||
binding.value.textContent = `${status} · ${formatNanoseconds(duration)}`;
|
||
}
|
||
this.updateMotionStatus();
|
||
}
|
||
pixelSignature(buffer, width, height, stride) {
|
||
const bytes = new Uint8Array(buffer, 16, stride * height);
|
||
let hash = (2166136261 ^ width ^ (height << 16)) >>> 0;
|
||
const sampleCount = Math.min(4096, bytes.length);
|
||
if (sampleCount <= 1) return Math.imul(hash ^ (bytes[0] || 0), 16777619) >>> 0;
|
||
for (let index = 0; index < sampleCount; index++) {
|
||
const offset = Math.floor(index * (bytes.length - 1) / (sampleCount - 1));
|
||
hash = Math.imul(hash ^ bytes[offset], 16777619) >>> 0;
|
||
}
|
||
return hash;
|
||
}
|
||
updateMotionStatus(now = performance.now()) {
|
||
let state = "waiting", label = "等待动态帧";
|
||
if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) {
|
||
state = "stalled"; label = "像素流断开";
|
||
} else if (!this.streamActive()) {
|
||
state = "waiting"; label = streamsPaused && this.displayVisible() ? "像素流已暂停" : "非活动视图";
|
||
} else if (this.lastPixelChangeAt && now - this.lastPixelChangeAt < 1500) {
|
||
state = "moving"; label = `画面变化 ${this.changedPixelFrames.toLocaleString()}`;
|
||
} else if (this.lastPixelReceivedAt && now - this.lastPixelReceivedAt < 1500) {
|
||
state = "duplicate"; label = `重复像素帧 ${this.duplicatePixelFrames.toLocaleString()}`;
|
||
} else if (this.lastPixelReceivedAt) {
|
||
state = "stalled"; label = "像素帧已停滞";
|
||
}
|
||
if (state !== this.motionState || this.motionStatus.querySelector("span").textContent !== label) {
|
||
this.motionState = state;
|
||
this.motionStatus.dataset.state = state;
|
||
this.motionStatus.querySelector("span").textContent = label;
|
||
}
|
||
}
|
||
receivePixels(buffer) {
|
||
this.framePending = false;
|
||
if (this.frameTimeout !== null) { clearTimeout(this.frameTimeout); this.frameTimeout = null; }
|
||
if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 16) {
|
||
this.setSocketState("error", "像素帧无效 · 自动重连");
|
||
this.socket?.close(1003, "invalid pixel frame");
|
||
return;
|
||
}
|
||
const header = new DataView(buffer, 0, 16);
|
||
const magic = String.fromCharCode(...new Uint8Array(buffer, 0, 4));
|
||
const width = header.getUint32(4, true), height = header.getUint32(8, true), stride = header.getUint32(12, true);
|
||
if (magic !== "RVP1" || !width || !height || stride < width * 4 || buffer.byteLength < 16 + stride * height) {
|
||
this.setSocketState("error", "像素帧协议错误 · 自动重连");
|
||
this.socket?.close(1003, "invalid pixel frame");
|
||
return;
|
||
}
|
||
const now = performance.now();
|
||
const signature = this.pixelSignature(buffer, width, height, stride);
|
||
if (this.lastPixelSignature === null || signature !== this.lastPixelSignature) {
|
||
this.changedPixelFrames++;
|
||
this.lastPixelChangeAt = now;
|
||
} else this.duplicatePixelFrames++;
|
||
this.lastPixelSignature = signature;
|
||
this.lastPixelReceivedAt = now;
|
||
if (this.frameRequestStartedAt > 0) {
|
||
this.frameRoundTripMs = Math.max(0, now - this.frameRequestStartedAt);
|
||
this.frameRequestStartedAt = 0;
|
||
}
|
||
if (this.latestPixelBuffer !== null) this.overwrittenPixelFrames++;
|
||
this.latestPixelBuffer = buffer;
|
||
this.setSocketState("ready", `${this.mode.strategy || "Core2"} 在线`);
|
||
this.transportFps = this.recordRate(this.transportTimes, now);
|
||
this.frameCount++;
|
||
this.frameLabel.textContent = this.frameCount.toLocaleString();
|
||
this.updateMotionStatus(now);
|
||
if (this.mode.request_after_response) this.requestFrame(now);
|
||
}
|
||
presentLatest(time) {
|
||
const buffer = this.latestPixelBuffer;
|
||
if (!buffer) return;
|
||
this.latestPixelBuffer = null;
|
||
const header = new DataView(buffer, 0, 16);
|
||
const width = header.getUint32(4, true), height = header.getUint32(8, true), stride = header.getUint32(12, true);
|
||
if (this.canvas.width !== width || this.canvas.height !== height) {
|
||
this.canvas.width = width;
|
||
this.canvas.height = height;
|
||
}
|
||
if (stride === width * 4) {
|
||
this.context.putImageData(new ImageData(new Uint8ClampedArray(buffer, 16, width * height * 4), width, height), 0, 0);
|
||
} else {
|
||
const packed = new Uint8ClampedArray(width * height * 4), source = new Uint8Array(buffer, 16);
|
||
for (let row = 0; row < height; row++) packed.set(source.subarray(row * stride, row * stride + width * 4), row * width * 4);
|
||
this.context.putImageData(new ImageData(packed, width, height), 0, 0);
|
||
}
|
||
this.presentationFps = this.recordRate(this.presentationTimes, time);
|
||
}
|
||
currentRate(history, now) {
|
||
while (history.length && history[0] < now - 1000) history.shift();
|
||
if (history.length < 2) return 0;
|
||
return (history.length - 1) * 1000 / Math.max(1, history[history.length - 1] - history[0]);
|
||
}
|
||
recordRate(history, now) {
|
||
history.push(now);
|
||
return this.currentRate(history, now);
|
||
}
|
||
resize() {
|
||
if (this.socket?.readyState !== WebSocket.OPEN) return;
|
||
const rect = this.shell.getBoundingClientRect();
|
||
this.send("resize", {width: Math.max(240, Math.round(rect.width)), height: Math.max(180, Math.round(rect.height))});
|
||
}
|
||
requestFrame(time, explicit = false) {
|
||
if ((!explicit && !this.streamActive()) || (explicit && !this.displayVisible()) || !this.ready || this.framePending || this.socket?.readyState !== WebSocket.OPEN) return;
|
||
if (!explicit && !this.mode.automatic) return;
|
||
this.frameRequestStartedAt = time;
|
||
this.framePending = true;
|
||
this.send("frame");
|
||
this.frameTimeout = setTimeout(() => {
|
||
this.framePending = false;
|
||
this.frameRequestStartedAt = 0;
|
||
this.frameTimeout = null;
|
||
this.frameTimeoutCount++;
|
||
if (!this.streamActive() || this.socket?.readyState !== WebSocket.OPEN) return;
|
||
this.setSocketState("error", "像素响应超时 · 正在恢复");
|
||
this.syncActivity(true);
|
||
}, 1500);
|
||
}
|
||
observe(time) {
|
||
if (!this.ready || !this.displayVisible() || this.socket?.readyState !== WebSocket.OPEN || time - this.lastObserveRequest < 650) return;
|
||
this.lastObserveRequest = time;
|
||
this.transportFps = this.currentRate(this.transportTimes, time);
|
||
this.presentationFps = this.currentRate(this.presentationTimes, time);
|
||
this.send("gallery_observe", {client_metrics: {
|
||
transport_fps: this.transportFps,
|
||
presentation_fps: this.presentationFps,
|
||
websocket_buffered_bytes: this.socket.bufferedAmount || 0,
|
||
changed_pixel_frames: this.changedPixelFrames,
|
||
duplicate_pixel_frames: this.duplicatePixelFrames,
|
||
frame_request_timeout_count: this.frameTimeoutCount,
|
||
frame_round_trip_ms: this.frameRoundTripMs,
|
||
display_interval_ms: displayIntervalMs,
|
||
display_interval_latest_ms: displayIntervalLatestMs,
|
||
display_interval_p95_ms: displayIntervalP95Ms,
|
||
display_jitter_ms: displayJitterMs,
|
||
overwritten_pixel_frames: this.overwrittenPixelFrames,
|
||
last_pixel_receive_age_ms: this.lastPixelReceivedAt ? Math.max(0, time - this.lastPixelReceivedAt) : 0,
|
||
last_pixel_change_age_ms: this.lastPixelChangeAt ? Math.max(0, time - this.lastPixelChangeAt) : 0
|
||
}});
|
||
}
|
||
position(event) {
|
||
const rect = this.canvas.getBoundingClientRect();
|
||
return {x: (event.clientX - rect.left) * this.canvas.width / Math.max(1, rect.width), y: (event.clientY - rect.top) * this.canvas.height / Math.max(1, rect.height)};
|
||
}
|
||
modifiers(event) { return (event.shiftKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.altKey ? 4 : 0) | (event.metaKey ? 8 : 0); }
|
||
installCanvasEvents() {
|
||
const pointer = (type, event) => this.send(type, {...this.position(event), button: ["left", "middle", "right"][event.button] || "none", buttons: event.buttons, modifiers: this.modifiers(event)});
|
||
this.canvas.addEventListener("pointermove", event => pointer("pointer_move", event));
|
||
this.canvas.addEventListener("pointerdown", event => { if (event.button !== 2) { this.shell.focus(); this.canvas.setPointerCapture(event.pointerId); pointer("pointer_press", event); } });
|
||
this.canvas.addEventListener("pointerup", event => { if (event.button !== 2) pointer("pointer_release", event); });
|
||
this.canvas.addEventListener("pointerleave", () => this.send("leave"));
|
||
this.canvas.addEventListener("wheel", event => { event.preventDefault(); this.send("wheel", {...this.position(event), pixelDeltaX: event.deltaX, pixelDeltaY: event.deltaY, angleDeltaX: -event.deltaX * 8, angleDeltaY: -event.deltaY * 8, buttons: event.buttons, modifiers: this.modifiers(event)}); }, {passive: false});
|
||
this.shell.addEventListener("keydown", event => this.send("key_press", {key: event.key, nativeKey: event.keyCode, repeat: event.repeat, modifiers: this.modifiers(event)}));
|
||
this.shell.addEventListener("keyup", event => this.send("key_release", {key: event.key, nativeKey: event.keyCode, repeat: false, modifiers: this.modifiers(event)}));
|
||
}
|
||
}
|
||
|
||
function createPage(mode) {
|
||
const page = elements.pageTemplate.content.firstElementChild.cloneNode(true);
|
||
page.dataset.mode = mode.id;
|
||
page.querySelector(".page-strategy").textContent = mode.strategy;
|
||
page.querySelector(".page-title").textContent = `${mode.title} · 全控件页`;
|
||
page.querySelector(".page-description").textContent = mode.description;
|
||
const gallery = page.querySelector(".gallery");
|
||
const cards = definitions.map(definition => new GalleryCard(definition, mode));
|
||
cards.forEach(card => gallery.append(card.node));
|
||
elements.pages.append(page);
|
||
pages.set(mode.id, {mode, page, cards});
|
||
return pages.get(mode.id);
|
||
}
|
||
function syncCardActivity() {
|
||
for (const page of pages.values()) for (const card of page.cards) card.syncActivity();
|
||
}
|
||
function selectMode(id) {
|
||
activeMode = id;
|
||
let selected = pages.get(id);
|
||
if (!selected) selected = createPage(modes.find(mode => mode.id === id));
|
||
for (const [modeId, page] of pages) page.page.hidden = modeId !== id;
|
||
[...elements.modeTabs.children].forEach(button => button.classList.toggle("active", button.dataset.mode === id));
|
||
elements.modeDescription.textContent = selected.mode.description;
|
||
applyCategory();
|
||
syncCardActivity();
|
||
closeMenu();
|
||
}
|
||
function applyCategory() {
|
||
const page = pages.get(activeMode);
|
||
if (!page) return;
|
||
for (const card of page.cards) {
|
||
card.node.hidden = !card.categoryVisible();
|
||
card.syncActivity();
|
||
}
|
||
}
|
||
function installNavigation() {
|
||
elements.modeTabs.replaceChildren(...modes.map(mode => {
|
||
const button = document.createElement("button");
|
||
button.type = "button"; button.dataset.mode = mode.id;
|
||
button.innerHTML = `<span>${mode.title}</span><code>${mode.strategy}</code>`;
|
||
button.addEventListener("click", () => selectMode(mode.id));
|
||
return button;
|
||
}));
|
||
const categories = [navigation.all_categories_label, ...new Set(definitions.map(item => item.category))];
|
||
elements.filters.replaceChildren(...categories.map(category => {
|
||
const button = document.createElement("button");
|
||
button.type = "button"; button.textContent = category;
|
||
button.classList.toggle("active", category === activeCategory);
|
||
button.addEventListener("click", () => {
|
||
activeCategory = category;
|
||
[...elements.filters.children].forEach(child => child.classList.toggle("active", child === button));
|
||
applyCategory();
|
||
});
|
||
return button;
|
||
}));
|
||
}
|
||
|
||
function openMenu(card, x, y) {
|
||
if (!card.ready) { toast("该控件仍在等待后端描述", true); return; }
|
||
activeCard = card; activeTab = "controls";
|
||
elements.menuComponent.textContent = `${card.mode.strategy} / ${card.definition.component}`;
|
||
elements.menuTitle.textContent = card.definition.title;
|
||
elements.menuDescription.textContent = card.definition.description;
|
||
elements.menuStatus.textContent = "菜单仅包含当前控件和当前帧策略可调用的 API";
|
||
elements.menuTabs.forEach(button => button.classList.toggle("active", button.dataset.tab === activeTab));
|
||
renderMenuBody();
|
||
elements.menu.hidden = false;
|
||
const rect = elements.menu.getBoundingClientRect(), gap = 10;
|
||
elements.menu.style.left = `${Math.max(gap, Math.min(x, innerWidth - rect.width - gap))}px`;
|
||
elements.menu.style.top = `${Math.max(gap, Math.min(y, innerHeight - rect.height - gap))}px`;
|
||
}
|
||
function closeMenu() { elements.menu.hidden = true; activeCard = null; }
|
||
function renderControl(item) {
|
||
const card = activeCard;
|
||
const row = document.createElement("div"); row.className = "control-row";
|
||
const copy = document.createElement("div"); copy.className = "control-copy";
|
||
const label = document.createElement("label"); label.textContent = item.label;
|
||
copy.append(label);
|
||
let input;
|
||
if (item.input === "select") {
|
||
input = document.createElement("select");
|
||
for (const entry of item.options || []) { const option = document.createElement("option"); option.value = entry.value; option.textContent = entry.label; input.append(option); }
|
||
input.value = String(item.value);
|
||
} else {
|
||
input = document.createElement("input"); input.type = item.input === "boolean" ? "checkbox" : item.input;
|
||
if (item.input === "boolean") input.checked = Boolean(item.value); else input.value = item.value;
|
||
if (item.input === "number") { input.min = item.minimum; input.max = item.maximum; input.step = item.step || "any"; }
|
||
}
|
||
input.className = "control-input";
|
||
input.setAttribute("aria-label", item.label);
|
||
input.title = item.description || item.label;
|
||
const submit = value => {
|
||
card.send("gallery_patch", {target: item.target, patch: nestedPatch(item.path, value)});
|
||
elements.menuStatus.textContent = `提交“${item.label}”并等待后端回读`;
|
||
};
|
||
if (item.input === "number") {
|
||
let committedValue = input.value;
|
||
const commit = () => {
|
||
const value = input.valueAsNumber;
|
||
const minimum = Number(item.minimum);
|
||
const maximum = Number(item.maximum);
|
||
if (input.value === "" || !Number.isFinite(value) ||
|
||
(Number.isFinite(minimum) && value < minimum) ||
|
||
(Number.isFinite(maximum) && value > maximum)) {
|
||
input.value = committedValue;
|
||
return;
|
||
}
|
||
if (value === Number(committedValue)) {
|
||
input.value = committedValue;
|
||
return;
|
||
}
|
||
committedValue = input.value;
|
||
item.value = value;
|
||
submit(value);
|
||
};
|
||
input.addEventListener("blur", commit);
|
||
input.addEventListener("keydown", event => {
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
commit();
|
||
} else if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
input.value = committedValue;
|
||
}
|
||
});
|
||
} else input.addEventListener("change", () => submit(item.input === "boolean" ? input.checked : input.value));
|
||
row.append(copy, input); return row;
|
||
}
|
||
function renderAction(item) {
|
||
const row = document.createElement("div"); row.className = "action-row";
|
||
const copy = document.createElement("div"); copy.className = "action-copy";
|
||
const label = document.createElement("strong"); label.textContent = item.label;
|
||
const api = document.createElement("code"); api.textContent = item.api; api.title = item.api; copy.append(label, api);
|
||
const controls = document.createElement("div"); controls.className = "action-controls";
|
||
let argument = null;
|
||
if (item.argument_input) { argument = document.createElement("input"); argument.className = "control-input"; argument.type = item.argument_input; argument.value = item.argument_default; controls.append(argument); }
|
||
const button = document.createElement("button"); button.className = "action-button"; button.type = "button"; button.textContent = "执行";
|
||
button.addEventListener("click", () => {
|
||
const card = activeCard;
|
||
const payload = {action: item.id};
|
||
if (argument) payload.argument = item.argument_input === "number" ? Number(argument.value) : argument.value;
|
||
card.send("gallery_action", payload);
|
||
elements.menuStatus.textContent = `执行 ${item.api}`;
|
||
if (item.request_frame) setTimeout(() => card.requestFrame(performance.now(), true), 40);
|
||
});
|
||
controls.append(button); row.append(copy, controls); return row;
|
||
}
|
||
function renderGroups(items, renderer) {
|
||
const fragment = document.createDocumentFragment();
|
||
for (const [name, children] of grouped(items)) {
|
||
const section = document.createElement("section"); section.className = "control-group";
|
||
const title = document.createElement("h3"); title.textContent = name;
|
||
section.append(title, ...children.map(renderer)); fragment.append(section);
|
||
}
|
||
elements.menuBody.replaceChildren(fragment);
|
||
}
|
||
function renderData(value) {
|
||
const list = document.createElement("dl"); list.className = "telemetry-grid";
|
||
for (const [key, content] of flatten(value)) { const dt = document.createElement("dt"), dd = document.createElement("dd"); dt.textContent = key; dd.textContent = content; list.append(dt, dd); }
|
||
elements.menuBody.replaceChildren(list);
|
||
}
|
||
function renderMenuBody() {
|
||
if (!activeCard) return;
|
||
if (activeTab === "actions") renderGroups(activeCard.actions, renderAction);
|
||
else if (activeTab === "controls") renderGroups(activeCard.controls, renderControl);
|
||
else {
|
||
const view = dashboard.menu_views[activeTab];
|
||
renderData(view.source ? valueAtPath(activeCard.telemetry, view.source) || {} : activeCard.telemetry);
|
||
}
|
||
}
|
||
|
||
function buildCatalog(data) {
|
||
definitions = [...(data.cases || [])].sort((a, b) => a.order - b.order);
|
||
modes = [...(data.frame_modes || [])].sort((a, b) => a.order - b.order);
|
||
navigation = data.navigation;
|
||
dashboard = data.dashboard;
|
||
activeMode = navigation.default_mode;
|
||
activeCategory = navigation.all_categories_label;
|
||
if (!modes.length) throw new Error("后端没有返回帧策略目录");
|
||
elements.pages.replaceChildren();
|
||
elements.heroEyebrow.textContent = navigation.hero_eyebrow;
|
||
elements.heroTitle.textContent = navigation.hero_title;
|
||
elements.pageCount.textContent = data.coverage?.page_count ?? modes.length;
|
||
elements.caseCount.textContent = data.coverage?.case_count ?? definitions.length;
|
||
elements.canvasCount.textContent = data.coverage?.canvas_count ?? definitions.length * modes.length;
|
||
elements.apiCount.textContent = (data.coverage?.manual_control_count || 0) + (data.coverage?.manual_action_count || 0);
|
||
installNavigation();
|
||
if (!modes.some(mode => mode.id === activeMode)) activeMode = modes[0].id;
|
||
selectMode(activeMode);
|
||
setConnection("ready", navigation.catalog_loaded_text);
|
||
}
|
||
function connectCatalog() {
|
||
const socket = new WebSocket(socketUrl);
|
||
socket.addEventListener("open", () => socket.send(message("gallery_catalog")));
|
||
socket.addEventListener("message", event => {
|
||
if (typeof event.data !== "string") return;
|
||
try { const data = JSON.parse(event.data); if (data.type === "catalog") { buildCatalog(data); socket.close(); } else if (data.type === "error") throw new Error(data.message); }
|
||
catch (error) { setConnection("error", "目录解析失败"); toast(error.message, true); }
|
||
});
|
||
socket.addEventListener("error", () => { setConnection("error", `无法连接 ${socketUrl} · 自动重连`); });
|
||
socket.addEventListener("close", () => {
|
||
if (!definitions.length) setTimeout(connectCatalog, 1000);
|
||
});
|
||
}
|
||
function loop(time) {
|
||
updateDisplayTiming(time);
|
||
const page = pages.get(activeMode);
|
||
for (const card of page?.cards || []) {
|
||
card.presentLatest(time);
|
||
card.updateMotionStatus(time);
|
||
if (card.mode.request_on_animation_frame) card.requestFrame(time);
|
||
card.observe(time);
|
||
}
|
||
requestAnimationFrame(loop);
|
||
}
|
||
|
||
elements.streamToggle.addEventListener("click", () => {
|
||
streamsPaused = !streamsPaused;
|
||
elements.streamToggle.textContent = streamsPaused ? "恢复自动像素流" : "暂停自动像素流";
|
||
syncCardActivity();
|
||
});
|
||
elements.menuClose.addEventListener("click", closeMenu);
|
||
elements.menuTabs.forEach(button => button.addEventListener("click", () => { activeTab = button.dataset.tab; elements.menuTabs.forEach(item => item.classList.toggle("active", item === button)); renderMenuBody(); }));
|
||
document.addEventListener("keydown", event => { if (event.key === "Escape" && !elements.menu.hidden) closeMenu(); });
|
||
document.addEventListener("pointerdown", event => { if (!elements.menu.hidden && !elements.menu.contains(event.target) && !event.target.closest(".open-menu")) closeMenu(); });
|
||
document.addEventListener("visibilitychange", () => {
|
||
lastAnimationFrameAt = 0;
|
||
displayIntervalMs = 0;
|
||
displayIntervalLatestMs = 0;
|
||
displayIntervalP95Ms = 0;
|
||
displayJitterMs = 0;
|
||
displayIntervalSamples.length = 0;
|
||
syncCardActivity();
|
||
});
|
||
window.addEventListener("beforeunload", () => { for (const page of pages.values()) for (const card of page.cards) { card.disposed = true; clearTimeout(card.frameTimeout); clearTimeout(card.reconnectTimer); card.send("hide"); card.socket?.close(); } });
|
||
|
||
connectCatalog();
|
||
requestAnimationFrame(loop);
|