From 945e8287d49b7e772d812d121489a4baa60c1bbf Mon Sep 17 00:00:00 2001 From: wyc <1104749580@qq.com> Date: Sat, 22 Aug 2026 11:06:38 +0800 Subject: [PATCH] =?UTF-8?q?=E7=95=8C=E9=9D=A2=E7=BE=8E=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web_server/src/Gallery_Plots_2D.cpp | 103 +++++++++- web_server/src/Gallery_Plots_3D.cpp | 43 ++++ web_server/src/Plot.cpp | 35 +++- web_server/src/Plot.hpp | 3 + web_server/src/Web_Server.cpp | 38 ++++ webapp_gallery/src/app.tsx | 308 +++++++++++++++++++--------- webapp_gallery/src/styles.css | 25 ++- 7 files changed, 449 insertions(+), 106 deletions(-) diff --git a/web_server/src/Gallery_Plots_2D.cpp b/web_server/src/Gallery_Plots_2D.cpp index 68fe46b..0202e12 100644 --- a/web_server/src/Gallery_Plots_2D.cpp +++ b/web_server/src/Gallery_Plots_2D.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -23,10 +24,13 @@ using Selection_Object = Impl; template class Scene_View_Model final : public Plot::Scene_View { public: + using Data_Generator = std::function; Scene_View_Model(std::vector> value_descriptors, std::function 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> descriptors; std::function update_scene; + Data_Generator data_generator; std::tuple objects; }; template @@ -136,6 +151,87 @@ std::unique_ptr make_axis_component>( std::move(id), std::move(label), "axis", axis); } +template +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 distribution(minimum, maximum); + const auto values = [&] { + std::vector result(count); + std::ranges::generate(result, [&] { return distribution(engine); }); + return result; + }; + if constexpr (std::same_as) { + object.update_samples(values()); + } + else if constexpr (std::same_as) { + std::vector samples(count); + for (std::size_t index = 0; index < count; ++index) + samples[index] = {static_cast(index), distribution(engine)}; + object.template set<&Frequency_Trace::Prop::samples>(std::move(samples)); + } + else if constexpr (std::same_as) { + const auto& state = object.template read_prop(); + const auto width = std::max(1, state.bins_per_block); + std::vector> 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(first), + generated.begin() + static_cast(last)); + } + object.template set<&Sweep_Spectrum::Prop::blocks>(std::move(blocks)); + } + else if constexpr (std::same_as) { + const auto row_count = std::clamp(static_cast(std::sqrt(count)), 1, 64); + const auto width = (count + row_count - 1) / row_count; + std::vector> 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) { + const auto row_count = std::max(1, static_cast(std::sqrt(count))); + const auto width = (count + row_count - 1) / row_count; + std::vector 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 row_values(size); + std::ranges::generate(row_values, [&] { return distribution(engine); }); + rows.push_back({static_cast(row), std::move(row_values)}); + generated += size; + } + object.template set<&Waterfall::Prop::rows>(std::move(rows)); + } + else if constexpr (std::same_as) { + object.template set<&Constellation_Diagram::Prop::points>(std::vector{}); + for (std::size_t index = 0; index < count; ++index) + object.append_point({distribution(engine), distribution(engine)}); + } + else if constexpr (std::same_as) { + std::vector 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 std::unique_ptr make_scene_view( Object& object, @@ -171,6 +267,9 @@ std::unique_ptr make_scene_view( return std::make_unique...>>( 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)...); } std::unique_ptr make_frequency_axis() { @@ -264,7 +363,9 @@ std::shared_ptr 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>( - std::move(components), std::move(update), std::move(frequency), std::move(numeric), std::move(time)); + std::move(components), std::move(update), + Scene_View_Model::Data_Generator{}, + std::move(frequency), std::move(numeric), std::move(time)); return std::make_shared(std::move(executor), std::move(scene), std::move(view)); } std::shared_ptr make_spectrum_plot(asio::any_io_executor executor) { diff --git a/web_server/src/Gallery_Plots_3D.cpp b/web_server/src/Gallery_Plots_3D.cpp index ec6433c..3f4b9aa 100644 --- a/web_server/src/Gallery_Plots_3D.cpp +++ b/web_server/src/Gallery_Plots_3D.cpp @@ -4,7 +4,9 @@ #include #include #include +#include #include +#include #include #include @@ -13,6 +15,20 @@ namespace { using namespace render_3d; using Scene_3D = Impl; +template +void randomize_item(Item& item, std::uniform_real_distribution& 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 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().items)>; + const auto& current = visual_->template read_prop().items; + if (current.empty()) return {{"success", false}, {"error", "visual has no item template"}}; + std::mt19937_64 engine{std::random_device{}()}; + std::uniform_real_distribution distribution( + static_cast(minimum), static_cast(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: diff --git a/web_server/src/Plot.cpp b/web_server/src/Plot.cpp index c4bb667..41c4277 100644 --- a/web_server/src/Plot.cpp +++ b/web_server/src/Plot.cpp @@ -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()); 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(); 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(); 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; +struct Data_Generation { + std::size_t count{}; + double minimum{}; + double maximum{}; + Plot::Json_Handler handler; +}; +using Plot_Input = std::variant; template 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(&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(&input)) { + generation->handler(self->d->view->generate_data( + generation->count, generation->minimum, generation->maximum)); + continue; + } auto submission = std::get(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"); +} } diff --git a/web_server/src/Plot.hpp b/web_server/src/Plot.hpp index a72b1bc..7f05316 100644 --- a/web_server/src/Plot.hpp +++ b/web_server/src/Plot.hpp @@ -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(); diff --git a/web_server/src/Web_Server.cpp b/web_server/src/Web_Server.cpp index 35587c6..80f8ff8 100644 --- a/web_server/src/Web_Server.cpp +++ b/web_server/src/Web_Server.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -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&& 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(); + const auto minimum = input["minimum"].get(); + const auto maximum = input["maximum"].get(); + 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::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") diff --git a/webapp_gallery/src/app.tsx b/webapp_gallery/src/app.tsx index 927f0ff..5f0580e 100644 --- a/webapp_gallery/src/app.tsx +++ b/webapp_gallery/src/app.tsx @@ -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}; -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 & {data_generator?: Data_Generator}; +type Schema = {protocol: "aethera.plot.inspector"; version: 2; components: Component[]; frame_analysis: Frame_Analysis}; type State_Histories = Record; 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(null); const socket_ref = useRef(null); const pending_pointer_move = useRef | null>(null); + const viewport_ref = useRef({width: 720, height: 420}); const envelope = useCallback((kind: "frame" | "input", event?: Record) => { 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 { 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 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 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; } -const diagnostic_stage_labels: Record = { - 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) => void}) { const host_ref = useRef(null); const chart_ref = useRef(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
; + }, [diagnostics, paused]); + return
+ {paused ? 已暂停视图 · 采样仍在继续 : null} +
+
; } -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("average"); const [stage_unit, set_stage_unit] = useState("value"); - if (!diagnostics) return
等待帧诊断数据浏览器收到第一组 JSON 元数据与 RGBA 像素后开始统计。
; - 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(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
等待流水线样本采样独立于图形面板可见性;收到第一帧后开始统计。
; + 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 = {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) => { + 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
-
统计由浏览器基于最近 {diagnostics.samples.length} 帧原始样本计算;“呈现机会”采用双 requestAnimationFrame,只代表浏览器经过一次绘制机会,不等同于物理屏幕扫描时刻。
-
{summaries.map(([label, value]) =>
{label}
{value}
)}
- -
帧阶段统计#{diagnostics.metadata.sequence} / 请求 {diagnostics.metadata.correlation_id}
+
当前区间 {displayed.samples.length} 帧;图表右键可暂停并缩放查看。暂停只冻结视图,采样始终继续。“呈现机会”不等同于物理屏幕扫描时刻。
+
{summaries.map(([label, value, description]) =>
{label}
{value}
)}
+ +
流水线阶段统计#{displayed.metadata.sequence} / 请求 {displayed.metadata.correlation_id}
{(["average", "variability", "p95", "p99"] as Stage_Statistic[]).map(mode => - )}
+ )}
{(["value", "percentage"] as Stage_Unit[]).map(unit => - )}
+ )}
-
{stage_values.map(([key, value]) =>
{diagnostic_stage_labels[key]}
{!Number.isFinite(value) ? "--" +
{stage_values.map(([key, label, description, value]) =>
{label}
{!Number.isFinite(value) ? "--" : stage_unit === "percentage" ? `${(total_value > 0 ? value / total_value * 100 : 0).toFixed(1)}%` : diagnostic_value(value, key)}
)}
-
最新帧原始诊断 JSON
协议 v{diagnostics.metadata.version}
-
{JSON.stringify(diagnostics.metadata, null, 2)}
+
最新帧原始诊断 JSON
协议 v{displayed.metadata.version}
+
{JSON.stringify(displayed.metadata, null, 2)}
+ {context_menu ?
event.stopPropagation()}> + + +
: null} ; } -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 ; 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
{fields.length} 个只读字段 @@ -736,7 +786,7 @@ function State_Component({component, histories, diagnostics}: {component: Compon
原始状态 JSON
{JSON.stringify(component.state, null, 2)}
; } -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; 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}) { 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
{busy && !schema ?

正在读取组件信息…

: component ?
{component.label}{fields.length} 个可编辑属性
- {component.id === "frame-runtime" ?
{plot.dimension === "3D" ? : null}相机复位作为输入随下一次正常 render 处理,不改变帧策略频率。
: null}
+
{fields.map(field => on_update(component, field, value)}/>)}
: null}
; } -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
-
{component ? : null}
; +
{component ? : null}
; +} + +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
{generator.label}{generator.description}
+
+ + + + +
{status ?

{status}

: null}
; +} + +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; on_manual_frame: () => void; + on_camera_reset: () => void; on_reset: () => void; +}) { + const fields = analysis?.fields.filter(field => field.editable) ?? []; + return
+
+
采样与帧策略独立于属性编辑、运行状态和图形面板可见性。
+
{plot.dimension === "3D" ? : null}
+ {analysis ?
{fields.map(field => on_update(analysis, field, value)}/>)}
+ :

正在读取帧策略…

} +
+ {analysis?.data_generator ? { on_reset(); on_manual_frame(); }}/> + :
原始数据生成

当前图形没有可替换的原始数据集合。

} + +
+
; } 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 : null}
; } -const workspace_layout_key = "aethera-flexlayout-v1"; +const workspace_layout_key = "aethera-flexlayout-v2"; const layout_labels: Record = { [I18nLabel.Close_Tab]: "关闭标签", [I18nLabel.Pinned_Tab]: "已固定", @@ -861,15 +960,19 @@ const layout_labels: Record = { [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: "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: "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("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 =
AETHERA 渲染实验室

实时图形组件库

@@ -942,8 +1053,11 @@ export function App() { const factory = (node: TabNode) => { if (node.getComponent() === "gallery") return gallery; if (!selected) return
请选择一个图形组件。
; - if (node.getComponent() === "properties") return ; - if (node.getComponent() === "state") return ; + if (node.getComponent() === "properties") return ; + if (node.getComponent() === "state") return ; + if (node.getComponent() === "frame-analysis") return ; return
未知工作区面板。
; }; return
layout_labels[label]} diff --git a/webapp_gallery/src/styles.css b/webapp_gallery/src/styles.css index dc1a55f..886b1fa 100644 --- a/webapp_gallery/src/styles.css +++ b/webapp_gallery/src/styles.css @@ -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; }