界面美化

This commit is contained in:
2026-08-22 11:06:38 +08:00
parent b569af2f00
commit 945e8287d4
7 changed files with 449 additions and 106 deletions
+102 -1
View File
@@ -6,6 +6,7 @@
#include <cmath>
#include <memory>
#include <numbers>
#include <random>
#include <stdexcept>
#include <string_view>
#include <type_traits>
@@ -23,10 +24,13 @@ using Selection_Object = Impl<Selection_Rectangle_Overlay>;
template <typename... Owned_Objects>
class Scene_View_Model final : public Plot::Scene_View {
public:
using Data_Generator = std::function<nlohmann::json(std::size_t, double, double)>;
Scene_View_Model(std::vector<std::unique_ptr<detail::Renderable_Descriptor>> value_descriptors,
std::function<void(const Plot_Frame_Request&)> value_update,
Data_Generator value_data_generator,
Owned_Objects... owned_objects) : descriptors(std::move(value_descriptors)),
update_scene(std::move(value_update)),
data_generator(std::move(value_data_generator)),
objects(std::move(owned_objects)...) {}
nlohmann::json schema() const override {
nlohmann::json components = nlohmann::json::array();
@@ -42,12 +46,23 @@ public:
result["component"] = component;
return result;
}
nlohmann::json data_generator_schema() const override {
if (!data_generator) return nullptr;
return {{"label", "生成二维输入样本"},
{"description", "按当前图形的数据语义生成指定数量的随机输入;坐标轴、时间槽和分块由该图形自行组织。"},
{"count", 4096}, {"minimum", -1.0}, {"maximum", 1.0}};
}
nlohmann::json generate_data(std::size_t count, double minimum, double maximum) override {
if (!data_generator) return {{"success", false}, {"error", "this plot has no raw data input"}};
return data_generator(count, minimum, maximum);
}
void update(const Plot_Frame_Request& request) override {
update_scene(request);
}
private:
std::vector<std::unique_ptr<detail::Renderable_Descriptor>> descriptors;
std::function<void(const Plot_Frame_Request&)> update_scene;
Data_Generator data_generator;
std::tuple<Owned_Objects...> objects;
};
template <auto Member, structive::Fixed_String Key, structive::Fixed_String Description>
@@ -136,6 +151,87 @@ std::unique_ptr<detail::Renderable_Descriptor> make_axis_component<Time_Axis_Obj
State_Field<Time_Axis, &Time_Axis::State::samples, "samples", "Published time sample window.">>(
std::move(id), std::move(label), "axis", axis);
}
template <typename Object>
nlohmann::json generate_2d_data(Object& object, std::size_t count, double minimum, double maximum) {
using Definition = typename Object::Attached_Object;
std::mt19937_64 engine{std::random_device{}()};
std::uniform_real_distribution<double> distribution(minimum, maximum);
const auto values = [&] {
std::vector<Plot_Value> result(count);
std::ranges::generate(result, [&] { return distribution(engine); });
return result;
};
if constexpr (std::same_as<Definition, Spectrum>) {
object.update_samples(values());
}
else if constexpr (std::same_as<Definition, Frequency_Trace>) {
std::vector<Frequency_Trace_Sample> samples(count);
for (std::size_t index = 0; index < count; ++index)
samples[index] = {static_cast<Plot_Time_Tick>(index), distribution(engine)};
object.template set<&Frequency_Trace::Prop::samples>(std::move(samples));
}
else if constexpr (std::same_as<Definition, Sweep_Spectrum>) {
const auto& state = object.template read_prop<Sweep_Spectrum::Base_Tag>();
const auto width = std::max<std::size_t>(1, state.bins_per_block);
std::vector<std::vector<Plot_Value>> blocks;
blocks.reserve((count + width - 1) / width);
auto generated = values();
for (std::size_t first = 0; first < generated.size(); first += width) {
const auto last = std::min(generated.size(), first + width);
blocks.emplace_back(generated.begin() + static_cast<std::ptrdiff_t>(first),
generated.begin() + static_cast<std::ptrdiff_t>(last));
}
object.template set<&Sweep_Spectrum::Prop::blocks>(std::move(blocks));
}
else if constexpr (std::same_as<Definition, Afterglow>) {
const auto row_count = std::clamp<std::size_t>(static_cast<std::size_t>(std::sqrt(count)), 1, 64);
const auto width = (count + row_count - 1) / row_count;
std::vector<std::vector<Plot_Value>> spectra(row_count);
std::size_t generated{};
for (auto& spectrum : spectra) {
const auto size = std::min(width, count - generated);
spectrum.resize(size);
std::ranges::generate(spectrum, [&] { return distribution(engine); });
generated += size;
}
object.template set<&Afterglow::Prop::spectra>(std::move(spectra));
}
else if constexpr (std::same_as<Definition, Waterfall>) {
const auto row_count = std::max<std::size_t>(1, static_cast<std::size_t>(std::sqrt(count)));
const auto width = (count + row_count - 1) / row_count;
std::vector<Waterfall_Row> rows;
rows.reserve(row_count);
std::size_t generated{};
for (std::size_t row = 0; row < row_count && generated < count; ++row) {
const auto size = std::min(width, count - generated);
std::vector<Plot_Value> row_values(size);
std::ranges::generate(row_values, [&] { return distribution(engine); });
rows.push_back({static_cast<Plot_Time_Tick>(row), std::move(row_values)});
generated += size;
}
object.template set<&Waterfall::Prop::rows>(std::move(rows));
}
else if constexpr (std::same_as<Definition, Constellation_Diagram>) {
object.template set<&Constellation_Diagram::Prop::points>(std::vector<Constellation_Point>{});
for (std::size_t index = 0; index < count; ++index)
object.append_point({distribution(engine), distribution(engine)});
}
else if constexpr (std::same_as<Definition, Selection_Rectangle_Overlay>) {
std::vector<Axis_Rectangle> regions(count);
const auto span = maximum - minimum;
for (auto& region : regions) {
const auto x = distribution(engine);
const auto y = distribution(engine);
region = {{x, x + std::min(span * 0.1, maximum - x)},
{y, y + std::min(span * 0.1, maximum - y)}};
}
object.template set<&Selection_Rectangle_Overlay::Prop::selected_regions>(std::move(regions));
}
else {
return {{"success", false}, {"error", "this plot has no raw data input"}};
}
return {{"success", true}, {"generated_count", count}, {"minimum", minimum}, {"maximum", maximum}};
}
template <typename... Fields, typename Object, typename... Owned_Objects>
std::unique_ptr<Plot::Scene_View> make_scene_view(
Object& object,
@@ -171,6 +267,9 @@ std::unique_ptr<Plot::Scene_View> make_scene_view(
return std::make_unique<Scene_View_Model<std::remove_cvref_t<Owned_Objects>...>>(
std::move(components),
std::move(update),
[&object](std::size_t count, double minimum, double maximum) {
return generate_2d_data(object, count, minimum, maximum);
},
std::forward<Owned_Objects>(owned_objects)...);
}
std::unique_ptr<Frequency_Axis_Object> make_frequency_axis() {
@@ -264,7 +363,9 @@ std::shared_ptr<Plot> make_axes_plot(asio::any_io_executor executor) {
components.push_back(make_axis_component("axis-value", "数值轴", *numeric));
components.push_back(make_axis_component("axis-time", "时间轴", *time));
auto view = std::make_unique<Scene_View_Model<decltype(frequency), decltype(numeric), decltype(time)>>(
std::move(components), std::move(update), std::move(frequency), std::move(numeric), std::move(time));
std::move(components), std::move(update),
Scene_View_Model<decltype(frequency), decltype(numeric), decltype(time)>::Data_Generator{},
std::move(frequency), std::move(numeric), std::move(time));
return std::make_shared<Plot>(std::move(executor), std::move(scene), std::move(view));
}
std::shared_ptr<Plot> make_spectrum_plot(asio::any_io_executor executor) {
+43
View File
@@ -4,7 +4,9 @@
#include <algorithm>
#include <cmath>
#include <numbers>
#include <random>
#include <stdexcept>
#include <type_traits>
#include <utility>
#include <vector>
@@ -13,6 +15,20 @@ namespace {
using namespace render_3d;
using Scene_3D = Impl<Render_Scene_3D>;
template <typename Item>
void randomize_item(Item& item, std::uniform_real_distribution<float>& distribution,
std::mt19937_64& engine) {
const auto vector = [&] { return Vec3{distribution(engine), distribution(engine), distribution(engine)}; };
if constexpr (requires { item.position = vector(); }) item.position = vector();
else if constexpr (requires { item.center = vector(); }) item.center = vector();
else if constexpr (requires { item.origin = vector(); }) item.origin = vector();
else if constexpr (requires { item.start = vector(); item.end = vector(); }) {
item.start = vector();
item.end = vector();
}
else if constexpr (requires { item.value = distribution(engine); }) item.value = distribution(engine);
}
template <typename Visual_Object>
class Visual_Scene_View final : public Plot::Scene_View {
public:
@@ -53,6 +69,33 @@ public:
return result;
}
[[nodiscard]] nlohmann::json data_generator_schema() const override {
return {{"label", "生成三维原始数据"},
{"description", "复制当前图元样式并在给定 Scene 坐标范围内随机生成位置。"},
{"count", 10000}, {"minimum", -1.0}, {"maximum", 1.0}};
}
[[nodiscard]] nlohmann::json generate_data(std::size_t count, double minimum, double maximum) override {
using Definition = typename Visual_Object::Attached_Object;
using Prop = typename Definition::Prop;
using Items = std::remove_cvref_t<decltype(std::declval<Prop>().items)>;
const auto& current = visual_->template read_prop<typename Definition::Base_Tag>().items;
if (current.empty()) return {{"success", false}, {"error", "visual has no item template"}};
std::mt19937_64 engine{std::random_device{}()};
std::uniform_real_distribution<float> distribution(
static_cast<float>(minimum), static_cast<float>(maximum));
Items generated;
generated.reserve(count);
for (std::size_t index = 0; index < count; ++index) {
auto item = current[index % current.size()];
randomize_item(item, distribution, engine);
generated.push_back(std::move(item));
}
visual_->template set<&Prop::items>(std::move(generated));
return {{"success", true}, {"generated_count", count},
{"minimum", minimum}, {"maximum", maximum}};
}
void update(const Plot_Frame_Request&) override {}
private:
+28 -7
View File
@@ -80,7 +80,7 @@ nlohmann::json Frame_Policy::schema() const {
fields.push_back({{"key", "pacing_mode"}, {"label", "帧刷新策略"}, {"editor", "select"}, {"editable", true}, {"description", "选择浏览器如何安排下一次 render 调用。"}, {"technical_description", "Controls client-side render cadence using end-to-end samples computed by the browser."}, {"value", pacing_mode_name(pacing.mode)}, {"options", nlohmann::json::array({{{"value", "manual"}, {"label", "手动刷新"}}, {{"value", "fixed_rate"}, {"label", "固定频率"}}, {{"value", "minimum_latency"}, {"label", "最低延迟"}}, {{"value", "maximum_rate"}, {"label", "最高频率"}}})}});
fields.push_back({{"key", "fixed_rate_fps"}, {"label", "固定目标帧率"}, {"editor", "number"}, {"editable", true}, {"description", "固定频率策略下每秒发起的 render 次数。"}, {"technical_description", "Target render request rate used by fixed_rate pacing, in frames per second."}, {"value", pacing.fixed_rate_fps}});
fields.push_back({{"key", "minimum_latency_headroom"}, {"label", "最低延迟余量"}, {"editor", "number"}, {"editable", true}, {"description", "最低延迟策略使用的浏览器端 P95 端到端耗时安全系数。"}, {"technical_description", "Multiplier applied to browser-computed P95 request-to-pixel latency before scheduling the next render request."}, {"value", pacing.minimum_latency_headroom}});
return {{"id", "frame-runtime"}, {"label", "帧策略与诊断"}, {"kind", "runtime"}, {"fields", std::move(fields)}, {"state", nlohmann::json::object()}};
return {{"id", "frame-analysis"}, {"label", "渲染性能实验室"}, {"kind", "analysis"}, {"fields", std::move(fields)}};
}
nlohmann::json Frame_Policy::write_prop(std::string_view key, const nlohmann::json& value) {
std::lock_guard lock(mutex);
@@ -89,21 +89,21 @@ nlohmann::json Frame_Policy::write_prop(std::string_view key, const nlohmann::js
const auto parsed = parse_pacing_mode(value.get_ref<const std::string&>());
if (!parsed) return {{"success", false}, {"error", "unknown frame pacing mode"}};
pacing.mode = *parsed;
return {{"success", true}, {"component", "frame-runtime"}, {"key", key}, {"value", pacing_mode_name(pacing.mode)}};
return {{"success", true}, {"component", "frame-analysis"}, {"key", key}, {"value", pacing_mode_name(pacing.mode)}};
}
if (key == "fixed_rate_fps") {
if (!value.is_number()) return {{"success", false}, {"error", "fixed_rate_fps requires a number"}};
const double next = value.get<double>();
if (!std::isfinite(next) || next < 0.1 || next > 240.0) return {{"success", false}, {"error", "fixed_rate_fps must be between 0.1 and 240"}};
pacing.fixed_rate_fps = next;
return {{"success", true}, {"component", "frame-runtime"}, {"key", key}, {"value", pacing.fixed_rate_fps}};
return {{"success", true}, {"component", "frame-analysis"}, {"key", key}, {"value", pacing.fixed_rate_fps}};
}
if (key == "minimum_latency_headroom") {
if (!value.is_number()) return {{"success", false}, {"error", "minimum_latency_headroom requires a number"}};
const double next = value.get<double>();
if (!std::isfinite(next) || next < 1.0 || next > 4.0) return {{"success", false}, {"error", "minimum_latency_headroom must be between 1 and 4"}};
pacing.minimum_latency_headroom = next;
return {{"success", true}, {"component", "frame-runtime"}, {"key", key}, {"value", pacing.minimum_latency_headroom}};
return {{"success", true}, {"component", "frame-analysis"}, {"key", key}, {"value", pacing.minimum_latency_headroom}};
}
return {{"success", false}, {"error", "unknown frame runtime property"}};
}
@@ -167,7 +167,13 @@ struct Prop_Write {
nlohmann::json value;
Plot::Json_Handler handler;
};
using Plot_Input = std::variant<Frame_Submission, Schema_Query, Prop_Write>;
struct Data_Generation {
std::size_t count{};
double minimum{};
double maximum{};
Plot::Json_Handler handler;
};
using Plot_Input = std::variant<Frame_Submission, Schema_Query, Prop_Write, Data_Generation>;
template <typename Scene_Object>
void dispatch_plot_input(Scene_Object& scene, const Plot_Input_Event& input) {
const auto dispatch = [&](auto event) {
@@ -260,7 +266,10 @@ struct Plot::Private {
};
nlohmann::json Plot::Private::schema() const {
auto result = view->schema();
result["components"].push_back(frame_policy.schema());
auto analysis = frame_policy.schema();
const auto generator = view->data_generator_schema();
if (!generator.is_null()) analysis["data_generator"] = generator;
result["frame_analysis"] = std::move(analysis);
return result;
}
Plot::Private::Managed_Frame Plot::Private::make_frame(Frame_Submission submission) {
@@ -370,11 +379,16 @@ void Plot::ensure_started() {
continue;
}
if (auto* write = std::get_if<Prop_Write>(&input)) {
write->handler(write->component == "frame-runtime"
write->handler(write->component == "frame-analysis"
? self->d->frame_policy.write_prop(write->key, write->value)
: self->d->view->write_prop(write->component, write->key, write->value));
continue;
}
if (auto* generation = std::get_if<Data_Generation>(&input)) {
generation->handler(self->d->view->generate_data(
generation->count, generation->minimum, generation->maximum));
continue;
}
auto submission = std::get<Frame_Submission>(input);
self->d->request_frame(std::move(submission));
}
@@ -414,4 +428,11 @@ void Plot::async_write_prop(std::string component, std::string key, nlohmann::js
}))
throw std::runtime_error("plot input queue is unavailable");
}
void Plot::async_generate_data(std::size_t count, double minimum, double maximum, Json_Handler handler) {
ensure_started();
if (!d->inputs.try_send(asio::error_code{}, Plot_Input{
Data_Generation{count, minimum, maximum, std::move(handler)}
}))
throw std::runtime_error("plot input queue is unavailable");
}
}
+3
View File
@@ -44,6 +44,8 @@ public:
[[nodiscard]] virtual nlohmann::json schema() const = 0;
[[nodiscard]] virtual nlohmann::json write_prop(std::string_view component, std::string_view key,
const nlohmann::json& value) = 0;
[[nodiscard]] virtual nlohmann::json data_generator_schema() const = 0;
[[nodiscard]] virtual nlohmann::json generate_data(std::size_t count, double minimum, double maximum) = 0;
virtual void update(const Plot_Frame_Request& request) = 0;
};
Plot(asio::any_io_executor executor,
@@ -61,6 +63,7 @@ public:
void submit_input(Plot_Input_Event event);
void async_schema(Json_Handler handler);
void async_write_prop(std::string component, std::string key, nlohmann::json value, Json_Handler handler);
void async_generate_data(std::size_t count, double minimum, double maximum, Json_Handler handler);
private:
struct Private;
void ensure_started();
+38
View File
@@ -5,6 +5,7 @@
#include <drogon/drogon.h>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cmath>
#include <functional>
#include <memory>
#include <string>
@@ -122,6 +123,43 @@ int run_web_server(std::uint16_t port, const std::filesystem::path& asset_root)
});
}, {drogon::Put});
app.registerHandler("/plot/{1}/data/generate", [plots](
const drogon::HttpRequestPtr& request,
std::function<void(const drogon::HttpResponsePtr&)>&& callback,
std::string plot_id) {
auto plot = find_plot(*plots, plot_id);
if (!plot) {
callback(error_response(drogon::k404NotFound, "unknown plot"));
return;
}
nlohmann::json input;
try {
input = nlohmann::json::parse(request->body());
} catch (const nlohmann::json::exception&) {
callback(error_response(drogon::k400BadRequest, "invalid data generation request"));
return;
}
if (!input.contains("count") || !input["count"].is_number_unsigned()
|| !input.contains("minimum") || !input["minimum"].is_number()
|| !input.contains("maximum") || !input["maximum"].is_number()) {
callback(error_response(drogon::k400BadRequest, "count, minimum and maximum are required"));
return;
}
const auto count = input["count"].get<std::size_t>();
const auto minimum = input["minimum"].get<double>();
const auto maximum = input["maximum"].get<double>();
if (count == 0 || count > 1'000'000 || !std::isfinite(minimum)
|| !std::isfinite(maximum) || minimum >= maximum) {
callback(error_response(drogon::k400BadRequest, "invalid count or range"));
return;
}
auto output = std::make_shared<std::function<void(const drogon::HttpResponsePtr&)>>(
std::move(callback));
plot->async_generate_data(count, minimum, maximum, [output](nlohmann::json result) {
(*output)(json_response(std::move(result)));
});
}, {drogon::Post});
app.registerController(websocket)
.setDocumentRoot(asset_root.string())
.setHomePage("index.html")
+205 -91
View File
@@ -4,14 +4,14 @@ import {Responsive, useContainerWidth, type LayoutItem, type ResponsiveLayouts}
import ReconnectingWebSocket from "reconnecting-websocket";
import * as echarts from "echarts/core";
import {LineChart} from "echarts/charts";
import {GridComponent, LegendComponent, TooltipComponent} from "echarts/components";
import {DataZoomComponent, GridComponent, LegendComponent, TooltipComponent} from "echarts/components";
import {CanvasRenderer} from "echarts/renderers";
import type {EChartsType} from "echarts/core";
import "flexlayout-react/style/alpha_dark.css";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
echarts.use([LineChart, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer]);
echarts.use([LineChart, DataZoomComponent, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer]);
type Plot = {id: string; title: string; category: string; description: string; dimension: "2D" | "3D"; websocket: string; schema: string};
type Option = {value: string; label: string};
@@ -19,7 +19,9 @@ type Editor = "boolean" | "integer" | "number" | "text" | "select" | "color" | "
type Color_Channel_Scale = "normalized" | "byte";
type Field = {key: string; label: string; description: string; technical_description?: string; editor: Editor; editable: boolean; value: unknown; options?: Option[]; color_channel_scale?: Color_Channel_Scale};
type Component = {id: string; label: string; kind: string; fields: Field[]; state: Record<string, unknown>};
type Schema = {protocol: "aethera.plot.inspector"; version: 2; components: Component[]};
type Data_Generator = {label: string; description: string; count: number; minimum: number; maximum: number};
type Frame_Analysis = Omit<Component, "state"> & {data_generator?: Data_Generator};
type Schema = {protocol: "aethera.plot.inspector"; version: 2; components: Component[]; frame_analysis: Frame_Analysis};
type State_Histories = Record<string, number[]>;
type Stream_Status = "CONNECTING" | "LIVE" | "OFFLINE";
type Frame_Pacing_Mode = "manual" | "fixed_rate" | "minimum_latency" | "maximum_rate";
@@ -129,6 +131,30 @@ function frame_stage_values(metadata: Frame_Metadata, request_started_at: number
return values;
}
function pipeline_stage_values(values: Frame_Stage_Values): Frame_Stage_Values {
const total = Math.max(0, values.request_to_presentation_opportunity_ms ?? values.request_to_pixels_ms ?? 0);
const server = Math.min(total, Math.max(0, values.server_to_websocket_ready_ms ?? 0));
let server_remaining = server;
const take_server_stage = (key: string) => {
const value = Math.min(server_remaining, Math.max(0, values[key] ?? 0));
server_remaining -= value;
return value;
};
const request_to_metadata = Math.min(total, Math.max(0, values.request_to_metadata_ms ?? 0));
const metadata_to_pixels = Math.min(Math.max(0, total - request_to_metadata), Math.max(0, values.metadata_to_pixels_ms ?? 0));
const canvas_upload = Math.min(Math.max(0, total - request_to_metadata - metadata_to_pixels), Math.max(0, values.canvas_upload_ms ?? 0));
return {...values,
pipeline_request_transport_ms: Math.max(0, request_to_metadata - server),
pipeline_scene_ms: take_server_stage("scene_render_ms"),
pipeline_callback_ms: take_server_stage("callback_ms"),
pipeline_websocket_ms: take_server_stage("websocket_encode_ms"),
pipeline_server_other_ms: server_remaining,
pipeline_payload_transport_ms: metadata_to_pixels,
pipeline_canvas_upload_ms: canvas_upload,
pipeline_presentation_wait_ms: Math.max(0, total - request_to_metadata - metadata_to_pixels - canvas_upload)
};
}
function build_frame_diagnostics(metadata: Frame_Metadata, samples: Frame_Sample[]): Frame_Diagnostics {
const request_latencies = samples.map(sample => sample.values.request_to_pixels_ms).filter(Number.isFinite);
const intervals = samples.slice(1).map((sample, index) => sample.received_at_ms - samples[index].received_at_ms).filter(value => value >= 0);
@@ -159,13 +185,18 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
const [metrics, set_metrics] = useState<Frame_Metrics | null>(null);
const socket_ref = useRef<ReconnectingWebSocket | null>(null);
const pending_pointer_move = useRef<Record<string, unknown> | null>(null);
const viewport_ref = useRef({width: 720, height: 420});
const envelope = useCallback((kind: "frame" | "input", event?: Record<string, unknown>) => {
const canvas = canvas_ref.current;
if (!canvas) return null;
const bounds = canvas.getBoundingClientRect();
if (bounds.width > 0 && bounds.height > 0) viewport_ref.current = {
width: Math.max(1, Math.round(bounds.width * devicePixelRatio)),
height: Math.max(1, Math.round(bounds.height * devicePixelRatio))
};
return {kind, time: local_time_milliseconds(), viewport: {
width: Math.round(bounds.width * devicePixelRatio), height: Math.round(bounds.height * devicePixelRatio)
width: viewport_ref.current.width, height: viewport_ref.current.height
}, ...(event ? {event} : {})};
}, [canvas_ref]);
@@ -221,13 +252,10 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
clear_timer();
if (stopped) return;
if (frame_pending) { if (manual) manual_frame_pending = true; return; }
const retry_when_visible = () => { if (manual) manual_frame_pending = true; timer = window.setTimeout(() => request_frame(manual), 250); };
if (socket.readyState !== WebSocket.OPEN) { retry_when_visible(); return; }
const retry = () => { if (manual) manual_frame_pending = true; timer = window.setTimeout(() => request_frame(manual), 250); };
if (socket.readyState !== WebSocket.OPEN) { retry(); return; }
const canvas = canvas_ref.current;
if (!canvas) { retry_when_visible(); return; }
const bounds = canvas.getBoundingClientRect();
const visible = bounds.bottom > 0 && bounds.top < innerHeight && bounds.right > 0 && bounds.left < innerWidth;
if (!visible || bounds.width <= 0 || bounds.height <= 0) { retry_when_visible(); return; }
if (!canvas) { retry(); return; }
if (pending_pointer_move.current) {
const input_message = envelope("input", pending_pointer_move.current);
if (input_message) socket.send(JSON.stringify(input_message));
@@ -289,9 +317,9 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
const sample = samples.find(value => value.sequence === sequence);
if (!sample) return;
const browser_tail = Math.max(0, timestamp - pixels_received_at);
samples = samples.map(value => value.sequence !== sequence ? value : {...value, values: {...value.values,
samples = samples.map(value => value.sequence !== sequence ? value : {...value, values: pipeline_stage_values({...value.values,
pixels_to_presentation_opportunity_ms: browser_tail,
request_to_presentation_opportunity_ms: (value.values.request_to_pixels_ms ?? 0) + browser_tail}});
request_to_presentation_opportunity_ms: (value.values.request_to_pixels_ms ?? 0) + browser_tail})});
publish_diagnostics(false);
});
presentation_callbacks.add(second);
@@ -316,8 +344,8 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
sequence: pair.value.sequence,
correlation_id: pair.value.correlation_id,
received_at_ms: pixels_received_at,
values: frame_stage_values(pair.value, started_at, pair.received_at, pixels_received_at, canvas_upload_ms)
}].slice(-180);
values: pipeline_stage_values(frame_stage_values(pair.value, started_at, pair.received_at, pixels_received_at, canvas_upload_ms))
}].slice(-10_000);
publish_diagnostics(samples.length === 1);
mark_presentation_opportunity(pair.value.sequence, pixels_received_at);
}
@@ -347,9 +375,20 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
const pointer = (type: "pointer_press" | "pointer_release", buttons: number) => transmit("input", {type, position, global_position, button: "left", buttons, modifiers: 0});
pointer("pointer_press", 1); pointer("pointer_release", 0); pointer("pointer_press", 1); pointer("pointer_release", 0);
};
const on_diagnostics_reset = (event: Event) => {
const detail = (event as CustomEvent<{plot_id: string}>).detail;
if (detail?.plot_id !== plot.id) return;
samples = [];
latest_metadata = null;
latest_metrics = null;
previous_diagnostics_publish_time = 0;
set_metrics(null);
window.dispatchEvent(new CustomEvent("aethera-frame-diagnostics-cleared", {detail: {plot_id: plot.id}}));
};
window.addEventListener("aethera-frame-policy", on_policy_change);
window.addEventListener("aethera-manual-frame", on_manual_frame);
window.addEventListener("aethera-reset-camera", on_camera_reset);
window.addEventListener("aethera-reset-frame-diagnostics", on_diagnostics_reset);
socket.onopen = () => { frame_pending = false; pending_metadata = null; request_started_at.clear(); set_status("LIVE"); request_frame(true); };
socket.onclose = () => { clear_timer(); frame_pending = false; pending_metadata = null; request_started_at.clear(); if (!stopped) set_status("CONNECTING"); };
socket.onmessage = event => {
@@ -372,6 +411,7 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
window.removeEventListener("aethera-frame-policy", on_policy_change);
window.removeEventListener("aethera-manual-frame", on_manual_frame);
window.removeEventListener("aethera-reset-camera", on_camera_reset);
window.removeEventListener("aethera-reset-frame-diagnostics", on_diagnostics_reset);
socket_ref.current = null;
socket.close();
};
@@ -614,38 +654,23 @@ function State_Field_View({component, field, histories}: {component: Component;
</article>;
}
const diagnostic_stage_labels: Record<string, string> = {
request_to_metadata_ms: "请求 → 元数据",
request_to_pixels_ms: "请求 → 像素到达",
request_to_presentation_opportunity_ms: "请求 → 浏览器呈现机会",
metadata_to_pixels_ms: "元数据 → 像素",
server_to_websocket_ready_ms: "帧创建 → WebSocket 就绪",
scene_render_ms: "Scene 渲染",
event_dispatch_ms: "事件分发",
prepare_ms: "准备数据",
paint_ms: "绘制",
backend_queue_ms: "后台排队",
gpu_submission_ms: "GPU 提交 → 完成",
gpu_fence_wait_ms: "GPU 栅栏等待",
gpu_render_ms: "GPU 渲染",
gpu_transition_ms: "GPU 资源转换",
gpu_copy_ms: "GPU 回读复制",
gpu_total_ms: "GPU 总耗时",
readback_ms: "后端回读",
readback_stage_ms: "回读阶段",
callback_ms: "Render 回调",
websocket_encode_ms: "像素编码",
canvas_upload_ms: "Canvas 上传",
pixels_to_presentation_opportunity_ms: "像素到达 → 呈现机会",
payload_megabytes: "像素载荷"
};
const pipeline_stage_definitions: Array<[string, string, string]> = [
["pipeline_request_transport_ms", "请求传输", "浏览器发出帧请求到服务端开始帧处理之间的耗时。"],
["pipeline_scene_ms", "Scene 渲染", "引擎遍历 Scene 并执行 Renderable 渲染的耗时。"],
["pipeline_callback_ms", "结果回调", "渲染完成后回调进入 WebSocket 发布流程的耗时。"],
["pipeline_websocket_ms", "像素编码", "服务端将帧像素编码为 WebSocket 消息的耗时。"],
["pipeline_server_other_ms", "服务端其余阶段", "服务端总耗时扣除可单独观测阶段后的剩余部分,包含准备、排队、GPU 与回读。"],
["pipeline_payload_transport_ms", "像素传输", "浏览器收到元数据后,直到完整像素载荷到达的耗时。"],
["pipeline_canvas_upload_ms", "Canvas 上传", "浏览器把 RGBA 像素写入 Canvas 的耗时。"],
["pipeline_presentation_wait_ms", "呈现机会等待", "Canvas 写入后等待浏览器经过一次绘制机会的耗时。"]
];
function diagnostic_value(value: number, key: string) {
if (key === "payload_megabytes") return `${value.toFixed(2)} MiB`;
return `${value.toFixed(3)} ms`;
}
function Frame_Timeline_Chart({diagnostics}: {diagnostics: Frame_Diagnostics}) {
function Frame_Timeline_Chart({diagnostics, paused, on_context_menu}: {diagnostics: Frame_Diagnostics; paused: boolean; on_context_menu: (event: React.MouseEvent<HTMLDivElement>) => void}) {
const host_ref = useRef<HTMLDivElement>(null);
const chart_ref = useRef<EChartsType | null>(null);
useEffect(() => {
@@ -659,13 +684,13 @@ function Frame_Timeline_Chart({diagnostics}: {diagnostics: Frame_Diagnostics}) {
useEffect(() => {
const chart = chart_ref.current;
if (!chart) return;
const visible_samples = diagnostics.samples.slice(-120);
const visible_samples = diagnostics.samples;
const series_keys: Array<[string, string, string]> = [
["request_to_pixels_ms", "端到端", "#5ce4c2"],
["server_to_websocket_ready_ms", "服务端", "#62a8ff"],
["prepare_ms", "准备", "#f4bd63"],
["paint_ms", "绘制", "#ff7d9c"],
["gpu_total_ms", "GPU", "#b998ff"]
["request_to_presentation_opportunity_ms", "完整流水线", "#5ce4c2"],
["pipeline_scene_ms", "Scene", "#62a8ff"],
["pipeline_server_other_ms", "服务端其余", "#f4bd63"],
["pipeline_payload_transport_ms", "像素传输", "#ff7d9c"],
["pipeline_presentation_wait_ms", "呈现等待", "#b998ff"]
];
chart.setOption({
backgroundColor: "transparent",
@@ -673,61 +698,86 @@ function Frame_Timeline_Chart({diagnostics}: {diagnostics: Frame_Diagnostics}) {
color: series_keys.map(([, , color]) => color),
tooltip: {trigger: "axis", valueFormatter: (value: unknown) => `${Number(value).toFixed(3)} ms`},
legend: {top: 0, textStyle: {color: "#91a5c0", fontSize: 10}},
grid: {left: 45, right: 12, top: 34, bottom: 28},
grid: {left: 45, right: 12, top: 34, bottom: paused ? 54 : 28},
dataZoom: paused ? [{type: "inside", start: 70, end: 100}, {type: "slider", height: 18, bottom: 5, start: 70, end: 100}] : [],
xAxis: {type: "category", name: "帧", data: visible_samples.map(sample => sample.sequence), axisLabel: {color: "#71839e", hideOverlap: true}},
yAxis: {type: "value", name: "ms", min: 0, axisLabel: {color: "#71839e"}, splitLine: {lineStyle: {color: "#1d304a"}}},
series: series_keys.map(([key, name]) => ({name, type: "line", showSymbol: false, connectNulls: false,
data: visible_samples.map(sample => Number.isFinite(sample.values[key]) ? sample.values[key] : null), lineStyle: {width: 1.5}}))
}, true);
}, [diagnostics]);
return <div className="frameTimelineChart" ref={host_ref} role="img" aria-label="最近 120 帧耗时波形图"/>;
}, [diagnostics, paused]);
return <div className="frameChartHost" onContextMenu={on_context_menu} title="右键暂停实时视图并缩放查看历史样本">
{paused ? <span className="chartPausedBadge"> · </span> : null}
<div className="frameTimelineChart" ref={host_ref} role="img" aria-label="帧流水线耗时波形图"/>
</div>;
}
function Frame_Diagnostics_View({diagnostics}: {diagnostics: Frame_Diagnostics | null}) {
function Frame_Diagnostics_View({diagnostics, on_reset}: {diagnostics: Frame_Diagnostics | null; on_reset: () => void}) {
const [copied, set_copied] = useState(false);
const [stage_statistic_mode, set_stage_statistic_mode] = useState<Stage_Statistic>("average");
const [stage_unit, set_stage_unit] = useState<Stage_Unit>("value");
if (!diagnostics) return <section className="diagnosticEmpty"><strong></strong><span> JSON RGBA </span></section>;
const copy = async () => { await navigator.clipboard.writeText(JSON.stringify(diagnostics.metadata, null, 2)); set_copied(true); window.setTimeout(() => set_copied(false), 1200); };
const stage_values = Object.keys(diagnostic_stage_labels).filter(key => stage_unit === "value" || key.endsWith("_ms")).map(key => {
const history = diagnostics.samples.map(sample => sample.values[key]).filter(Number.isFinite);
return [key, stage_statistic(history, stage_statistic_mode)] as const;
const [paused, set_paused] = useState(false);
const [snapshot, set_snapshot] = useState<Frame_Diagnostics | null>(null);
const [context_menu, set_context_menu] = useState<{x: number; y: number} | null>(null);
useEffect(() => {
const close = () => set_context_menu(null);
window.addEventListener("pointerdown", close);
return () => window.removeEventListener("pointerdown", close);
}, []);
const displayed = paused ? snapshot : diagnostics;
if (!displayed) return <section className="diagnosticEmpty"><strong>线</strong><span></span></section>;
const copy = async () => { await navigator.clipboard.writeText(JSON.stringify(displayed.metadata, null, 2)); set_copied(true); window.setTimeout(() => set_copied(false), 1200); };
const stage_values = pipeline_stage_definitions.map(([key, label, description]) => {
const history = displayed.samples.map(sample => sample.values[key]).filter(Number.isFinite);
return [key, label, description, stage_statistic(history, stage_statistic_mode)] as const;
});
const total_history = diagnostics.samples.map(sample => sample.values.request_to_pixels_ms).filter(Number.isFinite);
const total_value = stage_statistic(total_history, stage_statistic_mode);
const total_value = stage_values.reduce((sum, [, , , value]) => sum + (Number.isFinite(value) ? value : 0), 0);
const statistic_labels: Record<Stage_Statistic, string> = {average: "滑动平均", variability: "波动", p95: "P95", p99: "P99"};
const summaries = [
["帧率", `${diagnostics.frame_rate_fps.toFixed(1)} FPS`],
["端到端平均", `${diagnostics.request_to_pixels_average_ms.toFixed(2)} ms`],
["端到端 P50", `${diagnostics.request_to_pixels_p50_ms.toFixed(2)} ms`],
["端到端 P95", `${diagnostics.request_to_pixels_p95_ms.toFixed(2)} ms`],
["端到端 P99", `${diagnostics.request_to_pixels_p99_ms.toFixed(2)} ms`],
["帧间隔平均", `${diagnostics.frame_interval_average_ms.toFixed(2)} ms`],
["帧间隔抖动 P95", `${diagnostics.interval_jitter_p95_ms.toFixed(2)} ms`],
["请求 ID 缺口", diagnostics.dropped_sequence_count.toLocaleString("zh-CN")]
const summaries: Array<[string, string, string]> = [
["帧率", `${displayed.frame_rate_fps.toFixed(1)} FPS`, "当前统计区间内每秒完成的帧数。"],
["端到端平均", `${displayed.request_to_pixels_average_ms.toFixed(2)} ms`, "帧请求发出到像素完整到达浏览器的平均耗时。"],
["端到端 P50", `${displayed.request_to_pixels_p50_ms.toFixed(2)} ms`, "一半样本不超过该端到端耗时。"],
["端到端 P95", `${displayed.request_to_pixels_p95_ms.toFixed(2)} ms`, "95% 样本不超过该端到端耗时,用于观察长尾。"],
["端到端 P99", `${displayed.request_to_pixels_p99_ms.toFixed(2)} ms`, "99% 样本不超过该端到端耗时,用于观察极端长尾。"],
["帧间隔平均", `${displayed.frame_interval_average_ms.toFixed(2)} ms`, "相邻两帧像素到达浏览器的平均间隔。"],
["帧间隔抖动 P95", `${displayed.interval_jitter_p95_ms.toFixed(2)} ms`, "帧间隔相对中位数偏差的第 95 百分位。"],
["请求 ID 缺口", displayed.dropped_sequence_count.toLocaleString("zh-CN"), "相邻已完成请求 ID 之间缺失的数量;可能表示请求未形成完整样本。"]
];
const open_context_menu = (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
set_context_menu({x: event.clientX, y: event.clientY});
};
const toggle_pause = () => {
if (paused) { set_paused(false); set_snapshot(null); }
else { set_snapshot(diagnostics); set_paused(true); }
set_context_menu(null);
};
const reset = () => { set_paused(false); set_snapshot(null); set_context_menu(null); on_reset(); };
return <section className="frameDiagnosticPanel">
<div className="diagnosticNotice"> {diagnostics.samples.length} requestAnimationFrame</div>
<dl className="frameDiagnosticSummary">{summaries.map(([label, value]) => <div key={label}><dt>{label}</dt><dd>{value}</dd></div>)}</dl>
<Frame_Timeline_Chart diagnostics={diagnostics}/>
<section className="frameStagePanel"><header><div><strong></strong><code>#{diagnostics.metadata.sequence} / {diagnostics.metadata.correlation_id}</code></div>
<div className="diagnosticNotice" title="暂停只冻结分析视图,不会停止后台帧请求与样本采集。"> {displayed.samples.length} </div>
<dl className="frameDiagnosticSummary">{summaries.map(([label, value, description]) => <div key={label} title={description}><dt>{label}</dt><dd>{value}</dd></div>)}</dl>
<Frame_Timeline_Chart diagnostics={displayed} paused={paused} on_context_menu={open_context_menu}/>
<section className="frameStagePanel"><header><div><strong title="互斥阶段来自同一条端到端流水线,各阶段占比之和约为 100%。">线</strong><code>#{displayed.metadata.sequence} / {displayed.metadata.correlation_id}</code></div>
<div className="stageStatisticControls" aria-label="帧阶段统计显示方式">
<div className="stageSegmented" role="group" aria-label="统计口径">{(["average", "variability", "p95", "p99"] as Stage_Statistic[]).map(mode =>
<button key={mode} aria-pressed={stage_statistic_mode === mode} className={stage_statistic_mode === mode ? "active" : ""} onClick={() => set_stage_statistic_mode(mode)}>{statistic_labels[mode]}</button>)}</div>
<button key={mode} title={{average: "各帧该阶段耗时的算术平均。", variability: "各帧该阶段耗时的标准差。", p95: "95% 样本不超过此值。", p99: "99% 样本不超过此值。"}[mode]} aria-pressed={stage_statistic_mode === mode} className={stage_statistic_mode === mode ? "active" : ""} onClick={() => set_stage_statistic_mode(mode)}>{statistic_labels[mode]}</button>)}</div>
<div className="stageSegmented" role="group" aria-label="显示单位">{(["value", "percentage"] as Stage_Unit[]).map(unit =>
<button key={unit} aria-pressed={stage_unit === unit} className={stage_unit === unit ? "active" : ""} onClick={() => set_stage_unit(unit)}>{unit === "value" ? "数值" : "百分比"}</button>)}</div>
<button key={unit} title={unit === "value" ? "显示阶段耗时(毫秒)。" : "按当前统计口径归一化;所有互斥阶段合计约 100%。"} aria-pressed={stage_unit === unit} className={stage_unit === unit ? "active" : ""} onClick={() => set_stage_unit(unit)}>{unit === "value" ? "数值" : "百分比"}</button>)}</div>
</div></header>
<dl>{stage_values.map(([key, value]) => <div key={key}><dt>{diagnostic_stage_labels[key]}</dt><dd>{!Number.isFinite(value) ? "--"
<dl>{stage_values.map(([key, label, description, value]) => <div key={key} title={description}><dt>{label}</dt><dd>{!Number.isFinite(value) ? "--"
: stage_unit === "percentage" ? `${(total_value > 0 ? value / total_value * 100 : 0).toFixed(1)}%`
: diagnostic_value(value, key)}</dd></div>)}</dl></section>
<details className="rawState"><summary> JSON</summary><div className="jsonStateHeader"><span> v{diagnostics.metadata.version}</span><button onClick={() => void copy()}>{copied ? "已复制" : "复制 JSON"}</button></div>
<pre className="stateJson">{JSON.stringify(diagnostics.metadata, null, 2)}</pre></details>
<details className="rawState"><summary title="查看服务端随帧发送的未加工时间戳与测量值。"> JSON</summary><div className="jsonStateHeader"><span> v{displayed.metadata.version}</span><button onClick={() => void copy()}>{copied ? "已复制" : "复制 JSON"}</button></div>
<pre className="stateJson">{JSON.stringify(displayed.metadata, null, 2)}</pre></details>
{context_menu ? <div className="diagnosticContextMenu" style={{left: context_menu.x, top: context_menu.y}} onPointerDown={event => event.stopPropagation()}>
<button onClick={toggle_pause}>{paused ? "继续实时查看" : "暂停视图并详细查看"}</button>
<button onClick={reset}></button>
</div> : null}
</section>;
}
function State_Component({component, histories, diagnostics}: {component: Component; histories: State_Histories; diagnostics: Frame_Diagnostics | null}) {
function State_Component({component, histories}: {component: Component; histories: State_Histories}) {
const [copied, set_copied] = useState(false);
if (component.id === "frame-runtime") return <Frame_Diagnostics_View diagnostics={diagnostics}/>;
const copy = async () => { await navigator.clipboard.writeText(JSON.stringify(component.state, null, 2)); set_copied(true); window.setTimeout(() => set_copied(false), 1200); };
const fields = component.fields.filter(field => !field.editable);
return <section className="componentContent"><div className="stateToolbar"><span>{fields.length} </span>
@@ -736,7 +786,7 @@ function State_Component({component, histories, diagnostics}: {component: Compon
<details className="rawState"><summary> JSON</summary><pre className="stateJson">{JSON.stringify(component.state, null, 2)}</pre></details></section>;
}
function Property_Pane({plot, schema, busy, on_refresh, on_update, on_manual_frame, on_camera_reset}: {plot: Plot; schema: Schema | null; busy: boolean; on_refresh: () => void; on_update: (component: Component, field: Field, value: unknown) => Promise<void>; on_manual_frame: () => void; on_camera_reset: () => void}) {
function Property_Pane({plot, schema, busy, on_refresh, on_update}: {plot: Plot; schema: Schema | null; busy: boolean; on_refresh: () => void; on_update: (component: Component, field: Field, value: unknown) => Promise<void>}) {
const {components, selected, set_active} = use_active_component(schema);
const component = components.find(item => item.id === selected);
const fields = component?.fields.filter(field => field.editable) ?? [];
@@ -745,17 +795,66 @@ function Property_Pane({plot, schema, busy, on_refresh, on_update, on_manual_fra
<Component_Tabs components={components} selected={selected} on_select={set_active}/><div className="workspaceBody">
{busy && !schema ? <p className="muted"></p> : component ? <section className="componentContent"><div className="sectionIntro">
<strong>{component.label}</strong><span>{fields.length} </span></div>
{component.id === "frame-runtime" ? <div className="framePolicyActions"><button onClick={on_manual_frame}></button>{plot.dimension === "3D" ? <button onClick={on_camera_reset}></button> : null}<small> render </small></div> : null}<div className="propGrid">
<div className="propGrid">
{fields.map(field => <Property_Control key={field.key} field={field} on_commit={value => on_update(component, field, value)}/>)}</div></section> : null}</div></section>;
}
function State_Pane({plot, schema, busy, on_refresh, histories, diagnostics}: {plot: Plot; schema: Schema | null; busy: boolean; on_refresh: () => void; histories: State_Histories; diagnostics: Frame_Diagnostics | null}) {
function State_Pane({plot, schema, busy, on_refresh, histories}: {plot: Plot; schema: Schema | null; busy: boolean; on_refresh: () => void; histories: State_Histories}) {
const {components, selected, set_active} = use_active_component(schema);
const component = components.find(item => item.id === selected);
const count = components.reduce((sum, item) => sum + item.fields.filter(field => !field.editable).length, 0) + (diagnostics ? Object.keys(diagnostics.latest).length : 0);
const count = components.reduce((sum, item) => sum + item.fields.filter(field => !field.editable).length, 0);
return <section className="workspacePane"><Workspace_Header plot={plot} label="运行状态 JSON" count={count} busy={busy} on_refresh={on_refresh}/>
<Component_Tabs components={components} selected={selected} on_select={set_active}/>
<div className="workspaceBody">{component ? <State_Component component={component} histories={histories} diagnostics={diagnostics}/> : null}</div></section>;
<div className="workspaceBody">{component ? <State_Component component={component} histories={histories}/> : null}</div></section>;
}
function Data_Generator_View({plot, generator, on_generated}: {plot: Plot; generator: Data_Generator; on_generated: () => void}) {
const [count, set_count] = useState(generator.count);
const [minimum, set_minimum] = useState(generator.minimum);
const [maximum, set_maximum] = useState(generator.maximum);
const [busy, set_busy] = useState(false);
const [status, set_status] = useState("");
useEffect(() => { set_count(generator.count); set_minimum(generator.minimum); set_maximum(generator.maximum); set_status(""); }, [plot.id, generator.count, generator.minimum, generator.maximum]);
const generate = async () => {
set_busy(true); set_status("");
try {
const response = await fetch(`/plot/${encodeURIComponent(plot.id)}/data/generate`, {
method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({count, minimum, maximum})
});
const result = await response.json() as {success?: boolean; error?: string; generated_count?: number};
if (!result.success) throw new Error(result.error ?? "生成原始数据失败");
on_generated();
set_status(`已生成 ${(result.generated_count ?? count).toLocaleString("zh-CN")} 条数据,并从头统计。`);
} catch (error) { set_status(error instanceof Error ? error.message : "生成原始数据失败"); }
finally { set_busy(false); }
};
return <section className="analysisSection dataGenerator"><header><div><strong>{generator.label}</strong><span>{generator.description}</span></div></header>
<div className="dataGeneratorFields">
<label title="本次替换到图形组件中的原始数据条数。"><span></span><input type="number" min={1} max={1_000_000} step={1} value={count} onChange={event => set_count(Number(event.target.value))}/></label>
<label title="随机坐标或随机样本值的闭区间下界。"><span></span><input type="number" value={minimum} onChange={event => set_minimum(Number(event.target.value))}/></label>
<label title="随机坐标或随机样本值的闭区间上界,必须大于下界。"><span></span><input type="number" value={maximum} onChange={event => set_maximum(Number(event.target.value))}/></label>
<button disabled={busy || count < 1 || count > 1_000_000 || minimum >= maximum} onClick={() => void generate()}>{busy ? "生成中…" : "生成并从头统计"}</button>
</div>{status ? <p className="analysisStatus">{status}</p> : null}</section>;
}
function Frame_Analysis_Pane({plot, analysis, diagnostics, busy, on_refresh, on_update, on_manual_frame, on_camera_reset, on_reset}: {
plot: Plot; analysis: Frame_Analysis | null; diagnostics: Frame_Diagnostics | null; busy: boolean; on_refresh: () => void;
on_update: (component: Frame_Analysis, field: Field, value: unknown) => Promise<void>; on_manual_frame: () => void;
on_camera_reset: () => void; on_reset: () => void;
}) {
const fields = analysis?.fields.filter(field => field.editable) ?? [];
return <section className="workspacePane frameAnalysisPane"><Workspace_Header plot={plot} label="渲染性能实验室" count={diagnostics?.samples.length ?? 0} busy={busy} on_refresh={on_refresh}/>
<div className="workspaceBody frameAnalysisBody">
<section className="analysisSection"><header><div><strong></strong><span></span></div>
<div className="framePolicyActions"><button onClick={on_manual_frame}></button>{plot.dimension === "3D" ? <button onClick={on_camera_reset}></button> : null}<button onClick={on_reset}></button></div></header>
{analysis ? <div className="propGrid">{fields.map(field => <Property_Control key={field.key} field={field} on_commit={value => on_update(analysis, field, value)}/>)}</div>
: <p className="muted"></p>}
</section>
{analysis?.data_generator ? <Data_Generator_View plot={plot} generator={analysis.data_generator} on_generated={() => { on_reset(); on_manual_frame(); }}/>
: <section className="analysisSection"><strong></strong><p className="muted"></p></section>}
<Frame_Diagnostics_View diagnostics={diagnostics} on_reset={on_reset}/>
</div>
</section>;
}
const Plot_Card = memo(function Plot_Card({plot, selected, on_select}: {plot: Plot; selected: boolean; on_select: (plot: Plot) => void}) {
@@ -825,7 +924,7 @@ function Gallery_Grid({plots, selected, on_select}: {plots: Plot[]; selected: Pl
</Responsive> : null}</div>;
}
const workspace_layout_key = "aethera-flexlayout-v1";
const workspace_layout_key = "aethera-flexlayout-v2";
const layout_labels: Record<I18nLabel, string> = {
[I18nLabel.Close_Tab]: "关闭标签",
[I18nLabel.Pinned_Tab]: "已固定",
@@ -861,15 +960,19 @@ const layout_labels: Record<I18nLabel, string> = {
[I18nLabel.Menu_Close_Others]: "关闭其他标签"
};
const default_workspace_layout: IJsonModel = {
global: {tabEnableRename: false, tabSetEnableMaximize: true, tabEnablePopout: false},
global: {tabEnableRename: false, tabSetEnableMaximize: true, tabEnablePopout: false, rootOrientationVertical: true},
borders: [],
layout: {type: "row", children: [
{type: "row", weight: 62, children: [
{type: "tabset", id: "gallery-set", weight: 54, minWidth: 360, children: [
{type: "tab", id: "gallery-tab", name: "图形组件", component: "gallery", enableClose: false, enableScrollbars: false, minWidth: 320, minHeight: 240}]},
{type: "tabset", id: "properties-set", weight: 28, minWidth: 280, children: [
{type: "tab", id: "properties-tab", name: "属性编辑", component: "properties", enableClose: false, enableScrollbars: false, minWidth: 260, minHeight: 220}]},
{type: "tabset", id: "state-set", weight: 18, minWidth: 260, children: [
{type: "tab", id: "state-tab", name: "运行状态", component: "state", enableClose: false, enableScrollbars: false, minWidth: 240, minHeight: 220}]}
]},
{type: "tabset", id: "frame-analysis-set", weight: 38, minHeight: 280, children: [
{type: "tab", id: "frame-analysis-tab", name: "渲染性能实验室", component: "frame-analysis", enableClose: false, enableScrollbars: false, minHeight: 260}]}
]}
};
@@ -898,8 +1001,13 @@ export function App() {
const detail = (event as CustomEvent<{plot_id: string; diagnostics: Frame_Diagnostics}>).detail;
if (detail?.plot_id === selected?.id) set_frame_diagnostics(detail.diagnostics);
};
const clear = (event: Event) => {
const detail = (event as CustomEvent<{plot_id: string}>).detail;
if (detail?.plot_id === selected?.id) set_frame_diagnostics(null);
};
window.addEventListener("aethera-frame-diagnostics", receive);
return () => window.removeEventListener("aethera-frame-diagnostics", receive);
window.addEventListener("aethera-frame-diagnostics-cleared", clear);
return () => { window.removeEventListener("aethera-frame-diagnostics", receive); window.removeEventListener("aethera-frame-diagnostics-cleared", clear); };
}, [selected?.id]);
const load_schema = useCallback(async (show_busy: boolean) => {
if (!selected) return;
@@ -920,17 +1028,20 @@ export function App() {
}, [selected]);
useEffect(() => { ++schema_request.current; ++schema_busy_request.current; set_schema_busy(false); set_schema(null); set_state_histories({}); if (selected) void load_schema(true); }, [selected, load_schema]);
useEffect(() => { if (!selected) return; const timer = window.setInterval(() => void load_schema(false), 1000); return () => window.clearInterval(timer); }, [selected, load_schema]);
const update = async (component: Component, field: Field, value: unknown) => {
const update = async (component: Component | Frame_Analysis, field: Field, value: unknown) => {
if (!selected) return;
const response = await fetch(`/plot/${encodeURIComponent(selected.id)}/component/${encodeURIComponent(component.id)}/prop/${encodeURIComponent(field.key)}`, {
method: "PUT", headers: {"Content-Type": "application/json"}, body: JSON.stringify(value)});
const result = await response.json();
if (!result.success) throw new Error(result.error ?? "属性提交失败");
if (component.id === "frame-runtime" && ["pacing_mode", "fixed_rate_fps", "minimum_latency_headroom"].includes(field.key))
if (component.id === "frame-analysis" && ["pacing_mode", "fixed_rate_fps", "minimum_latency_headroom"].includes(field.key))
window.dispatchEvent(new CustomEvent<Frame_Policy_Event>("aethera-frame-policy", {detail: {plot_id: selected.id, key: field.key as Frame_Policy_Event["key"], value: result.value}}));
set_schema(current => current ? {...current, components: current.components.map(item => item.id !== component.id ? item : {...item,
fields: item.fields.map(current_field => current_field.key === field.key ? {...current_field, value: result.value} : current_field)})} : current);
set_schema(current => !current ? current : component.id === "frame-analysis" ? {...current, frame_analysis: {...current.frame_analysis,
fields: current.frame_analysis.fields.map(current_field => current_field.key === field.key ? {...current_field, value: result.value} : current_field)}}
: {...current, components: current.components.map(item => item.id !== component.id ? item : {...item,
fields: item.fields.map(current_field => current_field.key === field.key ? {...current_field, value: result.value} : current_field)})});
};
const reset_frame_diagnostics = () => { if (selected) window.dispatchEvent(new CustomEvent("aethera-reset-frame-diagnostics", {detail: {plot_id: selected.id}})); };
const categories = useMemo(() => ["全部", ...new Set(plots.map(() => "绘图组件"))], [plots]);
const visible = category === "全部" ? plots : plots;
const gallery = <section className="galleryPanel"><header className="topbar"><div><span className="eyebrow">AETHERA </span><h1></h1></div><div className="topbarActions">
@@ -942,8 +1053,11 @@ export function App() {
const factory = (node: TabNode) => {
if (node.getComponent() === "gallery") return gallery;
if (!selected) return <div className="emptyPane"></div>;
if (node.getComponent() === "properties") return <aside className="inspector" aria-label="属性编辑面板"><Property_Pane plot={selected} schema={schema} busy={schema_busy} on_refresh={() => void load_schema(true)} on_update={update} on_manual_frame={() => window.dispatchEvent(new CustomEvent("aethera-manual-frame", {detail: {plot_id: selected.id}}))} on_camera_reset={() => window.dispatchEvent(new CustomEvent("aethera-reset-camera", {detail: {plot_id: selected.id}}))}/></aside>;
if (node.getComponent() === "state") return <aside className="inspector" aria-label="状态查看面板"><State_Pane plot={selected} schema={schema} busy={schema_busy} on_refresh={() => void load_schema(true)} histories={state_histories} diagnostics={frame_diagnostics}/></aside>;
if (node.getComponent() === "properties") return <aside className="inspector" aria-label="属性编辑面板"><Property_Pane plot={selected} schema={schema} busy={schema_busy} on_refresh={() => void load_schema(true)} on_update={update}/></aside>;
if (node.getComponent() === "state") return <aside className="inspector" aria-label="状态查看面板"><State_Pane plot={selected} schema={schema} busy={schema_busy} on_refresh={() => void load_schema(true)} histories={state_histories}/></aside>;
if (node.getComponent() === "frame-analysis") return <aside className="inspector frameAnalysisInspector" aria-label="渲染性能实验室"><Frame_Analysis_Pane plot={selected} analysis={schema?.frame_analysis ?? null} diagnostics={frame_diagnostics} busy={schema_busy} on_refresh={() => void load_schema(true)} on_update={update}
on_manual_frame={() => window.dispatchEvent(new CustomEvent("aethera-manual-frame", {detail: {plot_id: selected.id}}))}
on_camera_reset={() => window.dispatchEvent(new CustomEvent("aethera-reset-camera", {detail: {plot_id: selected.id}}))} on_reset={reset_frame_diagnostics}/></aside>;
return <div className="emptyPane"></div>;
};
return <div className="appShell flexlayout__theme_alpha"><Layout model={layout_model} factory={factory} realtimeResize i18nMapper={label => layout_labels[label]}
+24 -1
View File
@@ -113,6 +113,19 @@ canvas:focus { outline: 1px solid #5ce4c2; outline-offset: -1px; }
.framePolicyActions { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; padding: 11px; border: 1px solid #27594f; border-radius: 9px; background: #0b1b1a; }
.framePolicyActions button { flex: 0 0 auto; padding: 8px 12px; color: #062019; border: 1px solid #5ce4c2; border-radius: 7px; background: #5ce4c2; cursor: pointer; }
.framePolicyActions small { color: #7fa99f; line-height: 1.4; }
.frameAnalysisBody { display: grid; grid-template-columns: minmax(300px, .8fr) minmax(300px, .8fr) minmax(560px, 1.5fr); align-items: start; gap: 14px; }
.analysisSection { min-width: 0; padding: 14px; border: 1px solid #213653; border-radius: 10px; background: #0a1422; }
.analysisSection > header { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-bottom: 14px; }
.analysisSection > header > div:first-child { display: grid; gap: 5px; }
.analysisSection > header strong { color: #dce8f8; font-size: 15px; }
.analysisSection > header span { color: #71839e; font-size: 11px; line-height: 1.45; }
.analysisSection .framePolicyActions { flex-wrap: wrap; margin: 0; padding: 0; border: 0; background: transparent; }
.dataGeneratorFields { display: grid; grid-template-columns: repeat(3, minmax(88px, 1fr)); gap: 9px; }
.dataGeneratorFields label { display: grid; gap: 5px; color: #8296b2; font-size: 11px; }
.dataGeneratorFields input { min-width: 0; width: 100%; padding: 8px 9px; color: #eaf1ff; border: 1px solid #2d405f; border-radius: 7px; outline: none; background: #0d1828; }
.dataGeneratorFields button { grid-column: 1 / -1; padding: 9px 12px; color: #062019; border: 1px solid #5ce4c2; border-radius: 7px; background: #5ce4c2; cursor: pointer; }
.dataGeneratorFields button:disabled { opacity: .45; cursor: default; }
.analysisStatus { margin: 10px 0 0; color: #8eb6aa; font-size: 11px; }
.frameDiagnosticPanel { display: grid; gap: 14px; min-width: 0; }
.diagnosticNotice { padding: 10px 12px; color: #8eabca; border: 1px solid #29435e; border-radius: 9px; background: #0c1a2a; font-size: 11px; line-height: 1.55; }
@@ -120,7 +133,12 @@ canvas:focus { outline: 1px solid #5ce4c2; outline-offset: -1px; }
.frameDiagnosticSummary > div { min-width: 0; padding: 10px; border: 1px solid #213653; border-radius: 9px; background: #0a1422; }
.frameDiagnosticSummary dt { color: #71839e; font-size: 10px; }
.frameDiagnosticSummary dd { margin: 5px 0 0; color: #5ce4c2; font: 700 14px/1.2 ui-monospace, monospace; font-variant-numeric: tabular-nums; }
.frameTimelineChart { width: 100%; height: 260px; min-height: 220px; border: 1px solid #213653; border-radius: 10px; background: #08111e; }
.frameChartHost { position: relative; min-width: 0; }
.frameTimelineChart { width: 100%; height: 300px; min-height: 240px; border: 1px solid #213653; border-radius: 10px; background: #08111e; }
.chartPausedBadge { position: absolute; z-index: 2; top: 8px; right: 10px; padding: 4px 8px; color: #161008; border-radius: 99px; background: #f4bd63; font-size: 10px; pointer-events: none; }
.diagnosticContextMenu { position: fixed; z-index: 10000; display: grid; min-width: 190px; overflow: hidden; padding: 5px; border: 1px solid #3c5779; border-radius: 9px; background: #101d2f; box-shadow: 0 14px 36px #000a; }
.diagnosticContextMenu button { padding: 9px 11px; color: #cfdded; border: 0; border-radius: 6px; background: transparent; text-align: left; cursor: pointer; }
.diagnosticContextMenu button:hover { color: #06110f; background: #5ce4c2; }
.frameStagePanel { overflow: hidden; border: 1px solid #213653; border-radius: 10px; background: #0a1422; }
.frameStagePanel > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 11px 13px; border-bottom: 1px solid #1d304a; background: #101d2f; }
.frameStagePanel > header code { color: #71839e; font: 10px/1 ui-monospace, monospace; }
@@ -137,6 +155,11 @@ canvas:focus { outline: 1px solid #5ce4c2; outline-offset: -1px; }
.diagnosticEmpty { display: grid; min-height: 180px; place-content: center; gap: 8px; padding: 24px; color: #71839e; border: 1px dashed #29435e; border-radius: 10px; text-align: center; }
.diagnosticEmpty strong { color: #cbd8ea; }
@media (max-width: 1280px) {
.frameAnalysisBody { grid-template-columns: repeat(2, minmax(280px, 1fr)); }
.frameDiagnosticPanel { grid-column: 1 / -1; }
}
.componentCard { margin-bottom: 13px; overflow: hidden; border: 1px solid #213653; border-radius: 11px; background: #0a1422; }
.componentCard > summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 11px 13px; color: #dce8f8; background: #101d2f; cursor: pointer; }
.componentCard > summary em { color: #6f89aa; font-size: 11px; font-style: normal; }