三维频谱marker
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <iomanip>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <numbers>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
@@ -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<std::string> strings;
|
||||
std::vector<DvzTextItem> 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<std::uint32_t>(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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,6 +10,7 @@ struct Prepared_Visual_Data {
|
||||
std::vector<std::array<float, 2>> sigma{}; /* Splat 椭圆标准差字段。 */
|
||||
std::vector<float> angles{}; /* Splat、Marker 旋转角字段。 */
|
||||
std::vector<std::uint32_t> shapes{}; /* Marker 形状字段。 */
|
||||
std::vector<std::uint8_t> coordinate_label_visibility{}; /* Marker XYZ 标签逐项开关。 */
|
||||
std::vector<std::array<float, 3>> secondary_positions{}; /* 线段终点或向量字段。 */
|
||||
std::vector<std::array<float, 3>> normals{}; /* Primitive、Mesh 法线字段。 */
|
||||
std::vector<std::array<float, 2>> extents{}; /* Image、Labels 尺寸字段。 */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,7 +9,7 @@ inline void Splat_Spec::prepare(const std::vector<Item>& 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<Item>& 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<Item>& 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<std::uint32_t>(item.shape)); } }
|
||||
inline void Marker_Spec::prepare(const std::vector<Item>& 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<std::uint32_t>(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<Item>& 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; }
|
||||
|
||||
@@ -189,16 +189,39 @@ std::pair<double, double> 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<Plot_Value>& 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<double> noise(0.0, noise_standard_deviation);
|
||||
const double span = maximum - minimum;
|
||||
const double denominator = static_cast<double>(std::max<std::size_t>(1, values.size() - 1));
|
||||
for (std::size_t index = 0; index < values.size(); ++index) {
|
||||
const double x = static_cast<double>(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<double>(signal + 1) / static_cast<double>(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<double>((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 <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 = "生成一条完整功率频谱,样本沿当前频率范围均匀分布。";
|
||||
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<Definition, Frequency_Trace>) {
|
||||
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<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));
|
||||
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<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));
|
||||
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<Definition, Constellation_Diagram>) {
|
||||
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 <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::mt19937_64 engine{generator_count(input, "seed", 4'294'967'295ULL)};
|
||||
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); });
|
||||
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<Definition, Frequency_Trace>) {
|
||||
@@ -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<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); });
|
||||
std::vector<Plot_Value> 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<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); });
|
||||
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<Definition, Waterfall>) {
|
||||
@@ -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<double> distribution(minimum, maximum);
|
||||
std::vector<Waterfall_Row> 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<Plot_Value> 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<Plot_Time_Tick>(row), std::move(row_values)});
|
||||
}
|
||||
object.template set<&Waterfall::Prop::frequency_bin_count>(width);
|
||||
@@ -461,9 +496,38 @@ std::shared_ptr<Plot> 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<Axis_Visible_Count>(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<std::int64_t>((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<Scene_View_Model<decltype(frequency), decltype(numeric), decltype(time)>>(
|
||||
std::move(components), std::move(update),
|
||||
Scene_View_Model<decltype(frequency), decltype(numeric), decltype(time)>::Data_Generator{},
|
||||
Scene_View_Model<decltype(frequency), decltype(numeric), decltype(time)>::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<Plot>(std::move(executor), std::move(scene), std::move(view));
|
||||
}
|
||||
|
||||
@@ -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<decltype(std::declval<Prop>().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<Definition, Volume_Visual>) {
|
||||
@@ -324,7 +326,7 @@ public:
|
||||
prop.field_depth = static_cast<std::uint32_t>(depth);
|
||||
});
|
||||
} else {
|
||||
count = input_count(input, "count");
|
||||
count = input_count(input, "count", 5'000'000);
|
||||
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();
|
||||
@@ -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<bool>();
|
||||
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<Mesh_Visual>& visual, Impl<Axes_3D>&,
|
||||
Impl<Marker_Visual>* 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<Plot> make_datoviz_spectrogram_plot(asio::any_io_executor execut
|
||||
auto marker_result = Impl<Marker_Visual>::Builder{}
|
||||
.set(&Marker_Visual::Prop::items, std::vector<Marker>{
|
||||
{{-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)
|
||||
|
||||
+94
-36
@@ -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<string, unknown>};
|
||||
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<string, Plot_Execution_Policy>;
|
||||
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[]>;
|
||||
@@ -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
|
||||
</div></fieldset>;
|
||||
}
|
||||
|
||||
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 <fieldset className="control markerListControl" title={field_tooltip(field)}><legend>{field_label(field)} <code>{field.key}</code></legend>
|
||||
<div className="markerToolbar"><span>{markers.length} 个 Marker</span><button type="button" onClick={add}>添加 Marker</button></div>
|
||||
<div className="markerRows">{markers.map((marker, index) => <section className="markerRow" key={index}>
|
||||
@@ -670,6 +674,7 @@ function Marker_List_Control({field, on_change}: {field: Field; on_change: (valu
|
||||
<div className="markerCoordinates">{(["x", "y", "z"] as const).map(axis => <label key={axis}><span>{axis === "z" && surface_attached ? "Z(谱面自动值)" : axis.toUpperCase()}</span><input type="number" min="-1" max="1" step="0.01" disabled={axis === "z" && surface_attached} value={marker.position?.[axis] ?? 0} onChange={event => replace(index, {...marker, position: {...marker.position, [axis]: Number(event.target.value)}})}/></label>)}</div>
|
||||
<div className="markerAppearance"><label><span>形状</span><select value={marker.shape ?? "diamond"} onChange={event => replace(index, {...marker, shape: event.target.value})}>{marker_shapes.map(shape => <option key={shape} value={shape}>{enum_label(shape)}</option>)}</select></label>
|
||||
<label><span>直径 px</span><input type="number" min="1" max="256" step="1" value={marker.diameter_px ?? 22} onChange={event => replace(index, {...marker, diameter_px: Number(event.target.value)})}/></label>
|
||||
<label title="在 Marker 旁显示随实际位置更新的 X/Y/Z 值"><span>显示 XYZ</span><input type="checkbox" checked={marker.coordinate_label_visible ?? false} onChange={event => replace(index, {...marker, coordinate_label_visible: event.target.checked})}/></label>
|
||||
<label><span>颜色</span><input type="color" value={rgba_hex(marker.color ?? {red: 255, green: 218, blue: 112, alpha: 255})} onChange={event => replace(index, {...marker, color: update_rgb(marker.color, event.target.value) as Marker_Value["color"]})}/></label></div>
|
||||
</section>)}</div>
|
||||
</fieldset>;
|
||||
@@ -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<Record<string, unknown>>(defaults);
|
||||
const [input, set_input] = useState<Record<string, unknown>>({});
|
||||
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 <section className="analysisSection dataGenerator"><header><div><strong>{generator.label}</strong><span>{generator.description}</span></div></header>
|
||||
<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>
|
||||
<div className="generatorPropGrid">{generator.fields.map(field => <Field_Control key={field.key} field={{...field, value: input[field.key] ?? field.value}} 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>;
|
||||
}
|
||||
|
||||
@@ -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 <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>;
|
||||
: <section className="analysisSection"><strong>当前图形没有原始数据生成能力</strong><p className="muted">此页只编辑当前选中图的后端 Schema 数据输入。</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}) {
|
||||
@@ -1033,20 +1045,40 @@ function Frame_Statistics_Pane({plot, diagnostics, busy, on_refresh, on_reset}:
|
||||
<div className="workspaceBody"><Frame_Diagnostics_View key={plot.id} 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}) {
|
||||
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<Plot_Execution_Policy>) => void; on_select: (plot: Plot) => void;
|
||||
}) {
|
||||
const canvas_ref = useRef<HTMLCanvasElement>(null);
|
||||
const {status, metrics} = use_plot_stream(plot, canvas_ref, true, selected);
|
||||
const card_ref = useRef<HTMLElement>(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 <article className={`card${selected ? " selected" : ""}`} data-plot-id={plot.id} aria-current={selected ? "true" : undefined}
|
||||
onPointerDownCapture={() => on_select(plot)} onFocusCapture={() => on_select(plot)}><header className="cardDragHandle"><div><span className="eyebrow">绘图组件 · {plot.dimension}</span><h2>{plot_labels[plot.id] ?? plot.title}</h2></div><div className="cardRuntime" title={`帧序号 ${metrics?.sequence ?? 0} · 创建于 ${generated_time}`}><div><span className="status">{{IDLE: "待选中", CONNECTING: "重连中", LIVE: "实时", OFFLINE: "已离线"}[status]}</span><span className="framePolicy">{metrics?.delivery === "diagnostics" ? "后台诊断" : metrics ? enum_label(metrics.pacing_mode) : "等待策略"}</span></div><div className="frameMetrics"><span>{metrics ? metrics.frame_rate_fps.toFixed(1) : "--.-"} FPS</span><span>E2E {metrics ? metrics.request_to_pixels_ms.toFixed(1) : "--.-"} ms</span><span>P95 {metrics ? metrics.p95_request_to_pixels_ms.toFixed(1) : "--.-"} ms</span><span>P99 {metrics ? metrics.p99_request_to_pixels_ms.toFixed(1) : "--.-"} ms</span></div></div></header>
|
||||
{plot.description ? <p>{plot.description}</p> : null}<div className="plotViewport"><canvas ref={canvas_ref} tabIndex={0}/></div></article>;
|
||||
return <article ref={card_ref} className={`card${selected ? " selected" : ""}`} data-plot-id={plot.id} aria-current={selected ? "true" : undefined}
|
||||
onClick={() => on_select(plot)} onPointerUpCapture={event => {
|
||||
if (event.target instanceof HTMLCanvasElement) on_select(plot);
|
||||
}}><header className="cardDragHandle"><div><span className="eyebrow">绘图组件 · {plot.dimension}</span><h2>{plot_labels[plot.id] ?? plot.title}</h2></div><div className="cardRuntime" title={`帧序号 ${metrics?.sequence ?? 0} · 创建于 ${generated_time}`}><div><span className="status">{{IDLE: "已停止", CONNECTING: "重连中", LIVE: "实时", OFFLINE: "已离线"}[status]}</span><span className="framePolicy">{metrics?.delivery === "diagnostics" ? "无像素传输" : metrics ? enum_label(metrics.pacing_mode) : "等待策略"}</span></div><div className="frameMetrics"><span>{metrics ? metrics.frame_rate_fps.toFixed(1) : "--.-"} FPS</span><span>E2E {metrics ? metrics.request_to_pixels_ms.toFixed(1) : "--.-"} ms</span><span>P95 {metrics ? metrics.p95_request_to_pixels_ms.toFixed(1) : "--.-"} ms</span><span>P99 {metrics ? metrics.p99_request_to_pixels_ms.toFixed(1) : "--.-"} ms</span></div></div></header>
|
||||
<div className="plotExecutionPolicy" onPointerDown={event => event.stopPropagation()}>
|
||||
<label title="控制这张图的画面是否显示;不改变 Scene 自身的 visible 属性。"><input type="checkbox" checked={policy.visible} onChange={event => on_policy(plot.id, {visible: event.target.checked})}/>显示画面</label>
|
||||
<label title="关闭显示画面后,是否仍持续请求真实后端帧并采集诊断。"><input type="checkbox" checked={policy.refresh_hidden} onChange={event => on_policy(plot.id, {refresh_hidden: event.target.checked})}/>隐藏时继续采样</label>
|
||||
<label title="开启时传输完整 RGBA 像素;关闭时只传诊断 JSON,用于隔离 GPU 渲染与 WebSocket 像素传输开销。"><input type="checkbox" checked={policy.transfer_pixels} onChange={event => on_policy(plot.id, {transfer_pixels: event.target.checked})}/>WebSocket 像素</label>
|
||||
</div>
|
||||
{plot.description ? <p>{plot.description}</p> : null}<div className={`plotViewport${policy.visible ? "" : " plotViewportHidden"}`}><canvas ref={canvas_ref} tabIndex={0}/>{!policy.visible ? <div className="plotHiddenState"><strong>画面不可见</strong><span>{policy.refresh_hidden ? "后端仍在渲染与采样" : "后端帧请求已停止"}</span></div> : null}</div></article>;
|
||||
});
|
||||
|
||||
type Gallery_Breakpoint = "lg" | "md" | "sm" | "xs";
|
||||
const gallery_layout_key = "aethera-gallery-grid-v4";
|
||||
const gallery_breakpoints: Record<Gallery_Breakpoint, number> = {lg: 1280, md: 860, sm: 560, xs: 0};
|
||||
const gallery_layout_key = "aethera-gallery-grid-v8";
|
||||
const gallery_breakpoints: Record<Gallery_Breakpoint, number> = {lg: 1200, md: 760, sm: 420, xs: 0};
|
||||
const gallery_columns: Record<Gallery_Breakpoint, number> = {lg: 12, md: 12, sm: 12, xs: 12};
|
||||
const gallery_item_width: Record<Gallery_Breakpoint, number> = {lg: 4, md: 6, sm: 12, xs: 12};
|
||||
const gallery_item_width: Record<Gallery_Breakpoint, number> = {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<Gallery_Breakpoint> {
|
||||
type Stored_Gallery_Layouts = Record<string, ResponsiveLayouts<Gallery_Breakpoint>>;
|
||||
function load_gallery_layouts(scope: string): ResponsiveLayouts<Gallery_Breakpoint> {
|
||||
const saved = localStorage.getItem(gallery_layout_key);
|
||||
if (!saved) return {};
|
||||
try { return JSON.parse(saved) as ResponsiveLayouts<Gallery_Breakpoint>; }
|
||||
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<Gallery_Breakpoint>) {
|
||||
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<Plot_Execution_Policy>) => void; on_select: (plot: Plot) => void}) {
|
||||
const {width, containerRef, mounted} = useContainerWidth({measureBeforeMount: true});
|
||||
const [stored_layouts, set_stored_layouts] = useState<ResponsiveLayouts<Gallery_Breakpoint>>(load_gallery_layouts);
|
||||
const [stored_layouts, set_stored_layouts] = useState<ResponsiveLayouts<Gallery_Breakpoint>>(() => 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<Gallery_Breakpoint>, [plots, stored_layouts]);
|
||||
const save_layouts = (_layout: readonly LayoutItem[], next: ResponsiveLayouts<Gallery_Breakpoint>) => {
|
||||
set_stored_layouts(next);
|
||||
localStorage.setItem(gallery_layout_key, JSON.stringify(next));
|
||||
save_gallery_layouts(layout_scope, next);
|
||||
};
|
||||
return <div className="plotGridHost" ref={node => { (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node; }}>{mounted ? <Responsive<Gallery_Breakpoint>
|
||||
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 => <div className="plotGridItem" key={plot.id}><Plot_Card plot={plot} selected={selected?.id === plot.id} on_select={on_select}/></div>)}
|
||||
{plots.map(plot => <div className="plotGridItem" key={plot.id}><Plot_Card plot={plot} selected={selected?.id === plot.id} policy={policies[plot.id] ?? default_plot_execution_policy()} on_policy={on_policy} on_select={on_select}/></div>)}
|
||||
</Responsive> : null}</div>;
|
||||
}
|
||||
|
||||
@@ -1181,6 +1219,7 @@ function load_workspace_model() {
|
||||
|
||||
export function App() {
|
||||
const [plots, set_plots] = useState<Plot[]>([]); const [category, set_category] = useState("全部"); const [selected, set_selected] = useState<Plot | null>(null);
|
||||
const [execution_policies, set_execution_policies] = useState<Plot_Execution_Policies>({});
|
||||
const [schema, set_schema] = useState<Schema | null>(null);
|
||||
const [state_histories, set_state_histories] = useState<State_Histories>({});
|
||||
const [frame_diagnostics, set_frame_diagnostics] = useState<Frame_Diagnostics | null>(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<Plot_Execution_Policy>) => {
|
||||
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 = <section className="galleryPanel"><header className="topbar"><div><span className="eyebrow">AETHERA 渲染实验室</span><h1>实时图形组件库</h1></div><div className="topbarActions">
|
||||
{selected ? <span className="selectionName">当前图形 <strong>{plot_labels[selected.id] ?? selected.title}</strong></span> : <span className="muted">点击任意图形后,属性和运行状态会自动同步。</span>}
|
||||
<button onClick={() => { localStorage.removeItem(workspace_layout_key); localStorage.removeItem(gallery_layout_key);
|
||||
set_layout_model(Model.fromJson(default_workspace_layout)); set_gallery_layout_revision(value => value + 1); }}>恢复默认布局</button></div></header>
|
||||
<nav>{categories.map(value => <button key={value} className={category === value ? "active" : ""} onClick={() => set_category(value)}>{value}</button>)}</nav>
|
||||
<Gallery_Grid key={gallery_layout_revision} plots={visible} selected={selected} on_select={set_selected}/></section>;
|
||||
<Gallery_Grid key={`${gallery_layout_revision}:${category}`} plots={visible} selected={selected} policies={execution_policies} layout_scope={category} on_policy={update_execution_policy} on_select={set_selected}/></section>;
|
||||
const factory = (node: TabNode) => {
|
||||
if (node.getComponent() === "gallery") return gallery;
|
||||
if (!selected) return <div className="emptyPane">请选择一个图形组件。</div>;
|
||||
@@ -1254,7 +1307,12 @@ export function App() {
|
||||
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_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() === "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={() => {
|
||||
window.dispatchEvent(new CustomEvent("aethera-reset-frame-diagnostics", {detail: {plot_id: selected.id}}));
|
||||
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>;
|
||||
};
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user