界面美化
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <numbers>
|
#include <numbers>
|
||||||
|
#include <random>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
#include <type_traits>
|
#include <type_traits>
|
||||||
@@ -23,10 +24,13 @@ using Selection_Object = Impl<Selection_Rectangle_Overlay>;
|
|||||||
template <typename... Owned_Objects>
|
template <typename... Owned_Objects>
|
||||||
class Scene_View_Model final : public Plot::Scene_View {
|
class Scene_View_Model final : public Plot::Scene_View {
|
||||||
public:
|
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,
|
Scene_View_Model(std::vector<std::unique_ptr<detail::Renderable_Descriptor>> value_descriptors,
|
||||||
std::function<void(const Plot_Frame_Request&)> value_update,
|
std::function<void(const Plot_Frame_Request&)> value_update,
|
||||||
|
Data_Generator value_data_generator,
|
||||||
Owned_Objects... owned_objects) : descriptors(std::move(value_descriptors)),
|
Owned_Objects... owned_objects) : descriptors(std::move(value_descriptors)),
|
||||||
update_scene(std::move(value_update)),
|
update_scene(std::move(value_update)),
|
||||||
|
data_generator(std::move(value_data_generator)),
|
||||||
objects(std::move(owned_objects)...) {}
|
objects(std::move(owned_objects)...) {}
|
||||||
nlohmann::json schema() const override {
|
nlohmann::json schema() const override {
|
||||||
nlohmann::json components = nlohmann::json::array();
|
nlohmann::json components = nlohmann::json::array();
|
||||||
@@ -42,12 +46,23 @@ public:
|
|||||||
result["component"] = component;
|
result["component"] = component;
|
||||||
return result;
|
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 {
|
void update(const Plot_Frame_Request& request) override {
|
||||||
update_scene(request);
|
update_scene(request);
|
||||||
}
|
}
|
||||||
private:
|
private:
|
||||||
std::vector<std::unique_ptr<detail::Renderable_Descriptor>> descriptors;
|
std::vector<std::unique_ptr<detail::Renderable_Descriptor>> descriptors;
|
||||||
std::function<void(const Plot_Frame_Request&)> update_scene;
|
std::function<void(const Plot_Frame_Request&)> update_scene;
|
||||||
|
Data_Generator data_generator;
|
||||||
std::tuple<Owned_Objects...> objects;
|
std::tuple<Owned_Objects...> objects;
|
||||||
};
|
};
|
||||||
template <auto Member, structive::Fixed_String Key, structive::Fixed_String Description>
|
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.">>(
|
State_Field<Time_Axis, &Time_Axis::State::samples, "samples", "Published time sample window.">>(
|
||||||
std::move(id), std::move(label), "axis", axis);
|
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>
|
template <typename... Fields, typename Object, typename... Owned_Objects>
|
||||||
std::unique_ptr<Plot::Scene_View> make_scene_view(
|
std::unique_ptr<Plot::Scene_View> make_scene_view(
|
||||||
Object& object,
|
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>...>>(
|
return std::make_unique<Scene_View_Model<std::remove_cvref_t<Owned_Objects>...>>(
|
||||||
std::move(components),
|
std::move(components),
|
||||||
std::move(update),
|
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::forward<Owned_Objects>(owned_objects)...);
|
||||||
}
|
}
|
||||||
std::unique_ptr<Frequency_Axis_Object> make_frequency_axis() {
|
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-value", "数值轴", *numeric));
|
||||||
components.push_back(make_axis_component("axis-time", "时间轴", *time));
|
components.push_back(make_axis_component("axis-time", "时间轴", *time));
|
||||||
auto view = std::make_unique<Scene_View_Model<decltype(frequency), decltype(numeric), decltype(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));
|
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) {
|
std::shared_ptr<Plot> make_spectrum_plot(asio::any_io_executor executor) {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <numbers>
|
#include <numbers>
|
||||||
|
#include <random>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
|
#include <type_traits>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -13,6 +15,20 @@ namespace {
|
|||||||
using namespace render_3d;
|
using namespace render_3d;
|
||||||
using Scene_3D = Impl<Render_Scene_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>
|
template <typename Visual_Object>
|
||||||
class Visual_Scene_View final : public Plot::Scene_View {
|
class Visual_Scene_View final : public Plot::Scene_View {
|
||||||
public:
|
public:
|
||||||
@@ -53,6 +69,33 @@ public:
|
|||||||
return result;
|
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 {}
|
void update(const Plot_Frame_Request&) override {}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
+28
-7
@@ -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", "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", "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}});
|
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) {
|
nlohmann::json Frame_Policy::write_prop(std::string_view key, const nlohmann::json& value) {
|
||||||
std::lock_guard lock(mutex);
|
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&>());
|
const auto parsed = parse_pacing_mode(value.get_ref<const std::string&>());
|
||||||
if (!parsed) return {{"success", false}, {"error", "unknown frame pacing mode"}};
|
if (!parsed) return {{"success", false}, {"error", "unknown frame pacing mode"}};
|
||||||
pacing.mode = *parsed;
|
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 (key == "fixed_rate_fps") {
|
||||||
if (!value.is_number()) return {{"success", false}, {"error", "fixed_rate_fps requires a number"}};
|
if (!value.is_number()) return {{"success", false}, {"error", "fixed_rate_fps requires a number"}};
|
||||||
const double next = value.get<double>();
|
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"}};
|
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;
|
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 (key == "minimum_latency_headroom") {
|
||||||
if (!value.is_number()) return {{"success", false}, {"error", "minimum_latency_headroom requires a number"}};
|
if (!value.is_number()) return {{"success", false}, {"error", "minimum_latency_headroom requires a number"}};
|
||||||
const double next = value.get<double>();
|
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"}};
|
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;
|
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"}};
|
return {{"success", false}, {"error", "unknown frame runtime property"}};
|
||||||
}
|
}
|
||||||
@@ -167,7 +167,13 @@ struct Prop_Write {
|
|||||||
nlohmann::json value;
|
nlohmann::json value;
|
||||||
Plot::Json_Handler handler;
|
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>
|
template <typename Scene_Object>
|
||||||
void dispatch_plot_input(Scene_Object& scene, const Plot_Input_Event& input) {
|
void dispatch_plot_input(Scene_Object& scene, const Plot_Input_Event& input) {
|
||||||
const auto dispatch = [&](auto event) {
|
const auto dispatch = [&](auto event) {
|
||||||
@@ -260,7 +266,10 @@ struct Plot::Private {
|
|||||||
};
|
};
|
||||||
nlohmann::json Plot::Private::schema() const {
|
nlohmann::json Plot::Private::schema() const {
|
||||||
auto result = view->schema();
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
Plot::Private::Managed_Frame Plot::Private::make_frame(Frame_Submission submission) {
|
Plot::Private::Managed_Frame Plot::Private::make_frame(Frame_Submission submission) {
|
||||||
@@ -370,11 +379,16 @@ void Plot::ensure_started() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (auto* write = std::get_if<Prop_Write>(&input)) {
|
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->frame_policy.write_prop(write->key, write->value)
|
||||||
: self->d->view->write_prop(write->component, write->key, write->value));
|
: self->d->view->write_prop(write->component, write->key, write->value));
|
||||||
continue;
|
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);
|
auto submission = std::get<Frame_Submission>(input);
|
||||||
self->d->request_frame(std::move(submission));
|
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");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ public:
|
|||||||
[[nodiscard]] virtual nlohmann::json schema() const = 0;
|
[[nodiscard]] virtual nlohmann::json schema() const = 0;
|
||||||
[[nodiscard]] virtual nlohmann::json write_prop(std::string_view component, std::string_view key,
|
[[nodiscard]] virtual nlohmann::json write_prop(std::string_view component, std::string_view key,
|
||||||
const nlohmann::json& value) = 0;
|
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;
|
virtual void update(const Plot_Frame_Request& request) = 0;
|
||||||
};
|
};
|
||||||
Plot(asio::any_io_executor executor,
|
Plot(asio::any_io_executor executor,
|
||||||
@@ -61,6 +63,7 @@ public:
|
|||||||
void submit_input(Plot_Input_Event event);
|
void submit_input(Plot_Input_Event event);
|
||||||
void async_schema(Json_Handler handler);
|
void async_schema(Json_Handler handler);
|
||||||
void async_write_prop(std::string component, std::string key, nlohmann::json value, 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:
|
private:
|
||||||
struct Private;
|
struct Private;
|
||||||
void ensure_started();
|
void ensure_started();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <drogon/drogon.h>
|
#include <drogon/drogon.h>
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -122,6 +123,43 @@ int run_web_server(std::uint16_t port, const std::filesystem::path& asset_root)
|
|||||||
});
|
});
|
||||||
}, {drogon::Put});
|
}, {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)
|
app.registerController(websocket)
|
||||||
.setDocumentRoot(asset_root.string())
|
.setDocumentRoot(asset_root.string())
|
||||||
.setHomePage("index.html")
|
.setHomePage("index.html")
|
||||||
|
|||||||
+205
-91
@@ -4,14 +4,14 @@ import {Responsive, useContainerWidth, type LayoutItem, type ResponsiveLayouts}
|
|||||||
import ReconnectingWebSocket from "reconnecting-websocket";
|
import ReconnectingWebSocket from "reconnecting-websocket";
|
||||||
import * as echarts from "echarts/core";
|
import * as echarts from "echarts/core";
|
||||||
import {LineChart} from "echarts/charts";
|
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 {CanvasRenderer} from "echarts/renderers";
|
||||||
import type {EChartsType} from "echarts/core";
|
import type {EChartsType} from "echarts/core";
|
||||||
import "flexlayout-react/style/alpha_dark.css";
|
import "flexlayout-react/style/alpha_dark.css";
|
||||||
import "react-grid-layout/css/styles.css";
|
import "react-grid-layout/css/styles.css";
|
||||||
import "react-resizable/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 Plot = {id: string; title: string; category: string; description: string; dimension: "2D" | "3D"; websocket: string; schema: string};
|
||||||
type Option = {value: string; label: 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 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 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 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 State_Histories = Record<string, number[]>;
|
||||||
type Stream_Status = "CONNECTING" | "LIVE" | "OFFLINE";
|
type Stream_Status = "CONNECTING" | "LIVE" | "OFFLINE";
|
||||||
type Frame_Pacing_Mode = "manual" | "fixed_rate" | "minimum_latency" | "maximum_rate";
|
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;
|
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 {
|
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 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);
|
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 [metrics, set_metrics] = useState<Frame_Metrics | null>(null);
|
||||||
const socket_ref = useRef<ReconnectingWebSocket | null>(null);
|
const socket_ref = useRef<ReconnectingWebSocket | null>(null);
|
||||||
const pending_pointer_move = useRef<Record<string, unknown> | 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 envelope = useCallback((kind: "frame" | "input", event?: Record<string, unknown>) => {
|
||||||
const canvas = canvas_ref.current;
|
const canvas = canvas_ref.current;
|
||||||
if (!canvas) return null;
|
if (!canvas) return null;
|
||||||
const bounds = canvas.getBoundingClientRect();
|
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: {
|
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} : {})};
|
}, ...(event ? {event} : {})};
|
||||||
}, [canvas_ref]);
|
}, [canvas_ref]);
|
||||||
|
|
||||||
@@ -221,13 +252,10 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
|
|||||||
clear_timer();
|
clear_timer();
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
if (frame_pending) { if (manual) manual_frame_pending = true; 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); };
|
const retry = () => { if (manual) manual_frame_pending = true; timer = window.setTimeout(() => request_frame(manual), 250); };
|
||||||
if (socket.readyState !== WebSocket.OPEN) { retry_when_visible(); return; }
|
if (socket.readyState !== WebSocket.OPEN) { retry(); return; }
|
||||||
const canvas = canvas_ref.current;
|
const canvas = canvas_ref.current;
|
||||||
if (!canvas) { retry_when_visible(); return; }
|
if (!canvas) { retry(); 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 (pending_pointer_move.current) {
|
if (pending_pointer_move.current) {
|
||||||
const input_message = envelope("input", pending_pointer_move.current);
|
const input_message = envelope("input", pending_pointer_move.current);
|
||||||
if (input_message) socket.send(JSON.stringify(input_message));
|
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);
|
const sample = samples.find(value => value.sequence === sequence);
|
||||||
if (!sample) return;
|
if (!sample) return;
|
||||||
const browser_tail = Math.max(0, timestamp - pixels_received_at);
|
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,
|
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);
|
publish_diagnostics(false);
|
||||||
});
|
});
|
||||||
presentation_callbacks.add(second);
|
presentation_callbacks.add(second);
|
||||||
@@ -316,8 +344,8 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
|
|||||||
sequence: pair.value.sequence,
|
sequence: pair.value.sequence,
|
||||||
correlation_id: pair.value.correlation_id,
|
correlation_id: pair.value.correlation_id,
|
||||||
received_at_ms: pixels_received_at,
|
received_at_ms: pixels_received_at,
|
||||||
values: frame_stage_values(pair.value, started_at, pair.received_at, pixels_received_at, canvas_upload_ms)
|
values: pipeline_stage_values(frame_stage_values(pair.value, started_at, pair.received_at, pixels_received_at, canvas_upload_ms))
|
||||||
}].slice(-180);
|
}].slice(-10_000);
|
||||||
publish_diagnostics(samples.length === 1);
|
publish_diagnostics(samples.length === 1);
|
||||||
mark_presentation_opportunity(pair.value.sequence, pixels_received_at);
|
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});
|
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);
|
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-frame-policy", on_policy_change);
|
||||||
window.addEventListener("aethera-manual-frame", on_manual_frame);
|
window.addEventListener("aethera-manual-frame", on_manual_frame);
|
||||||
window.addEventListener("aethera-reset-camera", on_camera_reset);
|
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.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.onclose = () => { clear_timer(); frame_pending = false; pending_metadata = null; request_started_at.clear(); if (!stopped) set_status("CONNECTING"); };
|
||||||
socket.onmessage = event => {
|
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-frame-policy", on_policy_change);
|
||||||
window.removeEventListener("aethera-manual-frame", on_manual_frame);
|
window.removeEventListener("aethera-manual-frame", on_manual_frame);
|
||||||
window.removeEventListener("aethera-reset-camera", on_camera_reset);
|
window.removeEventListener("aethera-reset-camera", on_camera_reset);
|
||||||
|
window.removeEventListener("aethera-reset-frame-diagnostics", on_diagnostics_reset);
|
||||||
socket_ref.current = null;
|
socket_ref.current = null;
|
||||||
socket.close();
|
socket.close();
|
||||||
};
|
};
|
||||||
@@ -614,38 +654,23 @@ function State_Field_View({component, field, histories}: {component: Component;
|
|||||||
</article>;
|
</article>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const diagnostic_stage_labels: Record<string, string> = {
|
const pipeline_stage_definitions: Array<[string, string, string]> = [
|
||||||
request_to_metadata_ms: "请求 → 元数据",
|
["pipeline_request_transport_ms", "请求传输", "浏览器发出帧请求到服务端开始帧处理之间的耗时。"],
|
||||||
request_to_pixels_ms: "请求 → 像素到达",
|
["pipeline_scene_ms", "Scene 渲染", "引擎遍历 Scene 并执行 Renderable 渲染的耗时。"],
|
||||||
request_to_presentation_opportunity_ms: "请求 → 浏览器呈现机会",
|
["pipeline_callback_ms", "结果回调", "渲染完成后回调进入 WebSocket 发布流程的耗时。"],
|
||||||
metadata_to_pixels_ms: "元数据 → 像素",
|
["pipeline_websocket_ms", "像素编码", "服务端将帧像素编码为 WebSocket 消息的耗时。"],
|
||||||
server_to_websocket_ready_ms: "帧创建 → WebSocket 就绪",
|
["pipeline_server_other_ms", "服务端其余阶段", "服务端总耗时扣除可单独观测阶段后的剩余部分,包含准备、排队、GPU 与回读。"],
|
||||||
scene_render_ms: "Scene 渲染",
|
["pipeline_payload_transport_ms", "像素传输", "浏览器收到元数据后,直到完整像素载荷到达的耗时。"],
|
||||||
event_dispatch_ms: "事件分发",
|
["pipeline_canvas_upload_ms", "Canvas 上传", "浏览器把 RGBA 像素写入 Canvas 的耗时。"],
|
||||||
prepare_ms: "准备数据",
|
["pipeline_presentation_wait_ms", "呈现机会等待", "Canvas 写入后等待浏览器经过一次绘制机会的耗时。"]
|
||||||
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: "像素载荷"
|
|
||||||
};
|
|
||||||
|
|
||||||
function diagnostic_value(value: number, key: string) {
|
function diagnostic_value(value: number, key: string) {
|
||||||
if (key === "payload_megabytes") return `${value.toFixed(2)} MiB`;
|
if (key === "payload_megabytes") return `${value.toFixed(2)} MiB`;
|
||||||
return `${value.toFixed(3)} ms`;
|
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 host_ref = useRef<HTMLDivElement>(null);
|
||||||
const chart_ref = useRef<EChartsType | null>(null);
|
const chart_ref = useRef<EChartsType | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -659,13 +684,13 @@ function Frame_Timeline_Chart({diagnostics}: {diagnostics: Frame_Diagnostics}) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const chart = chart_ref.current;
|
const chart = chart_ref.current;
|
||||||
if (!chart) return;
|
if (!chart) return;
|
||||||
const visible_samples = diagnostics.samples.slice(-120);
|
const visible_samples = diagnostics.samples;
|
||||||
const series_keys: Array<[string, string, string]> = [
|
const series_keys: Array<[string, string, string]> = [
|
||||||
["request_to_pixels_ms", "端到端", "#5ce4c2"],
|
["request_to_presentation_opportunity_ms", "完整流水线", "#5ce4c2"],
|
||||||
["server_to_websocket_ready_ms", "服务端", "#62a8ff"],
|
["pipeline_scene_ms", "Scene", "#62a8ff"],
|
||||||
["prepare_ms", "准备", "#f4bd63"],
|
["pipeline_server_other_ms", "服务端其余", "#f4bd63"],
|
||||||
["paint_ms", "绘制", "#ff7d9c"],
|
["pipeline_payload_transport_ms", "像素传输", "#ff7d9c"],
|
||||||
["gpu_total_ms", "GPU", "#b998ff"]
|
["pipeline_presentation_wait_ms", "呈现等待", "#b998ff"]
|
||||||
];
|
];
|
||||||
chart.setOption({
|
chart.setOption({
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
@@ -673,61 +698,86 @@ function Frame_Timeline_Chart({diagnostics}: {diagnostics: Frame_Diagnostics}) {
|
|||||||
color: series_keys.map(([, , color]) => color),
|
color: series_keys.map(([, , color]) => color),
|
||||||
tooltip: {trigger: "axis", valueFormatter: (value: unknown) => `${Number(value).toFixed(3)} ms`},
|
tooltip: {trigger: "axis", valueFormatter: (value: unknown) => `${Number(value).toFixed(3)} ms`},
|
||||||
legend: {top: 0, textStyle: {color: "#91a5c0", fontSize: 10}},
|
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}},
|
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"}}},
|
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,
|
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}}))
|
data: visible_samples.map(sample => Number.isFinite(sample.values[key]) ? sample.values[key] : null), lineStyle: {width: 1.5}}))
|
||||||
}, true);
|
}, true);
|
||||||
}, [diagnostics]);
|
}, [diagnostics, paused]);
|
||||||
return <div className="frameTimelineChart" ref={host_ref} role="img" aria-label="最近 120 帧耗时波形图"/>;
|
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 [copied, set_copied] = useState(false);
|
||||||
const [stage_statistic_mode, set_stage_statistic_mode] = useState<Stage_Statistic>("average");
|
const [stage_statistic_mode, set_stage_statistic_mode] = useState<Stage_Statistic>("average");
|
||||||
const [stage_unit, set_stage_unit] = useState<Stage_Unit>("value");
|
const [stage_unit, set_stage_unit] = useState<Stage_Unit>("value");
|
||||||
if (!diagnostics) return <section className="diagnosticEmpty"><strong>等待帧诊断数据</strong><span>浏览器收到第一组 JSON 元数据与 RGBA 像素后开始统计。</span></section>;
|
const [paused, set_paused] = useState(false);
|
||||||
const copy = async () => { await navigator.clipboard.writeText(JSON.stringify(diagnostics.metadata, null, 2)); set_copied(true); window.setTimeout(() => set_copied(false), 1200); };
|
const [snapshot, set_snapshot] = useState<Frame_Diagnostics | null>(null);
|
||||||
const stage_values = Object.keys(diagnostic_stage_labels).filter(key => stage_unit === "value" || key.endsWith("_ms")).map(key => {
|
const [context_menu, set_context_menu] = useState<{x: number; y: number} | null>(null);
|
||||||
const history = diagnostics.samples.map(sample => sample.values[key]).filter(Number.isFinite);
|
useEffect(() => {
|
||||||
return [key, stage_statistic(history, stage_statistic_mode)] as const;
|
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_values.reduce((sum, [, , , value]) => sum + (Number.isFinite(value) ? value : 0), 0);
|
||||||
const total_value = stage_statistic(total_history, stage_statistic_mode);
|
|
||||||
const statistic_labels: Record<Stage_Statistic, string> = {average: "滑动平均", variability: "波动", p95: "P95", p99: "P99"};
|
const statistic_labels: Record<Stage_Statistic, string> = {average: "滑动平均", variability: "波动", p95: "P95", p99: "P99"};
|
||||||
const summaries = [
|
const summaries: Array<[string, string, string]> = [
|
||||||
["帧率", `${diagnostics.frame_rate_fps.toFixed(1)} FPS`],
|
["帧率", `${displayed.frame_rate_fps.toFixed(1)} FPS`, "当前统计区间内每秒完成的帧数。"],
|
||||||
["端到端平均", `${diagnostics.request_to_pixels_average_ms.toFixed(2)} ms`],
|
["端到端平均", `${displayed.request_to_pixels_average_ms.toFixed(2)} ms`, "帧请求发出到像素完整到达浏览器的平均耗时。"],
|
||||||
["端到端 P50", `${diagnostics.request_to_pixels_p50_ms.toFixed(2)} ms`],
|
["端到端 P50", `${displayed.request_to_pixels_p50_ms.toFixed(2)} ms`, "一半样本不超过该端到端耗时。"],
|
||||||
["端到端 P95", `${diagnostics.request_to_pixels_p95_ms.toFixed(2)} ms`],
|
["端到端 P95", `${displayed.request_to_pixels_p95_ms.toFixed(2)} ms`, "95% 样本不超过该端到端耗时,用于观察长尾。"],
|
||||||
["端到端 P99", `${diagnostics.request_to_pixels_p99_ms.toFixed(2)} ms`],
|
["端到端 P99", `${displayed.request_to_pixels_p99_ms.toFixed(2)} ms`, "99% 样本不超过该端到端耗时,用于观察极端长尾。"],
|
||||||
["帧间隔平均", `${diagnostics.frame_interval_average_ms.toFixed(2)} ms`],
|
["帧间隔平均", `${displayed.frame_interval_average_ms.toFixed(2)} ms`, "相邻两帧像素到达浏览器的平均间隔。"],
|
||||||
["帧间隔抖动 P95", `${diagnostics.interval_jitter_p95_ms.toFixed(2)} ms`],
|
["帧间隔抖动 P95", `${displayed.interval_jitter_p95_ms.toFixed(2)} ms`, "帧间隔相对中位数偏差的第 95 百分位。"],
|
||||||
["请求 ID 缺口", diagnostics.dropped_sequence_count.toLocaleString("zh-CN")]
|
["请求 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">
|
return <section className="frameDiagnosticPanel">
|
||||||
<div className="diagnosticNotice">统计由浏览器基于最近 {diagnostics.samples.length} 帧原始样本计算;“呈现机会”采用双 requestAnimationFrame,只代表浏览器经过一次绘制机会,不等同于物理屏幕扫描时刻。</div>
|
<div className="diagnosticNotice" title="暂停只冻结分析视图,不会停止后台帧请求与样本采集。">当前区间 {displayed.samples.length} 帧;图表右键可暂停并缩放查看。暂停只冻结视图,采样始终继续。“呈现机会”不等同于物理屏幕扫描时刻。</div>
|
||||||
<dl className="frameDiagnosticSummary">{summaries.map(([label, value]) => <div key={label}><dt>{label}</dt><dd>{value}</dd></div>)}</dl>
|
<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={diagnostics}/>
|
<Frame_Timeline_Chart diagnostics={displayed} paused={paused} on_context_menu={open_context_menu}/>
|
||||||
<section className="frameStagePanel"><header><div><strong>帧阶段统计</strong><code>#{diagnostics.metadata.sequence} / 请求 {diagnostics.metadata.correlation_id}</code></div>
|
<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="stageStatisticControls" aria-label="帧阶段统计显示方式">
|
||||||
<div className="stageSegmented" role="group" aria-label="统计口径">{(["average", "variability", "p95", "p99"] as Stage_Statistic[]).map(mode =>
|
<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 =>
|
<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>
|
</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)}%`
|
: stage_unit === "percentage" ? `${(total_value > 0 ? value / total_value * 100 : 0).toFixed(1)}%`
|
||||||
: diagnostic_value(value, key)}</dd></div>)}</dl></section>
|
: 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>
|
<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(diagnostics.metadata, null, 2)}</pre></details>
|
<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>;
|
</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);
|
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 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);
|
const fields = component.fields.filter(field => !field.editable);
|
||||||
return <section className="componentContent"><div className="stateToolbar"><span>{fields.length} 个只读字段</span>
|
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>;
|
<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 {components, selected, set_active} = use_active_component(schema);
|
||||||
const component = components.find(item => item.id === selected);
|
const component = components.find(item => item.id === selected);
|
||||||
const fields = component?.fields.filter(field => field.editable) ?? [];
|
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">
|
<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">
|
{busy && !schema ? <p className="muted">正在读取组件信息…</p> : component ? <section className="componentContent"><div className="sectionIntro">
|
||||||
<strong>{component.label}</strong><span>{fields.length} 个可编辑属性</span></div>
|
<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>;
|
{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 {components, selected, set_active} = use_active_component(schema);
|
||||||
const component = components.find(item => item.id === selected);
|
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}/>
|
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}/>
|
<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}) {
|
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>;
|
</Responsive> : null}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspace_layout_key = "aethera-flexlayout-v1";
|
const workspace_layout_key = "aethera-flexlayout-v2";
|
||||||
const layout_labels: Record<I18nLabel, string> = {
|
const layout_labels: Record<I18nLabel, string> = {
|
||||||
[I18nLabel.Close_Tab]: "关闭标签",
|
[I18nLabel.Close_Tab]: "关闭标签",
|
||||||
[I18nLabel.Pinned_Tab]: "已固定",
|
[I18nLabel.Pinned_Tab]: "已固定",
|
||||||
@@ -861,15 +960,19 @@ const layout_labels: Record<I18nLabel, string> = {
|
|||||||
[I18nLabel.Menu_Close_Others]: "关闭其他标签"
|
[I18nLabel.Menu_Close_Others]: "关闭其他标签"
|
||||||
};
|
};
|
||||||
const default_workspace_layout: IJsonModel = {
|
const default_workspace_layout: IJsonModel = {
|
||||||
global: {tabEnableRename: false, tabSetEnableMaximize: true, tabEnablePopout: false},
|
global: {tabEnableRename: false, tabSetEnableMaximize: true, tabEnablePopout: false, rootOrientationVertical: true},
|
||||||
borders: [],
|
borders: [],
|
||||||
layout: {type: "row", children: [
|
layout: {type: "row", children: [
|
||||||
|
{type: "row", weight: 62, children: [
|
||||||
{type: "tabset", id: "gallery-set", weight: 54, minWidth: 360, 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: "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: "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: "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: "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: "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;
|
const detail = (event as CustomEvent<{plot_id: string; diagnostics: Frame_Diagnostics}>).detail;
|
||||||
if (detail?.plot_id === selected?.id) set_frame_diagnostics(detail.diagnostics);
|
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);
|
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]);
|
}, [selected?.id]);
|
||||||
const load_schema = useCallback(async (show_busy: boolean) => {
|
const load_schema = useCallback(async (show_busy: boolean) => {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
@@ -920,17 +1028,20 @@ export function App() {
|
|||||||
}, [selected]);
|
}, [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(() => { ++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]);
|
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;
|
if (!selected) return;
|
||||||
const response = await fetch(`/plot/${encodeURIComponent(selected.id)}/component/${encodeURIComponent(component.id)}/prop/${encodeURIComponent(field.key)}`, {
|
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)});
|
method: "PUT", headers: {"Content-Type": "application/json"}, body: JSON.stringify(value)});
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (!result.success) throw new Error(result.error ?? "属性提交失败");
|
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}}));
|
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,
|
set_schema(current => !current ? current : component.id === "frame-analysis" ? {...current, frame_analysis: {...current.frame_analysis,
|
||||||
fields: item.fields.map(current_field => current_field.key === field.key ? {...current_field, value: result.value} : current_field)})} : current);
|
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 categories = useMemo(() => ["全部", ...new Set(plots.map(() => "绘图组件"))], [plots]);
|
||||||
const visible = category === "全部" ? plots : 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">
|
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) => {
|
const factory = (node: TabNode) => {
|
||||||
if (node.getComponent() === "gallery") return gallery;
|
if (node.getComponent() === "gallery") return gallery;
|
||||||
if (!selected) return <div className="emptyPane">请选择一个图形组件。</div>;
|
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() === "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} diagnostics={frame_diagnostics}/></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="emptyPane">未知工作区面板。</div>;
|
||||||
};
|
};
|
||||||
return <div className="appShell flexlayout__theme_alpha"><Layout model={layout_model} factory={factory} realtimeResize i18nMapper={label => layout_labels[label]}
|
return <div className="appShell flexlayout__theme_alpha"><Layout model={layout_model} factory={factory} realtimeResize i18nMapper={label => layout_labels[label]}
|
||||||
|
|||||||
@@ -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 { 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 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; }
|
.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; }
|
.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; }
|
.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 > div { min-width: 0; padding: 10px; border: 1px solid #213653; border-radius: 9px; background: #0a1422; }
|
||||||
.frameDiagnosticSummary dt { color: #71839e; font-size: 10px; }
|
.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; }
|
.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 { 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 { 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; }
|
.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 { 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; }
|
.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 { 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 { 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; }
|
.componentCard > summary em { color: #6f89aa; font-size: 11px; font-style: normal; }
|
||||||
|
|||||||
Reference in New Issue
Block a user