diff --git a/web_server/app/Gallery_Plot_Session.cpp b/web_server/app/Gallery_Plot_Session.cpp index 85cbad5..f0a9895 100644 --- a/web_server/app/Gallery_Plot_Session.cpp +++ b/web_server/app/Gallery_Plot_Session.cpp @@ -23,12 +23,8 @@ #include namespace renderive::web { namespace { -template -const T& state_value(const Gallery_State& state, std::string_view id) { - return std::get(state.values.at(std::string(id))); -} double number_value(const Gallery_State& state, std::string_view id) { - return state_value(state, id); + return std::get(gallery_property_value(state, id)); } std::uint64_t milliseconds_to_ns(double milliseconds) { return milliseconds > 0.0 ? static_cast(std::llround(milliseconds * 1'000'000.0)) : 0; @@ -40,13 +36,13 @@ int integer_value(const Gallery_State& state, std::string_view id) { return static_cast(std::lround(number_value(state, id))); } bool bool_value(const Gallery_State& state, std::string_view id) { - return state_value(state, id); + return std::get(gallery_property_value(state, id)); } -const std::string& string_value(const Gallery_State& state, std::string_view id) { - return state_value(state, id); +std::string string_value(const Gallery_State& state, std::string_view id) { + return std::get(gallery_property_value(state, id)); } bool has_value(const Gallery_State& state, std::string_view id) { - return state.values.contains(id); + return gallery_has_property(state, id); } int hex_digit(char value) { if (value >= '0' && value <= '9') @@ -245,10 +241,8 @@ public: } [[nodiscard]] bool requires_rebuild(const Gallery_State& candidate) const { const auto changed = [this, &candidate](std::string_view id) { - const auto current = state_.values.find(id); - const auto next = candidate.values.find(id); - return current != state_.values.end() && next != candidate.values.end() && - current->second != next->second; + return gallery_has_property(state_, id) && gallery_has_property(candidate, id) && + gallery_property_value(state_, id) != gallery_property_value(candidate, id); }; if (case_id_ == "frequency_trace") return changed("trace_pen") || changed("trace_pen_width"); diff --git a/web_server/app/Gallery_Properties.h b/web_server/app/Gallery_Properties.h new file mode 100644 index 0000000..2664d54 --- /dev/null +++ b/web_server/app/Gallery_Properties.h @@ -0,0 +1,251 @@ +#pragma once +#include +#include +#include +#include +#include +#include +namespace renderive::web { +template +class Gallery_Number final { +public: + using value_type = T; + static constexpr T minimum = Minimum; + static constexpr T maximum = Maximum; + static constexpr T step = Step; + constexpr explicit Gallery_Number(T value) : value_(checked(value)) {} + constexpr Gallery_Number& operator=(T value) { + value_ = checked(value); + return *this; + } + constexpr operator T() const noexcept { + return value_; + } + constexpr T value() const noexcept { + return value_; + } +private: + static constexpr T checked(T value) { + if(value < Minimum || value > Maximum) + throw std::out_of_range("value is outside the declared range"); + return value; + } + T value_; +}; +class Gallery_Text final { +public: + explicit Gallery_Text(std::string value) : value_(checked(std::move(value))) {} + Gallery_Text& operator=(std::string value) { + value_ = checked(std::move(value)); + return *this; + } + const std::string& value() const noexcept { + return value_; + } +private: + static std::string checked(std::string value) { + if(value.size() > 160) + throw std::out_of_range("text is longer than 160 bytes"); + return value; + } + std::string value_; +}; +class Gallery_Color final { +public: + explicit Gallery_Color(std::string value) : value_(checked(std::move(value))) {} + Gallery_Color& operator=(std::string value) { + value_ = checked(std::move(value)); + return *this; + } + const std::string& value() const noexcept { + return value_; + } +private: + static std::string checked(std::string value) { + if(value.size() != 7 || value.front() != '#') + throw std::invalid_argument("color must use #RRGGBB"); + for(const char character : std::string_view(value).substr(1)) { + if(!std::isxdigit(static_cast(character))) + throw std::invalid_argument("color must use #RRGGBB"); + } + return value; + } + std::string value_; +}; +enum class Gallery_Cache_Mode { Direct, Local_Pixel }; +enum class Gallery_Axis_Orientation { Horizontal, Vertical }; +enum class Gallery_Decimal_Separator { Dot, Comma }; +enum class Gallery_Line_Interpolation { Nearest_Sample, Linear_Value, Linear_Power_Domain, Step_Left, Step_Right, Cubic_Value }; +enum class Gallery_Image_Interpolation { Nearest, Bilinear, Bicubic }; +enum class Gallery_Color_Map { Spectrum, Ember, Grayscale }; +enum class Gallery_Constellation_Type { PSK4, PSK8, PSK16 }; +using Gallery_Frequency = Gallery_Number; +using Gallery_Positive_Frequency = Gallery_Number; +using Gallery_Axis_Offset = Gallery_Number; +using Gallery_Axis_Length = Gallery_Number; +using Gallery_Tick_Length = Gallery_Number; +using Gallery_Sub_Tick_Length = Gallery_Number; +using Gallery_Font_Size = Gallery_Number; +using Gallery_Font_Weight = Gallery_Number; +using Gallery_Label_Rotation = Gallery_Number; +using Gallery_Label_Precision = Gallery_Number; +struct Gallery_Common_Properties { + Gallery_Color background_color{"#07111f"}; + bool performance_overlay{}; + bool renderable_visible{true}; + Gallery_Cache_Mode cache_mode{Gallery_Cache_Mode::Local_Pixel}; + Gallery_Text object_name{"Gallery_Renderable"}; +}; +struct Gallery_Low_Latency_Properties { + bool frequency_limit_enabled{true}; + Gallery_Positive_Frequency max_render_fps{30}; + bool consumer_feedback_enabled{true}; + bool consumer_pixel_feedback_enabled{}; + bool consumer_presentation_feedback_enabled{true}; + bool consumer_manual_feedback_enabled{}; + Gallery_Positive_Frequency consumer_manual_fps{60}; +}; +struct Gallery_Axis_Properties { + Gallery_Axis_Orientation axis_orientation{Gallery_Axis_Orientation::Horizontal}; + Gallery_Axis_Offset axis_x_offset{0}; + Gallery_Axis_Offset axis_y_offset{0}; + Gallery_Axis_Length axis_length_percent{100}; + Gallery_Tick_Length tick_length{8}; + Gallery_Sub_Tick_Length sub_tick_length{4}; + Gallery_Color axis_color{"#7691b8"}; + Gallery_Decimal_Separator decimal_separator{Gallery_Decimal_Separator::Dot}; + Gallery_Text unit_text{"Hz"}; + Gallery_Font_Size unit_font_size{12}; + Gallery_Font_Weight unit_font_weight{500}; + bool unit_font_italic{}; + Gallery_Color unit_pen_color{"#dce9ff"}; + Gallery_Color unit_background_color{"#07111f"}; + Gallery_Label_Rotation label_rotation{0}; + Gallery_Frequency coord_origin{88'000'000}; + Gallery_Frequency coord_target{108'000'000}; + Gallery_Label_Precision label_precision{2}; + bool use_wheel{true}; + bool use_drag{true}; +}; +struct Gallery_Time_Axis_Properties { + Gallery_Number time_visible_count{80}; + Gallery_Number time_label_spacing{28}; + Gallery_Text time_format{"mm:ss.zzz"}; + Gallery_Number time_font_size{11}; + bool time_newest_at_start{}; +}; +struct Gallery_Hover_Properties { + bool hover_enabled{true}; + Gallery_Number hover_font_size{12}; + Gallery_Color hover_text_color{"#ffffff"}; + Gallery_Color hover_background{"#111827"}; +}; +struct Gallery_Axis_Lab_Properties : Gallery_Common_Properties, Gallery_Axis_Properties, Gallery_Time_Axis_Properties {}; +struct Gallery_Spectrum_Properties : Gallery_Common_Properties, Gallery_Axis_Properties, Gallery_Hover_Properties { + Gallery_Number frequency_point_size{512}; + Gallery_Frequency frequency_origin{88'000'000}; + Gallery_Frequency frequency_target{108'000'000}; + Gallery_Frequency center_frequency{98'000'000}; + Gallery_Frequency sweep_origin{94'000'000}; + Gallery_Frequency sweep_target{102'000'000}; + bool max_hold_visible{true}; + bool min_hold_visible{}; + bool max_marker_visible{true}; + bool use_min_marker{}; + bool sweep_region_visible{true}; + bool visible_range_only{true}; + Gallery_Line_Interpolation line_interpolation{Gallery_Line_Interpolation::Linear_Value}; + Gallery_Color max_brush{"#332810"}; + Gallery_Color current_brush{"#0e3a35"}; + Gallery_Color min_brush{"#19213a"}; + Gallery_Color max_pen{"#ffd166"}; + Gallery_Color current_pen{"#35e6b2"}; + Gallery_Color min_pen{"#7aa2ff"}; + Gallery_Color selected_marker_pen{"#ff4d8d"}; + Gallery_Color marker_pen{"#ff7a59"}; + Gallery_Color middle_frequency_pen{"#6bc8ff"}; + Gallery_Color sweep_region_brush{"#293257"}; +}; +struct Gallery_Selection_Overlay_Properties : Gallery_Spectrum_Properties { + Gallery_Number selection_font_size{12}; + Gallery_Color selection_label_pen{"#ffffff"}; + Gallery_Color selection_brush{"#173b66"}; + Gallery_Color selection_border{"#7dd3fc"}; +}; +struct Gallery_Waterfall_Properties : Gallery_Common_Properties, Gallery_Axis_Properties, Gallery_Time_Axis_Properties, Gallery_Hover_Properties { + Gallery_Frequency frequency_origin{88'000'000}; + Gallery_Frequency frequency_target{108'000'000}; + Gallery_Number power_origin{-120}; + Gallery_Number power_target{-20}; + Gallery_Number frequency_bin_count{256}; + bool visible_range_only{true}; + Gallery_Image_Interpolation image_interpolation{Gallery_Image_Interpolation::Nearest}; + Gallery_Color_Map color_map{Gallery_Color_Map::Spectrum}; +}; +struct Gallery_Afterglow_Properties : Gallery_Common_Properties, Gallery_Axis_Properties { + Gallery_Frequency frequency_origin{88'000'000}; + Gallery_Frequency frequency_target{108'000'000}; + Gallery_Number power_origin{-120}; + Gallery_Number power_target{-20}; + Gallery_Number frequency_point_size{192}; + Gallery_Number power_point_size{96}; + bool interpolate_power{true}; + Gallery_Number attenuation_rate{0.18}; + Gallery_Color_Map color_map{Gallery_Color_Map::Spectrum}; +}; +struct Gallery_Sweep_Spectrum_Properties : Gallery_Common_Properties, Gallery_Axis_Properties { + Gallery_Sweep_Spectrum_Properties() { + coord_origin = 0; + coord_target = 300; + unit_text = "MHz"; + } + Gallery_Frequency frequency_origin{0}; + Gallery_Frequency frequency_target{300}; + Gallery_Number bins_per_block{64}; + Gallery_Number block_count{8}; + Gallery_Color sweep_pen{"#ffd166"}; + Gallery_Color current_frequency_pen{"#ff5d73"}; + bool visible_range_only{true}; + Gallery_Line_Interpolation line_interpolation{Gallery_Line_Interpolation::Linear_Value}; +}; +struct Gallery_Frequency_Trace_Properties : Gallery_Common_Properties, Gallery_Axis_Properties, Gallery_Time_Axis_Properties { + Gallery_Frequency_Trace_Properties() { + coord_origin = -1; + coord_target = 1; + unit_text = "value"; + } + Gallery_Color trace_pen{"#35e6b2"}; + Gallery_Number trace_pen_width{2}; + Gallery_Number trace_value_min{-1}; + Gallery_Number trace_value_max{1}; +}; +struct Gallery_Constellation_Properties : Gallery_Common_Properties, Gallery_Axis_Properties { + Gallery_Constellation_Properties() { + coord_origin = -1.25; + coord_target = 1.25; + unit_text = "I / Q"; + } + Gallery_Number i_origin{-1.25}; + Gallery_Number i_target{1.25}; + Gallery_Number q_origin{-1.25}; + Gallery_Number q_target{1.25}; + Gallery_Color point_color{"#49e6c3"}; + Gallery_Color anchor_color{"#ffd166"}; + Gallery_Number point_lifetime_ms{1800}; + Gallery_Constellation_Type constellation_type{Gallery_Constellation_Type::PSK8}; + Gallery_Number phase_offset{0}; +}; +template +struct Gallery_Low_Latency_Case_Properties : Properties, Gallery_Low_Latency_Properties {}; +using Gallery_Property_Model = std::variant, + Gallery_Low_Latency_Case_Properties, + Gallery_Low_Latency_Case_Properties, + Gallery_Low_Latency_Case_Properties, + Gallery_Low_Latency_Case_Properties, + Gallery_Low_Latency_Case_Properties, + Gallery_Low_Latency_Case_Properties, + Gallery_Low_Latency_Case_Properties>; +} diff --git a/web_server/app/Gallery_Properties_Adminive.h b/web_server/app/Gallery_Properties_Adminive.h new file mode 100644 index 0000000..92ad1f4 --- /dev/null +++ b/web_server/app/Gallery_Properties_Adminive.h @@ -0,0 +1,423 @@ +#pragma once +#include "Gallery_Properties.h" +#include "adminive/adminive.hpp" +#include "adminive/adapters/nlohmann_json.hpp" +#include +#include +#include +#include +#include +#include +#include +namespace renderive::web::gallery_adminive { +template +struct Enum_Metadata; +template <> +struct Enum_Metadata { + static constexpr std::array values{ + std::pair{Gallery_Cache_Mode::Direct, std::string_view{"Direct"}}, + std::pair{Gallery_Cache_Mode::Local_Pixel, std::string_view{"Local_Pixel"}} + }; +}; +template <> +struct Enum_Metadata { + static constexpr std::array values{ + std::pair{Gallery_Axis_Orientation::Horizontal, std::string_view{"Horizontal"}}, + std::pair{Gallery_Axis_Orientation::Vertical, std::string_view{"Vertical"}} + }; +}; +template <> +struct Enum_Metadata { + static constexpr std::array values{ + std::pair{Gallery_Decimal_Separator::Dot, std::string_view{"."}}, + std::pair{Gallery_Decimal_Separator::Comma, std::string_view{","}} + }; +}; +template <> +struct Enum_Metadata { + static constexpr std::array values{ + std::pair{Gallery_Line_Interpolation::Nearest_Sample, std::string_view{"Nearest_Sample"}}, + std::pair{Gallery_Line_Interpolation::Linear_Value, std::string_view{"Linear_Value"}}, + std::pair{Gallery_Line_Interpolation::Linear_Power_Domain, std::string_view{"Linear_Power_Domain"}}, + std::pair{Gallery_Line_Interpolation::Step_Left, std::string_view{"Step_Left"}}, + std::pair{Gallery_Line_Interpolation::Step_Right, std::string_view{"Step_Right"}}, + std::pair{Gallery_Line_Interpolation::Cubic_Value, std::string_view{"Cubic_Value"}} + }; +}; +template <> +struct Enum_Metadata { + static constexpr std::array values{ + std::pair{Gallery_Image_Interpolation::Nearest, std::string_view{"Nearest"}}, + std::pair{Gallery_Image_Interpolation::Bilinear, std::string_view{"Bilinear"}}, + std::pair{Gallery_Image_Interpolation::Bicubic, std::string_view{"Bicubic"}} + }; +}; +template <> +struct Enum_Metadata { + static constexpr std::array values{ + std::pair{Gallery_Color_Map::Spectrum, std::string_view{"Spectrum"}}, + std::pair{Gallery_Color_Map::Ember, std::string_view{"Ember"}}, + std::pair{Gallery_Color_Map::Grayscale, std::string_view{"Grayscale"}} + }; +}; +template <> +struct Enum_Metadata { + static constexpr std::array values{ + std::pair{Gallery_Constellation_Type::PSK4, std::string_view{"PSK4"}}, + std::pair{Gallery_Constellation_Type::PSK8, std::string_view{"PSK8"}}, + std::pair{Gallery_Constellation_Type::PSK16, std::string_view{"PSK16"}} + }; +}; +template +struct Enum_Adapter { + static std::vector values() { + std::vector result; + result.reserve(Enum_Metadata::values.size()); + for(const auto& [value, name] : Enum_Metadata::values) + result.push_back(value); + return result; + } + static std::string_view name(Enum value) { + for(const auto& [candidate, name] : Enum_Metadata::values) { + if(candidate == value) + return name; + } + return {}; + } + static std::optional cast(std::string_view name) { + for(const auto& [value, candidate] : Enum_Metadata::values) { + if(candidate == name) + return value; + } + return std::nullopt; + } +}; +template +auto boolean_property(std::string name, std::string label, std::string api) { + return adminive::field(std::move(name), std::move(label)).editable().boolean_input().description(std::move(api)); +} +template +auto number_property(std::string name, std::string label, std::string api) { + return adminive::field(std::move(name), std::move(label)).editable().number_input().description(std::move(api)); +} +template +auto text_property(std::string name, std::string label, std::string api) { + return adminive::field(std::move(name), std::move(label)).editable().text_input().description(std::move(api)); +} +template +auto color_property(std::string name, std::string label, std::string api) { + return adminive::field(std::move(name), std::move(label)).editable().color_input().description(std::move(api)); +} +template +auto select_property(std::string name, std::string label, std::string api) { + return adminive::field(std::move(name), std::move(label)).editable().select_input().description(std::move(api)); +} +inline auto common_fields() { + using T = Gallery_Common_Properties; + return std::tuple{ + color_property<&T::background_color>("background_color", "背景颜色", "Plot_Core::set_background_color"), + boolean_property<&T::performance_overlay>("performance_overlay", "性能叠层", "set_performance_overlay_enabled"), + boolean_property<&T::renderable_visible>("renderable_visible", "控件可见", "Renderable::set_visible"), + select_property<&T::cache_mode>("cache_mode", "缓存模式", "Renderable::set_cache_mode"), + text_property<&T::object_name>("object_name", "对象名称", "Renderable::set_object_name") + }; +} +inline auto low_latency_fields() { + using T = Gallery_Low_Latency_Properties; + return std::tuple{ + boolean_property<&T::frequency_limit_enabled>("frequency_limit_enabled", "启用频率上限", "Plot_Core::set_max_render_fps / clear_max_render_fps"), + number_property<&T::max_render_fps>("max_render_fps", "最大渲染 FPS", "Plot_Core::set_max_render_fps"), + boolean_property<&T::consumer_feedback_enabled>("consumer_feedback_enabled", "启用消费者反馈", "Plot_Core::set_consumer_feedback / clear_consumer_feedback"), + boolean_property<&T::consumer_pixel_feedback_enabled>("consumer_pixel_feedback_enabled", "启用像素响应反馈", "Gallery_Plot_Session::set_client_metrics"), + boolean_property<&T::consumer_presentation_feedback_enabled>("consumer_presentation_feedback_enabled", "启用浏览器呈现反馈", "Gallery_Plot_Session::set_client_metrics"), + boolean_property<&T::consumer_manual_feedback_enabled>("consumer_manual_feedback_enabled", "启用手动消费者反馈", "Gallery_Plot_Session::apply_consumer_feedback"), + number_property<&T::consumer_manual_fps>("consumer_manual_fps", "手动消费者 FPS", "Gallery_Plot_Session::apply_consumer_feedback") + }; +} +inline auto axis_fields() { + using T = Gallery_Axis_Properties; + return std::tuple{ + select_property<&T::axis_orientation>("axis_orientation", "主轴方向", "Abs_Axis::set_orientation"), + number_property<&T::axis_x_offset>("axis_x_offset", "X 偏移", "Abs_Axis::set_x"), + number_property<&T::axis_y_offset>("axis_y_offset", "Y 偏移", "Abs_Axis::set_y"), + number_property<&T::axis_length_percent>("axis_length_percent", "轴长百分比", "Abs_Axis::set_pixel_length"), + number_property<&T::tick_length>("tick_length", "主刻度长度", "Abs_Axis::set_tick_length"), + number_property<&T::sub_tick_length>("sub_tick_length", "次刻度长度", "Abs_Axis::set_sub_tick_length"), + color_property<&T::axis_color>("axis_color", "轴颜色", "Abs_Axis::set_color"), + select_property<&T::decimal_separator>("decimal_separator", "小数点", "Abs_Axis::set_locale"), + text_property<&T::unit_text>("unit_text", "单位文本", "Abs_Axis::set_unit_text"), + number_property<&T::unit_font_size>("unit_font_size", "单位字号", "Abs_Axis::set_unit_text_font"), + number_property<&T::unit_font_weight>("unit_font_weight", "单位字重", "Abs_Axis::set_unit_text_font"), + boolean_property<&T::unit_font_italic>("unit_font_italic", "单位斜体", "Abs_Axis::set_unit_text_font"), + color_property<&T::unit_pen_color>("unit_pen_color", "单位文字颜色", "Abs_Axis::set_unit_text_pen"), + color_property<&T::unit_background_color>("unit_background_color", "单位背景颜色", "Abs_Axis::set_unit_text_background_brush"), + number_property<&T::label_rotation>("label_rotation", "标签旋转角度", "Abs_Axis::set_label_rotation_degrees"), + number_property<&T::coord_origin>("coord_origin", "坐标起点", "Axis::set_coord_range"), + number_property<&T::coord_target>("coord_target", "坐标终点", "Axis::set_coord_range"), + number_property<&T::label_precision>("label_precision", "标签精度", "Axis::set_label_precision"), + boolean_property<&T::use_wheel>("use_wheel", "启用滚轮缩放", "Axis::set_use_wheel"), + boolean_property<&T::use_drag>("use_drag", "启用拖拽平移", "Axis::set_use_drag") + }; +} +inline auto time_axis_fields() { + using T = Gallery_Time_Axis_Properties; + return std::tuple{ + number_property<&T::time_visible_count>("time_visible_count", "可见时间点", "Time_Axis::set_visible_time_point_count"), + number_property<&T::time_label_spacing>("time_label_spacing", "时间标签间距", "Time_Axis::set_tick_label_spacing_px"), + text_property<&T::time_format>("time_format", "时间格式", "Time_Axis::set_time_format"), + number_property<&T::time_font_size>("time_font_size", "时间字体", "Time_Axis::set_font"), + boolean_property<&T::time_newest_at_start>("time_newest_at_start", "最新数据位于轴起点", "Time_Axis::set_newest_at_axis_start") + }; +} +inline auto hover_fields() { + using T = Gallery_Hover_Properties; + return std::tuple{ + boolean_property<&T::hover_enabled>("hover_enabled", "悬浮信息", "Hover_Tooltip_Mixin::set_use_hover_info"), + number_property<&T::hover_font_size>("hover_font_size", "悬浮字体", "Hover_Tooltip_Mixin::set_hover_tooltip_font"), + color_property<&T::hover_text_color>("hover_text_color", "悬浮文字", "Hover_Tooltip_Mixin::set_tooltip_text_pen"), + color_property<&T::hover_background>("hover_background", "悬浮背景", "Hover_Tooltip_Mixin::set_hover_tooltip_background_brush") + }; +} +inline auto spectrum_fields() { + using T = Gallery_Spectrum_Properties; + return std::tuple{ + number_property<&T::frequency_point_size>("frequency_point_size", "频率点数", "Spectrum::set_frequency_point_size"), + number_property<&T::frequency_origin>("frequency_origin", "数据频率起点", "Spectrum::set_frequency_range"), + number_property<&T::frequency_target>("frequency_target", "数据频率终点", "Spectrum::set_frequency_range"), + number_property<&T::center_frequency>("center_frequency", "中心频率", "Spectrum::set_center_frequency"), + number_property<&T::sweep_origin>("sweep_origin", "扫频区起点", "Spectrum::set_sweep_frequency_range"), + number_property<&T::sweep_target>("sweep_target", "扫频区终点", "Spectrum::set_sweep_frequency_range"), + boolean_property<&T::max_hold_visible>("max_hold_visible", "最大保持线", "Spectrum::set_max_hold_visible"), + boolean_property<&T::min_hold_visible>("min_hold_visible", "最小保持线", "Spectrum::set_min_hold_visible"), + boolean_property<&T::max_marker_visible>("max_marker_visible", "峰值 Marker", "Spectrum::set_max_marker_visible"), + boolean_property<&T::use_min_marker>("use_min_marker", "最小值 Marker", "Spectrum::set_use_min_marker"), + boolean_property<&T::sweep_region_visible>("sweep_region_visible", "扫频区域", "Spectrum::set_sweep_region_visible"), + boolean_property<&T::visible_range_only>("visible_range_only", "仅绘制可见范围", "Spectrum::set_visible_range_only"), + select_property<&T::line_interpolation>("line_interpolation", "线插值模式", "Spectrum::set_interpolation_mode"), + color_property<&T::max_brush>("max_brush", "最大保持填充", "Spectrum::set_max_brush"), + color_property<&T::current_brush>("current_brush", "当前曲线填充", "Spectrum::set_current_brush"), + color_property<&T::min_brush>("min_brush", "最小保持填充", "Spectrum::set_min_brush"), + color_property<&T::max_pen>("max_pen", "最大保持线颜色", "Spectrum::set_max_pen"), + color_property<&T::current_pen>("current_pen", "当前曲线颜色", "Spectrum::set_current_pen"), + color_property<&T::min_pen>("min_pen", "最小保持线颜色", "Spectrum::set_min_pen"), + color_property<&T::selected_marker_pen>("selected_marker_pen", "选中 Marker", "Spectrum::set_selected_marker_pen"), + color_property<&T::marker_pen>("marker_pen", "Marker 颜色", "Spectrum::set_marker_pen"), + color_property<&T::middle_frequency_pen>("middle_frequency_pen", "中心频率线", "Spectrum::set_middle_frequency_pen"), + color_property<&T::sweep_region_brush>("sweep_region_brush", "扫频区填充", "Spectrum::set_sweep_region_brush") + }; +} +template +struct Property_Fields; +template <> +struct Property_Fields { + static auto get() { + return std::tuple_cat(common_fields(), axis_fields(), time_axis_fields()); + } +}; +template <> +struct Property_Fields { + static auto get() { + return std::tuple_cat(common_fields(), axis_fields(), spectrum_fields(), hover_fields()); + } +}; +template <> +struct Property_Fields { + static auto get() { + using T = Gallery_Selection_Overlay_Properties; + return std::tuple_cat(Property_Fields::get(), std::tuple{ + number_property<&T::selection_font_size>("selection_font_size", "框选标签字体", "Selection_Rectangle_Overlay::set_label_font"), + color_property<&T::selection_label_pen>("selection_label_pen", "框选标签颜色", "Selection_Rectangle_Overlay::set_label_pen"), + color_property<&T::selection_brush>("selection_brush", "框选填充", "Selection_Rectangle_Overlay::set_selection_brush"), + color_property<&T::selection_border>("selection_border", "框选边框", "Selection_Rectangle_Overlay::set_selection_border_pen") + }); + } +}; +template <> +struct Property_Fields { + static auto get() { + using T = Gallery_Waterfall_Properties; + return std::tuple_cat(common_fields(), axis_fields(), time_axis_fields(), std::tuple{ + number_property<&T::frequency_origin>("frequency_origin", "频率起点", "Waterfall::set_frequency_range"), + number_property<&T::frequency_target>("frequency_target", "频率终点", "Waterfall::set_frequency_range"), + number_property<&T::power_origin>("power_origin", "功率起点", "Waterfall::set_power_range"), + number_property<&T::power_target>("power_target", "功率终点", "Waterfall::set_power_range"), + number_property<&T::frequency_bin_count>("frequency_bin_count", "频率 Bin", "Waterfall::set_frequency_bin_count"), + boolean_property<&T::visible_range_only>("visible_range_only", "仅绘制可见范围", "Waterfall::set_visible_range_only"), + select_property<&T::image_interpolation>("image_interpolation", "图像插值(三模式)", "Waterfall::set_interpolation_mode"), + select_property<&T::color_map>("color_map", "颜色映射(重建 Builder)", "Waterfall::Builder::set_color_map / Color_Map::set_colors") + }, hover_fields()); + } +}; +template <> +struct Property_Fields { + static auto get() { + using T = Gallery_Afterglow_Properties; + return std::tuple_cat(common_fields(), axis_fields(), std::tuple{ + number_property<&T::frequency_origin>("frequency_origin", "频率起点", "Afterglow::set_frequency_range"), + number_property<&T::frequency_target>("frequency_target", "频率终点", "Afterglow::set_frequency_range"), + number_property<&T::power_origin>("power_origin", "功率起点", "Afterglow::set_power_range"), + number_property<&T::power_target>("power_target", "功率终点", "Afterglow::set_power_range"), + number_property<&T::frequency_point_size>("frequency_point_size", "频率格点", "Afterglow::set_frequency_point_size"), + number_property<&T::power_point_size>("power_point_size", "功率格点", "Afterglow::set_power_point_size"), + boolean_property<&T::interpolate_power>("interpolate_power", "功率 Bin 插值", "Afterglow::set_interpolate"), + number_property<&T::attenuation_rate>("attenuation_rate", "衰减率", "Afterglow::set_attenuation_rate"), + select_property<&T::color_map>("color_map", "颜色映射(重建 Builder)", "Afterglow::Builder::set_color_map / Color_Map::set_colors") + }); + } +}; +template <> +struct Property_Fields { + static auto get() { + using T = Gallery_Sweep_Spectrum_Properties; + return std::tuple_cat(common_fields(), axis_fields(), std::tuple{ + number_property<&T::frequency_origin>("frequency_origin", "扫频起点", "Sweep_Spectrum::set_frequency_range"), + number_property<&T::frequency_target>("frequency_target", "扫频终点", "Sweep_Spectrum::set_frequency_range"), + number_property<&T::bins_per_block>("bins_per_block", "每块 Bin", "Sweep_Spectrum::set_bins_per_block"), + number_property<&T::block_count>("block_count", "块数量", "Sweep_Spectrum::set_block_count"), + color_property<&T::sweep_pen>("sweep_pen", "扫频曲线", "Sweep_Spectrum::set_pen"), + color_property<&T::current_frequency_pen>("current_frequency_pen", "当前频率游标", "Sweep_Spectrum::set_cur_frequency_pen"), + boolean_property<&T::visible_range_only>("visible_range_only", "仅绘制可见范围", "Sweep_Spectrum::set_visible_range_only"), + select_property<&T::line_interpolation>("line_interpolation", "线插值模式", "Sweep_Spectrum::set_interpolation_mode") + }); + } +}; +template <> +struct Property_Fields { + static auto get() { + using T = Gallery_Frequency_Trace_Properties; + return std::tuple_cat(common_fields(), axis_fields(), time_axis_fields(), std::tuple{ + color_property<&T::trace_pen>("trace_pen", "轨迹颜色(重建 Builder)", "Frequency_Trace::Builder::set_pen"), + number_property<&T::trace_pen_width>("trace_pen_width", "轨迹宽度(重建 Builder)", "Frequency_Trace::Builder::set_pen"), + number_property<&T::trace_value_min>("trace_value_min", "数值下限", "Axis::set_coord_range"), + number_property<&T::trace_value_max>("trace_value_max", "数值上限", "Axis::set_coord_range") + }); + } +}; +template <> +struct Property_Fields { + static auto get() { + using T = Gallery_Constellation_Properties; + return std::tuple_cat(common_fields(), axis_fields(), std::tuple{ + number_property<&T::i_origin>("i_origin", "I 起点", "Constellation_Diagram::set_i_range"), + number_property<&T::i_target>("i_target", "I 终点", "Constellation_Diagram::set_i_range"), + number_property<&T::q_origin>("q_origin", "Q 起点", "Constellation_Diagram::set_q_range"), + number_property<&T::q_target>("q_target", "Q 终点", "Constellation_Diagram::set_q_range"), + color_property<&T::point_color>("point_color", "采样点颜色", "Constellation_Diagram::set_point_color"), + color_property<&T::anchor_color>("anchor_color", "锚点颜色", "Constellation_Diagram::set_anchor_color"), + number_property<&T::point_lifetime_ms>("point_lifetime_ms", "采样点寿命", "Constellation_Diagram::set_point_lifetime_ms"), + select_property<&T::constellation_type>("constellation_type", "星座模式(重建 Builder)", "Constellation_Diagram::Builder::set_type"), + number_property<&T::phase_offset>("phase_offset", "相位偏移(重建 Builder)", "Constellation_Diagram::Builder::set_phase_offset_radians") + }); + } +}; +template +struct Property_Fields> { + static auto get() { + return std::tuple_cat(Property_Fields::get(), low_latency_fields()); + } +}; +struct Property_Validator { + template + void operator()(const T& value, adminive::Validation_Context& context) const { + if constexpr(requires { value.coord_origin; value.coord_target; }) + validate_pair(context, "coord_origin", "coord_target", value.coord_origin, value.coord_target); + if constexpr(requires { value.frequency_origin; value.frequency_target; }) + validate_pair(context, "frequency_origin", "frequency_target", value.frequency_origin, value.frequency_target); + if constexpr(requires { value.power_origin; value.power_target; }) + validate_pair(context, "power_origin", "power_target", value.power_origin, value.power_target); + if constexpr(requires { value.sweep_origin; value.sweep_target; }) + validate_pair(context, "sweep_origin", "sweep_target", value.sweep_origin, value.sweep_target); + if constexpr(requires { value.trace_value_min; value.trace_value_max; }) + validate_pair(context, "trace_value_min", "trace_value_max", value.trace_value_min, value.trace_value_max); + if constexpr(requires { value.i_origin; value.i_target; }) + validate_pair(context, "i_origin", "i_target", value.i_origin, value.i_target); + if constexpr(requires { value.q_origin; value.q_target; }) + validate_pair(context, "q_origin", "q_target", value.q_origin, value.q_target); + } +private: + template + static void validate_pair(adminive::Validation_Context& context, std::string_view origin, std::string_view target, const Origin& origin_value, const Target& target_value) { + if(static_cast(origin_value) == static_cast(target_value)) { + context.error(std::string(origin), std::string(origin) + " 与 " + std::string(target) + " 不能相等"); + context.error(std::string(target), std::string(origin) + " 与 " + std::string(target) + " 不能相等"); + } + } +}; +template +auto property_descriptor() { + auto fields = Property_Fields::get(); + return std::apply([](auto... field) { + return adminive::object("renderive_gallery_properties", "Renderive 控件属性", std::move(field)...).validator(Property_Validator{}); + }, std::move(fields)); +} +} +namespace adminive { +template +struct Value_Adapter, Json> { + using Storage = renderive::web::Gallery_Number; + using value_type = T; + static constexpr T minimum = Minimum; + static constexpr T maximum = Maximum; + static constexpr T multiple_of = Step; + static T read(const Storage& value) noexcept { + return value.value(); + } + static void write(Storage& target, T value) { + target = value; + } +}; +template +struct Value_Adapter { + using value_type = std::string; + static const std::string& read(const renderive::web::Gallery_Text& value) noexcept { + return value.value(); + } + static void write(renderive::web::Gallery_Text& target, std::string value) { + target = std::move(value); + } +}; +template +struct Value_Adapter { + using value_type = std::string; + static const std::string& read(const renderive::web::Gallery_Color& value) noexcept { + return value.value(); + } + static void write(renderive::web::Gallery_Color& target, std::string value) { + target = std::move(value); + } +}; +#define RENDERIVE_GALLERY_ENUM_ADAPTER(Type) \ +template <> \ +struct Enum_Adapter : renderive::web::gallery_adminive::Enum_Adapter {} +RENDERIVE_GALLERY_ENUM_ADAPTER(Gallery_Cache_Mode); +RENDERIVE_GALLERY_ENUM_ADAPTER(Gallery_Axis_Orientation); +RENDERIVE_GALLERY_ENUM_ADAPTER(Gallery_Decimal_Separator); +RENDERIVE_GALLERY_ENUM_ADAPTER(Gallery_Line_Interpolation); +RENDERIVE_GALLERY_ENUM_ADAPTER(Gallery_Image_Interpolation); +RENDERIVE_GALLERY_ENUM_ADAPTER(Gallery_Color_Map); +RENDERIVE_GALLERY_ENUM_ADAPTER(Gallery_Constellation_Type); +#undef RENDERIVE_GALLERY_ENUM_ADAPTER +#define RENDERIVE_GALLERY_PROPERTY_DESCRIPTOR(Type) \ +template <> \ +struct Type_Descriptor { \ + static auto get() { \ + return renderive::web::gallery_adminive::property_descriptor(); \ + } \ +} +RENDERIVE_GALLERY_PROPERTY_DESCRIPTOR(Gallery_Axis_Lab_Properties); +RENDERIVE_GALLERY_PROPERTY_DESCRIPTOR(Gallery_Spectrum_Properties); +RENDERIVE_GALLERY_PROPERTY_DESCRIPTOR(Gallery_Selection_Overlay_Properties); +RENDERIVE_GALLERY_PROPERTY_DESCRIPTOR(Gallery_Waterfall_Properties); +RENDERIVE_GALLERY_PROPERTY_DESCRIPTOR(Gallery_Afterglow_Properties); +RENDERIVE_GALLERY_PROPERTY_DESCRIPTOR(Gallery_Sweep_Spectrum_Properties); +RENDERIVE_GALLERY_PROPERTY_DESCRIPTOR(Gallery_Frequency_Trace_Properties); +RENDERIVE_GALLERY_PROPERTY_DESCRIPTOR(Gallery_Constellation_Properties); +#undef RENDERIVE_GALLERY_PROPERTY_DESCRIPTOR +template +struct Type_Descriptor> { + static auto get() { + using T = renderive::web::Gallery_Low_Latency_Case_Properties; + return renderive::web::gallery_adminive::property_descriptor(); + } +}; +} diff --git a/web_server/app/Gallery_Protocol.cpp b/web_server/app/Gallery_Protocol.cpp index e5504d6..841147c 100644 --- a/web_server/app/Gallery_Protocol.cpp +++ b/web_server/app/Gallery_Protocol.cpp @@ -1,13 +1,17 @@ #include "Gallery_Protocol.h" +#include "Gallery_Properties_Adminive.h" #include "adminive/adminive.hpp" #include "adminive/adapters/nlohmann_json.hpp" #include +#include #include #include #include #include +#include +#include #include #include @@ -26,19 +30,6 @@ struct Case_Model { int preferred_height = 320; }; -struct Control_Model { - std::string id; - std::string label; - std::string api; - std::string description; - std::string group; - std::string input; - bool integer{}; - double minimum{}; - double maximum{}; - double step{}; -}; - struct Action_Model { std::string id; std::string label; @@ -51,12 +42,6 @@ struct Action_Model { bool request_frame{}; }; -struct Control_Definition { - Control_Model model; - Gallery_Value initial; - std::vector options; -}; - struct Action_Definition { Action_Model model; }; @@ -83,318 +68,6 @@ const std::vector& cases() { return value; } -Control_Definition flag(std::string id, std::string label, std::string api, - bool initial, std::string group, std::string description = {}) { - return {{std::move(id), std::move(label), std::move(api), std::move(description), - std::move(group), "boolean"}, initial, {}}; -} - -Control_Definition number(std::string id, std::string label, std::string api, - double initial, double minimum, double maximum, double step, - std::string group, bool integer = false, - std::string description = {}) { - return {{std::move(id), std::move(label), std::move(api), std::move(description), - std::move(group), "number", integer, minimum, maximum, step}, initial, {}}; -} - -Control_Definition text_control(std::string id, std::string label, std::string api, - std::string initial, std::string group, - std::string description = {}) { - return {{std::move(id), std::move(label), std::move(api), std::move(description), - std::move(group), "text"}, std::move(initial), {}}; -} - -Control_Definition color(std::string id, std::string label, std::string api, - std::string initial, std::string group, - std::string description = {}) { - auto result = text_control(std::move(id), std::move(label), std::move(api), - std::move(initial), std::move(group), std::move(description)); - result.model.input = "color"; - return result; -} - -Control_Definition select(std::string id, std::string label, std::string api, - std::string initial, std::vector options, - std::string group, std::string description = {}) { - return {{std::move(id), std::move(label), std::move(api), std::move(description), - std::move(group), "select"}, std::move(initial), std::move(options)}; -} - -void append_common(std::vector& result, Gallery_Frame_Mode frame_mode) { - result.push_back(color("background_color", "背景颜色", "Plot_Core::set_background_color", - "#07111f", "Plot_Core")); - if (frame_mode == Gallery_Frame_Mode::Low_Latency) { - result.push_back(flag("frequency_limit_enabled", "启用频率上限", - "Plot_Core::set_max_render_fps / clear_max_render_fps", true, - "Low_Latency_Strategy", "关闭后用户频率不参与调度。")); - result.push_back(number("max_render_fps", "最大渲染 FPS", "Plot_Core::set_max_render_fps", - 30, 0.01, 1'000'000'000, 1, "Low_Latency_Strategy", false, - "仅在启用频率上限时参与调度。")); - result.push_back(flag("consumer_feedback_enabled", "启用消费者反馈", - "Plot_Core::set_consumer_feedback / clear_consumer_feedback", true, - "Low_Latency_Strategy", "消费者反馈总开关;关闭后所有消费者来源都不参与调度。")); - result.push_back(flag("consumer_pixel_feedback_enabled", "启用像素响应反馈", - "Gallery_Plot_Session::set_client_metrics", false, - "Low_Latency_Strategy", "启用后按浏览器实际收到像素帧的 FPS 计算消费者周期。")); - result.push_back(flag("consumer_presentation_feedback_enabled", "启用浏览器呈现反馈", - "Gallery_Plot_Session::set_client_metrics", true, - "Low_Latency_Strategy", "启用后按浏览器 RAF 中位周期计算消费者周期。")); - result.push_back(flag("consumer_manual_feedback_enabled", "启用手动消费者反馈", - "Gallery_Plot_Session::apply_consumer_feedback", false, - "Low_Latency_Strategy", "启用后手动消费者 FPS 参与消费者限速。")); - result.push_back(number("consumer_manual_fps", "手动消费者 FPS", - "Gallery_Plot_Session::apply_consumer_feedback", - 60, 0.01, 1'000'000'000, 1, "Low_Latency_Strategy", false, - "仅在启用手动消费者反馈时参与调度。")); - } - result.push_back(flag("performance_overlay", "性能叠层", "set_performance_overlay_enabled", - false, "Plot_Core")); - result.push_back(flag("renderable_visible", "控件可见", "Renderable::set_visible", - true, "Renderable")); - result.push_back(select("cache_mode", "缓存模式", "Renderable::set_cache_mode", "Local_Pixel", - {"Direct", "Local_Pixel"}, "Renderable")); - result.push_back(text_control("object_name", "对象名称", "Renderable::set_object_name", - "Gallery_Renderable", "Renderable")); -} - -void append_axis(std::vector& result) { - result.push_back(select("axis_orientation", "主轴方向", "Abs_Axis::set_orientation", "Horizontal", - {"Horizontal", "Vertical"}, "Abs_Axis")); - result.push_back(number("axis_x_offset", "X 偏移", "Abs_Axis::set_x", 0, -160, 160, 1, - "Abs_Axis", true)); - result.push_back(number("axis_y_offset", "Y 偏移", "Abs_Axis::set_y", 0, -160, 160, 1, - "Abs_Axis", true)); - result.push_back(number("axis_length_percent", "轴长百分比", "Abs_Axis::set_pixel_length", 100, - 25, 125, 1, "Abs_Axis", true)); - result.push_back(number("tick_length", "主刻度长度", "Abs_Axis::set_tick_length", 8, - -24, 24, 1, "Abs_Axis", true)); - result.push_back(number("sub_tick_length", "次刻度长度", "Abs_Axis::set_sub_tick_length", 4, - -16, 16, 1, "Abs_Axis", true)); - result.push_back(color("axis_color", "轴颜色", "Abs_Axis::set_color", "#7691b8", "Abs_Axis")); - result.push_back(select("decimal_separator", "小数点", "Abs_Axis::set_locale", ".", - {".", ","}, "Abs_Axis")); - result.push_back(text_control("unit_text", "单位文本", "Abs_Axis::set_unit_text", "Hz", "Abs_Axis")); - result.push_back(number("unit_font_size", "单位字号", "Abs_Axis::set_unit_text_font", 12, - 6, 36, 1, "Abs_Axis")); - result.push_back(number("unit_font_weight", "单位字重", "Abs_Axis::set_unit_text_font", 500, - 100, 900, 100, "Abs_Axis", true)); - result.push_back(flag("unit_font_italic", "单位斜体", "Abs_Axis::set_unit_text_font", false, - "Abs_Axis")); - result.push_back(color("unit_pen_color", "单位文字颜色", "Abs_Axis::set_unit_text_pen", - "#dce9ff", "Abs_Axis")); - result.push_back(color("unit_background_color", "单位背景颜色", - "Abs_Axis::set_unit_text_background_brush", "#07111f", "Abs_Axis")); - result.push_back(number("label_rotation", "标签旋转角度", "Abs_Axis::set_label_rotation_degrees", - 0, -90, 90, 1, "Abs_Axis", true)); - result.push_back(number("coord_origin", "坐标起点", "Axis::set_coord_range", 88'000'000, - -1e10, 1e10, 1000, "Axis")); - result.push_back(number("coord_target", "坐标终点", "Axis::set_coord_range", 108'000'000, - -1e10, 1e10, 1000, "Axis")); - result.push_back(number("label_precision", "标签精度", "Axis::set_label_precision", 2, - 0, 9, 1, "Axis", true)); - result.push_back(flag("use_wheel", "启用滚轮缩放", "Axis::set_use_wheel", true, "Axis")); - result.push_back(flag("use_drag", "启用拖拽平移", "Axis::set_use_drag", true, "Axis")); -} - -void append_time_axis(std::vector& result) { - result.push_back(number("time_visible_count", "可见时间点", "Time_Axis::set_visible_time_point_count", - 80, 8, 400, 1, "Time_Axis", true)); - result.push_back(number("time_label_spacing", "时间标签间距", "Time_Axis::set_tick_label_spacing_px", - 28, 0, 120, 1, "Time_Axis", true)); - result.push_back(text_control("time_format", "时间格式", "Time_Axis::set_time_format", - "mm:ss.zzz", "Time_Axis")); - result.push_back(number("time_font_size", "时间字体", "Time_Axis::set_font", 11, - 6, 32, 1, "Time_Axis")); - result.push_back(flag("time_newest_at_start", "最新数据位于轴起点", - "Time_Axis::set_newest_at_axis_start", false, "Time_Axis")); -} - -void append_hover(std::vector& result) { - result.push_back(flag("hover_enabled", "悬浮信息", "Hover_Tooltip_Mixin::set_use_hover_info", - true, "Hover Tooltip")); - result.push_back(number("hover_font_size", "悬浮字体", "Hover_Tooltip_Mixin::set_hover_tooltip_font", - 12, 6, 32, 1, "Hover Tooltip")); - result.push_back(color("hover_text_color", "悬浮文字", "Hover_Tooltip_Mixin::set_tooltip_text_pen", - "#ffffff", "Hover Tooltip")); - result.push_back(color("hover_background", "悬浮背景", - "Hover_Tooltip_Mixin::set_hover_tooltip_background_brush", - "#111827", "Hover Tooltip")); -} - -std::vector controls(std::string_view case_id, - Gallery_Frame_Mode frame_mode) { - std::vector result; - append_common(result, frame_mode); - append_axis(result); - - const auto set_initial = [&result](std::string_view id, Gallery_Value value) { - const auto found = std::find_if(result.begin(), result.end(), [id](const auto& item) { - return item.model.id == id; - }); - if (found != result.end()) - found->initial = std::move(value); - }; - if (case_id == "sweep_spectrum") { - set_initial("coord_origin", 0.0); - set_initial("coord_target", 300.0); - set_initial("unit_text", std::string("MHz")); - } else if (case_id == "frequency_trace") { - set_initial("coord_origin", -1.0); - set_initial("coord_target", 1.0); - set_initial("unit_text", std::string("value")); - } else if (case_id == "constellation") { - set_initial("coord_origin", -1.25); - set_initial("coord_target", 1.25); - set_initial("unit_text", std::string("I / Q")); - } - - if (case_id == "axis_lab") { - append_time_axis(result); - return result; - } - if (case_id == "spectrum" || case_id == "selection_overlay") { - result.push_back(number("frequency_point_size", "频率点数", "Spectrum::set_frequency_point_size", - 512, 16, 4096, 16, "Spectrum", true)); - result.push_back(number("frequency_origin", "数据频率起点", "Spectrum::set_frequency_range", - 88e6, -1e10, 1e10, 1000, "Spectrum")); - result.push_back(number("frequency_target", "数据频率终点", "Spectrum::set_frequency_range", - 108e6, -1e10, 1e10, 1000, "Spectrum")); - result.push_back(number("center_frequency", "中心频率", "Spectrum::set_center_frequency", - 98e6, -1e10, 1e10, 1000, "Spectrum")); - result.push_back(number("sweep_origin", "扫频区起点", "Spectrum::set_sweep_frequency_range", - 94e6, -1e10, 1e10, 1000, "Spectrum")); - result.push_back(number("sweep_target", "扫频区终点", "Spectrum::set_sweep_frequency_range", - 102e6, -1e10, 1e10, 1000, "Spectrum")); - result.push_back(flag("max_hold_visible", "最大保持线", "Spectrum::set_max_hold_visible", true, "Spectrum")); - result.push_back(flag("min_hold_visible", "最小保持线", "Spectrum::set_min_hold_visible", false, "Spectrum")); - result.push_back(flag("max_marker_visible", "峰值 Marker", "Spectrum::set_max_marker_visible", true, "Spectrum")); - result.push_back(flag("use_min_marker", "最小值 Marker", "Spectrum::set_use_min_marker", false, "Spectrum")); - result.push_back(flag("sweep_region_visible", "扫频区域", "Spectrum::set_sweep_region_visible", true, "Spectrum")); - result.push_back(flag("visible_range_only", "仅绘制可见范围", "Spectrum::set_visible_range_only", true, "Spectrum")); - result.push_back(select("line_interpolation", "线插值模式", "Spectrum::set_interpolation_mode", - "Linear_Value", {"Nearest_Sample", "Linear_Value", "Linear_Power_Domain", - "Step_Left", "Step_Right", "Cubic_Value"}, "Spectrum")); - result.push_back(color("max_brush", "最大保持填充", "Spectrum::set_max_brush", "#332810", "Spectrum")); - result.push_back(color("current_brush", "当前曲线填充", "Spectrum::set_current_brush", "#0e3a35", "Spectrum")); - result.push_back(color("min_brush", "最小保持填充", "Spectrum::set_min_brush", "#19213a", "Spectrum")); - result.push_back(color("max_pen", "最大保持线颜色", "Spectrum::set_max_pen", "#ffd166", "Spectrum")); - result.push_back(color("current_pen", "当前曲线颜色", "Spectrum::set_current_pen", "#35e6b2", "Spectrum")); - result.push_back(color("min_pen", "最小保持线颜色", "Spectrum::set_min_pen", "#7aa2ff", "Spectrum")); - result.push_back(color("selected_marker_pen", "选中 Marker", "Spectrum::set_selected_marker_pen", "#ff4d8d", "Spectrum")); - result.push_back(color("marker_pen", "Marker 颜色", "Spectrum::set_marker_pen", "#ff7a59", "Spectrum")); - result.push_back(color("middle_frequency_pen", "中心频率线", "Spectrum::set_middle_frequency_pen", "#6bc8ff", "Spectrum")); - result.push_back(color("sweep_region_brush", "扫频区填充", "Spectrum::set_sweep_region_brush", "#293257", "Spectrum")); - append_hover(result); - if (case_id == "selection_overlay") { - result.push_back(number("selection_font_size", "框选标签字体", "Selection_Rectangle_Overlay::set_label_font", - 12, 6, 32, 1, "Selection Overlay")); - result.push_back(color("selection_label_pen", "框选标签颜色", "Selection_Rectangle_Overlay::set_label_pen", - "#ffffff", "Selection Overlay")); - result.push_back(color("selection_brush", "框选填充", "Selection_Rectangle_Overlay::set_selection_brush", - "#173b66", "Selection Overlay")); - result.push_back(color("selection_border", "框选边框", "Selection_Rectangle_Overlay::set_selection_border_pen", - "#7dd3fc", "Selection Overlay")); - } - return result; - } - if (case_id == "waterfall") { - append_time_axis(result); - result.push_back(number("frequency_origin", "频率起点", "Waterfall::set_frequency_range", 88e6, - -1e10, 1e10, 1000, "Waterfall")); - result.push_back(number("frequency_target", "频率终点", "Waterfall::set_frequency_range", 108e6, - -1e10, 1e10, 1000, "Waterfall")); - result.push_back(number("power_origin", "功率起点", "Waterfall::set_power_range", -120, - -240, 120, 1, "Waterfall")); - result.push_back(number("power_target", "功率终点", "Waterfall::set_power_range", -20, - -240, 120, 1, "Waterfall")); - result.push_back(number("frequency_bin_count", "频率 Bin", "Waterfall::set_frequency_bin_count", - 256, 16, 2048, 16, "Waterfall", true)); - result.push_back(flag("visible_range_only", "仅绘制可见范围", "Waterfall::set_visible_range_only", true, "Waterfall")); - result.push_back(select("image_interpolation", "图像插值(三模式)", "Waterfall::set_interpolation_mode", - "Nearest", {"Nearest", "Bilinear", "Bicubic"}, "Waterfall")); - result.push_back(select("color_map", "颜色映射(重建 Builder)", - "Waterfall::Builder::set_color_map / Color_Map::set_colors", - "Spectrum", {"Spectrum", "Ember", "Grayscale"}, "Waterfall")); - append_hover(result); - return result; - } - if (case_id == "afterglow") { - result.push_back(number("frequency_origin", "频率起点", "Afterglow::set_frequency_range", 88e6, - -1e10, 1e10, 1000, "Afterglow")); - result.push_back(number("frequency_target", "频率终点", "Afterglow::set_frequency_range", 108e6, - -1e10, 1e10, 1000, "Afterglow")); - result.push_back(number("power_origin", "功率起点", "Afterglow::set_power_range", -120, - -240, 120, 1, "Afterglow")); - result.push_back(number("power_target", "功率终点", "Afterglow::set_power_range", -20, - -240, 120, 1, "Afterglow")); - result.push_back(number("frequency_point_size", "频率格点", "Afterglow::set_frequency_point_size", - 192, 16, 1024, 8, "Afterglow", true)); - result.push_back(number("power_point_size", "功率格点", "Afterglow::set_power_point_size", - 96, 8, 512, 8, "Afterglow", true)); - result.push_back(flag("interpolate_power", "功率 Bin 插值", "Afterglow::set_interpolate", true, "Afterglow")); - result.push_back(number("attenuation_rate", "衰减率", "Afterglow::set_attenuation_rate", 0.18, - 0.01, 1, 0.01, "Afterglow")); - result.push_back(select("color_map", "颜色映射(重建 Builder)", - "Afterglow::Builder::set_color_map / Color_Map::set_colors", - "Spectrum", {"Spectrum", "Ember", "Grayscale"}, "Afterglow")); - return result; - } - if (case_id == "sweep_spectrum") { - result.push_back(number("frequency_origin", "扫频起点", "Sweep_Spectrum::set_frequency_range", 0, - -1e10, 1e10, 1, "Sweep Spectrum")); - result.push_back(number("frequency_target", "扫频终点", "Sweep_Spectrum::set_frequency_range", 300, - -1e10, 1e10, 1, "Sweep Spectrum")); - result.push_back(number("bins_per_block", "每块 Bin", "Sweep_Spectrum::set_bins_per_block", 64, - 2, 1024, 1, "Sweep Spectrum", true)); - result.push_back(number("block_count", "块数量", "Sweep_Spectrum::set_block_count", 8, - 1, 128, 1, "Sweep Spectrum", true)); - result.push_back(color("sweep_pen", "扫频曲线", "Sweep_Spectrum::set_pen", "#ffd166", "Sweep Spectrum")); - result.push_back(color("current_frequency_pen", "当前频率游标", "Sweep_Spectrum::set_cur_frequency_pen", - "#ff5d73", "Sweep Spectrum")); - result.push_back(flag("visible_range_only", "仅绘制可见范围", "Sweep_Spectrum::set_visible_range_only", true, "Sweep Spectrum")); - result.push_back(select("line_interpolation", "线插值模式", "Sweep_Spectrum::set_interpolation_mode", - "Linear_Value", {"Nearest_Sample", "Linear_Value", "Linear_Power_Domain", - "Step_Left", "Step_Right", "Cubic_Value"}, "Sweep Spectrum")); - return result; - } - if (case_id == "frequency_trace") { - append_time_axis(result); - result.push_back(color("trace_pen", "轨迹颜色(重建 Builder)", "Frequency_Trace::Builder::set_pen", - "#35e6b2", "Frequency Trace")); - result.push_back(number("trace_pen_width", "轨迹宽度(重建 Builder)", "Frequency_Trace::Builder::set_pen", - 2, 0.25, 12, 0.25, "Frequency Trace")); - result.push_back(number("trace_value_min", "数值下限", "Axis::set_coord_range", -1, - -1e6, 1e6, 0.1, "Frequency Trace")); - result.push_back(number("trace_value_max", "数值上限", "Axis::set_coord_range", 1, - -1e6, 1e6, 0.1, "Frequency Trace")); - return result; - } - if (case_id == "constellation") { - result.push_back(number("i_origin", "I 起点", "Constellation_Diagram::set_i_range", -1.25, - -1000, 1000, 0.05, "Constellation")); - result.push_back(number("i_target", "I 终点", "Constellation_Diagram::set_i_range", 1.25, - -1000, 1000, 0.05, "Constellation")); - result.push_back(number("q_origin", "Q 起点", "Constellation_Diagram::set_q_range", -1.25, - -1000, 1000, 0.05, "Constellation")); - result.push_back(number("q_target", "Q 终点", "Constellation_Diagram::set_q_range", 1.25, - -1000, 1000, 0.05, "Constellation")); - result.push_back(color("point_color", "采样点颜色", "Constellation_Diagram::set_point_color", - "#49e6c3", "Constellation")); - result.push_back(color("anchor_color", "锚点颜色", "Constellation_Diagram::set_anchor_color", - "#ffd166", "Constellation")); - result.push_back(number("point_lifetime_ms", "采样点寿命", "Constellation_Diagram::set_point_lifetime_ms", - 1800, 50, 20000, 50, "Constellation", true)); - result.push_back(select("constellation_type", "星座模式(重建 Builder)", - "Constellation_Diagram::Builder::set_type", "PSK8", - {"PSK4", "PSK8", "PSK16"}, "Constellation")); - result.push_back(number("phase_offset", "相位偏移(重建 Builder)", - "Constellation_Diagram::Builder::set_phase_offset_radians", 0, - -6.283185307, 6.283185307, 0.05, "Constellation")); - return result; - } - return result; -} - Action_Definition action(std::string id, std::string label, std::string api, std::string group, std::string description = {}, std::string argument_input = {}, std::string argument_label = {}, @@ -509,102 +182,70 @@ const Case_Model* find_case(std::string_view id) { return found == all.end() ? nullptr : &*found; } -const Control_Definition* find_control(const std::vector& all, - std::string_view id) { - const auto found = std::find_if(all.begin(), all.end(), - [id](const Control_Definition& item) { - return item.model.id == id; - }); - return found == all.end() ? nullptr : &*found; -} - -bool valid_color(std::string_view value) { - if (value.size() != 7 || value.front() != '#') - return false; - return std::all_of(value.begin() + 1, value.end(), [](char character) { - return std::isxdigit(static_cast(character)) != 0; - }); -} - -Json value_json(const Gallery_Value& value) { - return std::visit([](const auto& item) { return Json(item); }, value); -} - -std::optional parse_value(const Json& value, - const Control_Definition& definition, - std::string& error) { - if (definition.model.input == "boolean") { - if (!value.is_boolean()) { - error = "必须是布尔值"; - return std::nullopt; - } - return Gallery_Value(value.get()); - } - if (definition.model.input == "number") { - if (!value.is_number()) { - error = "必须是数字"; - return std::nullopt; - } - const double number_value = value.get(); - if (!std::isfinite(number_value) || number_value < definition.model.minimum || - number_value > definition.model.maximum) { - error = "数值超出后端允许范围"; - return std::nullopt; - } - if (definition.model.integer && std::floor(number_value) != number_value) { - error = "必须是整数"; - return std::nullopt; - } - return Gallery_Value(number_value); - } - if (!value.is_string()) { - error = "必须是字符串"; - return std::nullopt; - } - std::string string_value = value.get(); - if (definition.model.input == "color" && !valid_color(string_value)) { - error = "颜色必须使用 #RRGGBB"; - return std::nullopt; - } - if (definition.model.input == "select" && - std::find(definition.options.begin(), definition.options.end(), string_value) == - definition.options.end()) { - error = "枚举值不在后端选项中"; - return std::nullopt; - } - if (string_value.size() > 160) { - error = "文本过长"; - return std::nullopt; - } - return Gallery_Value(std::move(string_value)); -} - -double numeric(const Gallery_State& state, std::string_view id) { - const auto found = state.values.find(id); - return found == state.values.end() ? 0.0 : std::get(found->second); -} - -std::optional range_error(const Gallery_State& state) { - constexpr std::pair pairs[] = { - {"coord_origin", "coord_target"}, {"frequency_origin", "frequency_target"}, - {"power_origin", "power_target"}, {"sweep_origin", "sweep_target"}, - {"trace_value_min", "trace_value_max"}, {"i_origin", "i_target"}, - {"q_origin", "q_target"} - }; - for (const auto& [origin, target] : pairs) { - if (state.values.contains(origin) && state.values.contains(target) && - numeric(state, origin) == numeric(state, target)) { - return std::string(origin) + " 与 " + std::string(target) + " 不能相等"; - } - } - return std::nullopt; -} - Json parse_request(std::string_view message) { return Json::parse(message.begin(), message.end()); } +template +Gallery_Property_Model make_property_model(Gallery_Frame_Mode frame_mode) { + if(frame_mode == Gallery_Frame_Mode::Low_Latency) { + using T = Gallery_Low_Latency_Case_Properties; + return Gallery_Property_Model{std::in_place_type}; + } + return Gallery_Property_Model{std::in_place_type}; +} +Gallery_Property_Model make_property_model(std::string_view case_id, Gallery_Frame_Mode frame_mode) { + if(case_id == "spectrum") + return make_property_model(frame_mode); + if(case_id == "afterglow") + return make_property_model(frame_mode); + if(case_id == "sweep_spectrum") + return make_property_model(frame_mode); + if(case_id == "waterfall") + return make_property_model(frame_mode); + if(case_id == "frequency_trace") + return make_property_model(frame_mode); + if(case_id == "selection_overlay") + return make_property_model(frame_mode); + if(case_id == "constellation") + return make_property_model(frame_mode); + return make_property_model(frame_mode); +} +Gallery_Value gallery_value(const Json& value) { + if(value.is_boolean()) + return value.get(); + if(value.is_number()) + return value.get(); + return value.get(); +} +template +std::optional property_value(const T& value, std::string_view id) { + std::optional result; + const auto descriptor = adminive::describe(); + std::apply([&](const auto&... field) { + ([&] { + if(!result && field.name() == id) + result = gallery_value(adminive::encode_json_value(field.get(value))); + }(), ...); + }, descriptor.fields()); + return result; +} +template +std::size_t property_count(const T&) { + return std::tuple_size_v>; +} +template +Json property_resource(const T& value) { + return { + {"descriptor", adminive::to_descriptor_json()}, + {"view", adminive::to_view_json(adminive::describe_edit_view().titled("控件属性").submit("应用"))}, + {"data", adminive::to_frontend_json(value)} + }; +} +template +adminive::Update_Result apply_property_patch(T& value, const Json& patch) { + return adminive::apply_frontend_patch(value, patch); +} -adminive::Table_View control_view(); adminive::Table_View action_view(); } // namespace renderive::web::gallery_detail @@ -628,25 +269,6 @@ struct Type_Descriptor { } }; -template <> -struct Type_Descriptor { - static auto get() { - using T = renderive::web::gallery_detail::Control_Model; - return object("renderive_gallery_control", - ADMINIVE_FIELD_LABEL(T, id, "字段"), - ADMINIVE_FIELD_LABEL(T, label, "控件"), - ADMINIVE_FIELD_LABEL(T, api, "Core2 API"), - ADMINIVE_FIELD_LABEL(T, description, "说明"), - ADMINIVE_FIELD_LABEL(T, group, "分组"), - ADMINIVE_FIELD_LABEL(T, input, "输入类型"), - ADMINIVE_FIELD_LABEL(T, integer, "整数"), - ADMINIVE_FIELD_LABEL(T, minimum, "最小值"), - ADMINIVE_FIELD_LABEL(T, maximum, "最大值"), - ADMINIVE_FIELD_LABEL(T, step, "步长")) - .label("后端控制菜单字段"); - } -}; - template <> struct Type_Descriptor { static auto get() { @@ -669,16 +291,6 @@ struct Type_Descriptor { namespace renderive::web::gallery_detail { -adminive::Table_View control_view() { - using T = Control_Model; - return adminive::table_view( - adminive::column<&T::label>("控件"), - adminive::column<&T::group>("分组"), - adminive::column<&T::api>("Core2 API"), - adminive::column<&T::input>("输入类型")) - .titled("后端控制菜单"); -} - adminive::Table_View action_view() { using T = Action_Model; return adminive::table_view( @@ -920,6 +532,27 @@ Json frame_mode_contract(Gallery_Frame_Mode mode) { } // namespace +Gallery_Value gallery_property_value(const Gallery_State& state, std::string_view id) { + const auto result = std::visit([id](const auto& properties) { + return gallery_detail::property_value(properties, id); + }, state.properties); + if(!result) + throw std::out_of_range("gallery property does not exist: " + std::string(id)); + return *result; +} + +bool gallery_has_property(const Gallery_State& state, std::string_view id) { + return std::visit([id](const auto& properties) { + return gallery_detail::property_value(properties, id).has_value(); + }, state.properties); +} + +std::size_t gallery_property_count(const Gallery_State& state) { + return std::visit([](const auto& properties) { + return gallery_detail::property_count(properties); + }, state.properties); +} + std::string Gallery_Protocol::catalog_json() { Json result = protocol_base(); result["type"] = "catalog"; @@ -976,10 +609,7 @@ bool Gallery_Protocol::is_case(std::string_view case_id) { Gallery_State Gallery_Protocol::default_state(std::string_view case_id, Gallery_Frame_Mode frame_mode) { - Gallery_State state; - for (const auto& definition : gallery_detail::controls(case_id, frame_mode)) - state.values.emplace(definition.model.id, definition.initial); - return state; + return {gallery_detail::make_property_model(case_id, frame_mode)}; } Gallery_State Gallery_Protocol::default_state(std::string_view case_id) { @@ -995,21 +625,9 @@ std::string Gallery_Protocol::case_json(std::string_view case_id, result["type"] = "case_state"; result["case"] = case_contract(case_id); result["frame_mode"] = frame_mode_contract(frame_mode); - result["controls"] = { - {"descriptor", adminive::to_descriptor_json()}, - {"view", adminive::to_view_json( - gallery_detail::control_view())}, - {"data", Json::array()} - }; - for (const auto& definition : gallery_detail::controls(case_id, frame_mode)) { - Json item = adminive::to_frontend_json(definition.model); - const auto found = state.values.find(definition.model.id); - item["value"] = found == state.values.end() - ? gallery_detail::value_json(definition.initial) - : gallery_detail::value_json(found->second); - item["options"] = definition.options; - result["controls"]["data"].push_back(std::move(item)); - } + result["controls"] = std::visit([](const auto& properties) { + return gallery_detail::property_resource(properties); + }, state.properties); result["actions"] = { {"descriptor", adminive::to_descriptor_json()}, {"view", adminive::to_view_json( @@ -1074,20 +692,17 @@ Gallery_Patch_Result Gallery_Protocol::apply_patch(std::string_view case_id, !request.contains("patch") || !request.at("patch").is_object()) { return {std::nullopt, error_json("gallery_patch 格式无效")}; } - const auto definitions = gallery_detail::controls(case_id, frame_mode); Gallery_State candidate = current; - for (const auto& [id, value] : request.at("patch").items()) { - const auto* definition = gallery_detail::find_control(definitions, id); - if (!definition) - return {std::nullopt, error_json("该控件不属于当前画布", id)}; - std::string validation_error; - auto parsed = gallery_detail::parse_value(value, *definition, validation_error); - if (!parsed) - return {std::nullopt, error_json(validation_error, id)}; - candidate.values[id] = std::move(*parsed); + const auto update = std::visit([&request](auto& properties) { + return gallery_detail::apply_property_patch(properties, request.at("patch")); + }, candidate.properties); + if(!update.success) { + Json response = protocol_base(); + response["type"] = "error"; + response["message"] = update.message; + response["field_errors"] = update.field_errors; + return {std::nullopt, response.dump()}; } - if (const auto error = gallery_detail::range_error(candidate)) - return {std::nullopt, error_json(*error)}; return {std::move(candidate), {}}; } catch (const std::exception& error) { return {std::nullopt, error_json(error.what())}; @@ -1150,7 +765,7 @@ std::optional Gallery_Protocol::action_request( std::size_t Gallery_Protocol::control_count(std::string_view case_id, Gallery_Frame_Mode frame_mode) { - return gallery_detail::controls(case_id, frame_mode).size(); + return gallery_property_count(default_state(case_id, frame_mode)); } std::size_t Gallery_Protocol::control_count(std::string_view case_id) { diff --git a/web_server/app/Gallery_Protocol.h b/web_server/app/Gallery_Protocol.h index c9141d6..08a5c36 100644 --- a/web_server/app/Gallery_Protocol.h +++ b/web_server/app/Gallery_Protocol.h @@ -1,5 +1,5 @@ #pragma once -#include +#include "Gallery_Properties.h" #include #include #include @@ -17,8 +17,11 @@ struct Gallery_Open_Request { }; using Gallery_Value = std::variant; struct Gallery_State { - std::map> values; + Gallery_Property_Model properties; }; +[[nodiscard]] Gallery_Value gallery_property_value(const Gallery_State& state, std::string_view id); +[[nodiscard]] bool gallery_has_property(const Gallery_State& state, std::string_view id); +[[nodiscard]] std::size_t gallery_property_count(const Gallery_State& state); struct Gallery_Patch_Result { std::optional candidate; std::string response_json; diff --git a/web_server/tests/Web_Bridge_Tests.cpp b/web_server/tests/Web_Bridge_Tests.cpp index 85847eb..c25db10 100644 --- a/web_server/tests/Web_Bridge_Tests.cpp +++ b/web_server/tests/Web_Bridge_Tests.cpp @@ -298,9 +298,9 @@ TEST(RenderiveWebGallery, BackendControlSchemaValidatesEveryPatch) { "spectrum", current, R"({"category":"event","type":"gallery_patch","patch":{"current_pen":"#ff8844","frequency_point_size":1024,"line_interpolation":"Cubic_Value","max_render_fps":1000}})"); ASSERT_TRUE(accepted.candidate.has_value()); - EXPECT_EQ(std::get(accepted.candidate->values.at("current_pen")), "#ff8844"); - EXPECT_DOUBLE_EQ(std::get(accepted.candidate->values.at("frequency_point_size")), 1024); - EXPECT_DOUBLE_EQ(std::get(accepted.candidate->values.at("max_render_fps")), 1000); + EXPECT_EQ(std::get(gallery_property_value(*accepted.candidate, "current_pen")), "#ff8844"); + EXPECT_DOUBLE_EQ(std::get(gallery_property_value(*accepted.candidate, "frequency_point_size")), 1024); + EXPECT_DOUBLE_EQ(std::get(gallery_property_value(*accepted.candidate, "max_render_fps")), 1000); const auto excessive_fps = Gallery_Protocol::apply_patch( "spectrum", current, @@ -342,18 +342,22 @@ TEST(RenderiveWebGallery, EveryPublishedControlValidatesItsCompleteInputContract const auto defaults = Gallery_Protocol::default_state(case_id, mode.value); const auto contract = parse_json(Gallery_Protocol::case_json( case_id, defaults, "{}", {}, mode.value)); - const auto& controls = contract.at("controls").at("data"); - EXPECT_EQ(controls.size(), defaults.values.size()) << case_id << '/' << mode.id; + const auto& resource = contract.at("controls"); + const auto& controls = resource.at("descriptor").at("fields"); + const auto& data = resource.at("data"); + EXPECT_EQ(controls.size(), gallery_property_count(defaults)) << case_id << '/' << mode.id; std::set ids; for (const auto& control : controls) { ++validated_control_instances; - const std::string id = control.at("id"); + const std::string id = control.at("name"); + const auto& presentation = control.at("presentation"); SCOPED_TRACE(std::string(case_id) + "/" + std::string(mode.id) + "/" + id); EXPECT_TRUE(ids.insert(id).second); - EXPECT_FALSE(control.at("api").get().empty()); - EXPECT_FALSE(control.at("group").get().empty()); + EXPECT_TRUE(control.at("editable").get()); + EXPECT_FALSE(presentation.at("description").get().empty()); - const std::string input = control.at("input"); + const std::string input = presentation.at("control"); + const auto& current_value = data.at(id); const auto apply = [&](const nlohmann::json& value) { nlohmann::json request{{"category", "event"}, {"type", "gallery_patch"}, {"patch", {{id, value}}}}; @@ -367,65 +371,69 @@ TEST(RenderiveWebGallery, EveryPublishedControlValidatesItsCompleteInputContract }; if (input == "boolean") { - const bool alternative = !control.at("value").get(); + const bool alternative = !current_value.get(); const auto accepted = apply(alternative); ASSERT_TRUE(accepted.candidate.has_value()); - EXPECT_EQ(std::get(accepted.candidate->values.at(id)), alternative); + EXPECT_EQ(std::get(gallery_property_value(*accepted.candidate, id)), alternative); expect_rejected(1); } else if (input == "number") { const double minimum = control.at("minimum"); const double maximum = control.at("maximum"); - const double current = control.at("value"); - const double step = std::max(control.at("step").get(), 1e-9); + const double current = current_value; + const double step = std::max(control.at("multiple_of").get(), 1e-9); double alternative = current + step; if (alternative > maximum) alternative = current - step; if (alternative < minimum || alternative == current) alternative = current == minimum ? maximum : minimum; - if (control.at("integer").get()) + const bool integer = control.at("value_type") == "integer"; + if (integer) alternative = std::round(alternative); - const auto accepted = apply(alternative); + const nlohmann::json alternative_value = integer ? + nlohmann::json(static_cast(std::llround(alternative))) : + nlohmann::json(alternative); + const auto accepted = apply(alternative_value); ASSERT_TRUE(accepted.candidate.has_value()); - EXPECT_DOUBLE_EQ(std::get(accepted.candidate->values.at(id)), + EXPECT_DOUBLE_EQ(std::get(gallery_property_value(*accepted.candidate, id)), alternative); - EXPECT_TRUE(apply(minimum).candidate.has_value()); - EXPECT_TRUE(apply(maximum).candidate.has_value()); + EXPECT_TRUE(apply(integer ? nlohmann::json(static_cast(minimum)) : nlohmann::json(minimum)).candidate.has_value()); + EXPECT_TRUE(apply(integer ? nlohmann::json(static_cast(maximum)) : nlohmann::json(maximum)).candidate.has_value()); expect_rejected(minimum - std::max(1.0, std::abs(minimum) * 0.1 + 1.0)); expect_rejected(maximum + std::max(1.0, std::abs(maximum) * 0.1 + 1.0)); expect_rejected("not-a-number"); - if (control.at("integer").get() && maximum - minimum >= 1.0) + if (integer && maximum - minimum >= 1.0) expect_rejected(std::clamp(std::floor(current) + 0.5, minimum + 0.5, maximum - 0.5)); } else if (input == "select") { - const auto& options = control.at("options"); + const auto& options = presentation.at("options"); ASSERT_FALSE(options.empty()); - const std::string current = control.at("value"); + const std::string current = current_value; const auto alternative = std::find_if( options.begin(), options.end(), [¤t](const auto& option) { - return option.template get() != current; + return option.at("value").template get() != current; }); ASSERT_NE(alternative, options.end()); - const std::string value = alternative->get(); + const std::string value = alternative->at("value").get(); const auto accepted = apply(value); ASSERT_TRUE(accepted.candidate.has_value()); - EXPECT_EQ(std::get(accepted.candidate->values.at(id)), value); + EXPECT_EQ(std::get(gallery_property_value(*accepted.candidate, id)), value); expect_rejected("__invalid_gallery_option__"); expect_rejected(7); } else if (input == "color") { - const std::string value = control.at("value") == "#123456" ? + const std::string value = current_value == "#123456" ? "#654321" : "#123456"; const auto accepted = apply(value); ASSERT_TRUE(accepted.candidate.has_value()); - EXPECT_EQ(std::get(accepted.candidate->values.at(id)), value); + EXPECT_EQ(std::get(gallery_property_value(*accepted.candidate, id)), value); expect_rejected("red"); expect_rejected(7); } else { ASSERT_EQ(input, "text"); - std::string value = control.at("value").get() + "_qa"; + std::string value = current_value.get() + "_qa"; const auto accepted = apply(value); ASSERT_TRUE(accepted.candidate.has_value()); - EXPECT_EQ(std::get(accepted.candidate->values.at(id)), value); + EXPECT_EQ(std::get(gallery_property_value(*accepted.candidate, id)), value); expect_rejected(std::string(161, 'x')); expect_rejected(7); } diff --git a/webapp_gallery/app.js b/webapp_gallery/app.js index 56b7aa6..92bc3e1 100644 --- a/webapp_gallery/app.js +++ b/webapp_gallery/app.js @@ -58,6 +58,26 @@ function grouped(items) { } return groups; } +function adminiveControls(resource) { + const data = resource?.data || {}; + const group = resource?.view?.title || resource?.descriptor?.label || "控件属性"; + return (resource?.descriptor?.fields || []).filter(field => field.editable).map(field => { + const presentation = field.presentation || {}; + return { + id: field.name, + label: presentation.label || field.name, + api: presentation.description || "", + description: presentation.description || "", + group, + input: presentation.control || "text", + minimum: field.minimum, + maximum: field.maximum, + step: field.multiple_of, + options: presentation.options || [], + value: data[field.name] + }; + }); +} function flatten(value, prefix = "", result = []) { if (value !== null && typeof value === "object" && !Array.isArray(value)) { for (const [key, child] of Object.entries(value)) flatten(child, prefix ? `${prefix}.${key}` : key, result); @@ -333,7 +353,7 @@ class GalleryCard { return; } if (data.type !== "case_state") return; - this.controls = data.controls?.data || []; + this.controls = adminiveControls(data.controls); this.actions = data.actions?.data || []; this.telemetry = data.telemetry || {}; this.ready = true; @@ -602,7 +622,7 @@ function renderControl(item) { let input; if (item.input === "select") { input = document.createElement("select"); - for (const value of item.options || []) { const option = document.createElement("option"); option.value = value; option.textContent = value; input.append(option); } + for (const entry of item.options || []) { const option = document.createElement("option"); option.value = entry.value; option.textContent = entry.label; input.append(option); } input.value = String(item.value); } else { input = document.createElement("input"); input.type = item.input === "boolean" ? "checkbox" : item.input;