This commit is contained in:
2026-08-22 11:34:52 +08:00
parent 945e8287d4
commit 049516bf3b
7 changed files with 576 additions and 223 deletions
+188 -89
View File
@@ -24,9 +24,13 @@ using Selection_Object = Impl<Selection_Rectangle_Overlay>;
template <typename... Owned_Objects>
class Scene_View_Model final : public Plot::Scene_View {
public:
using Data_Generator = std::function<nlohmann::json(std::size_t, double, double)>;
struct Data_Generator {
nlohmann::json schema;
std::function<nlohmann::json(const nlohmann::json&)> generate;
explicit operator bool() const noexcept { return static_cast<bool>(generate); }
};
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&, bool)> 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<std::unique_ptr<detail::Renderable_Descriptor>> descriptors;
std::function<void(const Plot_Frame_Request&)> update_scene;
std::function<void(const Plot_Frame_Request&, bool)> update_scene;
Data_Generator data_generator;
bool generated_data_active{}; /* true 后保留用户生成的数据,不再用演示输入覆盖;viewport 更新仍持续。 */
std::tuple<Owned_Objects...> objects;
};
template <auto Member, structive::Fixed_String Key, structive::Fixed_String Description>
@@ -151,92 +156,188 @@ std::unique_ptr<detail::Renderable_Descriptor> make_axis_component<Time_Axis_Obj
State_Field<Time_Axis, &Time_Axis::State::samples, "samples", "Published time sample window.">>(
std::move(id), std::move(label), "axis", axis);
}
template <typename Object>
nlohmann::json generate_2d_data(Object& object, std::size_t count, double minimum, double maximum) {
using Definition = typename Object::Attached_Object;
std::mt19937_64 engine{std::random_device{}()};
std::uniform_real_distribution<double> distribution(minimum, maximum);
const auto values = [&] {
std::vector<Plot_Value> result(count);
std::ranges::generate(result, [&] { return distribution(engine); });
return result;
};
if constexpr (std::same_as<Definition, Spectrum>) {
object.update_samples(values());
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}};
}
else if constexpr (std::same_as<Definition, Frequency_Trace>) {
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<double>(value), 1.0, static_cast<double>(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<double>();
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<double>(maximum) || std::floor(value) != value)
throw std::invalid_argument(std::string(key) + " is outside the supported integer range");
return static_cast<std::size_t>(value);
}
std::pair<double, double> 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 <typename Definition>
Json generator_2d_schema() {
Json fields = Json::array();
std::string label;
std::string description;
if constexpr (std::same_as<Definition, Spectrum>) {
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<Definition, Frequency_Trace>) {
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<Definition, Sweep_Spectrum>) {
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<Definition, Afterglow>) {
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<Definition, Waterfall>) {
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<Definition, Constellation_Diagram>) {
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<Definition, Selection_Rectangle_Overlay>) {
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));
}
return {{"label", std::move(label)}, {"description", std::move(description)}, {"fields", std::move(fields)}};
}
template <typename Object>
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<Definition, Spectrum>) {
const auto count = generator_count(input, "sample_count");
const auto [minimum, maximum] = generator_range(input, "power_min", "power_max");
std::uniform_real_distribution<double> distribution(minimum, maximum);
std::vector<Plot_Value> values(count);
std::ranges::generate(values, [&] { return distribution(engine); });
object.update_samples(values);
generated_count = count;
} else if constexpr (std::same_as<Definition, Frequency_Trace>) {
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<double> distribution(minimum, maximum);
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)};
samples[index] = {static_cast<Plot_Time_Tick>(index * tick_step), 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));
}
generated_count = count;
} else if constexpr (std::same_as<Definition, Sweep_Spectrum>) {
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<double> distribution(minimum, maximum);
std::vector<std::vector<Plot_Value>> blocks(block_count, std::vector<Plot_Value>(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<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;
}
generated_count = block_count * width;
} else if constexpr (std::same_as<Definition, Afterglow>) {
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<double> distribution(minimum, maximum);
std::vector<std::vector<Plot_Value>> spectra(row_count, std::vector<Plot_Value>(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<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;
generated_count = row_count * width;
} else if constexpr (std::same_as<Definition, Waterfall>) {
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<double> distribution(minimum, maximum);
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);
for (std::size_t row = 0; row < row_count; ++row) {
std::vector<Plot_Value> row_values(width);
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::frequency_bin_count>(width);
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>{});
generated_count = row_count * width;
} else if constexpr (std::same_as<Definition, Constellation_Diagram>) {
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<double> i_distribution(i_min, i_max), q_distribution(q_min, q_max);
std::vector<Constellation_Point> 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<Definition, Selection_Rectangle_Overlay>) {
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<Definition, Selection_Rectangle_Overlay>) {
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<double> x_distribution(x_min, x_max), y_distribution(y_min, y_max);
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)}};
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 <typename... Fields, typename Object, typename... Owned_Objects>
std::unique_ptr<Plot::Scene_View> make_scene_view(
Object& object,
Scene_2D& scene,
std::function<void(const Plot_Frame_Request&)> update,
std::function<void(const Plot_Frame_Request&, bool)> update,
Owned_Objects&&... owned_objects) {
using Definition = typename Object::Attached_Object;
using Tag = typename Definition::Base_Tag;
@@ -259,7 +360,6 @@ std::unique_ptr<Plot::Scene_View> 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_Rectangle_Overlay, &Selection_Rectangle_Overlay::State::selected_region_count, "selected_region_count", "Number of rectangular regions currently selected.">>("selection", "矩形选区", "overlay", *owned));
}
};
@@ -267,9 +367,8 @@ std::unique_ptr<Plot::Scene_View> make_scene_view(
return std::make_unique<Scene_View_Model<std::remove_cvref_t<Owned_Objects>...>>(
std::move(components),
std::move(update),
[&object](std::size_t count, double minimum, double maximum) {
return generate_2d_data(object, count, minimum, maximum);
},
typename Scene_View_Model<std::remove_cvref_t<Owned_Objects>...>::Data_Generator{
generator_2d_schema<Definition>(), [&object](const nlohmann::json& input) { return generate_2d_data(object, input); }},
std::forward<Owned_Objects>(owned_objects)...);
}
std::unique_ptr<Frequency_Axis_Object> make_frequency_axis() {
@@ -349,7 +448,7 @@ std::shared_ptr<Plot> make_axes_plot(asio::any_io_executor executor) {
.add_dependency_node<Paint_Tag>(numeric.get())
.add_dependency_node<Paint_Tag>(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<int>(event.width), static_cast<int>(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<Plot> make_spectrum_plot(asio::any_io_executor executor) {
.add_renderable(selection.get())
.add_dependency<Paint_Tag>(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<int>(event.width), static_cast<int>(event.height)}, frequency, vertical);
if (!demo_data) return;
std::array<double, 256> samples{};
for (std::size_t i = 0; i < samples.size(); ++i) {
const double x = static_cast<double>(i) / samples.size();
@@ -443,8 +543,9 @@ std::shared_ptr<Plot> make_frequency_trace_plot(asio::any_io_executor executor)
.add_renderable(selection.get())
.add_dependency<Paint_Tag>(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<int>(event.width), static_cast<int>(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<std::int64_t>(
@@ -458,7 +559,6 @@ std::shared_ptr<Plot> 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<Frequency_Trace, &Frequency_Trace::State::sample_count, "sample_count", "Number of samples retained by the current trace.">,
State_Field<Frequency_Trace, &Frequency_Trace::State::rendered_point_count, "rendered_point_count", "Number of points emitted for the latest trace frame.">>(
*trace, *scene, std::move(update), std::move(time), std::move(vertical), std::move(trace), std::move(selection));
@@ -483,8 +583,9 @@ std::shared_ptr<Plot> make_sweep_spectrum_plot(asio::any_io_executor executor) {
.add_renderable(selection.get())
.add_dependency<Paint_Tag>(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<int>(event.width), static_cast<int>(event.height)}, frequency, vertical);
if (!demo_data) return;
const auto& state = raw->template read_prop<Sweep_Spectrum::Base_Tag>();
const std::size_t block_count = std::max<std::size_t>(1, state.block_count);
const std::size_t bins_per_block = std::max<std::size_t>(1, state.bins_per_block);
@@ -506,7 +607,6 @@ std::shared_ptr<Plot> 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<Sweep_Spectrum, &Sweep_Spectrum::State::stored_block_count, "stored_block_count", "Number of frequency segments that currently contain data.">,
State_Field<Sweep_Spectrum, &Sweep_Spectrum::State::stored_point_count, "stored_point_count", "Total number of points retained by the single composite sweep curve.">,
State_Field<Sweep_Spectrum, &Sweep_Spectrum::State::rendered_point_count, "rendered_point_count", "Number of points emitted for the single composite sweep curve.">>(
@@ -532,8 +632,9 @@ std::shared_ptr<Plot> make_afterglow_plot(asio::any_io_executor executor) {
.add_renderable(selection.get())
.add_dependency<Paint_Tag>(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<int>(event.width), static_cast<int>(event.height)}, frequency, vertical);
if (!demo_data) return;
std::array<double, 192> 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<Plot> 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<Afterglow, &Afterglow::State::history_count, "history_count", "Number of spectrum frames retained in afterglow history.">,
State_Field<Afterglow, &Afterglow::State::latest_spectrum_point_count, "latest_spectrum_point_count", "Number of samples in the most recently appended spectrum.">,
State_Field<Afterglow, &Afterglow::State::rendered_cell_count, "rendered_cell_count", "Number of colored cells emitted for the latest frame.">>(
@@ -576,8 +676,9 @@ std::shared_ptr<Plot> make_waterfall_plot(asio::any_io_executor executor) {
.add_renderable(selection.get())
.add_dependency<Paint_Tag>(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<int>(event.width), static_cast<int>(event.height)}, frequency, time);
if (!demo_data) return;
std::array<double, 192> 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<Plot> 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<Waterfall, &Waterfall::State::row_count, "row_count", "Number of waterfall rows currently retained.">,
State_Field<Waterfall, &Waterfall::State::stored_point_count, "stored_point_count", "Total number of spectrum points retained across all rows.">,
State_Field<Waterfall, &Waterfall::State::rendered_cell_count, "rendered_cell_count", "Number of raster cells emitted for the latest frame.">>(
@@ -629,8 +729,9 @@ std::shared_ptr<Plot> make_constellation_plot(asio::any_io_executor executor) {
.add_renderable(selection.get())
.add_dependency<Paint_Tag>(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<int>(event.width), static_cast<int>(event.height)}, horizontal, vertical);
if (!demo_data) return;
const auto& state = raw->template read_prop<Constellation_Diagram::Base_Tag>();
const int anchor_count = static_cast<int>(state.type);
const double radius = std::min(state.i_range.size(), state.q_range.size()) * 0.4;
@@ -656,7 +757,6 @@ std::shared_ptr<Plot> 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_Diagram, &Constellation_Diagram::State::point_count, "point_count", "Number of constellation samples currently retained.">>(
*constellation, *scene, std::move(update), std::move(horizontal), std::move(vertical), std::move(constellation), std::move(selection));
return std::make_shared<Plot>(std::move(executor), std::move(scene), std::move(view));
@@ -674,7 +774,7 @@ std::shared_ptr<Plot> 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<int>(event.width), static_cast<int>(event.height)}, horizontal, vertical);
};
auto view = make_scene_view<
@@ -682,7 +782,6 @@ std::shared_ptr<Plot> 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<Selection_Rectangle_Overlay,
&Selection_Rectangle_Overlay::State::selected_region_count,
"selected_region_count",
+209 -23
View File
@@ -3,6 +3,7 @@
#include <render_3D/Render_3D.hpp>
#include <algorithm>
#include <cmath>
#include <limits>
#include <numbers>
#include <random>
#include <stdexcept>
@@ -15,19 +16,181 @@ namespace {
using namespace render_3d;
using Scene_3D = Impl<Render_Scene_3D>;
template <typename Item>
void randomize_item(Item& item, std::uniform_real_distribution<float>& distribution,
std::mt19937_64& engine) {
const auto vector = [&] { return Vec3{distribution(engine), distribution(engine), distribution(engine)}; };
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<double>(value), static_cast<double>(minimum),
static_cast<double>(maximum), 1.0);
field["editor"] = "integer";
return field;
}
template <typename Definition>
Json generator_schema() {
Json fields = Json::array();
if constexpr (std::same_as<Definition, Volume_Visual>) {
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<Definition, Point_Visual>) {
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<Definition, Splat_Visual>) {
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<Definition, Pixel_Visual>) {
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<Definition, Marker_Visual>) {
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<Definition, Sphere_Visual>) {
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<Definition, Segment_Visual>) {
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<Definition, Vector_Visual>) {
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<Definition, Primitive_Visual>) label = "生成三维图元顶点";
else if constexpr (std::same_as<Definition, Mesh_Visual>) label = "生成三维网格顶点";
else if constexpr (std::same_as<Definition, Path_Visual>) {
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<Definition, Image_Visual>) {
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<Definition, Labels_Visual>) {
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<Definition, Glyph_Visual>) {
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<Definition, Text_Visual>) {
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<double>();
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<double>(maximum) || std::floor(value) != value)
throw std::invalid_argument(std::string(key) + " is outside the supported integer range");
return static_cast<std::size_t>(value);
}
std::pair<float, float> 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<float>::max() || maximum > std::numeric_limits<float>::max())
throw std::invalid_argument("generator range exceeds float coordinates");
return {static_cast<float>(minimum), static_cast<float>(maximum)};
}
template <typename Definition>
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<float>(x_min, x_max);
y = std::uniform_real_distribution<float>(y_min, y_max);
z = std::uniform_real_distribution<float>(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<float>(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<float>(lower, upper);
};
if constexpr (std::same_as<Definition, Point_Visual> || std::same_as<Definition, Marker_Visual>) set_first("diameter_min", "diameter_max");
else if constexpr (std::same_as<Definition, Splat_Visual>) { set_first("sigma_min", "sigma_max"); set_second("angle_min", "angle_max"); }
else if constexpr (std::same_as<Definition, Pixel_Visual> || std::same_as<Definition, Text_Visual>) set_first("size_min", "size_max");
else if constexpr (std::same_as<Definition, Sphere_Visual>) set_first("radius_min", "radius_max");
else if constexpr (std::same_as<Definition, Segment_Visual> || std::same_as<Definition, Path_Visual>) set_first("width_min", "width_max");
else if constexpr (std::same_as<Definition, Vector_Visual>) set_first("direction_min", "direction_max");
else if constexpr (std::same_as<Definition, Image_Visual> || std::same_as<Definition, Labels_Visual>) set_first("extent_min", "extent_max");
else if constexpr (std::same_as<Definition, Glyph_Visual>) 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.value = distribution(engine); }) item.value = distribution(engine);
else if constexpr (requires { item.start = vector(); item.end = vector(); }) { item.start = vector(); item.end = vector(); }
if constexpr (std::same_as<Definition, Point_Visual>) item.diameter_px = first(engine);
else if constexpr (std::same_as<Definition, Splat_Visual>) {
item.sigma = {first(engine), first(engine)};
item.angle = second(engine);
} else if constexpr (std::same_as<Definition, Pixel_Visual>) item.size_px = first(engine);
else if constexpr (std::same_as<Definition, Marker_Visual>) item.diameter_px = first(engine);
else if constexpr (std::same_as<Definition, Sphere_Visual>) item.radius = first(engine);
else if constexpr (std::same_as<Definition, Segment_Visual>) item.width_px = first(engine);
else if constexpr (std::same_as<Definition, Vector_Visual>) {
item.direction = {first(engine), first(engine), first(engine)};
} else if constexpr (std::same_as<Definition, Path_Visual>) item.width_px = first(engine);
else if constexpr (std::same_as<Definition, Image_Visual> || std::same_as<Definition, Labels_Visual>)
item.extent = {first(engine), first(engine)};
else if constexpr (std::same_as<Definition, Glyph_Visual>) item.angle = first(engine);
else if constexpr (std::same_as<Definition, Text_Visual>) item.size_px = first(engine);
}
private:
std::uniform_real_distribution<float> x{};
std::uniform_real_distribution<float> y{};
std::uniform_real_distribution<float> z{};
std::uniform_real_distribution<float> first{};
std::uniform_real_distribution<float> second{};
};
template <typename Visual_Object>
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<Definition::Base_Tag, &State::item_count, "item_count", "Number of published input items.">,
detail::State_Field<Definition::Base_Tag, &State::prepared_item_count, "prepared_item_count", "Number of prepared backend items.">,
detail::State_Field<Definition::Base_Tag, &State::prepared_revision, "prepared_revision", "Property revision represented by prepared GPU data.">>;
@@ -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<Definition>();
}
[[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<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"}};
try {
std::mt19937_64 engine{std::random_device{}()};
std::uniform_real_distribution<float> distribution(
static_cast<float>(minimum), static_cast<float>(maximum));
Items generated;
std::size_t count{};
if constexpr (std::same_as<Definition, Volume_Visual>) {
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<float> 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<typename Definition::Base_Tag>();
prop.field_width = static_cast<std::uint32_t>(width);
prop.field_height = static_cast<std::uint32_t>(height);
prop.field_depth = static_cast<std::uint32_t>(depth);
});
} else {
count = input_count(input, "count");
const auto& current = visual_->template read_prop<typename Definition::Base_Tag>().items;
if (current.empty()) throw std::invalid_argument("visual has no item template");
const auto prototype = current.front();
Item_Randomizer<Definition> randomize(input);
generated.reserve(count);
for (std::size_t index = 0; index < count; ++index) {
auto item = current[index % current.size()];
randomize_item(item, distribution, engine);
auto item = prototype;
randomize(item, 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}};
}
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()}};
}
}
void update(const Plot_Frame_Request&) override {}
+4 -7
View File
@@ -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<Frame_Submission, Schema_Query, Prop_Write, Data_Generation>;
@@ -385,8 +383,7 @@ void Plot::ensure_started() {
continue;
}
if (auto* generation = std::get_if<Data_Generation>(&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<Frame_Submission>(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");
}
+2 -2
View File
@@ -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();
+3 -14
View File
@@ -5,7 +5,6 @@
#include <drogon/drogon.h>
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cmath>
#include <functional>
#include <memory>
#include <string>
@@ -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<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"));
if (!input.is_object()) {
callback(error_response(drogon::k400BadRequest, "data generation input must be an object"));
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) {
plot->async_generate_data(std::move(input), [output](nlohmann::json result) {
(*output)(json_response(std::move(result)));
});
}, {drogon::Post});
+157 -72
View File
@@ -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<string, unknown>};
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<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[]>;
@@ -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<HTMLCanvasEleme
const browser_tail = Math.max(0, timestamp - pixels_received_at);
samples = samples.map(value => value.sequence !== sequence ? value : {...value, values: pipeline_stage_values({...value.values,
pixels_to_presentation_opportunity_ms: browser_tail,
request_to_presentation_opportunity_ms: (value.values.request_to_pixels_ms ?? 0) + browser_tail})});
request_to_presentation_opportunity_ms: (value.values.request_to_pixels_ms ?? 0) + browser_tail}, plot.dimension)});
publish_diagnostics(false);
});
presentation_callbacks.add(second);
@@ -344,7 +389,7 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
sequence: pair.value.sequence,
correlation_id: pair.value.correlation_id,
received_at_ms: pixels_received_at,
values: pipeline_stage_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), plot.dimension)
}].slice(-10_000);
publish_diagnostics(samples.length === 1);
mark_presentation_opportunity(pair.value.sequence, pixels_received_at);
@@ -415,7 +460,7 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
socket_ref.current = null;
socket.close();
};
}, [plot.id, plot.websocket, canvas_ref, envelope, transmit]);
}, [plot.id, plot.websocket, plot.dimension, canvas_ref, envelope, transmit]);
useEffect(() => {
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 <label className="control" title={field_tooltip(field)}><span>{field_label(field)} <code>{field.key}</code></span><select value={String(field.value ?? "")} onChange={event => on_change(event.target.value)}>
{field.options?.map(option => <option key={option.value} value={option.value}>{enum_label(option.value)}</option>)}</select></label>;
if (field.editor === "text") return <label className="control" title={field_tooltip(field)}><span>{field_label(field)} <code>{field.key}</code></span><input value={String(field.value ?? "")} onChange={event => on_change(event.target.value)}/></label>;
return <label className="control" title={field_tooltip(field)}><span>{field_label(field)} <code>{field.key}</code></span><input type="number" value={String(field.value ?? "")} onChange={event => on_change(Number(event.target.value))}/></label>;
return <label className="control" title={field_tooltip(field)}><span>{field_label(field)} <code>{field.key}</code></span><input type="number" min={field.minimum} max={field.maximum} step={field.step} value={String(field.value ?? "")} onChange={event => on_change(Number(event.target.value))}/></label>;
}
function Property_Control({field, on_commit}: {field: Field; on_commit: (value: unknown) => Promise<void>}) {
@@ -654,23 +699,54 @@ function State_Field_View({component, field, histories}: {component: Component;
</article>;
}
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<HTMLDivElement>) => 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<HTMLDivElement>) => void}) {
const host_ref = useRef<HTMLDivElement>(null);
const chart_ref = useRef<EChartsType | null>(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 <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, 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<Stage_Statistic>("average");
const [stage_unit, set_stage_unit] = useState<Stage_Unit>("value");
@@ -727,7 +809,8 @@ function Frame_Diagnostics_View({diagnostics, on_reset}: {diagnostics: Frame_Dia
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 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 <section className="frameDiagnosticPanel">
<div className="diagnosticNotice" title="暂停只冻结分析视图,不会停止后台帧请求与样本采集。"> {displayed.samples.length} </div>
<dl className="frameDiagnosticSummary">{summaries.map(([label, value, description]) => <div key={label} title={description}><dt>{label}</dt><dd>{value}</dd></div>)}</dl>
<Frame_Timeline_Chart diagnostics={displayed} paused={paused} on_context_menu={open_context_menu}/>
<Frame_Timeline_Chart diagnostics={displayed} dimension={dimension} paused={paused} on_context_menu={open_context_menu}/>
<section className="frameStagePanel"><header><div><strong title="互斥阶段来自同一条端到端流水线,各阶段占比之和约为 100%。">线</strong><code>#{displayed.metadata.sequence} / {displayed.metadata.correlation_id}</code></div>
<div className="stageStatisticControls" aria-label="帧阶段统计显示方式">
<div className="stageSegmented" role="group" aria-label="统计口径">{(["average", "variability", "p95", "p99"] as Stage_Statistic[]).map(mode =>
@@ -764,6 +847,7 @@ function Frame_Diagnostics_View({diagnostics, on_reset}: {diagnostics: Frame_Dia
<div className="stageSegmented" role="group" aria-label="显示单位">{(["value", "percentage"] as Stage_Unit[]).map(unit =>
<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 className="pipelineDirection" aria-label={`${dimension} 帧流水线方向`}><strong></strong>{definitions.map(([key, label, description]) => <span key={key} title={description}><i></i>{label}</span>)}<span><i></i><strong></strong></span></div>
<dl>{stage_values.map(([key, label, description, value]) => <div key={key} title={description}><dt>{label}</dt><dd>{!Number.isFinite(value) ? "--"
: stage_unit === "percentage" ? `${(total_value > 0 ? value / total_value * 100 : 0).toFixed(1)}%`
: diagnostic_value(value, key)}</dd></div>)}</dl></section>
@@ -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<Record<string, unknown>>(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 <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>;
<div className="generatorPropGrid">{generator.fields.map(field => <Field_Control key={field.key} field={{...field, value: input[field.key]}} on_change={value => set_input(current => ({...current, [field.key]: value}))}/>)}</div>
<div className="generatorActions"><button disabled={busy} 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;
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<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>
return <section className="workspacePane"><Workspace_Header plot={plot} label="采样与帧策略" count={fields.length} busy={busy} on_refresh={on_refresh}/>
<div className="workspaceBody"><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>;
{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></div></section>;
}
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 <section className="workspacePane"><Workspace_Header plot={plot} label="原始数据生成" count={analysis?.data_generator?.fields.length ?? 0} busy={busy} on_refresh={on_refresh}/>
<div className="workspaceBody">{analysis?.data_generator ? <Data_Generator_View plot={plot} generator={analysis.data_generator} on_generated={on_generated}/>
: <section className="analysisSection"><strong></strong><p className="muted"></p></section>}</div></section>;
}
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 <section className="workspacePane"><Workspace_Header plot={plot} label={`${plot.dimension} 帧流水线统计`} count={diagnostics?.samples.length ?? 0} busy={busy} on_refresh={on_refresh}/>
<div className="workspaceBody"><Frame_Diagnostics_View diagnostics={diagnostics} dimension={plot.dimension} 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}) {
@@ -924,7 +1008,7 @@ function Gallery_Grid({plots, selected, on_select}: {plots: Plot[]; selected: Pl
</Responsive> : null}</div>;
}
const workspace_layout_key = "aethera-flexlayout-v2";
const workspace_layout_key = "aethera-flexlayout-v3";
const layout_labels: Record<I18nLabel, string> = {
[I18nLabel.Close_Tab]: "关闭标签",
[I18nLabel.Pinned_Tab]: "已固定",
@@ -960,19 +1044,18 @@ const layout_labels: Record<I18nLabel, string> = {
[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 <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}/></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}
if (node.getComponent() === "frame-policy") return <aside className="inspector" aria-label="采样与帧策略"><Frame_Policy_Pane plot={selected} analysis={schema?.frame_analysis ?? null} 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>;
if (node.getComponent() === "data-generation") return <aside className="inspector" aria-label="原始数据生成"><Data_Generation_Pane plot={selected} analysis={schema?.frame_analysis ?? null} busy={schema_busy} on_refresh={() => void load_schema(true)} on_generated={() => { reset_frame_diagnostics(); window.dispatchEvent(new CustomEvent("aethera-manual-frame", {detail: {plot_id: selected.id}})); }}/></aside>;
if (node.getComponent() === "frame-statistics") return <aside className="inspector" aria-label="帧流水线统计"><Frame_Statistics_Pane plot={selected} diagnostics={frame_diagnostics} busy={schema_busy} on_refresh={() => void load_schema(true)} on_reset={reset_frame_diagnostics}/></aside>;
return <div className="emptyPane"></div>;
};
return <div className="appShell flexlayout__theme_alpha"><Layout model={layout_model} factory={factory} realtimeResize i18nMapper={label => layout_labels[label]}
+7 -10
View File
@@ -113,18 +113,16 @@ canvas:focus { outline: 1px solid #5ce4c2; outline-offset: -1px; }
.framePolicyActions { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; padding: 11px; border: 1px solid #27594f; border-radius: 9px; background: #0b1b1a; }
.framePolicyActions button { flex: 0 0 auto; padding: 8px 12px; color: #062019; border: 1px solid #5ce4c2; border-radius: 7px; background: #5ce4c2; cursor: pointer; }
.framePolicyActions small { color: #7fa99f; line-height: 1.4; }
.frameAnalysisBody { display: grid; grid-template-columns: minmax(300px, .8fr) minmax(300px, .8fr) minmax(560px, 1.5fr); align-items: start; gap: 14px; }
.analysisSection { min-width: 0; padding: 14px; border: 1px solid #213653; border-radius: 10px; background: #0a1422; }
.analysisSection > header { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-bottom: 14px; }
.analysisSection > header > div:first-child { display: grid; gap: 5px; }
.analysisSection > header strong { color: #dce8f8; font-size: 15px; }
.analysisSection > header span { color: #71839e; font-size: 11px; line-height: 1.45; }
.analysisSection .framePolicyActions { flex-wrap: wrap; margin: 0; padding: 0; border: 0; background: transparent; }
.dataGeneratorFields { display: grid; grid-template-columns: repeat(3, minmax(88px, 1fr)); gap: 9px; }
.dataGeneratorFields label { display: grid; gap: 5px; color: #8296b2; font-size: 11px; }
.dataGeneratorFields input { min-width: 0; width: 100%; padding: 8px 9px; color: #eaf1ff; border: 1px solid #2d405f; border-radius: 7px; outline: none; background: #0d1828; }
.dataGeneratorFields button { grid-column: 1 / -1; padding: 9px 12px; color: #062019; border: 1px solid #5ce4c2; border-radius: 7px; background: #5ce4c2; cursor: pointer; }
.dataGeneratorFields button:disabled { opacity: .45; cursor: default; }
.generatorPropGrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.generatorActions { display: flex; justify-content: flex-end; margin-top: 14px; }
.generatorActions button { padding: 9px 14px; color: #062019; border: 1px solid #5ce4c2; border-radius: 7px; background: #5ce4c2; cursor: pointer; }
.generatorActions button:disabled { opacity: .45; cursor: default; }
.analysisStatus { margin: 10px 0 0; color: #8eb6aa; font-size: 11px; }
.frameDiagnosticPanel { display: grid; gap: 14px; min-width: 0; }
@@ -143,6 +141,9 @@ canvas:focus { outline: 1px solid #5ce4c2; outline-offset: -1px; }
.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 > div:first-child { display: flex; flex-direction: column; gap: 5px; }
.pipelineDirection { display: flex; align-items: stretch; gap: 0; overflow-x: auto; padding: 10px 12px; color: #8fa2bd; border-bottom: 1px solid #1d304a; background: #091321; font-size: 10px; white-space: nowrap; }
.pipelineDirection > strong, .pipelineDirection > span { display: inline-flex; align-items: center; }
.pipelineDirection span i { margin: 0 7px; color: #5ce4c2; font-style: normal; }
.stageStatisticControls { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 7px; }
.stageSegmented { display: inline-flex; padding: 2px; border: 1px solid #294463; border-radius: 7px; background: #091321; }
.stageSegmented button { min-width: 42px; padding: 5px 8px; border: 0; border-radius: 5px; background: transparent; color: #8397b3; font-size: 10px; cursor: pointer; }
@@ -155,10 +156,6 @@ canvas:focus { outline: 1px solid #5ce4c2; outline-offset: -1px; }
.diagnosticEmpty { display: grid; min-height: 180px; place-content: center; gap: 8px; padding: 24px; color: #71839e; border: 1px dashed #29435e; border-radius: 10px; text-align: center; }
.diagnosticEmpty strong { color: #cbd8ea; }
@media (max-width: 1280px) {
.frameAnalysisBody { grid-template-columns: repeat(2, minmax(280px, 1fr)); }
.frameDiagnosticPanel { grid-column: 1 / -1; }
}
.componentCard { margin-bottom: 13px; overflow: hidden; border: 1px solid #213653; border-radius: 11px; background: #0a1422; }
.componentCard > summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 11px 13px; color: #dce8f8; background: #101d2f; cursor: pointer; }