Files
Renderive/webapp_gallery/app.js
T
2026-08-12 13:46:53 +08:00

1052 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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"), menuReset: $("menu-reset"), menuRefresh: $("menu-refresh"),
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;
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 descriptorValue(field, value) {
const options = field.presentation?.options || [];
const option = options.find(item => String(item.value) === String(value));
if (option) return option.label;
if (typeof value === "boolean") return value ? "启用" : "关闭";
if (value === null || value === undefined) return "—";
if (field.name?.endsWith("_ns")) return formatNanoseconds(value);
if (typeof value === "number") return value.toLocaleString();
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function descriptorRows(descriptor, data, fieldNames = null) {
const selected = fieldNames ? new Set(fieldNames) : null;
const rows = [];
const visit = (fields, value, labels = []) => {
for (const field of fields || []) {
const presentation = field.presentation || {};
if (!adminiveFieldVisible(presentation.visible_on, value)) continue;
const fieldLabels = [...labels, presentation.label || field.name];
if (field.children?.length) {
visit(field.children, value?.[field.name], fieldLabels);
continue;
}
if (!selected || selected.has(field.name))
rows.push({label: fieldLabels.join(" / "), value: descriptorValue(field, value?.[field.name])});
}
};
visit(descriptor?.fields, data || {});
return rows;
}
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 rollingStatistics(values) {
if (!values.length) return {average: 0, deviation: 0, p50: 0, p95: 0, p99: 0};
const sorted = [...values].sort((left, right) => left - right);
const average = sorted.reduce((sum, value) => sum + value, 0) / sorted.length;
const deviation = Math.sqrt(sorted.reduce((sum, value) => {
const difference = value - average;
return sum + difference * difference;
}, 0) / sorted.length);
const percentile = ratio => sorted[Math.max(0, Math.ceil(ratio * sorted.length) - 1)];
return {average, deviation, p50: percentile(0.5), p95: percentile(0.95), p99: percentile(0.99)};
}
function resetDisplayTiming() {
lastAnimationFrameAt = 0;
displayIntervalSamples.length = 0;
}
function updateDisplayTiming(time) {
if (lastAnimationFrameAt > 0) {
displayIntervalSamples.push({
time,
interval: Math.max(0, time - lastAnimationFrameAt)
});
}
lastAnimationFrameAt = time;
while (displayIntervalSamples.length && displayIntervalSamples[0].time < time - 10_000) displayIntervalSamples.shift();
}
class GalleryCard {
constructor(definition, mode) {
this.definition = definition;
this.mode = mode;
this.controls = [];
this.actions = [];
this.observers = [];
this.taskGraph = null;
this.menuGroupState = new Map();
this.menuRequestPending = false;
this.telemetry = {};
this.frameCount = 0;
this.framePending = false;
this.frameTimeout = null;
this.frameTimeoutCount = 0;
this.latestPixelBuffer = null;
this.transportTimes = [];
this.presentationTimes = [];
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.frameRoundTripSamples = [];
this.lastTelemetryRequest = 0;
this.overwrittenPixelFrames = 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;
this.menuRequestPending = false;
if (activeCard === this) setMenuRequestState(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;
this.menuRequestPending = false;
if (activeCard === this) setMenuRequestState(false);
toast(detail || "后端拒绝操作", true);
if (activeCard === this) elements.menuStatus.textContent = detail;
return;
}
if (data.type === "observer_state") {
this.telemetry = data.telemetry || {};
this.observers = this.telemetry.renderable_observers || this.observers;
this.updateDashboard();
return;
}
const manualRefresh = data.type === "refresh_state";
if (data.type !== "case_state" && !manualRefresh) return;
this.controls = adminiveControls(data.controls);
this.actions = data.actions?.data || [];
this.telemetry = data.telemetry || {};
this.observers = this.telemetry.renderable_observers || data.controls?.observers || [];
const nextTaskGraph = data.controls?.task_graph || null;
this.taskGraph = nextTaskGraph;
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 (manualRefresh) {
this.menuRequestPending = false;
if (activeCard === this) {
rememberMenuGroups();
renderMenuBody();
setMenuRequestState(false);
elements.menuStatus.textContent = data.notice || "当前页已手动刷新";
}
} else if (activeCard === this && data.notice && !data.notice.includes("已创建")) {
elements.menuStatus.textContent = `${data.notice};点击刷新读取当前页`;
}
}
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.frameRoundTripSamples.push({
time: now,
value: 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.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.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);
}
clientMetrics(time) {
while (this.frameRoundTripSamples.length &&
this.frameRoundTripSamples[0].time < time - 10_000)
this.frameRoundTripSamples.shift();
const roundTrip = rollingStatistics(this.frameRoundTripSamples.map(sample => sample.value));
const display = rollingStatistics(displayIntervalSamples.map(sample => sample.interval));
return {
transport_fps: this.currentRate(this.transportTimes, time),
presentation_fps: this.currentRate(this.presentationTimes, time),
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.frameRoundTripSamples.at(-1)?.value || 0,
frame_round_trip_average_ms: roundTrip.average,
frame_round_trip_deviation_ms: roundTrip.deviation,
frame_round_trip_p95_ms: roundTrip.p95,
frame_round_trip_p99_ms: roundTrip.p99,
display_interval_ms: display.p50,
display_interval_average_ms: display.average,
display_interval_latest_ms: displayIntervalSamples.at(-1)?.interval || 0,
display_interval_p95_ms: display.p95,
display_interval_p99_ms: display.p99,
display_interval_deviation_ms: display.deviation,
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
};
}
refreshMenu() {
if (!this.ready || this.menuRequestPending ||
this.socket?.readyState !== WebSocket.OPEN) return;
this.menuRequestPending = true;
setMenuRequestState(true);
elements.menuStatus.textContent = "正在读取当前页数据";
this.send("gallery_refresh", {client_metrics: this.clientMetrics(performance.now())});
}
resetMonitoring() {
if (!this.ready || this.menuRequestPending ||
this.socket?.readyState !== WebSocket.OPEN) return;
this.resetClientMonitoring();
this.menuRequestPending = true;
setMenuRequestState(true);
elements.menuStatus.textContent = "正在重置监测滑动窗口";
this.send("gallery_reset_monitoring");
}
resetClientMonitoring() {
this.transportTimes.length = 0;
this.presentationTimes.length = 0;
this.changedPixelFrames = 0;
this.duplicatePixelFrames = 0;
this.frameTimeoutCount = 0;
this.frameRoundTripSamples.length = 0;
this.overwrittenPixelFrames = 0;
this.frameCount = 0;
this.frameLabel.textContent = "0";
this.lastTelemetryRequest = 0;
resetDisplayTiming();
}
observeTelemetry(time) {
if (!this.ready || !this.displayVisible() ||
this.socket?.readyState !== WebSocket.OPEN ||
time - this.lastTelemetryRequest < 650) return;
this.lastTelemetryRequest = time;
this.send("gallery_observe", {client_metrics: this.clientMetrics(time)});
}
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";
setMenuRequestState(card.menuRequestPending);
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() {
rememberMenuGroups();
elements.menu.hidden = true;
activeCard = null;
}
function setMenuRequestState(pending) {
elements.menuReset.disabled = pending;
elements.menuRefresh.disabled = pending;
elements.menuReset.textContent = pending ? "处理中" : "↺ 重置监测";
elements.menuRefresh.textContent = pending ? "读取中" : "↻ 刷新当前页";
}
function groupStateKey(key) { return `${activeTab}:${key}`; }
function prepareGroup(section, key, defaultOpen = false) {
const stateKey = groupStateKey(key);
section.dataset.groupKey = stateKey;
section.open = activeCard?.menuGroupState.has(stateKey)
? activeCard.menuGroupState.get(stateKey)
: defaultOpen;
section.addEventListener("toggle", () => {
activeCard?.menuGroupState.set(stateKey, section.open);
});
}
function rememberMenuGroups() {
if (!activeCard) return;
for (const section of elements.menuBody.querySelectorAll("details[data-group-key]"))
activeCard.menuGroupState.set(section.dataset.groupKey, section.open);
}
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();
let index = 0;
for (const [name, children] of grouped(items)) {
const section = document.createElement("details"); section.className = "control-group";
prepareGroup(section, name, index++ === 0);
const title = document.createElement("summary");
const label = document.createElement("span"); label.textContent = name;
const count = document.createElement("small"); count.textContent = `${children.length}`;
title.append(label, count);
section.append(title, ...children.map(renderer)); fragment.append(section);
}
elements.menuBody.replaceChildren(fragment);
}
function descriptorGroup(resource, fieldNames, key, open = false) {
const rows = descriptorRows(resource.descriptor, resource.data, fieldNames);
const section = document.createElement("details"); section.className = "control-group descriptor-group";
prepareGroup(section, key, open);
const summary = document.createElement("summary");
const title = document.createElement("span");
title.textContent = resource.title || resource.descriptor?.label || "观察数据";
const count = document.createElement("small"); count.textContent = `${rows.length}`;
summary.append(title, count);
const list = document.createElement("dl"); list.className = "telemetry-grid";
for (const row of rows) {
const dt = document.createElement("dt"), dd = document.createElement("dd");
dt.textContent = row.label; dd.textContent = row.value; list.append(dt, dd);
}
section.append(summary, list);
return section;
}
function renderObserverMenu() {
const view = dashboard.menu_views.observer;
const kernel = {
...view.kernel,
data: valueAtPath(activeCard.telemetry, view.kernel.source) || {}
};
const groups = [descriptorGroup(kernel, null, "kernel", true)];
activeCard.observers.forEach(resource => groups.push(descriptorGroup(
resource, view.renderable_fields, `renderable:${resource.target}`)));
elements.menuBody.replaceChildren(...groups);
}
function renderPerformanceMenu() {
const view = dashboard.menu_views.performance;
const groups = activeCard.observers.map((resource, index) => descriptorGroup(
resource, view.renderable_fields, `renderable:${resource.target}`, index === 0));
const resources = view.resources.map(resource => ({
...resource,
data: valueAtPath(activeCard.telemetry, resource.source) || {}
}));
resources.forEach(resource => groups.push(descriptorGroup(
resource, null, `aggregate:${resource.source}`)));
elements.menuBody.replaceChildren(...groups);
}
function taskGraphSvg(graph, sceneGraph = false) {
const namespace = "http://www.w3.org/2000/svg";
const nodes = [...(graph.nodes || [])];
const byId = new Map(nodes.map(node => [node.id, node]));
const ordered = sceneGraph && graph.paint_order?.length
? graph.paint_order.map(id => byId.get(id)).filter(Boolean)
: nodes;
const positions = new Map();
const nodeWidth = 196, nodeHeight = 44, rowGap = 22;
ordered.forEach((node, index) => {
const lane = sceneGraph && node.kind === "control" ? 1 : 0;
positions.set(node.id, {x: 18 + lane * 234, y: 18 + index * (nodeHeight + rowGap)});
});
const width = sceneGraph ? 468 : 232;
const height = Math.max(82, ordered.length * (nodeHeight + rowGap) + 18);
const svg = document.createElementNS(namespace, "svg");
svg.classList.add("task-graph-svg");
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", sceneGraph ? "场景任务图" : "控件内部任务图");
for (const edge of graph.edges || []) {
const from = positions.get(edge.from), to = positions.get(edge.to);
if (!from || !to) continue;
const path = document.createElementNS(namespace, "path");
const fromX = from.x + nodeWidth / 2, fromY = from.y + nodeHeight;
const toX = to.x + nodeWidth / 2, toY = to.y;
const bend = Math.max(14, (toY - fromY) * .45);
path.setAttribute("d", `M ${fromX} ${fromY} C ${fromX} ${fromY + bend}, ${toX} ${toY - bend}, ${toX} ${toY}`);
path.dataset.kind = edge.kind || "dependency";
path.classList.add("task-edge");
svg.append(path);
}
for (const node of ordered) {
const position = positions.get(node.id);
const group = document.createElementNS(namespace, "g");
group.classList.add("task-node"); group.dataset.kind = node.kind || "task";
const rect = document.createElementNS(namespace, "rect");
rect.setAttribute("x", position.x); rect.setAttribute("y", position.y);
rect.setAttribute("width", nodeWidth); rect.setAttribute("height", nodeHeight);
const title = document.createElementNS(namespace, "title"); title.textContent = node.label;
const text = document.createElementNS(namespace, "text");
text.setAttribute("x", position.x + 10); text.setAttribute("y", position.y + 27);
const order = node.paint_order ? `${node.paint_order}. ` : "";
const label = `${order}${node.label}`;
text.textContent = label.length > 24 ? `${label.slice(0, 23)}` : label;
group.append(rect, title, text); svg.append(group);
}
return svg;
}
function graphGroup(title, graph, key, sceneGraph = false, open = false) {
const section = document.createElement("details"); section.className = "control-group graph-group";
prepareGroup(section, key, open);
const summary = document.createElement("summary");
const label = document.createElement("span"); label.textContent = title;
const count = document.createElement("small");
count.textContent = `${graph.nodes?.length || 0} 节点 · ${graph.edges?.length || 0} 连线`;
summary.append(label, count);
const canvas = document.createElement("div"); canvas.className = "task-graph-canvas";
canvas.append(taskGraphSvg(graph, sceneGraph));
section.append(summary, canvas);
return section;
}
function renderTaskGraphMenu() {
if (!activeCard.taskGraph) {
elements.menuBody.textContent = "当前场景尚未返回任务图";
return;
}
const view = document.createElement("div"); view.className = "task-graph-view";
view.append(graphGroup("场景依赖与绘制顺序", activeCard.taskGraph,
"scene", true, true));
for (const resource of activeCard.taskGraph.renderables || [])
view.append(graphGroup(resource.title, resource.graph,
`renderable:${resource.target}`));
elements.menuBody.replaceChildren(view);
}
function renderMenuBody() {
if (!activeCard) return;
if (activeTab === "actions") renderGroups(activeCard.actions, renderAction);
else if (activeTab === "controls") renderGroups(activeCard.controls, renderControl);
else if (activeTab === "observer") renderObserverMenu();
else if (activeTab === "performance") renderPerformanceMenu();
else if (activeTab === "task_graph") renderTaskGraphMenu();
}
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.observeTelemetry(time);
}
requestAnimationFrame(loop);
}
elements.streamToggle.addEventListener("click", () => {
streamsPaused = !streamsPaused;
elements.streamToggle.textContent = streamsPaused ? "恢复自动像素流" : "暂停自动像素流";
syncCardActivity();
});
elements.menuClose.addEventListener("click", closeMenu);
elements.menuReset.addEventListener("click", () => activeCard?.resetMonitoring());
elements.menuRefresh.addEventListener("click", () => activeCard?.refreshMenu());
elements.menuTabs.forEach(button => button.addEventListener("click", () => {
rememberMenuGroups();
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", () => {
resetDisplayTiming();
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);