diff --git a/web_server/src/Gallery_Plots_2D.cpp b/web_server/src/Gallery_Plots_2D.cpp index 0202e12..f2f5f96 100644 --- a/web_server/src/Gallery_Plots_2D.cpp +++ b/web_server/src/Gallery_Plots_2D.cpp @@ -24,9 +24,13 @@ using Selection_Object = Impl; template class Scene_View_Model final : public Plot::Scene_View { public: - using Data_Generator = std::function; + struct Data_Generator { + nlohmann::json schema; + std::function generate; + explicit operator bool() const noexcept { return static_cast(generate); } + }; Scene_View_Model(std::vector> value_descriptors, - std::function value_update, + std::function value_update, Data_Generator value_data_generator, Owned_Objects... owned_objects) : descriptors(std::move(value_descriptors)), update_scene(std::move(value_update)), @@ -48,21 +52,22 @@ public: } nlohmann::json data_generator_schema() const override { if (!data_generator) return nullptr; - return {{"label", "生成二维输入样本"}, - {"description", "按当前图形的数据语义生成指定数量的随机输入;坐标轴、时间槽和分块由该图形自行组织。"}, - {"count", 4096}, {"minimum", -1.0}, {"maximum", 1.0}}; + return data_generator.schema; } - nlohmann::json generate_data(std::size_t count, double minimum, double maximum) override { + nlohmann::json generate_data(const nlohmann::json& input) override { if (!data_generator) return {{"success", false}, {"error", "this plot has no raw data input"}}; - return data_generator(count, minimum, maximum); + auto result = data_generator.generate(input); + if (result.value("success", false)) generated_data_active = true; + return result; } void update(const Plot_Frame_Request& request) override { - update_scene(request); + update_scene(request, !generated_data_active); } private: std::vector> descriptors; - std::function update_scene; + std::function update_scene; Data_Generator data_generator; + bool generated_data_active{}; /* true 后保留用户生成的数据,不再用演示输入覆盖;viewport 更新仍持续。 */ std::tuple objects; }; template @@ -151,92 +156,188 @@ 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; - }; +using Json = nlohmann::json; +Json generator_number_field(std::string key, std::string label, std::string description, + double value, double minimum, double maximum, double step = 0.01) { + return {{"key", std::move(key)}, {"label", std::move(label)}, {"description", std::move(description)}, + {"editor", "number"}, {"editable", true}, {"value", value}, + {"minimum", minimum}, {"maximum", maximum}, {"step", step}}; +} +Json generator_integer_field(std::string key, std::string label, std::string description, + std::size_t value, std::size_t maximum = 1'000'000) { + auto field = generator_number_field(std::move(key), std::move(label), std::move(description), + static_cast(value), 1.0, static_cast(maximum), 1.0); + field["editor"] = "integer"; + return field; +} +double generator_number(const Json& input, std::string_view key) { + const auto& value = input.at(std::string(key)); + if (!value.is_number()) throw std::invalid_argument(std::string(key) + " must be a number"); + const auto result = value.get(); + if (!std::isfinite(result)) throw std::invalid_argument(std::string(key) + " must be finite"); + return result; +} +std::size_t generator_count(const Json& input, std::string_view key, std::size_t maximum = 1'000'000) { + const auto value = generator_number(input, key); + if (value < 1.0 || value > static_cast(maximum) || std::floor(value) != value) + throw std::invalid_argument(std::string(key) + " is outside the supported integer range"); + return static_cast(value); +} +std::pair generator_range(const Json& input, std::string_view minimum_key, std::string_view maximum_key) { + const auto minimum = generator_number(input, minimum_key); + const auto maximum = generator_number(input, maximum_key); + if (minimum >= maximum) throw std::invalid_argument(std::string(maximum_key) + " must be greater than " + std::string(minimum_key)); + return {minimum, maximum}; +} +template +Json generator_2d_schema() { + Json fields = Json::array(); + std::string label; + std::string description; if constexpr (std::same_as) { - object.update_samples(values()); + label = "生成频谱采样"; description = "生成一条完整功率频谱,样本沿当前频率范围均匀分布。"; + fields.push_back(generator_integer_field("sample_count", "频谱采样点数", "一次频谱更新包含的功率采样点数。", 4096)); + fields.push_back(generator_number_field("power_min", "功率下界", "随机功率值下界。", -110.0, -1'000'000.0, 1'000'000.0)); + fields.push_back(generator_number_field("power_max", "功率上界", "随机功率值上界,必须大于下界。", -20.0, -1'000'000.0, 1'000'000.0)); + } else if constexpr (std::same_as) { + label = "生成频率轨迹"; description = "按时间顺序生成一组轨迹采样。"; + fields.push_back(generator_integer_field("sample_count", "轨迹采样点数", "时间有序的轨迹点数量。", 4096)); + fields.push_back(generator_number_field("value_min", "轨迹值下界", "随机轨迹值下界。", -1.0, -1'000'000.0, 1'000'000.0)); + fields.push_back(generator_number_field("value_max", "轨迹值上界", "随机轨迹值上界,必须大于下界。", 1.0, -1'000'000.0, 1'000'000.0)); + fields.push_back(generator_integer_field("tick_step", "时间刻度步长", "相邻样本之间的整数时间刻度差。", 1, 1'000'000)); + } else if constexpr (std::same_as) { + label = "生成分块扫频"; description = "按块数与每块频点数生成一条完整扫频曲线。"; + fields.push_back(generator_integer_field("block_count", "扫频块数", "组成一次完整扫频的块数量。", 64, 65'536)); + fields.push_back(generator_integer_field("bins_per_block", "每块频点数", "每个扫频块保存的连续频点数量。", 8, 65'536)); + fields.push_back(generator_number_field("power_min", "功率下界", "随机扫频功率下界。", -110.0, -1'000'000.0, 1'000'000.0)); + fields.push_back(generator_number_field("power_max", "功率上界", "随机扫频功率上界,必须大于下界。", -20.0, -1'000'000.0, 1'000'000.0)); + } else if constexpr (std::same_as) { + label = "生成余辉历史"; description = "生成多帧频谱历史,用于测试余辉累积和衰减。"; + fields.push_back(generator_integer_field("history_count", "历史频谱帧数", "余辉中保留的历史频谱数量。", 32, 4096)); + fields.push_back(generator_integer_field("samples_per_spectrum", "每帧采样点数", "每条历史频谱包含的功率采样点数。", 512, 65'536)); + fields.push_back(generator_number_field("power_min", "功率下界", "随机功率值下界。", -110.0, -1'000'000.0, 1'000'000.0)); + fields.push_back(generator_number_field("power_max", "功率上界", "随机功率值上界,必须大于下界。", -20.0, -1'000'000.0, 1'000'000.0)); + } else if constexpr (std::same_as) { + label = "生成瀑布图历史"; description = "生成带时间刻度的多行频谱数据。"; + fields.push_back(generator_integer_field("row_count", "瀑布行数", "瀑布图中保存的时间行数量。", 256, 4096)); + fields.push_back(generator_integer_field("bins_per_row", "每行频点数", "每一时间行包含的频率采样点数。", 512, 65'536)); + fields.push_back(generator_number_field("power_min", "功率下界", "随机功率值下界。", -110.0, -1'000'000.0, 1'000'000.0)); + fields.push_back(generator_number_field("power_max", "功率上界", "随机功率值上界,必须大于下界。", -20.0, -1'000'000.0, 1'000'000.0)); + } else if constexpr (std::same_as) { + label = "生成星座采样"; description = "分别按 I/Q 坐标范围生成随机星座点。"; + fields.push_back(generator_integer_field("point_count", "星座点数", "本次写入的 I/Q 采样数量。", 10'000)); + fields.push_back(generator_number_field("i_min", "I 坐标下界", "同相分量随机范围下界。", -1.0, -1'000'000.0, 1'000'000.0)); + fields.push_back(generator_number_field("i_max", "I 坐标上界", "同相分量随机范围上界。", 1.0, -1'000'000.0, 1'000'000.0)); + fields.push_back(generator_number_field("q_min", "Q 坐标下界", "正交分量随机范围下界。", -1.0, -1'000'000.0, 1'000'000.0)); + fields.push_back(generator_number_field("q_max", "Q 坐标上界", "正交分量随机范围上界。", 1.0, -1'000'000.0, 1'000'000.0)); + } else if constexpr (std::same_as) { + label = "生成矩形选区"; description = "按 X/Y 坐标范围生成随机矩形区域。"; + fields.push_back(generator_integer_field("region_count", "矩形数量", "本次写入的选区数量。", 128)); + fields.push_back(generator_number_field("x_min", "X 坐标下界", "矩形起点 X 随机范围下界。", 0.0, -1'000'000.0, 1'000'000.0)); + fields.push_back(generator_number_field("x_max", "X 坐标上界", "矩形终点 X 随机范围上界。", 100.0, -1'000'000.0, 1'000'000.0)); + fields.push_back(generator_number_field("y_min", "Y 坐标下界", "矩形起点 Y 随机范围下界。", 0.0, -1'000'000.0, 1'000'000.0)); + fields.push_back(generator_number_field("y_max", "Y 坐标上界", "矩形终点 Y 随机范围上界。", 100.0, -1'000'000.0, 1'000'000.0)); } - else if constexpr (std::same_as) { + return {{"label", std::move(label)}, {"description", std::move(description)}, {"fields", std::move(fields)}}; +} +template +nlohmann::json generate_2d_data(Object& object, const Json& input) { + using Definition = typename Object::Attached_Object; + try { + std::mt19937_64 engine{std::random_device{}()}; + std::size_t generated_count{}; + if constexpr (std::same_as) { + const auto count = generator_count(input, "sample_count"); + const auto [minimum, maximum] = generator_range(input, "power_min", "power_max"); + std::uniform_real_distribution distribution(minimum, maximum); + std::vector values(count); + std::ranges::generate(values, [&] { return distribution(engine); }); + object.update_samples(values); + generated_count = count; + } else if constexpr (std::same_as) { + const auto count = generator_count(input, "sample_count"); + const auto tick_step = generator_count(input, "tick_step"); + const auto [minimum, maximum] = generator_range(input, "value_min", "value_max"); + std::uniform_real_distribution distribution(minimum, maximum); std::vector samples(count); for (std::size_t index = 0; index < count; ++index) - samples[index] = {static_cast(index), distribution(engine)}; + samples[index] = {static_cast(index * tick_step), 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)); - } + generated_count = count; + } else if constexpr (std::same_as) { + const auto block_count = generator_count(input, "block_count", 65'536); + const auto width = generator_count(input, "bins_per_block", 65'536); + if (block_count > 1'000'000 / width) throw std::invalid_argument("sweep data exceeds 1,000,000 samples"); + const auto [minimum, maximum] = generator_range(input, "power_min", "power_max"); + std::uniform_real_distribution distribution(minimum, maximum); + std::vector> blocks(block_count, std::vector(width)); + for (auto& block : blocks) std::ranges::generate(block, [&] { return distribution(engine); }); + object.template set<&Sweep_Spectrum::Prop::bins_per_block>(width); + object.template set<&Sweep_Spectrum::Prop::block_count>(block_count); 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; - } + generated_count = block_count * width; + } else if constexpr (std::same_as) { + const auto row_count = generator_count(input, "history_count", 4096); + const auto width = generator_count(input, "samples_per_spectrum", 65'536); + if (row_count > 1'000'000 / width) throw std::invalid_argument("afterglow data exceeds 1,000,000 samples"); + const auto [minimum, maximum] = generator_range(input, "power_min", "power_max"); + std::uniform_real_distribution distribution(minimum, maximum); + std::vector> spectra(row_count, std::vector(width)); + for (auto& spectrum : spectra) std::ranges::generate(spectrum, [&] { return distribution(engine); }); 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; + generated_count = row_count * width; + } else if constexpr (std::same_as) { + const auto row_count = generator_count(input, "row_count", 4096); + const auto width = generator_count(input, "bins_per_row", 65'536); + if (row_count > 1'000'000 / width) throw std::invalid_argument("waterfall data exceeds 1,000,000 samples"); + const auto [minimum, maximum] = generator_range(input, "power_min", "power_max"); + std::uniform_real_distribution distribution(minimum, maximum); 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); + for (std::size_t row = 0; row < row_count; ++row) { + std::vector row_values(width); 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::frequency_bin_count>(width); object.template set<&Waterfall::Prop::rows>(std::move(rows)); - } - else if constexpr (std::same_as) { - object.template set<&Constellation_Diagram::Prop::points>(std::vector{}); + generated_count = row_count * width; + } else if constexpr (std::same_as) { + const auto count = generator_count(input, "point_count"); + const auto [i_min, i_max] = generator_range(input, "i_min", "i_max"); + const auto [q_min, q_max] = generator_range(input, "q_min", "q_max"); + std::uniform_real_distribution i_distribution(i_min, i_max), q_distribution(q_min, q_max); + std::vector points(count); + const auto submitted = monotonic_milliseconds(); for (std::size_t index = 0; index < count; ++index) - object.append_point({distribution(engine), distribution(engine)}); - } - else if constexpr (std::same_as) { + points[index] = {{i_distribution(engine), q_distribution(engine)}, submitted}; + object.template set<&Constellation_Diagram::Prop::points>(std::move(points)); + generated_count = count; + } else if constexpr (std::same_as) { + const auto count = generator_count(input, "region_count"); + const auto [x_min, x_max] = generator_range(input, "x_min", "x_max"); + const auto [y_min, y_max] = generator_range(input, "y_min", "y_max"); + std::uniform_real_distribution x_distribution(x_min, x_max), y_distribution(y_min, y_max); 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)}}; + const auto first_x = x_distribution(engine), second_x = x_distribution(engine); + const auto first_y = y_distribution(engine), second_y = y_distribution(engine); + region = {{std::min(first_x, second_x), std::max(first_x, second_x)}, + {std::min(first_y, second_y), std::max(first_y, second_y)}}; } object.template set<&Selection_Rectangle_Overlay::Prop::selected_regions>(std::move(regions)); - } - else { + generated_count = count; + } else { return {{"success", false}, {"error", "this plot has no raw data input"}}; - } - return {{"success", true}, {"generated_count", count}, {"minimum", minimum}, {"maximum", maximum}}; + } + return {{"success", true}, {"generated_count", generated_count}}; + } catch (const std::exception& error) { return {{"success", false}, {"error", error.what()}}; } } template std::unique_ptr make_scene_view( Object& object, Scene_2D& scene, - std::function update, + std::function update, Owned_Objects&&... owned_objects) { using Definition = typename Object::Attached_Object; using Tag = typename Definition::Base_Tag; @@ -259,7 +360,6 @@ std::unique_ptr make_scene_view( Prop_Field<&Selection_Rectangle_Overlay::Prop::label_pen, "label_pen", "Pen used to draw selected-region label text.">, Prop_Field<&Selection_Rectangle_Overlay::Prop::selection_brush, "selection_brush", "Brush used to fill selected rectangular regions.">, Prop_Field<&Selection_Rectangle_Overlay::Prop::selection_border_pen, "selection_border_pen", "Pen used to draw selected-region borders.">, - Prop_Field<&Selection_Rectangle_Overlay::Prop::selected_regions, "selected_regions", "Collection of selected rectangles expressed in axis coordinates.">, State_Field>("selection", "矩形选区", "overlay", *owned)); } }; @@ -267,9 +367,8 @@ 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); - }, + typename Scene_View_Model...>::Data_Generator{ + generator_2d_schema(), [&object](const nlohmann::json& input) { return generate_2d_data(object, input); }}, std::forward(owned_objects)...); } std::unique_ptr make_frequency_axis() { @@ -349,7 +448,7 @@ std::shared_ptr make_axes_plot(asio::any_io_executor executor) { .add_dependency_node(numeric.get()) .add_dependency_node(time.get()) .build(); - auto update = [scene = scene.get(), frequency = frequency.get(), numeric = numeric.get(), time = time.get()](const Plot_Frame_Request& event) { + auto update = [scene = scene.get(), frequency = frequency.get(), numeric = numeric.get(), time = time.get()](const Plot_Frame_Request& event, bool) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, frequency, numeric, time); constexpr double day_milliseconds = 86'400'000.0; time->append_time(Time_Of_Day{ @@ -385,8 +484,9 @@ std::shared_ptr make_spectrum_plot(asio::any_io_executor executor) { .add_renderable(selection.get()) .add_dependency(selection.get(), spectrum.get()) .build(); - auto update = [scene = scene.get(), raw = spectrum.get(), frequency = frequency.get(), vertical = vertical.get()](const Plot_Frame_Request& event) { + auto update = [scene = scene.get(), raw = spectrum.get(), frequency = frequency.get(), vertical = vertical.get()](const Plot_Frame_Request& event, bool demo_data) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, frequency, vertical); + if (!demo_data) return; std::array samples{}; for (std::size_t i = 0; i < samples.size(); ++i) { const double x = static_cast(i) / samples.size(); @@ -443,8 +543,9 @@ std::shared_ptr make_frequency_trace_plot(asio::any_io_executor executor) .add_renderable(selection.get()) .add_dependency(selection.get(), trace.get()) .build(); - auto update = [scene = scene.get(), raw = trace.get(), time = time.get(), vertical = vertical.get()](const Plot_Frame_Request& event) { + auto update = [scene = scene.get(), raw = trace.get(), time = time.get(), vertical = vertical.get()](const Plot_Frame_Request& event, bool demo_data) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, time, vertical); + if (!demo_data) return; constexpr double day_milliseconds = 86'400'000.0; const auto tick = time->append_time(Time_Of_Day{ static_cast( @@ -458,7 +559,6 @@ std::shared_ptr make_frequency_trace_plot(asio::any_io_executor executor) Prop_Field<&Frequency_Trace::Prop::partition_count, "partition_count", "Number of partitions used to prepare the time-ordered trace.">, Prop_Field<&Frequency_Trace::Prop::pen, "pen", "Stroke style used to draw the frequency trace.">, Prop_Field<&Frequency_Trace::Prop::partition_mode, "partition_mode", "Selects how trace samples are divided between preparation tasks.">, - Prop_Field<&Frequency_Trace::Prop::samples, "samples", "Complete time-ordered collection of frequency trace samples.">, State_Field, State_Field>( *trace, *scene, std::move(update), std::move(time), std::move(vertical), std::move(trace), std::move(selection)); @@ -483,8 +583,9 @@ std::shared_ptr make_sweep_spectrum_plot(asio::any_io_executor executor) { .add_renderable(selection.get()) .add_dependency(selection.get(), sweep.get()) .build(); - auto update = [scene = scene.get(), raw = sweep.get(), frequency = frequency.get(), vertical = vertical.get()](const Plot_Frame_Request& event) { + auto update = [scene = scene.get(), raw = sweep.get(), frequency = frequency.get(), vertical = vertical.get()](const Plot_Frame_Request& event, bool demo_data) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, frequency, vertical); + if (!demo_data) return; const auto& state = raw->template read_prop(); const std::size_t block_count = std::max(1, state.block_count); const std::size_t bins_per_block = std::max(1, state.bins_per_block); @@ -506,7 +607,6 @@ std::shared_ptr make_sweep_spectrum_plot(asio::any_io_executor executor) { Prop_Field<&Sweep_Spectrum::Prop::pen, "pen", "Stroke style used for the completed sweep curve.">, Prop_Field<&Sweep_Spectrum::Prop::current_frequency_pen, "current_frequency_pen", "Stroke style used for the current sweep-frequency indicator.">, Prop_Field<&Sweep_Spectrum::Prop::interpolation_mode, "interpolation_mode", "Selects interpolation between adjacent sweep bins.">, - Prop_Field<&Sweep_Spectrum::Prop::blocks, "blocks", "Latest data stored in each fixed frequency-segment slot.">, State_Field, State_Field, State_Field>( @@ -532,8 +632,9 @@ std::shared_ptr make_afterglow_plot(asio::any_io_executor executor) { .add_renderable(selection.get()) .add_dependency(selection.get(), afterglow.get()) .build(); - auto update = [scene = scene.get(), raw = afterglow.get(), frequency = frequency.get(), vertical = vertical.get()](const Plot_Frame_Request& event) { + auto update = [scene = scene.get(), raw = afterglow.get(), frequency = frequency.get(), vertical = vertical.get()](const Plot_Frame_Request& event, bool demo_data) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, frequency, vertical); + if (!demo_data) return; std::array values{}; for (std::size_t i = 0; i < values.size(); ++i) values[i] = -95.0 + 62.0 * std::exp(-220.0 * std::pow( @@ -551,7 +652,6 @@ std::shared_ptr make_afterglow_plot(asio::any_io_executor executor) { Prop_Field<&Afterglow::Prop::power_range, "power_range", "Defines the minimum and maximum power represented by the color grid.">, Prop_Field<&Afterglow::Prop::partition_mode, "partition_mode", "Selects how afterglow cells are divided between preparation tasks.">, Prop_Field<&Afterglow::Prop::color_map, "color_map", "Maps accumulated energy values to rendered colors.">, - Prop_Field<&Afterglow::Prop::spectra, "spectra", "Spectrum history currently retained for afterglow rendering.">, State_Field, State_Field, State_Field>( @@ -576,8 +676,9 @@ std::shared_ptr make_waterfall_plot(asio::any_io_executor executor) { .add_renderable(selection.get()) .add_dependency(selection.get(), waterfall.get()) .build(); - auto update = [scene = scene.get(), raw = waterfall.get(), frequency = frequency.get(), time = time.get()](const Plot_Frame_Request& event) { + auto update = [scene = scene.get(), raw = waterfall.get(), frequency = frequency.get(), time = time.get()](const Plot_Frame_Request& event, bool demo_data) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, frequency, time); + if (!demo_data) return; std::array values{}; for (std::size_t i = 0; i < values.size(); ++i) values[i] = -100.0 + 70.0 * std::exp(-240.0 * std::pow( @@ -603,7 +704,6 @@ std::shared_ptr make_waterfall_plot(asio::any_io_executor executor) { Prop_Field<&Waterfall::Prop::partition_mode, "partition_mode", "Selects how waterfall rows are divided between preparation tasks.">, Prop_Field<&Waterfall::Prop::interpolation_mode, "interpolation_mode", "Selects interpolation when samples are mapped to raster cells.">, Prop_Field<&Waterfall::Prop::color_map, "color_map", "Maps sample power values to waterfall colors.">, - Prop_Field<&Waterfall::Prop::rows, "rows", "Time-ordered collection of spectrum rows retained by the waterfall.">, State_Field, State_Field, State_Field>( @@ -629,8 +729,9 @@ std::shared_ptr make_constellation_plot(asio::any_io_executor executor) { .add_renderable(selection.get()) .add_dependency(selection.get(), constellation.get()) .build(); - auto update = [scene = scene.get(), raw = constellation.get(), horizontal = horizontal.get(), vertical = vertical.get()](const Plot_Frame_Request& event) { + auto update = [scene = scene.get(), raw = constellation.get(), horizontal = horizontal.get(), vertical = vertical.get()](const Plot_Frame_Request& event, bool demo_data) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, horizontal, vertical); + if (!demo_data) return; const auto& state = raw->template read_prop(); const int anchor_count = static_cast(state.type); const double radius = std::min(state.i_range.size(), state.q_range.size()) * 0.4; @@ -656,7 +757,6 @@ std::shared_ptr make_constellation_plot(asio::any_io_executor executor) { Prop_Field<&Constellation_Diagram::Prop::q_range, "q_range", "Defines the vertical quadrature coordinate interval.">, Prop_Field<&Constellation_Diagram::Prop::point_color, "point_color", "Color used to render received I/Q samples.">, Prop_Field<&Constellation_Diagram::Prop::anchor_color, "anchor_color", "Color used to render ideal modulation anchors.">, - Prop_Field<&Constellation_Diagram::Prop::points, "points", "Current time-stamped collection of received I/Q samples.">, State_Field>( *constellation, *scene, std::move(update), std::move(horizontal), std::move(vertical), std::move(constellation), std::move(selection)); return std::make_shared(std::move(executor), std::move(scene), std::move(view)); @@ -674,7 +774,7 @@ std::shared_ptr make_selection_overlay_plot(asio::any_io_executor executor .set(&Render_Scene_2D::Prop::view_active, true) .add_renderable(selection.get()) .build(); - auto update = [scene = scene.get(), horizontal = horizontal.get(), vertical = vertical.get()](const Plot_Frame_Request& event) { + auto update = [scene = scene.get(), horizontal = horizontal.get(), vertical = vertical.get()](const Plot_Frame_Request& event, bool) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, horizontal, vertical); }; auto view = make_scene_view< @@ -682,7 +782,6 @@ std::shared_ptr make_selection_overlay_plot(asio::any_io_executor executor Prop_Field<&Selection_Rectangle_Overlay::Prop::label_pen, "label_pen", "Pen used to draw selected-region label text.">, Prop_Field<&Selection_Rectangle_Overlay::Prop::selection_brush, "selection_brush", "Brush used to fill selected rectangular regions.">, Prop_Field<&Selection_Rectangle_Overlay::Prop::selection_border_pen, "selection_border_pen", "Pen used to draw selected-region borders.">, - Prop_Field<&Selection_Rectangle_Overlay::Prop::selected_regions, "selected_regions", "Collection of selected rectangles expressed in axis coordinates.">, State_Field #include #include +#include #include #include #include @@ -15,19 +16,181 @@ 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)}; }; +using Json = nlohmann::json; + +Json number_field(std::string key, std::string label, std::string description, + double value, double minimum, double maximum, double step) { + return {{"key", std::move(key)}, {"label", std::move(label)}, {"description", std::move(description)}, + {"editor", "number"}, {"editable", true}, {"value", value}, + {"minimum", minimum}, {"maximum", maximum}, {"step", step}}; +} + +Json integer_field(std::string key, std::string label, std::string description, + std::size_t value, std::size_t minimum, std::size_t maximum) { + auto field = number_field(std::move(key), std::move(label), std::move(description), + static_cast(value), static_cast(minimum), + static_cast(maximum), 1.0); + field["editor"] = "integer"; + return field; +} + +template +Json generator_schema() { + Json fields = Json::array(); + if constexpr (std::same_as) { + fields.push_back(integer_field("width", "体数据宽度", "体素网格 X 方向尺寸;总量为宽×高×深。", 64, 1, 256)); + fields.push_back(integer_field("height", "体数据高度", "体素网格 Y 方向尺寸;总量为宽×高×深。", 64, 1, 256)); + fields.push_back(integer_field("depth", "体数据深度", "体素网格 Z 方向尺寸;总量为宽×高×深。", 64, 1, 256)); + fields.push_back(number_field("value_min", "体素值下界", "每个体素随机标量值的下界。", 0.0, -1'000'000.0, 1'000'000.0, 0.01)); + fields.push_back(number_field("value_max", "体素值上界", "每个体素随机标量值的上界,必须大于下界。", 1.0, -1'000'000.0, 1'000'000.0, 0.01)); + return {{"label", "生成体素标量场"}, {"description", "按三维网格尺寸生成连续体数据,不使用随机位置。"}, {"fields", std::move(fields)}}; + } + fields.push_back(integer_field("count", "图元数量", "本次替换到 Visual 的图元数量。", 10'000, 1, 1'000'000)); + fields.push_back(number_field("x_min", "Scene X 下界", "随机位置在 Scene X 轴上的下界。", -1.0, -1'000'000.0, 1'000'000.0, 0.01)); + fields.push_back(number_field("x_max", "Scene X 上界", "随机位置在 Scene X 轴上的上界,必须大于下界。", 1.0, -1'000'000.0, 1'000'000.0, 0.01)); + fields.push_back(number_field("y_min", "Scene Y 下界", "随机位置在 Scene Y 轴上的下界。", -1.0, -1'000'000.0, 1'000'000.0, 0.01)); + fields.push_back(number_field("y_max", "Scene Y 上界", "随机位置在 Scene Y 轴上的上界,必须大于下界。", 1.0, -1'000'000.0, 1'000'000.0, 0.01)); + fields.push_back(number_field("z_min", "Scene Z 下界", "随机位置在 Scene Z 轴上的下界。", -1.0, -1'000'000.0, 1'000'000.0, 0.01)); + fields.push_back(number_field("z_max", "Scene Z 上界", "随机位置在 Scene Z 轴上的上界,必须大于下界。", 1.0, -1'000'000.0, 1'000'000.0, 0.01)); + std::string label{"生成三维图元"}; + std::string description{"按各轴独立范围随机生成 Scene 坐标。"}; + if constexpr (std::same_as) { + label = "生成三维点"; + fields.push_back(number_field("diameter_min", "点直径下界", "随机点直径下界,单位为屏幕像素。", 2.0, 0.1, 4096.0, 0.1)); + fields.push_back(number_field("diameter_max", "点直径上界", "随机点直径上界,单位为屏幕像素。", 12.0, 0.1, 4096.0, 0.1)); + } else if constexpr (std::same_as) { + label = "生成三维高斯 Splat"; + fields.push_back(number_field("sigma_min", "标准差下界", "高斯主轴标准差下界,使用 Scene 坐标。", 0.01, 0.0001, 1000.0, 0.001)); + fields.push_back(number_field("sigma_max", "标准差上界", "高斯主轴标准差上界,使用 Scene 坐标。", 0.08, 0.0001, 1000.0, 0.001)); + fields.push_back(number_field("angle_min", "旋转角下界", "Splat 主轴旋转角下界,单位为弧度。", -3.14159, -1000.0, 1000.0, 0.01)); + fields.push_back(number_field("angle_max", "旋转角上界", "Splat 主轴旋转角上界,单位为弧度。", 3.14159, -1000.0, 1000.0, 0.01)); + } else if constexpr (std::same_as) { + label = "生成三维像素"; + fields.push_back(number_field("size_min", "像素边长下界", "方形像素边长下界,单位为屏幕像素。", 1.0, 0.1, 4096.0, 0.1)); + fields.push_back(number_field("size_max", "像素边长上界", "方形像素边长上界,单位为屏幕像素。", 6.0, 0.1, 4096.0, 0.1)); + } else if constexpr (std::same_as) { + label = "生成三维标记"; + fields.push_back(number_field("diameter_min", "标记直径下界", "标记直径下界,单位为屏幕像素。", 4.0, 0.1, 4096.0, 0.1)); + fields.push_back(number_field("diameter_max", "标记直径上界", "标记直径上界,单位为屏幕像素。", 18.0, 0.1, 4096.0, 0.1)); + } else if constexpr (std::same_as) { + label = "生成三维球体"; + fields.push_back(number_field("radius_min", "球体半径下界", "球体半径下界,使用 Scene 坐标。", 0.01, 0.0001, 1000.0, 0.001)); + fields.push_back(number_field("radius_max", "球体半径上界", "球体半径上界,使用 Scene 坐标。", 0.08, 0.0001, 1000.0, 0.001)); + } else if constexpr (std::same_as) { + label = "生成三维线段"; description = "起点和终点分别在各轴范围内随机生成。"; + fields.push_back(number_field("width_min", "线宽下界", "线段宽度下界,单位为屏幕像素。", 1.0, 0.1, 4096.0, 0.1)); + fields.push_back(number_field("width_max", "线宽上界", "线段宽度上界,单位为屏幕像素。", 5.0, 0.1, 4096.0, 0.1)); + } else if constexpr (std::same_as) { + label = "生成三维向量"; description = "原点按 Scene 范围随机生成,方向分量使用单独范围。"; + fields.push_back(number_field("direction_min", "方向分量下界", "向量 X/Y/Z 方向分量的随机下界。", -0.3, -1'000'000.0, 1'000'000.0, 0.01)); + fields.push_back(number_field("direction_max", "方向分量上界", "向量 X/Y/Z 方向分量的随机上界。", 0.3, -1'000'000.0, 1'000'000.0, 0.01)); + } else if constexpr (std::same_as) label = "生成三维图元顶点"; + else if constexpr (std::same_as) label = "生成三维网格顶点"; + else if constexpr (std::same_as) { + label = "生成三维路径顶点"; + fields.push_back(number_field("width_min", "路径宽度下界", "路径宽度下界,单位为屏幕像素。", 1.0, 0.1, 4096.0, 0.1)); + fields.push_back(number_field("width_max", "路径宽度上界", "路径宽度上界,单位为屏幕像素。", 5.0, 0.1, 4096.0, 0.1)); + } else if constexpr (std::same_as) { + label = "生成三维图像实例"; + fields.push_back(number_field("extent_min", "图像尺寸下界", "图像宽高的 Scene 坐标下界。", 0.02, 0.0001, 1000.0, 0.001)); + fields.push_back(number_field("extent_max", "图像尺寸上界", "图像宽高的 Scene 坐标上界。", 0.2, 0.0001, 1000.0, 0.001)); + } else if constexpr (std::same_as) { + label = "生成三维标签实例"; + fields.push_back(number_field("extent_min", "标签尺寸下界", "标签宽高的屏幕像素下界。", 8.0, 0.1, 4096.0, 0.1)); + fields.push_back(number_field("extent_max", "标签尺寸上界", "标签宽高的屏幕像素上界。", 48.0, 0.1, 4096.0, 0.1)); + } else if constexpr (std::same_as) { + label = "生成三维字形实例"; + fields.push_back(number_field("angle_min", "字形旋转下界", "字形旋转角下界,单位为弧度。", -3.14159, -1000.0, 1000.0, 0.01)); + fields.push_back(number_field("angle_max", "字形旋转上界", "字形旋转角上界,单位为弧度。", 3.14159, -1000.0, 1000.0, 0.01)); + } else if constexpr (std::same_as) { + label = "生成三维文本实例"; + fields.push_back(number_field("size_min", "字号下界", "随机文本字号下界,单位为屏幕像素。", 10.0, 0.1, 4096.0, 0.1)); + fields.push_back(number_field("size_max", "字号上界", "随机文本字号上界,单位为屏幕像素。", 28.0, 0.1, 4096.0, 0.1)); + } + return {{"label", std::move(label)}, {"description", std::move(description)}, {"fields", std::move(fields)}}; +} + +double input_number(const Json& input, std::string_view key) { + const auto& value = input.at(key); + if (!value.is_number()) throw std::invalid_argument(std::string(key) + " must be a number"); + const auto result = value.get(); + if (!std::isfinite(result)) throw std::invalid_argument(std::string(key) + " must be finite"); + return result; +} + +std::size_t input_count(const Json& input, std::string_view key, std::size_t maximum = 1'000'000) { + const auto value = input_number(input, key); + if (value < 1.0 || value > static_cast(maximum) || std::floor(value) != value) + throw std::invalid_argument(std::string(key) + " is outside the supported integer range"); + return static_cast(value); +} + +std::pair input_range(const Json& input, std::string_view minimum_key, + std::string_view maximum_key) { + const auto minimum = input_number(input, minimum_key); + const auto maximum = input_number(input, maximum_key); + if (minimum >= maximum) throw std::invalid_argument(std::string(maximum_key) + " must be greater than " + std::string(minimum_key)); + if (minimum < -std::numeric_limits::max() || maximum > std::numeric_limits::max()) + throw std::invalid_argument("generator range exceeds float coordinates"); + return {static_cast(minimum), static_cast(maximum)}; +} + +template +class Item_Randomizer { +public: + explicit Item_Randomizer(const Json& input) { + const auto [x_min, x_max] = input_range(input, "x_min", "x_max"); + const auto [y_min, y_max] = input_range(input, "y_min", "y_max"); + const auto [z_min, z_max] = input_range(input, "z_min", "z_max"); + x = std::uniform_real_distribution(x_min, x_max); + y = std::uniform_real_distribution(y_min, y_max); + z = std::uniform_real_distribution(z_min, z_max); + const auto set_first = [&](std::string_view minimum, std::string_view maximum) { + const auto [lower, upper] = input_range(input, minimum, maximum); + first = std::uniform_real_distribution(lower, upper); + }; + const auto set_second = [&](std::string_view minimum, std::string_view maximum) { + const auto [lower, upper] = input_range(input, minimum, maximum); + second = std::uniform_real_distribution(lower, upper); + }; + if constexpr (std::same_as || std::same_as) set_first("diameter_min", "diameter_max"); + else if constexpr (std::same_as) { set_first("sigma_min", "sigma_max"); set_second("angle_min", "angle_max"); } + else if constexpr (std::same_as || std::same_as) set_first("size_min", "size_max"); + else if constexpr (std::same_as) set_first("radius_min", "radius_max"); + else if constexpr (std::same_as || std::same_as) set_first("width_min", "width_max"); + else if constexpr (std::same_as) set_first("direction_min", "direction_max"); + else if constexpr (std::same_as || std::same_as) set_first("extent_min", "extent_max"); + else if constexpr (std::same_as) set_first("angle_min", "angle_max"); + } + void operator()(typename Definition::Item& item, std::mt19937_64& engine) { + const auto vector = [&] { return Vec3{x(engine), y(engine), z(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.start = vector(); item.end = vector(); }) { item.start = vector(); item.end = vector(); } + if constexpr (std::same_as) item.diameter_px = first(engine); + else if constexpr (std::same_as) { + item.sigma = {first(engine), first(engine)}; + item.angle = second(engine); + } else if constexpr (std::same_as) item.size_px = first(engine); + else if constexpr (std::same_as) item.diameter_px = first(engine); + else if constexpr (std::same_as) item.radius = first(engine); + else if constexpr (std::same_as) item.width_px = first(engine); + else if constexpr (std::same_as) { + item.direction = {first(engine), first(engine), first(engine)}; + } else if constexpr (std::same_as) item.width_px = first(engine); + else if constexpr (std::same_as || std::same_as) + item.extent = {first(engine), first(engine)}; + else if constexpr (std::same_as) item.angle = first(engine); + else if constexpr (std::same_as) item.size_px = first(engine); } - else if constexpr (requires { item.value = distribution(engine); }) item.value = distribution(engine); -} +private: + std::uniform_real_distribution x{}; + std::uniform_real_distribution y{}; + std::uniform_real_distribution z{}; + std::uniform_real_distribution first{}; + std::uniform_real_distribution second{}; +}; template class Visual_Scene_View final : public Plot::Scene_View { @@ -47,7 +210,6 @@ public: detail::Prop_Field<&Prop::transform, "transform", "World transform applied to the complete visual.">, detail::Prop_Field<&Prop::visible, "visible", "Whether the visual participates in rendering.">, detail::Prop_Field<&Prop::depth_test, "depth_test", "Whether fragments use depth testing.">, - detail::Prop_Field<&Prop::items, "items", "Editable item collection represented by this visual.">, detail::State_Field, detail::State_Field, detail::State_Field>; @@ -70,30 +232,54 @@ public: } [[nodiscard]] nlohmann::json data_generator_schema() const override { - return {{"label", "生成三维原始数据"}, - {"description", "复制当前图元样式并在给定 Scene 坐标范围内随机生成位置。"}, - {"count", 10000}, {"minimum", -1.0}, {"maximum", 1.0}}; + using Definition = typename Visual_Object::Attached_Object; + return generator_schema(); } - [[nodiscard]] nlohmann::json generate_data(std::size_t count, double minimum, double maximum) override { + [[nodiscard]] nlohmann::json generate_data(const nlohmann::json& input) 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)); + try { + std::mt19937_64 engine{std::random_device{}()}; + Items generated; + std::size_t count{}; + if constexpr (std::same_as) { + const auto width = input_count(input, "width", 256); + const auto height = input_count(input, "height", 256); + const auto depth = input_count(input, "depth", 256); + if (width > 1'000'000 / height || width * height > 1'000'000 / depth) + throw std::invalid_argument("volume dimensions exceed 1,000,000 voxels"); + count = width * height * depth; + const auto [minimum, maximum] = input_range(input, "value_min", "value_max"); + std::uniform_real_distribution distribution(minimum, maximum); + generated.resize(count); + for (auto& item : generated) item.value = distribution(engine); + visual_->template update_prop<&Prop::items>([&](auto props) { + auto& prop = props.template get(); + prop.field_width = static_cast(width); + prop.field_height = static_cast(height); + prop.field_depth = static_cast(depth); + }); + } else { + count = input_count(input, "count"); + const auto& current = visual_->template read_prop().items; + if (current.empty()) throw std::invalid_argument("visual has no item template"); + const auto prototype = current.front(); + Item_Randomizer randomize(input); + generated.reserve(count); + for (std::size_t index = 0; index < count; ++index) { + auto item = prototype; + randomize(item, engine); + generated.push_back(std::move(item)); + } + } + if (visual_->update_items(std::move(generated)) != Definition::Update_Items_Result::updated) + throw std::invalid_argument("generated items were rejected by the Visual validator"); + return {{"success", true}, {"generated_count", count}}; + } catch (const std::exception& error) { + return {{"success", false}, {"error", error.what()}}; } - 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 {} diff --git a/web_server/src/Plot.cpp b/web_server/src/Plot.cpp index 41c4277..9cf8f73 100644 --- a/web_server/src/Plot.cpp +++ b/web_server/src/Plot.cpp @@ -168,9 +168,7 @@ struct Prop_Write { Plot::Json_Handler handler; }; struct Data_Generation { - std::size_t count{}; - double minimum{}; - double maximum{}; + nlohmann::json input; Plot::Json_Handler handler; }; using Plot_Input = std::variant; @@ -385,8 +383,7 @@ void Plot::ensure_started() { continue; } if (auto* generation = std::get_if(&input)) { - generation->handler(self->d->view->generate_data( - generation->count, generation->minimum, generation->maximum)); + generation->handler(self->d->view->generate_data(generation->input)); continue; } auto submission = std::get(input); @@ -428,10 +425,10 @@ 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) { +void Plot::async_generate_data(nlohmann::json input, Json_Handler handler) { ensure_started(); if (!d->inputs.try_send(asio::error_code{}, Plot_Input{ - Data_Generation{count, minimum, maximum, std::move(handler)} + Data_Generation{std::move(input), 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 7f05316..ada1eb8 100644 --- a/web_server/src/Plot.hpp +++ b/web_server/src/Plot.hpp @@ -45,7 +45,7 @@ public: [[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; + [[nodiscard]] virtual nlohmann::json generate_data(const nlohmann::json& input) = 0; virtual void update(const Plot_Frame_Request& request) = 0; }; Plot(asio::any_io_executor executor, @@ -63,7 +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); + void async_generate_data(nlohmann::json input, 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 80f8ff8..97fc472 100644 --- a/web_server/src/Web_Server.cpp +++ b/web_server/src/Web_Server.cpp @@ -5,7 +5,6 @@ #include #include #include -#include #include #include #include @@ -139,23 +138,13 @@ int run_web_server(std::uint16_t port, const std::filesystem::path& asset_root) 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")); + if (!input.is_object()) { + callback(error_response(drogon::k400BadRequest, "data generation input must be an object")); return; } auto output = std::make_shared>( std::move(callback)); - plot->async_generate_data(count, minimum, maximum, [output](nlohmann::json result) { + plot->async_generate_data(std::move(input), [output](nlohmann::json result) { (*output)(json_response(std::move(result))); }); }, {drogon::Post}); diff --git a/webapp_gallery/src/app.tsx b/webapp_gallery/src/app.tsx index 5f0580e..392f1c5 100644 --- a/webapp_gallery/src/app.tsx +++ b/webapp_gallery/src/app.tsx @@ -17,9 +17,9 @@ type Plot = {id: string; title: string; category: string; description: string; d type Option = {value: string; label: string}; type Editor = "boolean" | "integer" | "number" | "text" | "select" | "color" | "point2" | "size" | "rect" | "range" | "pen" | "brush" | "font" | "color-map" | "vector3" | "matrix4" | "json"; 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; minimum?: number; maximum?: number; step?: number}; type Component = {id: string; label: string; kind: string; fields: Field[]; state: Record}; -type Data_Generator = {label: string; description: string; count: number; minimum: number; maximum: number}; +type Data_Generator = {label: string; description: string; fields: Field[]}; 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; @@ -128,27 +128,72 @@ function frame_stage_values(metadata: Frame_Metadata, request_started_at: number if (server_completion !== null) values.server_to_websocket_ready_ms = server_completion; for (const [key, value] of Object.entries(metadata.trace.measurements)) if (Number.isFinite(value)) values[key.endsWith("_ns") ? `${key.slice(0, -3)}_ms` : `${key}_ms`] = value / 1_000_000; + for (const [key, value] of Object.entries(metadata.trace.markers)) if (Number.isFinite(value)) + values[`marker_${key}_ms`] = value / 1_000_000; return values; } -function pipeline_stage_values(values: Frame_Stage_Values): Frame_Stage_Values { +function pipeline_stage_values(values: Frame_Stage_Values, dimension: Plot["dimension"]): 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; + let remaining = server; + const take = (requested: number) => { + const value = Math.min(remaining, Math.max(0, requested)); + remaining -= value; return value; }; + const interval = (start: string, finish: string) => Math.max(0, (values[`marker_${finish}_ms`] ?? 0) - (values[`marker_${start}_ms`] ?? 0)); 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, + const stages: Frame_Stage_Values = {pipeline_request_transport_ms: Math.max(0, request_to_metadata - server)}; + if (dimension === "2D") { + const scene = Math.max(0, values.scene_render_ms ?? 0); + const event = Math.min(scene, Math.max(0, values.event_dispatch_ms ?? 0)); + const prepare = Math.min(Math.max(0, scene - event), Math.max(0, values.prepare_ms ?? 0)); + const paint = Math.min(Math.max(0, scene - event - prepare), Math.max(0, values.paint_ms ?? 0)); + stages.pipeline_2d_event_ms = take(event); + stages.pipeline_2d_prepare_ms = take(prepare); + stages.pipeline_2d_paint_ms = take(paint); + stages.pipeline_2d_scene_coordination_ms = take(Math.max(0, scene - event - prepare - paint)); + stages.pipeline_2d_callback_ms = take(values.callback_ms ?? 0); + stages.pipeline_2d_encode_ms = take(values.websocket_encode_ms ?? 0); + stages.pipeline_2d_frame_handoff_ms = remaining; + remaining = 0; + } else { + const scene = Math.max(0, values.scene_render_ms ?? 0); + const event = Math.min(scene, Math.max(0, values.event_dispatch_ms ?? 0)); + const prepare = Math.min(Math.max(0, scene - event), Math.max(0, values.prepare_ms ?? 0)); + const paint = Math.min(Math.max(0, scene - event - prepare), Math.max(0, values.paint_ms ?? 0)); + stages.pipeline_3d_event_ms = take(event); + stages.pipeline_3d_prepare_ms = take(prepare); + stages.pipeline_3d_submit_graph_ms = take(paint); + stages.pipeline_3d_scene_coordination_ms = take(Math.max(0, scene - event - prepare - paint)); + const scene_finished = values.marker_scene_render_finished_ms ?? 0; + const queue_entered = values.marker_backend_queue_entered_ms ?? scene_finished; + const queue_left = values.marker_backend_queue_left_ms ?? scene_finished; + stages.pipeline_3d_backend_queue_ms = take(Math.max(0, queue_left - Math.max(scene_finished, queue_entered))); + const gpu_submitted = values.marker_gpu_submitted_ms ?? queue_left; + let backend_window = Math.max(0, gpu_submitted - queue_left); + const take_backend = (key: string) => { const value = Math.min(backend_window, Math.max(0, values[key] ?? 0)); backend_window -= value; return take(value); }; + stages.pipeline_3d_backend_apply_ms = take_backend("backend_apply_ms"); + stages.pipeline_3d_backend_plan_ms = take_backend("backend_plan_ms"); + stages.pipeline_3d_backend_execute_ms = take_backend("backend_execute_ms"); + stages.pipeline_3d_backend_submit_ms = take_backend("backend_submit_ms"); + stages.pipeline_3d_backend_commands_ms = take(backend_window); + let gpu_window = interval("gpu_submitted", "gpu_completed"); + const take_gpu = (key: string) => { const value = Math.min(gpu_window, Math.max(0, values[key] ?? 0)); gpu_window -= value; return take(value); }; + stages.pipeline_3d_gpu_render_ms = take_gpu("gpu_render_ms"); + stages.pipeline_3d_gpu_transition_ms = take_gpu("gpu_transition_ms"); + stages.pipeline_3d_gpu_copy_ms = take_gpu("gpu_copy_ms"); + stages.pipeline_3d_gpu_sync_ms = take(gpu_window); + stages.pipeline_3d_readback_ms = take(values.readback_stage_ms ?? values.readback_ms ?? 0); + stages.pipeline_3d_callback_ms = take(values.callback_ms ?? 0); + stages.pipeline_3d_encode_ms = take(values.websocket_encode_ms ?? 0); + stages.pipeline_3d_completion_handoff_ms = remaining; + remaining = 0; + } + return {...values, ...stages, 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) @@ -319,7 +364,7 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject 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}, plot.dimension)}); publish_diagnostics(false); }); presentation_callbacks.add(second); @@ -344,7 +389,7 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject { const canvas = canvas_ref.current; @@ -581,7 +626,7 @@ function Field_Control({field, on_change}: {field: Field; on_change: (value: unk if (field.editor === "select") return ; if (field.editor === "text") return ; - return ; + return ; } function Property_Control({field, on_commit}: {field: Field; on_commit: (value: unknown) => Promise}) { @@ -654,23 +699,54 @@ function State_Field_View({component, field, histories}: {component: Component; ; } -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 与回读。"], +type Pipeline_Stage_Definition = [string, string, string]; +const pipeline_common_start: Pipeline_Stage_Definition = ["pipeline_request_transport_ms", "请求传输", "浏览器发出帧请求到服务端创建 Render_Frame 之前的耗时。"]; +const pipeline_common_finish: Pipeline_Stage_Definition[] = [ ["pipeline_payload_transport_ms", "像素传输", "浏览器收到元数据后,直到完整像素载荷到达的耗时。"], - ["pipeline_canvas_upload_ms", "Canvas 上传", "浏览器把 RGBA 像素写入 Canvas 的耗时。"], + ["pipeline_canvas_upload_ms", "Canvas 写入", "浏览器把 RGBA 像素写入 Canvas 的耗时。"], ["pipeline_presentation_wait_ms", "呈现机会等待", "Canvas 写入后等待浏览器经过一次绘制机会的耗时。"] ]; +const pipeline_2d_definitions: Pipeline_Stage_Definition[] = [ + pipeline_common_start, + ["pipeline_2d_event_ms", "2D 事件分发", "Scene 将当前输入事件分发给二维 Renderable 的耗时。"], + ["pipeline_2d_prepare_ms", "2D 数据准备", "二维 Prepare 依赖图更新缓存、坐标映射和绘制数据的耗时。"], + ["pipeline_2d_paint_ms", "Blend2D 绘制", "二维 Paint 任务图清屏并写入 Blend2D 帧缓存的耗时。"], + ["pipeline_2d_scene_coordination_ms", "2D Scene 编排", "Scene 渲染区间内除事件、Prepare、Paint 外的依赖图编排耗时。"], + ["pipeline_2d_callback_ms", "2D 完成回调", "同步二维帧完成后回调到 Plot 发布线程的耗时。"], + ["pipeline_2d_encode_ms", "BGRA→RGBA 编码", "逐行把 Blend2D BGRA 帧缓存转换成 WebSocket RGBA 载荷的耗时。"], + ["pipeline_2d_frame_handoff_ms", "2D 帧建立与发布调度", "Render_Frame 建立、进入 Scene 以及编码完成后生成元数据的调度间隙。"], + ...pipeline_common_finish +]; +const pipeline_3d_definitions: Pipeline_Stage_Definition[] = [ + pipeline_common_start, + ["pipeline_3d_event_ms", "3D 事件入队", "Scene 将输入事件提交到三维 Render Domain 的耗时。"], + ["pipeline_3d_prepare_ms", "3D Visual Prepare", "将 Visual items 转换为不可变 Prepared_Visual GPU 字段的耗时;数据变化时执行。"], + ["pipeline_3d_submit_graph_ms", "3D Submit 图", "Submit 依赖图把 Prepared_Visual 入队到异步后端的耗时。"], + ["pipeline_3d_scene_coordination_ms", "3D Scene 编排", "三维 Scene 同步阶段中除事件、Prepare、Submit 外的依赖图编排耗时。"], + ["pipeline_3d_backend_queue_ms", "Render Domain 排队", "Scene 提交结束后等待单线程 Datoviz Render Domain 接管的耗时。"], + ["pipeline_3d_backend_apply_ms", "Datoviz Apply", "把本帧 Visual 与 Scene 参数应用到 Datoviz 对象的 CPU 耗时。"], + ["pipeline_3d_backend_plan_ms", "Datoviz Plan", "Datoviz 生成本帧 GPU 命令计划的 CPU 耗时。"], + ["pipeline_3d_backend_execute_ms", "Datoviz Execute", "Datoviz 执行命令构建的 CPU 耗时。"], + ["pipeline_3d_backend_submit_ms", "GPU 提交", "将已构建命令提交到 GPU 队列的 CPU 耗时。"], + ["pipeline_3d_backend_commands_ms", "后端命令衔接", "Render Domain 接管到 GPU 提交之间未落在四个 Datoviz trace 字段中的命令衔接时间。"], + ["pipeline_3d_gpu_render_ms", "GPU Render", "GPU 执行渲染通道的设备时间。"], + ["pipeline_3d_gpu_transition_ms", "GPU 资源转换", "GPU 图像布局和资源状态转换的设备时间。"], + ["pipeline_3d_gpu_copy_ms", "GPU 回读复制", "GPU 将渲染结果复制到可回读资源的设备时间。"], + ["pipeline_3d_gpu_sync_ms", "GPU 同步等待", "GPU 提交到完成区间扣除已测设备阶段后的 fence/调度时间。"], + ["pipeline_3d_readback_ms", "3D CPU 回读", "GPU 完成后由 Datoviz 收集并复制 RGBA 像素的耗时。"], + ["pipeline_3d_callback_ms", "3D 完成回调", "异步后端完成后回调到 Plot 发布线程的耗时。"], + ["pipeline_3d_encode_ms", "3D WebSocket 封装", "把连续 RGBA 像素封装为 WebSocket 消息并生成元数据的耗时。"], + ["pipeline_3d_completion_handoff_ms", "3D 完成调度衔接", "GPU 回读、回调与发布边界之间尚未由 trace marker 单独覆盖的调度衔接时间。"], + ...pipeline_common_finish +]; +const pipeline_definitions = (dimension: Plot["dimension"]) => dimension === "2D" ? pipeline_2d_definitions : pipeline_3d_definitions; 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, paused, on_context_menu}: {diagnostics: Frame_Diagnostics; paused: boolean; on_context_menu: (event: React.MouseEvent) => void}) { +function Frame_Timeline_Chart({diagnostics, dimension, paused, on_context_menu}: {diagnostics: Frame_Diagnostics; dimension: Plot["dimension"]; paused: boolean; on_context_menu: (event: React.MouseEvent) => void}) { const host_ref = useRef(null); const chart_ref = useRef(null); useEffect(() => { @@ -685,12 +761,18 @@ function Frame_Timeline_Chart({diagnostics, paused, on_context_menu}: {diagnosti const chart = chart_ref.current; if (!chart) return; const visible_samples = diagnostics.samples; - const series_keys: Array<[string, string, string]> = [ + const series_keys: Array<[string, string, string]> = dimension === "2D" ? [ ["request_to_presentation_opportunity_ms", "完整流水线", "#5ce4c2"], - ["pipeline_scene_ms", "Scene", "#62a8ff"], - ["pipeline_server_other_ms", "服务端其余", "#f4bd63"], + ["pipeline_2d_prepare_ms", "2D Prepare", "#62a8ff"], + ["pipeline_2d_paint_ms", "Blend2D 绘制", "#f4bd63"], ["pipeline_payload_transport_ms", "像素传输", "#ff7d9c"], ["pipeline_presentation_wait_ms", "呈现等待", "#b998ff"] + ] : [ + ["request_to_presentation_opportunity_ms", "完整流水线", "#5ce4c2"], + ["pipeline_3d_prepare_ms", "Visual Prepare", "#62a8ff"], + ["pipeline_3d_backend_queue_ms", "后端排队", "#f4bd63"], + ["pipeline_3d_gpu_render_ms", "GPU Render", "#ff7d9c"], + ["pipeline_3d_readback_ms", "CPU 回读", "#b998ff"] ]; chart.setOption({ backgroundColor: "transparent", @@ -705,14 +787,14 @@ function Frame_Timeline_Chart({diagnostics, paused, on_context_menu}: {diagnosti 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, paused]); + }, [diagnostics, dimension, paused]); return
{paused ? 已暂停视图 · 采样仍在继续 : null}
; } -function Frame_Diagnostics_View({diagnostics, on_reset}: {diagnostics: Frame_Diagnostics | null; on_reset: () => void}) { +function Frame_Diagnostics_View({diagnostics, dimension, on_reset}: {diagnostics: Frame_Diagnostics | null; dimension: Plot["dimension"]; 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"); @@ -727,7 +809,8 @@ function Frame_Diagnostics_View({diagnostics, on_reset}: {diagnostics: Frame_Dia 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 definitions = pipeline_definitions(dimension); + const stage_values = 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; }); @@ -756,7 +839,7 @@ function Frame_Diagnostics_View({diagnostics, on_reset}: {diagnostics: Frame_Dia return
当前区间 {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 => @@ -764,6 +847,7 @@ function Frame_Diagnostics_View({diagnostics, on_reset}: {diagnostics: Frame_Dia
{(["value", "percentage"] as Stage_Unit[]).map(unit => )}
+
浏览器请求{definitions.map(([key, label, description]) => {label})}浏览器呈现
{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)}
)}
@@ -809,52 +893,52 @@ function State_Pane({plot, schema, busy, on_refresh, histories}: {plot: Plot; sc } 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 defaults = () => Object.fromEntries(generator.fields.map(field => [field.key, field.value])); + const [input, set_input] = useState>(defaults); 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 generator_signature = JSON.stringify(generator.fields.map(field => [field.key, field.value])); + useEffect(() => { set_input(defaults()); set_status(""); }, [plot.id, generator_signature]); 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}) + method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(input) }); 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")} 条数据,并从头统计。`); + set_status(`已生成 ${(result.generated_count ?? 0).toLocaleString("zh-CN")} 条数据,并从头统计。`); } catch (error) { set_status(error instanceof Error ? error.message : "生成原始数据失败"); } finally { set_busy(false); } }; return
{generator.label}{generator.description}
-
- - - - -
{status ?

{status}

: null}
; +
{generator.fields.map(field => set_input(current => ({...current, [field.key]: value}))}/>)}
+
+ {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; +function Frame_Policy_Pane({plot, analysis, busy, on_refresh, on_update, on_manual_frame, on_camera_reset, on_reset}: { + plot: Plot; analysis: Frame_Analysis | 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(); }}/> - :
原始数据生成

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

} - -
-
; + return
+
持续采样控制采样独立于图形面板可见性;暂停统计图不会停止采样。
+
{plot.dimension === "3D" ? : null}
+ {analysis ?
{fields.map(field => on_update(analysis, field, value)}/>)}
:

正在读取帧策略…

} +
; +} + +function Data_Generation_Pane({plot, analysis, busy, on_refresh, on_generated}: {plot: Plot; analysis: Frame_Analysis | null; busy: boolean; on_refresh: () => void; on_generated: () => void}) { + return
+
{analysis?.data_generator ? + :
当前图形没有原始数据生成能力

坐标轴等结构组件不拥有独立原始数据集合。

}
; +} + +function Frame_Statistics_Pane({plot, diagnostics, busy, on_refresh, on_reset}: {plot: Plot; diagnostics: Frame_Diagnostics | null; busy: boolean; on_refresh: () => void; on_reset: () => void}) { + return
+
; } const Plot_Card = memo(function Plot_Card({plot, selected, on_select}: {plot: Plot; selected: boolean; on_select: (plot: Plot) => void}) { @@ -924,7 +1008,7 @@ function Gallery_Grid({plots, selected, on_select}: {plots: Plot[]; selected: Pl : null}
; } -const workspace_layout_key = "aethera-flexlayout-v2"; +const workspace_layout_key = "aethera-flexlayout-v3"; const layout_labels: Record = { [I18nLabel.Close_Tab]: "关闭标签", [I18nLabel.Pinned_Tab]: "已固定", @@ -960,19 +1044,18 @@ const layout_labels: Record = { [I18nLabel.Menu_Close_Others]: "关闭其他标签" }; const default_workspace_layout: IJsonModel = { - global: {tabEnableRename: false, tabSetEnableMaximize: true, tabEnablePopout: false, rootOrientationVertical: true}, + global: {tabEnableRename: false, tabSetEnableMaximize: true, tabEnablePopout: false}, borders: [], layout: {type: "row", children: [ - {type: "row", weight: 62, children: [ - {type: "tabset", id: "gallery-set", weight: 54, minWidth: 360, children: [ - {type: "tab", id: "gallery-tab", name: "图形组件", component: "gallery", enableClose: false, enableScrollbars: false, minWidth: 320, minHeight: 240}]}, - {type: "tabset", id: "properties-set", weight: 28, minWidth: 280, children: [ - {type: "tab", id: "properties-tab", name: "属性编辑", component: "properties", enableClose: false, enableScrollbars: false, minWidth: 260, minHeight: 220}]}, - {type: "tabset", id: "state-set", weight: 18, minWidth: 260, children: [ - {type: "tab", id: "state-tab", name: "运行状态", component: "state", enableClose: false, enableScrollbars: false, minWidth: 240, minHeight: 220}]} - ]}, - {type: "tabset", id: "frame-analysis-set", weight: 38, minHeight: 280, children: [ - {type: "tab", id: "frame-analysis-tab", name: "渲染性能实验室", component: "frame-analysis", enableClose: false, enableScrollbars: false, minHeight: 260}]} + {type: "tabset", id: "gallery-set", weight: 60, minWidth: 420, children: [ + {type: "tab", id: "gallery-tab", name: "图形组件", component: "gallery", enableClose: false, enableScrollbars: false, minWidth: 360, minHeight: 240}]}, + {type: "tabset", id: "inspector-set", weight: 40, minWidth: 380, children: [ + {type: "tab", id: "properties-tab", name: "属性编辑", component: "properties", enableClose: false, enableScrollbars: false, minWidth: 320, minHeight: 240}, + {type: "tab", id: "state-tab", name: "运行状态", component: "state", enableClose: false, enableScrollbars: false, minWidth: 320, minHeight: 240}, + {type: "tab", id: "frame-policy-tab", name: "采样与帧策略", component: "frame-policy", enableClose: false, enableScrollbars: false, minWidth: 320, minHeight: 240}, + {type: "tab", id: "data-generation-tab", name: "原始数据生成", component: "data-generation", enableClose: false, enableScrollbars: false, minWidth: 320, minHeight: 240}, + {type: "tab", id: "frame-statistics-tab", name: "帧流水线统计", component: "frame-statistics", enableClose: false, enableScrollbars: false, minWidth: 360, minHeight: 260} + ]} ]} }; @@ -1055,9 +1138,11 @@ export function App() { if (!selected) return
请选择一个图形组件。
; if (node.getComponent() === "properties") return ; if (node.getComponent() === "state") return ; - if (node.getComponent() === "frame-analysis") return