From 9540f257e5e9f23e0e1df8aedc3700a3a16fefa4 Mon Sep 17 00:00:00 2001 From: wyc <1104749580@qq.com> Date: Sat, 22 Aug 2026 23:43:44 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=89=E7=BB=B4=E9=A2=91=E8=B0=B1marker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 6 +- kernel/cmake/rely.cmake | 19 +++ kernel/main.cmake | 2 +- .../detail/Datoviz_Visual_Backend.cpp | 54 +++++++- .../detail/Datoviz_Visual_Backend.hpp | 1 + .../render_3D/visual/Prepared_Visual.hpp | 1 + render_3D/render_3D/visual/Visuals.hpp | 1 + render_3D/render_3D/visual/Visuals.ipp | 2 +- web_server/src/Gallery_Plots_2D.cpp | 92 +++++++++++-- web_server/src/Gallery_Plots_3D.cpp | 50 ++++--- webapp_gallery/src/app.tsx | 130 +++++++++++++----- webapp_gallery/src/styles.css | 7 + 12 files changed, 294 insertions(+), 71 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7cca283..ef88a2d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,13 +6,15 @@ \ae\proj\Aethera\cmake-build-vs2022_debug --target Aethera_Kernel_check -j 30 默认每次运行程序都通过CDB运行 D: \ae\ewdk\EWDK_22621_230929-1800\Program Files\Windows Kits\10\Debuggers\x64\cdb.exe -编译器环境脚本 C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\VsDevCmd.bat --host_arch=x64 -arch=x64 +编译器环境脚本 C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\VsDevCmd.bat -host_arch=x64 +-arch=x64 D:\ae\tools 可能会有有用的工具 任何 fallback 都必须先规划,禁止直接实现 +你每次出bug 很多时候是编译缓存的原因 你清掉缓存重新编译 还不行 再去找bug 你写完清理你自己创建的所有进程 我自己启动服务 + 写函数 和模块使用下面的约定 [错误处理规范](./Error_handling_specification.md) [命名约定](./Project_naming_conventions.md) diff --git a/kernel/cmake/rely.cmake b/kernel/cmake/rely.cmake index 23debb8..6e29e3b 100644 --- a/kernel/cmake/rely.cmake +++ b/kernel/cmake/rely.cmake @@ -39,4 +39,23 @@ block() "unset(TASKFLOW_ROOT)" "unset(Taskflow_DIR)" ) + set(MPMCQueue_option ${base_options}) + _register_git_cmake_library(aethera_kernel::MPMCQueue + "https://github.com/rigtorp/MPMCQueue.git" + "v1.0.0" + ) + rcl_get_effective_install_dir(aethera_kernel::MPMCQueue MPMCQUEUE_ROOT) + set(MPMCQUEUE_INCLUDE_DIR "${MPMCQUEUE_ROOT}") + rcl_cmake_library_set_cmake_options(aethera_kernel::MPMCQueue ${MPMCQueue_option}) + rcl_cmake_library_set_other_use_opt(aethera_kernel::MPMCQueue + "-DMPMCQueue_INCLUDE_DIR=\"${MPMCQUEUE_INCLUDE_DIR}\"" + ) + rcl_cmake_library_set_init_script(aethera_kernel::MPMCQueue + "set(MPMCQUEUE_ROOT \"${MPMCQUEUE_ROOT}\")" + "set(MPMCQueue_INCLUDE_DIR \"${MPMCQUEUE_INCLUDE_DIR}\")" + ) + rcl_cmake_library_set_clear_script(aethera_kernel::MPMCQueue + "unset(MPMCQUEUE_ROOT)" + "unset(MPMCQueue_INCLUDE_DIR)" + ) endblock() \ No newline at end of file diff --git a/kernel/main.cmake b/kernel/main.cmake index 23aa271..a65f8e6 100644 --- a/kernel/main.cmake +++ b/kernel/main.cmake @@ -1,5 +1,5 @@ include(${CMAKE_CURRENT_LIST_DIR}/cmake/rely.cmake) -set(Aethera_Kernel_dependencies aethera_kernel::taskflow global::magic_enum global::expected) +set(Aethera_Kernel_dependencies aethera_kernel::MPMCQueue aethera_kernel::taskflow global::magic_enum global::expected) set(Aethera_BUILD_TESTS TRUE) if (Aethera_BUILD_TESTS) set(Aethera_Kernel_test_targets) diff --git a/render_3D/render_3D/detail/Datoviz_Visual_Backend.cpp b/render_3D/render_3D/detail/Datoviz_Visual_Backend.cpp index 68fc4ee..3dd0864 100644 --- a/render_3D/render_3D/detail/Datoviz_Visual_Backend.cpp +++ b/render_3D/render_3D/detail/Datoviz_Visual_Backend.cpp @@ -9,10 +9,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -800,7 +802,22 @@ void Datoviz_Visual_Backend::create_scene( throw std::runtime_error("failed to enable Datoviz item queries"); wants_item_interaction = true; } - visuals_.push_back({registration.identity, family, visual, 0}); + DvzText* coordinate_text{}; + if (family == Visual_Family::marker) { + coordinate_text = dvz_text(panel_, 0); + DvzTextPlacement placement = dvz_text_placement(); + placement.mode = DVZ_TEXT_PLACEMENT_DATA; + placement.anchor = DVZ_SCENE_ANCHOR_DATA; + placement.depth_test = false; + DvzTextStyle style = dvz_text_style(); + style.renderer = DVZ_TEXT_RENDERER_MSDF_ATLAS; + style.size_px = 12.0F; + if (coordinate_text == nullptr || + dvz_text_set_placement(coordinate_text, &placement) != DVZ_OK || + dvz_text_set_style(coordinate_text, &style) != DVZ_OK) + throw std::runtime_error("failed to create Datoviz marker coordinate labels"); + } + visuals_.push_back({registration.identity, family, visual, coordinate_text, 0}); } if (wants_item_interaction) { item_interaction_ = dvz_item_interaction(panel_, nullptr); @@ -1096,6 +1113,41 @@ void Datoviz_Visual_Backend::apply_visual( if (point.revision == target.applied_revision) return; const auto& data = *point.data; auto* visual = target.visual; + if (target.coordinate_text != nullptr) { + std::vector strings; + std::vector items; + if (point.visible) { + strings.reserve(data.positions.size()); + items.reserve(data.positions.size()); + for (std::size_t index = 0; index < data.positions.size(); ++index) { + if (index >= data.coordinate_label_visibility.size() || + !data.coordinate_label_visibility[index]) continue; + const auto& source = data.positions[index]; + const auto& matrix = point.transform.values; + const float x = matrix[0] * source[0] + matrix[1] * source[1] + matrix[2] * source[2] + matrix[3]; + const float y = matrix[4] * source[0] + matrix[5] * source[1] + matrix[6] * source[2] + matrix[7]; + const float z = matrix[8] * source[0] + matrix[9] * source[1] + matrix[10] * source[2] + matrix[11]; + std::ostringstream stream; + stream << std::fixed << std::setprecision(3) + << "X " << x << " Y " << y << " Z " << z; + strings.push_back(std::move(stream).str()); + DvzTextItem item{}; + item.struct_size = sizeof(DvzTextItem); + item.position[0] = x; item.position[1] = y; item.position[2] = z; + item.offset[0] = 10.0F; item.offset[1] = -10.0F; + item.anchor[0] = 0.0F; item.anchor[1] = 1.0F; + item.size_px = 12.0F; + item.color = {235, 244, 255, 255}; + items.push_back(item); + } + } + for (std::size_t index = 0; index < items.size(); ++index) + items[index].string = strings[index].c_str(); + if (dvz_text_set_items(target.coordinate_text, + items.empty() ? nullptr : items.data(), + static_cast(items.size())) != DVZ_OK) + throw std::runtime_error("failed to upload Datoviz marker coordinate labels"); + } { mat4 transform{}; for (std::size_t row = 0; row < 4; ++row) diff --git a/render_3D/render_3D/detail/Datoviz_Visual_Backend.hpp b/render_3D/render_3D/detail/Datoviz_Visual_Backend.hpp index 2839a16..0016068 100644 --- a/render_3D/render_3D/detail/Datoviz_Visual_Backend.hpp +++ b/render_3D/render_3D/detail/Datoviz_Visual_Backend.hpp @@ -56,6 +56,7 @@ private: Visual_Identity identity{}; /* Scene 注册的稳定身份。 */ Visual_Family family{Visual_Family::point}; /* 原生 Visual 的确定 family。 */ DvzVisual* visual{}; /* 由 Datoviz Scene 拥有。 */ + DvzText* coordinate_text{}; /* Marker 的数据坐标 XYZ 标注。 */ std::uint64_t applied_revision{}; /* 此 Visual 已上传的 Prepare 版本。 */ }; void require_domain() const; diff --git a/render_3D/render_3D/visual/Prepared_Visual.hpp b/render_3D/render_3D/visual/Prepared_Visual.hpp index 4d1670c..8684fbf 100644 --- a/render_3D/render_3D/visual/Prepared_Visual.hpp +++ b/render_3D/render_3D/visual/Prepared_Visual.hpp @@ -10,6 +10,7 @@ struct Prepared_Visual_Data { std::vector> sigma{}; /* Splat 椭圆标准差字段。 */ std::vector angles{}; /* Splat、Marker 旋转角字段。 */ std::vector shapes{}; /* Marker 形状字段。 */ + std::vector coordinate_label_visibility{}; /* Marker XYZ 标签逐项开关。 */ std::vector> secondary_positions{}; /* 线段终点或向量字段。 */ std::vector> normals{}; /* Primitive、Mesh 法线字段。 */ std::vector> extents{}; /* Image、Labels 尺寸字段。 */ diff --git a/render_3D/render_3D/visual/Visuals.hpp b/render_3D/render_3D/visual/Visuals.hpp index f530dbd..26a768f 100644 --- a/render_3D/render_3D/visual/Visuals.hpp +++ b/render_3D/render_3D/visual/Visuals.hpp @@ -26,6 +26,7 @@ struct Marker { Pixel_Distance diameter_px{12.0F}; /* 标记直径,单位为像素;必须为正数。 */ Coordinate_3D angle{}; /* 标记旋转角,单位为弧度。 */ Marker_Shape shape{Marker_Shape::disc}; /* 标记图形。 */ + bool coordinate_label_visible{false}; /* 是否显示由此 Marker 实际位置派生的 XYZ 标签。 */ bool operator==(const Marker&) const = default; }; struct Sphere { diff --git a/render_3D/render_3D/visual/Visuals.ipp b/render_3D/render_3D/visual/Visuals.ipp index f2c287b..48acf34 100644 --- a/render_3D/render_3D/visual/Visuals.ipp +++ b/render_3D/render_3D/visual/Visuals.ipp @@ -9,7 +9,7 @@ inline void Splat_Spec::prepare(const std::vector& items, Prepared_Visual_ inline bool Pixel_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.size_px) && item.size_px > 0.0F; } inline void Pixel_Spec::prepare(const std::vector& items, Prepared_Visual_Data& output) { reserve_common(output, items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.size_px); } } inline bool Marker_Spec::valid(const Item& item) noexcept { return finite(item.position) && finite(item.diameter_px) && item.diameter_px > 0.0F && finite(item.angle); } -inline void Marker_Spec::prepare(const std::vector& items, Prepared_Visual_Data& output) { reserve_common(output, items.size()); output.angles.reserve(items.size()); output.shapes.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.diameter_px); output.angles.push_back(item.angle); output.shapes.push_back(static_cast(item.shape)); } } +inline void Marker_Spec::prepare(const std::vector& items, Prepared_Visual_Data& output) { reserve_common(output, items.size()); output.angles.reserve(items.size()); output.shapes.reserve(items.size()); output.coordinate_label_visibility.reserve(items.size()); for (const auto& item : items) { output.positions.push_back({item.position.x, item.position.y, item.position.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.diameter_px); output.angles.push_back(item.angle); output.shapes.push_back(static_cast(item.shape)); output.coordinate_label_visibility.push_back(item.coordinate_label_visible); } } inline bool Sphere_Spec::valid(const Item& item) noexcept { return finite(item.center) && finite(item.radius) && item.radius > 0.0F; } inline void Sphere_Spec::prepare(const std::vector& items, Prepared_Visual_Data& output) { reserve_common(output, items.size()); for (const auto& item : items) { output.positions.push_back({item.center.x, item.center.y, item.center.z}); output.colors.push_back(channels(item.color)); output.sizes.push_back(item.radius); } } inline bool Segment_Spec::valid(const Item& item) noexcept { return finite(item.start) && finite(item.end) && finite(item.width_px) && item.width_px > 0.0F; } diff --git a/web_server/src/Gallery_Plots_2D.cpp b/web_server/src/Gallery_Plots_2D.cpp index f2f5f96..6a9918d 100644 --- a/web_server/src/Gallery_Plots_2D.cpp +++ b/web_server/src/Gallery_Plots_2D.cpp @@ -189,16 +189,39 @@ std::pair generator_range(const Json& input, std::string_view mi if (minimum >= maximum) throw std::invalid_argument(std::string(maximum_key) + " must be greater than " + std::string(minimum_key)); return {minimum, maximum}; } +void generate_spectral_row(std::vector& values, std::size_t row, + std::size_t signal_count, double minimum, double maximum, + double noise_standard_deviation, std::mt19937_64& engine) { + if (noise_standard_deviation < 0.0) + throw std::invalid_argument("noise_stddev must not be negative"); + std::normal_distribution noise(0.0, noise_standard_deviation); + const double span = maximum - minimum; + const double denominator = static_cast(std::max(1, values.size() - 1)); + for (std::size_t index = 0; index < values.size(); ++index) { + const double x = static_cast(index) / denominator; + double value = minimum + span * 0.10 + noise(engine); + for (std::size_t signal = 0; signal < signal_count; ++signal) { + const double phase = static_cast(signal + 1) / static_cast(signal_count + 1); + const double center = std::clamp(phase + 0.035 * std::sin(row * 0.09 + signal * 1.73), 0.01, 0.99); + const double width = 0.003 + 0.018 * static_cast((signal % 5) + 1) / 5.0; + const double distance = (x - center) / width; + value += span * (0.45 + 0.45 * std::sin(signal * 2.17 + row * 0.037)) * std::exp(-0.5 * distance * distance); + } + values[index] = std::clamp(value, minimum, maximum); + } +} template Json generator_2d_schema() { Json fields = Json::array(); std::string label; std::string description; if constexpr (std::same_as) { - label = "生成频谱采样"; description = "生成一条完整功率频谱,样本沿当前频率范围均匀分布。"; + 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)); + 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)); + fields.push_back(generator_integer_field("signal_count", "窄带信号数", "叠加在噪声底上的漂移高斯谱峰数量。", 8, 256)); + fields.push_back(generator_number_field("noise_stddev", "噪声标准差", "功率噪声的标准差,单位与功率值一致。", 2.0, 0.0, 1'000'000.0)); } else if constexpr (std::same_as) { label = "生成频率轨迹"; description = "按时间顺序生成一组轨迹采样。"; fields.push_back(generator_integer_field("sample_count", "轨迹采样点数", "时间有序的轨迹点数量。", 4096)); @@ -211,18 +234,24 @@ Json generator_2d_schema() { 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)); + fields.push_back(generator_integer_field("signal_count", "窄带信号数", "跨扫频块连续分布的谱峰数量。", 8, 256)); + fields.push_back(generator_number_field("noise_stddev", "噪声标准差", "扫频噪声底标准差。", 2.0, 0.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)); + fields.push_back(generator_integer_field("signal_count", "漂移信号数", "在历史帧之间连续漂移的谱峰数量。", 8, 256)); + fields.push_back(generator_number_field("noise_stddev", "噪声标准差", "历史频谱噪声底标准差。", 2.0, 0.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)); + fields.push_back(generator_integer_field("signal_count", "漂移信号数", "沿时间行移动的窄带谱峰数量。", 8, 256)); + fields.push_back(generator_number_field("noise_stddev", "噪声标准差", "瀑布噪声底标准差。", 2.0, 0.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)); @@ -238,20 +267,21 @@ Json generator_2d_schema() { 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)}}; + if (!fields.empty()) + fields.push_back(generator_integer_field("seed", "随机种子", "固定种子可重现同一压力数据集,便于对比不同帧策略和像素传输模式。", 42, 4'294'967'295ULL)); + 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::mt19937_64 engine{generator_count(input, "seed", 4'294'967'295ULL)}; 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); }); + generate_spectral_row(values, 0, generator_count(input, "signal_count", 256), minimum, maximum, generator_number(input, "noise_stddev"), engine); object.update_samples(values); generated_count = count; } else if constexpr (std::same_as) { @@ -269,9 +299,11 @@ nlohmann::json generate_2d_data(Object& object, const Json& input) { 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); }); + std::vector complete(block_count * width); + generate_spectral_row(complete, 0, generator_count(input, "signal_count", 256), minimum, maximum, generator_number(input, "noise_stddev"), engine); + for (std::size_t block = 0; block < block_count; ++block) + std::ranges::copy_n(complete.begin() + block * width, width, blocks[block].begin()); 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)); @@ -281,9 +313,11 @@ nlohmann::json generate_2d_data(Object& object, const Json& input) { 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); }); + const auto signal_count = generator_count(input, "signal_count", 256); + const auto noise_stddev = generator_number(input, "noise_stddev"); + for (std::size_t row = 0; row < row_count; ++row) + generate_spectral_row(spectra[row], row, signal_count, minimum, maximum, noise_stddev, engine); object.template set<&Afterglow::Prop::spectra>(std::move(spectra)); generated_count = row_count * width; } else if constexpr (std::same_as) { @@ -291,12 +325,13 @@ nlohmann::json generate_2d_data(Object& object, const Json& input) { 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); + const auto signal_count = generator_count(input, "signal_count", 256); + const auto noise_stddev = generator_number(input, "noise_stddev"); for (std::size_t row = 0; row < row_count; ++row) { std::vector row_values(width); - std::ranges::generate(row_values, [&] { return distribution(engine); }); + generate_spectral_row(row_values, row, signal_count, minimum, maximum, noise_stddev, engine); rows.push_back({static_cast(row), std::move(row_values)}); } object.template set<&Waterfall::Prop::frequency_bin_count>(width); @@ -461,9 +496,38 @@ std::shared_ptr make_axes_plot(asio::any_io_executor executor) { components.push_back(make_axis_component("axis-frequency", "频率轴", *frequency)); components.push_back(make_axis_component("axis-value", "数值轴", *numeric)); components.push_back(make_axis_component("axis-time", "时间轴", *time)); + Json generator_fields = Json::array({ + generator_integer_field("time_sample_count", "时间样本数", "写入时间轴保留窗口的连续时间样本数量。", 16'384), + generator_integer_field("time_step_ms", "时间步长 (ms)", "相邻时间样本之间的毫秒间隔。", 10), + generator_number_field("frequency_min", "频率下界", "频率轴可见坐标下界。", 0.0, -1'000'000'000.0, 1'000'000'000.0), + generator_number_field("frequency_max", "频率上界", "频率轴可见坐标上界,必须大于下界。", 100.0, -1'000'000'000.0, 1'000'000'000.0), + generator_number_field("value_min", "数值下界", "数值轴可见坐标下界。", -100.0, -1'000'000'000.0, 1'000'000'000.0), + generator_number_field("value_max", "数值上界", "数值轴可见坐标上界,必须大于下界。", 0.0, -1'000'000'000.0, 1'000'000'000.0) + }); + auto generate = [frequency = frequency.get(), numeric = numeric.get(), time = time.get()](const Json& input) { + try { + const auto sample_count = generator_count(input, "time_sample_count"); + const auto time_step = generator_count(input, "time_step_ms"); + const auto [frequency_minimum, frequency_maximum] = generator_range(input, "frequency_min", "frequency_max"); + const auto [value_minimum, value_maximum] = generator_range(input, "value_min", "value_max"); + frequency->template set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{frequency_minimum, frequency_maximum}); + numeric->template set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{value_minimum, value_maximum}); + time->template set<&Time_Axis::Prop::visible_count>(static_cast(sample_count)); + constexpr std::uint64_t day_milliseconds = 86'400'000; + for (std::size_t index = 0; index < sample_count; ++index) + time->append_time(Time_Of_Day{static_cast((index * time_step) % day_milliseconds)}); + return Json{{"success", true}, {"generated_count", sample_count}}; + } catch (const std::exception& error) { + return Json{{"success", false}, {"error", error.what()}}; + } + }; auto view = std::make_unique>( std::move(components), std::move(update), - Scene_View_Model::Data_Generator{}, + Scene_View_Model::Data_Generator{ + Json{{"label", "生成坐标轴压力数据"}, + {"description", "按时间样本规模和三个业务坐标范围生成可重复的坐标轴压力负载。"}, + {"fields", std::move(generator_fields)}}, + std::move(generate)}, std::move(frequency), std::move(numeric), std::move(time)); return std::make_shared(std::move(executor), std::move(scene), std::move(view)); } diff --git a/web_server/src/Gallery_Plots_3D.cpp b/web_server/src/Gallery_Plots_3D.cpp index 0fe2a11..54e853c 100644 --- a/web_server/src/Gallery_Plots_3D.cpp +++ b/web_server/src/Gallery_Plots_3D.cpp @@ -44,9 +44,11 @@ Json generator_schema() { 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)); + fields.push_back(integer_field("seed", "随机种子", "固定种子可复现体素压力数据,便于跨策略对比。", 42, 1, 4'294'967'295ULL)); return {{"label", "生成体素标量场"}, {"description", "按三维网格尺寸生成连续体数据,不使用随机位置。"}, {"fields", std::move(fields)}}; } - fields.push_back(integer_field("count", "图元数量", "本次替换到 Visual 的图元数量。", 10'000, 1, 1'000'000)); + fields.push_back(integer_field("count", "图元数量", "本次替换到 Visual 的图元数量;用于逐级提升 CPU Prepare、GPU 上传与绘制压力。", 10'000, 1, 5'000'000)); + fields.push_back(integer_field("seed", "随机种子", "固定种子可复现相同空间分布,确保多图与传输模式的性能结果可比较。", 42, 1, 4'294'967'295ULL)); 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)); @@ -303,7 +305,7 @@ public: using Prop = typename Definition::Prop; using Items = std::remove_cvref_t().items)>; try { - std::mt19937_64 engine{std::random_device{}()}; + std::mt19937_64 engine{input_count(input, "seed", 4'294'967'295ULL)}; Items generated; std::size_t count{}; if constexpr (std::same_as) { @@ -324,7 +326,7 @@ public: prop.field_depth = static_cast(depth); }); } else { - count = input_count(input, "count"); + count = input_count(input, "count", 5'000'000); 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(); @@ -436,6 +438,9 @@ struct Spectrogram_Parameters { double maximum_frequency_hz{20'000.0}; double minimum_level_db{18.0}; double maximum_level_db{78.0}; + double animation_speed{1.0}; + std::size_t update_every_n_frames{1}; + bool animation_enabled{true}; }; Color spectrogram_color(float value) { @@ -568,14 +573,17 @@ struct Spectrogram_Data_Generator { [[nodiscard]] Json schema() const { Json fields = Json::array(); - fields.push_back(integer_field("time_sample_count", "时间采样数", "时间方向的网格采样数量;增大后表面沿时间方向更细密。", 80, 16, 256)); - fields.push_back(integer_field("frequency_bin_count", "频率分箱数", "对数频率方向的网格分箱数量。", 96, 16, 256)); + fields.push_back(integer_field("time_sample_count", "时间采样数", "时间方向网格采样数;GPU 顶点数约为 6×(时间采样数-1)×(频率分箱数-1)。", parameters.time_sample_count, 16, 1024)); + fields.push_back(integer_field("frequency_bin_count", "频率分箱数", "对数频率方向分箱数;与时间采样数共同决定三角形和每次上传的数据量。", parameters.frequency_bin_count, 16, 1024)); fields.push_back(integer_field("ridge_count", "谱峰轨迹数", "生成随时间漂移的窄带谱峰数量。", 5, 1, 12)); - fields.push_back(number_field("time_span_seconds", "时间跨度", "X 轴覆盖的时间长度,单位秒。", 4.0, 0.1, 120.0, 0.1)); - fields.push_back(number_field("minimum_frequency_hz", "最低频率", "对数频率轴的下界,必须大于零。", 10.0, 1.0, 1.0e9, 1.0)); - fields.push_back(number_field("maximum_frequency_hz", "最高频率", "对数频率轴的上界,必须大于最低频率。", 20'000.0, 2.0, 1.0e9, 10.0)); - fields.push_back(number_field("minimum_level_db", "最低声压级", "Z 轴色阶和高度的下界,单位 dB。", 18.0, -300.0, 300.0, 1.0)); - fields.push_back(number_field("maximum_level_db", "最高声压级", "Z 轴色阶和高度的上界,必须大于下界。", 78.0, -300.0, 300.0, 1.0)); + fields.push_back(number_field("time_span_seconds", "时间跨度", "X 轴时间范围,单位秒。", parameters.time_span_seconds, 0.1, 3600.0, 0.1)); + fields.push_back(number_field("minimum_frequency_hz", "最低频率", "对数频率轴下界,必须大于零。", parameters.minimum_frequency_hz, 0.001, 1.0e12, 1.0)); + fields.push_back(number_field("maximum_frequency_hz", "最高频率", "对数频率轴上界,必须大于最低频率。", parameters.maximum_frequency_hz, 0.002, 1.0e12, 10.0)); + fields.push_back(number_field("minimum_level_db", "最低声压级", "Z 轴色阶与高度下界,单位 dB。", parameters.minimum_level_db, -1000.0, 1000.0, 1.0)); + fields.push_back(number_field("maximum_level_db", "最高声压级", "Z 轴色阶与高度上界,必须大于下界。", parameters.maximum_level_db, -1000.0, 1000.0, 1.0)); + fields.push_back(number_field("animation_speed", "动态速度倍率", "谱峰随时间运动的倍率;0 表示保持当前相位。", parameters.animation_speed, 0.0, 100.0, 0.1)); + fields.push_back(integer_field("update_every_n_frames", "数据更新帧间隔", "每 N 个渲染请求重建并上传一次 Mesh;可分离固定几何绘制与持续数据上传压力。", parameters.update_every_n_frames, 1, 10'000)); + fields.push_back({{"key", "animation_enabled"}, {"label", "持续生成动态数据"}, {"description", "关闭后保留生成的数据集,仅测试固定 Mesh 的重复绘制;开启后按更新间隔持续重建。"}, {"editor", "boolean"}, {"editable", true}, {"value", parameters.animation_enabled}}); return {{"label", "生成三维频谱瀑布"}, {"description", "按时间采样、对数频率分箱和声压级范围生成连续 GPU Mesh 表面。"}, {"fields", std::move(fields)}}; @@ -585,19 +593,27 @@ struct Spectrogram_Data_Generator { const Json& input) { try { Spectrogram_Parameters next; - next.time_sample_count = input_count(input, "time_sample_count", 256); - next.frequency_bin_count = input_count(input, "frequency_bin_count", 256); + next.time_sample_count = input_count(input, "time_sample_count", 1024); + next.frequency_bin_count = input_count(input, "frequency_bin_count", 1024); next.ridge_count = input_count(input, "ridge_count", 12); next.time_span_seconds = input_number(input, "time_span_seconds"); next.minimum_frequency_hz = input_number(input, "minimum_frequency_hz"); next.maximum_frequency_hz = input_number(input, "maximum_frequency_hz"); next.minimum_level_db = input_number(input, "minimum_level_db"); next.maximum_level_db = input_number(input, "maximum_level_db"); + next.animation_speed = input_number(input, "animation_speed"); + next.update_every_n_frames = input_count(input, "update_every_n_frames", 10'000); + const auto animation = input.at("animation_enabled"); + if (!animation.is_boolean()) throw std::invalid_argument("animation_enabled must be boolean"); + next.animation_enabled = animation.get(); if (!(next.time_span_seconds > 0.0) || !(next.minimum_frequency_hz > 0.0) || !(next.maximum_frequency_hz > next.minimum_frequency_hz) || !(next.maximum_level_db > next.minimum_level_db)) throw std::invalid_argument("spectrogram ranges are invalid"); + const auto cells = (next.time_sample_count - 1) * (next.frequency_bin_count - 1); + if (cells > 1'400'000) + throw std::invalid_argument("spectrogram exceeds the 8,400,000 vertex stress-test limit"); parameters = next; auto mesh = spectrogram_mesh(parameters); const auto vertex_count = mesh.size(); @@ -623,8 +639,10 @@ struct Spectrogram_Data_Generator { void update(Impl& visual, Impl&, Impl* markers, - const Plot_Frame_Request& request) const { - const double animation_seconds = request.time_milliseconds / 1000.0; + const Plot_Frame_Request& request) { + if (!parameters.animation_enabled || + request.correlation_id % parameters.update_every_n_frames != 0) return; + const double animation_seconds = request.time_milliseconds / 1000.0 * parameters.animation_speed; auto mesh = spectrogram_mesh(parameters, animation_seconds); if (visual.update_items(std::move(mesh)) != Mesh_Visual::Update_Items_Result::updated) throw std::logic_error("animated spectrogram mesh was rejected"); @@ -695,9 +713,9 @@ std::shared_ptr make_datoviz_spectrogram_plot(asio::any_io_executor execut auto marker_result = Impl::Builder{} .set(&Marker_Visual::Prop::items, std::vector{ {{-0.35F, -0.18F, 0.72F}, color(255, 244, 170), 23.0F, 0.0F, - Marker_Shape::diamond}, + Marker_Shape::diamond, true}, {{0.28F, 0.34F, 0.86F}, color(88, 236, 211), 21.0F, 0.0F, - Marker_Shape::cross}}) + Marker_Shape::cross, true}}) .set(&Marker_Visual::Prop::depth_test, false) .build(); if (!marker_result) diff --git a/webapp_gallery/src/app.tsx b/webapp_gallery/src/app.tsx index 89d4527..5c9a724 100644 --- a/webapp_gallery/src/app.tsx +++ b/webapp_gallery/src/app.tsx @@ -20,6 +20,8 @@ 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; 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; fields: Field[]}; +type Plot_Execution_Policy = {visible: boolean; refresh_hidden: boolean; transfer_pixels: boolean}; +type Plot_Execution_Policies = Record; 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; @@ -43,6 +45,8 @@ type Frame_Policy_Event = {plot_id: string; key: "pacing_mode" | "fixed_rate_fps type Stage_Statistic = "average" | "variability" | "p95" | "p99"; type Stage_Unit = "value" | "percentage"; +const default_plot_execution_policy = (): Plot_Execution_Policy => ({visible: true, refresh_hidden: false, transfer_pixels: true}); + function socket_url(path: string) { return `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${path}`; } function local_time_milliseconds() { @@ -655,14 +659,14 @@ function Structured_Control({field, on_change}: {field: Field; on_change: (value ; } -type Marker_Value = {position: {x: number; y: number; z: number}; color: {red: number; green: number; blue: number; alpha: number}; diameter_px: number; angle: number; shape: string}; +type Marker_Value = {position: {x: number; y: number; z: number}; color: {red: number; green: number; blue: number; alpha: number}; diameter_px: number; angle: number; shape: string; coordinate_label_visible: boolean}; const marker_shapes = ["disc", "square", "triangle", "diamond", "cross"]; function Marker_List_Control({field, on_change}: {field: Field; on_change: (value: unknown) => void}) { const markers = Array.isArray(field.value) ? field.value as Marker_Value[] : []; const surface_attached = field.editor === "surface-marker-list"; const replace = (index: number, marker: Marker_Value) => on_change(markers.map((value, item_index) => item_index === index ? marker : value)); - const add = () => on_change([...markers, {position: {x: 0, y: 0, z: 0}, color: {red: 255, green: 218, blue: 112, alpha: 255}, diameter_px: 22, angle: 0, shape: "diamond"}]); + const add = () => on_change([...markers, {position: {x: 0, y: 0, z: 0}, color: {red: 255, green: 218, blue: 112, alpha: 255}, diameter_px: 22, angle: 0, shape: "diamond", coordinate_label_visible: surface_attached}]); return
{field_label(field)} {field.key}
{markers.length} 个 Marker
{markers.map((marker, index) =>
@@ -670,6 +674,7 @@ function Marker_List_Control({field, on_change}: {field: Field; on_change: (valu
{(["x", "y", "z"] as const).map(axis => )}
+
)}
; @@ -985,28 +990,35 @@ 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 defaults = () => Object.fromEntries(generator.fields.map(field => [field.key, field.value])); - const [input, set_input] = useState>(defaults); + const [input, set_input] = useState>({}); const [busy, set_busy] = useState(false); const [status, set_status] = useState(""); - const generator_signature = JSON.stringify(generator.fields.map(field => [field.key, field.value])); - useEffect(() => { set_input(defaults()); set_status(""); }, [plot.id, generator_signature]); + const generator_signature = `${plot.id}:${generator.fields.map(field => field.key).join("\u0000")}`; + useEffect(() => { + set_input(Object.fromEntries(generator.fields.map(field => [field.key, field.value]))); + set_status(""); + }, [generator_signature]); const generate = async () => { - set_busy(true); set_status(""); + 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(input) }); - const result = await response.json() as {success?: boolean; error?: string; generated_count?: number}; + const result = await response.json() as {success?: boolean; error?: string; generated_count?: number; triangle_count?: number}; if (!result.success) throw new Error(result.error ?? "生成原始数据失败"); on_generated(); - set_status(`已生成 ${(result.generated_count ?? 0).toLocaleString("zh-CN")} 条数据,并从头统计。`); - } catch (error) { set_status(error instanceof Error ? error.message : "生成原始数据失败"); } - finally { set_busy(false); } + const topology = result.triangle_count === undefined ? "" : `,${result.triangle_count.toLocaleString("zh-CN")} 个三角形`; + set_status(`已为当前图生成 ${(result.generated_count ?? 0).toLocaleString("zh-CN")} 条数据${topology},并从头统计。`); + } catch (error) { + set_status(error instanceof Error ? error.message : "生成原始数据失败"); + } finally { + set_busy(false); + } }; return
{generator.label}{generator.description}
-
{generator.fields.map(field => set_input(current => ({...current, [field.key]: value}))}/>)}
-
+
{generator.fields.map(field => set_input(current => ({...current, [field.key]: value}))}/>)}
+
{status ?

{status}

: null}
; } @@ -1025,7 +1037,7 @@ function Frame_Policy_Pane({plot, analysis, busy, on_refresh, on_update, on_manu 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 ? - :
当前图形没有原始数据生成能力

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

}
; + :
当前图形没有原始数据生成能力

此页只编辑当前选中图的后端 Schema 数据输入。

}; } 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}) { @@ -1033,20 +1045,40 @@ function Frame_Statistics_Pane({plot, diagnostics, busy, on_refresh, on_reset}:
; } -const Plot_Card = memo(function Plot_Card({plot, selected, on_select}: {plot: Plot; selected: boolean; on_select: (plot: Plot) => void}) { +const Plot_Card = memo(function Plot_Card({plot, selected, policy, on_policy, on_select}: { + plot: Plot; selected: boolean; policy: Plot_Execution_Policy; + on_policy: (plot_id: string, patch: Partial) => void; on_select: (plot: Plot) => void; +}) { const canvas_ref = useRef(null); - const {status, metrics} = use_plot_stream(plot, canvas_ref, true, selected); + const card_ref = useRef(null); + const [onscreen, set_onscreen] = useState(false); + useEffect(() => { + const card = card_ref.current; + if (!card) return; + const observer = new IntersectionObserver(entries => set_onscreen(entries[0]?.isIntersecting ?? false), {threshold: 0.05}); + observer.observe(card); + return () => observer.disconnect(); + }, []); + const sampling = (policy.visible && onscreen) || policy.refresh_hidden; + const {status, metrics} = use_plot_stream(plot, canvas_ref, policy.visible && onscreen && policy.transfer_pixels, sampling); const generated_time = metrics ? new Date(metrics.generated_time_unix_ms).toLocaleTimeString("zh-CN", {hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, fractionalSecondDigits: 3}) : "尚未生成帧"; - return
on_select(plot)} onFocusCapture={() => on_select(plot)}>
绘图组件 · {plot.dimension}

{plot_labels[plot.id] ?? plot.title}

{{IDLE: "待选中", CONNECTING: "重连中", LIVE: "实时", OFFLINE: "已离线"}[status]}{metrics?.delivery === "diagnostics" ? "后台诊断" : metrics ? enum_label(metrics.pacing_mode) : "等待策略"}
{metrics ? metrics.frame_rate_fps.toFixed(1) : "--.-"} FPSE2E {metrics ? metrics.request_to_pixels_ms.toFixed(1) : "--.-"} msP95 {metrics ? metrics.p95_request_to_pixels_ms.toFixed(1) : "--.-"} msP99 {metrics ? metrics.p99_request_to_pixels_ms.toFixed(1) : "--.-"} ms
- {plot.description ?

{plot.description}

: null}
; + return
on_select(plot)} onPointerUpCapture={event => { + if (event.target instanceof HTMLCanvasElement) on_select(plot); + }}>
绘图组件 · {plot.dimension}

{plot_labels[plot.id] ?? plot.title}

{{IDLE: "已停止", CONNECTING: "重连中", LIVE: "实时", OFFLINE: "已离线"}[status]}{metrics?.delivery === "diagnostics" ? "无像素传输" : metrics ? enum_label(metrics.pacing_mode) : "等待策略"}
{metrics ? metrics.frame_rate_fps.toFixed(1) : "--.-"} FPSE2E {metrics ? metrics.request_to_pixels_ms.toFixed(1) : "--.-"} msP95 {metrics ? metrics.p95_request_to_pixels_ms.toFixed(1) : "--.-"} msP99 {metrics ? metrics.p99_request_to_pixels_ms.toFixed(1) : "--.-"} ms
+
event.stopPropagation()}> + + + +
+ {plot.description ?

{plot.description}

: null}
{!policy.visible ?
画面不可见{policy.refresh_hidden ? "后端仍在渲染与采样" : "后端帧请求已停止"}
: null}
; }); type Gallery_Breakpoint = "lg" | "md" | "sm" | "xs"; -const gallery_layout_key = "aethera-gallery-grid-v4"; -const gallery_breakpoints: Record = {lg: 1280, md: 860, sm: 560, xs: 0}; +const gallery_layout_key = "aethera-gallery-grid-v8"; +const gallery_breakpoints: Record = {lg: 1200, md: 760, sm: 420, xs: 0}; const gallery_columns: Record = {lg: 12, md: 12, sm: 12, xs: 12}; -const gallery_item_width: Record = {lg: 4, md: 6, sm: 12, xs: 12}; +const gallery_item_width: Record = {lg: 4, md: 6, sm: 6, xs: 6}; function default_gallery_layout(plots: Plot[], breakpoint: Gallery_Breakpoint): LayoutItem[] { const column_count = gallery_columns[breakpoint]; @@ -1055,8 +1087,7 @@ function default_gallery_layout(plots: Plot[], breakpoint: Gallery_Breakpoint): let y = 0; let row_height = 0; return plots.map(plot => { - const showcase = plot.id === "datoviz_spectrogram"; - const width = showcase ? column_count : ordinary_width; + const width = ordinary_width; const height = 6; if (x + width > column_count) { x = 0; @@ -1069,7 +1100,7 @@ function default_gallery_layout(plots: Plot[], breakpoint: Gallery_Breakpoint): y, w: width, h: height, - minW: breakpoint === "lg" ? 3 : breakpoint === "md" ? 4 : 8, + minW: breakpoint === "lg" ? 3 : breakpoint === "md" ? 4 : 6, minH: 4, resizeHandles: ["s", "e", "se", "sw", "w"] }; @@ -1084,16 +1115,23 @@ function default_gallery_layout(plots: Plot[], breakpoint: Gallery_Breakpoint): }); } -function load_gallery_layouts(): ResponsiveLayouts { +type Stored_Gallery_Layouts = Record>; +function load_gallery_layouts(scope: string): ResponsiveLayouts { const saved = localStorage.getItem(gallery_layout_key); if (!saved) return {}; - try { return JSON.parse(saved) as ResponsiveLayouts; } + try { return (JSON.parse(saved) as Stored_Gallery_Layouts)[scope] ?? {}; } catch { localStorage.removeItem(gallery_layout_key); return {}; } } +function save_gallery_layouts(scope: string, layouts: ResponsiveLayouts) { + let stored: Stored_Gallery_Layouts = {}; + try { stored = JSON.parse(localStorage.getItem(gallery_layout_key) ?? "{}") as Stored_Gallery_Layouts; } + catch { /* 下面由当前权威布局覆盖损坏的持久化快照。 */ } + localStorage.setItem(gallery_layout_key, JSON.stringify({...stored, [scope]: layouts})); +} -function Gallery_Grid({plots, selected, on_select}: {plots: Plot[]; selected: Plot | null; on_select: (plot: Plot) => void}) { +function Gallery_Grid({plots, selected, policies, layout_scope, on_policy, on_select}: {plots: Plot[]; selected: Plot | null; policies: Plot_Execution_Policies; layout_scope: string; on_policy: (plot_id: string, patch: Partial) => void; on_select: (plot: Plot) => void}) { const {width, containerRef, mounted} = useContainerWidth({measureBeforeMount: true}); - const [stored_layouts, set_stored_layouts] = useState>(load_gallery_layouts); + const [stored_layouts, set_stored_layouts] = useState>(() => load_gallery_layouts(layout_scope)); const layouts = useMemo(() => Object.fromEntries((Object.keys(gallery_breakpoints) as Gallery_Breakpoint[]).map(breakpoint => { const defaults = default_gallery_layout(plots, breakpoint); const saved = stored_layouts[breakpoint] ?? []; @@ -1110,14 +1148,14 @@ function Gallery_Grid({plots, selected, on_select}: {plots: Plot[]; selected: Pl })) as ResponsiveLayouts, [plots, stored_layouts]); const save_layouts = (_layout: readonly LayoutItem[], next: ResponsiveLayouts) => { set_stored_layouts(next); - localStorage.setItem(gallery_layout_key, JSON.stringify(next)); + save_gallery_layouts(layout_scope, next); }; return
{ (containerRef as React.MutableRefObject).current = node; }}>{mounted ? width={width} breakpoints={gallery_breakpoints} cols={gallery_columns} layouts={layouts} rowHeight={64} margin={[16, 16]} containerPadding={[0, 0]} onLayoutChange={save_layouts} dragConfig={{handle: ".cardDragHandle", cancel: "canvas,button,input,select,textarea,a", threshold: 4}} resizeConfig={{handles: ["s", "e", "se", "sw", "w"]}}> - {plots.map(plot =>
)} + {plots.map(plot =>
)} : null}
; } @@ -1181,6 +1219,7 @@ function load_workspace_model() { export function App() { const [plots, set_plots] = useState([]); const [category, set_category] = useState("全部"); const [selected, set_selected] = useState(null); + const [execution_policies, set_execution_policies] = useState({}); const [schema, set_schema] = useState(null); const [state_histories, set_state_histories] = useState({}); const [frame_diagnostics, set_frame_diagnostics] = useState(null); @@ -1189,7 +1228,12 @@ export function App() { const [gallery_layout_revision, set_gallery_layout_revision] = useState(0); const schema_request = useRef(0); const schema_busy_request = useRef(0); - useEffect(() => { void fetch("/plot").then(response => response.json()).then(set_plots); }, []); + useEffect(() => { void fetch("/plot").then(response => response.json()).then((next: Plot[]) => { + set_plots(next); + set_execution_policies(current => Object.fromEntries(next.map(plot => [ + plot.id, current[plot.id] ?? default_plot_execution_policy() + ]))); + }); }, []); useEffect(() => { if (!selected && plots.length > 0) set_selected(plots[0]); }, [plots, selected]); useEffect(() => { set_frame_diagnostics(null); @@ -1237,15 +1281,24 @@ export function App() { : {...current, components: current.components.map(item => item.id !== component.id ? item : {...item, fields: item.fields.map(current_field => current_field.key === field.key ? {...current_field, value: result.value} : current_field)})}); }; + const update_execution_policy = useCallback((plot_id: string, patch: Partial) => { + set_execution_policies(current => ({ + ...current, + [plot_id]: {...(current[plot_id] ?? default_plot_execution_policy()), ...patch} + })); + }, []); const reset_frame_diagnostics = () => { if (selected) window.dispatchEvent(new CustomEvent("aethera-reset-frame-diagnostics", {detail: {plot_id: selected.id}})); }; - const categories = useMemo(() => ["全部", ...new Set(plots.map(() => "绘图组件"))], [plots]); - const visible = category === "全部" ? plots : plots; + const categories = ["全部", "2D", "3D"]; + const visible = useMemo(() => { + if (category === "2D" || category === "3D") return plots.filter(plot => plot.dimension === category); + return plots; + }, [category, plots]); const gallery =
AETHERA 渲染实验室

实时图形组件库

{selected ? 当前图形 {plot_labels[selected.id] ?? selected.title} : 点击任意图形后,属性和运行状态会自动同步。}
-
; + ; const factory = (node: TabNode) => { if (node.getComponent() === "gallery") return gallery; if (!selected) return
请选择一个图形组件。
; @@ -1254,7 +1307,12 @@ export function App() { if (node.getComponent() === "frame-policy") return ; - if (node.getComponent() === "data-generation") return ; + if (node.getComponent() === "data-generation") return ; if (node.getComponent() === "frame-statistics") return ; return
未知工作区面板。
; }; diff --git a/webapp_gallery/src/styles.css b/webapp_gallery/src/styles.css index 11c3b66..d8078f1 100644 --- a/webapp_gallery/src/styles.css +++ b/webapp_gallery/src/styles.css @@ -72,6 +72,13 @@ nav { display: flex; flex-wrap: wrap; gap: 8px; padding: 18px 0; } .frameMetrics { display: grid; grid-template-columns: auto auto; gap: 4px 9px; color: #8296b2; font: 9px/1.15 ui-monospace, monospace; font-variant-numeric: tabular-nums; white-space: nowrap; } .frameMetrics span:nth-child(2n) { text-align: right; } .plotViewport { flex: 1; width: 100%; min-height: 160px; overflow: hidden; background: #070d18; } +.plotExecutionPolicy { display:flex; flex-wrap:wrap; gap:6px 12px; padding:8px 12px; border-block:1px solid rgba(126,155,194,.12); background:rgba(6,13,24,.58); font-size:11px; color:#9dafc7; } +.plotExecutionPolicy label { display:flex; align-items:center; gap:5px; cursor:pointer; user-select:none; } +.plotExecutionPolicy input { accent-color:#52dbc1; } +.plotViewportHidden { position:relative; } +.plotViewportHidden canvas { visibility:hidden; } +.plotHiddenState { position:absolute; inset:0; display:grid; place-content:center; gap:5px; text-align:center; color:#91a5bf; background:repeating-linear-gradient(135deg,rgba(15,27,43,.96),rgba(15,27,43,.96) 12px,rgba(18,33,52,.96) 12px,rgba(18,33,52,.96) 24px); } +.plotHiddenState strong { color:#d9e8f6; } canvas { display: block; width: 100%; height: 100%; background: #070d18; overscroll-behavior: contain; touch-action: none; } canvas:focus { outline: 1px solid #5ce4c2; outline-offset: -1px; }