diff --git a/web_server/src/Graph_Metadata.cpp b/web_server/src/Graph_Metadata.cpp deleted file mode 100644 index efe3c34..0000000 --- a/web_server/src/Graph_Metadata.cpp +++ /dev/null @@ -1,41 +0,0 @@ -#include "Graph_Metadata.hpp" /* PFR 属性与状态描述。 */ -#include -namespace aethera::web { -const std::vector& graph_catalog() { - static const std::vector value{ - {"spectrum", "Spectrum", "Curves", "Current, maximum and minimum spectrum curves with markers.", false}, - {"frequency_trace", "Frequency trace", "Curves", "Time ordered frequency samples rendered as a partitioned curve.", false}, - {"sweep_spectrum", "Sweep spectrum", "Curves", "Incremental sweep blocks composed into one frequency curve.", false}, - {"afterglow", "Afterglow", "Raster", "Persistent spectrum energy rendered as reusable color blocks.", false}, - {"waterfall", "Waterfall", "Raster", "Time ordered spectrum rows rendered as a color raster.", false}, - {"constellation", "Constellation", "Signals", "I/Q samples and modulation anchors.", false}, - {"selection_overlay", "Selection overlay", "Interaction", "Direct-paint selection rectangle over numeric axes.", false}, - {"datoviz_point", "Datoviz point", "3D", "Asynchronous Vulkan point visual with GPU readback.", true} - }; - return value; -} -const Graph_Descriptor* find_graph(std::string_view graph_id) { const auto& catalog = graph_catalog(); const auto found = std::ranges::find(catalog, graph_id, &Graph_Descriptor::id); return found == catalog.end() ? nullptr : &*found; } -nlohmann::json graph_catalog_json() { nlohmann::json result = nlohmann::json::array(); for (const auto& graph : graph_catalog()) result.push_back({{"id", graph.id}, {"title", graph.title}, {"category", graph.category}, {"description", graph.description}, {"dimension", graph.three_dimensional ? "3D" : "2D"}, {"websocket", "/ws/graphs/" + graph.id}, {"state", "/api/graphs/" + graph.id + "/state"}, {"descriptor", "/api/graphs/" + graph.id + "/descriptor"}}); return result; } -nlohmann::json graph_state_descriptor_json(std::string_view graph_id) { - using namespace render_2d; - if (graph_id == "spectrum") return adminive::to_descriptor_json(); - if (graph_id == "frequency_trace") return adminive::to_descriptor_json(); - if (graph_id == "sweep_spectrum") return adminive::to_descriptor_json(); - if (graph_id == "afterglow") return adminive::to_descriptor_json(); - if (graph_id == "waterfall") return adminive::to_descriptor_json(); - if (graph_id == "constellation") return adminive::to_descriptor_json(); - if (graph_id == "selection_overlay") return adminive::to_descriptor_json(); - return {{"fields", nlohmann::json::array()}}; -} -nlohmann::json graph_prop_descriptor_json(std::string_view graph_id) { - using namespace render_2d; - if (graph_id == "spectrum") return adminive::to_descriptor_json(); - if (graph_id == "frequency_trace") return adminive::to_descriptor_json(); - if (graph_id == "sweep_spectrum") return adminive::to_descriptor_json(); - if (graph_id == "afterglow") return adminive::to_descriptor_json(); - if (graph_id == "waterfall") return adminive::to_descriptor_json(); - if (graph_id == "constellation") return adminive::to_descriptor_json(); - if (graph_id == "selection_overlay") return adminive::to_descriptor_json(); - return {{"fields", nlohmann::json::array()}}; -} -} diff --git a/web_server/src/Graph_Metadata.hpp b/web_server/src/Graph_Metadata.hpp deleted file mode 100644 index 22e469a..0000000 --- a/web_server/src/Graph_Metadata.hpp +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once -#include -#include -#include -#include -#include -#include -#include -#include -#include -namespace aethera::web { -struct Graph_Descriptor { - std::string id{}; /* HTTP 与 WebSocket 使用的稳定图标识。 */ - std::string title{}; /* Gallery 卡片显示名称。 */ - std::string category{}; /* Gallery 分类筛选值。 */ - std::string description{}; /* 图用途和数据语义说明。 */ - bool three_dimensional{}; /* 是否使用 Datoviz 3D 后端。 */ -}; -[[nodiscard]] const std::vector& graph_catalog(); -[[nodiscard]] const Graph_Descriptor* find_graph(std::string_view graph_id); -[[nodiscard]] nlohmann::json graph_catalog_json(); -[[nodiscard]] nlohmann::json graph_state_descriptor_json(std::string_view graph_id); -[[nodiscard]] nlohmann::json graph_prop_descriptor_json(std::string_view graph_id); -} -#include "Graph_Metadata.ipp" /* 模板和反射特化实现。 */ diff --git a/web_server/src/Graph_Metadata.ipp b/web_server/src/Graph_Metadata.ipp deleted file mode 100644 index 36d28e3..0000000 --- a/web_server/src/Graph_Metadata.ipp +++ /dev/null @@ -1,113 +0,0 @@ -#pragma once -#include -#include -#include -#include -namespace aethera::web::detail { -struct Editable_Field_Metadata { - template auto operator()(Field field) const; -}; -template -struct Member_Pfr_Reflection_Adapter { - static constexpr std::size_t field_count = boost::pfr::tuple_size_v; - template static decltype(auto) get(Object& value) noexcept; - template static decltype(auto) get(const Object& value) noexcept; - template static constexpr std::string_view name() noexcept; -}; -#define AETHERA_MEMBER(Type, Name) decltype(&Type::Name) Name{&Type::Name} -using namespace render_2d; -struct Spectrum_Prop_Members { AETHERA_MEMBER(Spectrum::Prop, center_frequency); AETHERA_MEMBER(Spectrum::Prop, partition_count); AETHERA_MEMBER(Spectrum::Prop, max_hold_visible); AETHERA_MEMBER(Spectrum::Prop, min_hold_visible); AETHERA_MEMBER(Spectrum::Prop, max_marker_visible); AETHERA_MEMBER(Spectrum::Prop, min_marker_visible); AETHERA_MEMBER(Spectrum::Prop, sweep_region_visible); AETHERA_MEMBER(Spectrum::Prop, visible_range_only); AETHERA_MEMBER(Spectrum::Prop, frequency_range); AETHERA_MEMBER(Spectrum::Prop, sweep_frequency_range); AETHERA_MEMBER(Spectrum::Prop, partition_mode); AETHERA_MEMBER(Spectrum::Prop, interpolation_mode); AETHERA_MEMBER(Spectrum::Prop, max_brush); AETHERA_MEMBER(Spectrum::Prop, current_brush); AETHERA_MEMBER(Spectrum::Prop, min_brush); AETHERA_MEMBER(Spectrum::Prop, max_pen); AETHERA_MEMBER(Spectrum::Prop, current_pen); AETHERA_MEMBER(Spectrum::Prop, min_pen); AETHERA_MEMBER(Spectrum::Prop, selected_marker_pen); AETHERA_MEMBER(Spectrum::Prop, marker_pen); AETHERA_MEMBER(Spectrum::Prop, middle_frequency_pen); AETHERA_MEMBER(Spectrum::Prop, sweep_region_brush); AETHERA_MEMBER(Spectrum::Prop, custom_markers); AETHERA_MEMBER(Spectrum::Prop, selected_marker); }; -struct Frequency_Trace_Prop_Members { AETHERA_MEMBER(Frequency_Trace::Prop, partition_count); AETHERA_MEMBER(Frequency_Trace::Prop, pen); AETHERA_MEMBER(Frequency_Trace::Prop, partition_mode); AETHERA_MEMBER(Frequency_Trace::Prop, samples); }; -struct Sweep_Spectrum_Prop_Members { AETHERA_MEMBER(Sweep_Spectrum::Prop, bins_per_block); AETHERA_MEMBER(Sweep_Spectrum::Prop, block_count); AETHERA_MEMBER(Sweep_Spectrum::Prop, partition_count); AETHERA_MEMBER(Sweep_Spectrum::Prop, visible_range_only); AETHERA_MEMBER(Sweep_Spectrum::Prop, frequency_range); AETHERA_MEMBER(Sweep_Spectrum::Prop, partition_mode); AETHERA_MEMBER(Sweep_Spectrum::Prop, pen); AETHERA_MEMBER(Sweep_Spectrum::Prop, current_frequency_pen); AETHERA_MEMBER(Sweep_Spectrum::Prop, interpolation_mode); AETHERA_MEMBER(Sweep_Spectrum::Prop, blocks); }; -struct Afterglow_Prop_Members { AETHERA_MEMBER(Afterglow::Prop, frequency_point_size); AETHERA_MEMBER(Afterglow::Prop, power_point_size); AETHERA_MEMBER(Afterglow::Prop, partition_count); AETHERA_MEMBER(Afterglow::Prop, interpolate); AETHERA_MEMBER(Afterglow::Prop, attenuation_rate); AETHERA_MEMBER(Afterglow::Prop, frequency_range); AETHERA_MEMBER(Afterglow::Prop, power_range); AETHERA_MEMBER(Afterglow::Prop, partition_mode); AETHERA_MEMBER(Afterglow::Prop, color_map); AETHERA_MEMBER(Afterglow::Prop, spectra); }; -struct Waterfall_Prop_Members { AETHERA_MEMBER(Waterfall::Prop, tooltip_enabled); AETHERA_MEMBER(Waterfall::Prop, tooltip_font); AETHERA_MEMBER(Waterfall::Prop, tooltip_text_pen); AETHERA_MEMBER(Waterfall::Prop, tooltip_background_brush); AETHERA_MEMBER(Waterfall::Prop, frequency_bin_count); AETHERA_MEMBER(Waterfall::Prop, partition_count); AETHERA_MEMBER(Waterfall::Prop, visible_range_only); AETHERA_MEMBER(Waterfall::Prop, frequency_range); AETHERA_MEMBER(Waterfall::Prop, power_range); AETHERA_MEMBER(Waterfall::Prop, partition_mode); AETHERA_MEMBER(Waterfall::Prop, interpolation_mode); AETHERA_MEMBER(Waterfall::Prop, color_map); AETHERA_MEMBER(Waterfall::Prop, rows); }; -struct Constellation_Prop_Members { AETHERA_MEMBER(Constellation_Diagram::Prop, point_lifetime_ms); AETHERA_MEMBER(Constellation_Diagram::Prop, type); AETHERA_MEMBER(Constellation_Diagram::Prop, phase_offset_radians); AETHERA_MEMBER(Constellation_Diagram::Prop, i_range); AETHERA_MEMBER(Constellation_Diagram::Prop, q_range); AETHERA_MEMBER(Constellation_Diagram::Prop, point_color); AETHERA_MEMBER(Constellation_Diagram::Prop, anchor_color); AETHERA_MEMBER(Constellation_Diagram::Prop, points); }; -struct Selection_Prop_Members { AETHERA_MEMBER(Selection_Rectangle_Overlay::Prop, label_font); AETHERA_MEMBER(Selection_Rectangle_Overlay::Prop, label_pen); AETHERA_MEMBER(Selection_Rectangle_Overlay::Prop, selection_brush); AETHERA_MEMBER(Selection_Rectangle_Overlay::Prop, selection_border_pen); AETHERA_MEMBER(Selection_Rectangle_Overlay::Prop, selected_regions); }; -struct Color_Map_Members { AETHERA_MEMBER(Color_Map, stops); }; -#define AETHERA_RENDER_STATE_MEMBERS(Type) AETHERA_MEMBER(Type, prepare_dirty); AETHERA_MEMBER(Type, paint_dirty); AETHERA_MEMBER(Type, prepare_executed); AETHERA_MEMBER(Type, paint_executed); AETHERA_MEMBER(Type, prepare_graph_rebuilt); AETHERA_MEMBER(Type, paint_graph_rebuilt); AETHERA_MEMBER(Type, prepare_task_count); AETHERA_MEMBER(Type, paint_task_count); AETHERA_MEMBER(Type, prepare_execution_time_ns); AETHERA_MEMBER(Type, paint_execution_time_ns) -struct Spectrum_State_Members { AETHERA_RENDER_STATE_MEMBERS(Spectrum::State); AETHERA_MEMBER(Spectrum::State, sample_count); AETHERA_MEMBER(Spectrum::State, rendered_point_count); AETHERA_MEMBER(Spectrum::State, selectable_marker_count); }; -struct Frequency_Trace_State_Members { AETHERA_RENDER_STATE_MEMBERS(Frequency_Trace::State); AETHERA_MEMBER(Frequency_Trace::State, sample_count); AETHERA_MEMBER(Frequency_Trace::State, rendered_point_count); }; -struct Sweep_Spectrum_State_Members { AETHERA_RENDER_STATE_MEMBERS(Sweep_Spectrum::State); AETHERA_MEMBER(Sweep_Spectrum::State, stored_block_count); AETHERA_MEMBER(Sweep_Spectrum::State, stored_point_count); AETHERA_MEMBER(Sweep_Spectrum::State, rendered_point_count); }; -struct Afterglow_State_Members { AETHERA_RENDER_STATE_MEMBERS(Afterglow::State); AETHERA_MEMBER(Afterglow::State, history_count); AETHERA_MEMBER(Afterglow::State, latest_spectrum_point_count); AETHERA_MEMBER(Afterglow::State, rendered_cell_count); }; -struct Waterfall_State_Members { AETHERA_RENDER_STATE_MEMBERS(Waterfall::State); AETHERA_MEMBER(Waterfall::State, row_count); AETHERA_MEMBER(Waterfall::State, stored_point_count); AETHERA_MEMBER(Waterfall::State, rendered_cell_count); }; -struct Constellation_State_Members { AETHERA_RENDER_STATE_MEMBERS(Constellation_Diagram::State); AETHERA_MEMBER(Constellation_Diagram::State, point_count); }; -struct Selection_State_Members { AETHERA_RENDER_STATE_MEMBERS(Selection_Rectangle_Overlay::State); AETHERA_MEMBER(Selection_Rectangle_Overlay::State, selected_region_count); }; -#undef AETHERA_RENDER_STATE_MEMBERS -#undef AETHERA_MEMBER -} -namespace adminive { -template struct Value_Adapter, Json> { - using value_type = std::vector; - static constexpr std::string_view type_name{"array"}; - static Json encode(const value_type& values); - static void decode(value_type& target, const Json& value); -}; -#define AETHERA_PLAIN_REFLECTION(Type, Id, Label) template <> struct Reflection_Adapter : Boost_Pfr_Reflection_Adapter {}; template <> struct Type_Descriptor { static auto get(); }; -AETHERA_PLAIN_REFLECTION(aethera::Color, "color", "Color") -AETHERA_PLAIN_REFLECTION(aethera::render_2d::Point_F, "point", "Point") -AETHERA_PLAIN_REFLECTION(aethera::render_2d::Rect_F, "rectangle", "Rectangle") -AETHERA_PLAIN_REFLECTION(aethera::render_2d::Axis_Range, "axis_range", "Axis range") -AETHERA_PLAIN_REFLECTION(aethera::render_2d::Pen, "pen", "Pen") -AETHERA_PLAIN_REFLECTION(aethera::render_2d::Brush, "brush", "Brush") -AETHERA_PLAIN_REFLECTION(aethera::render_2d::Font, "font", "Font") -AETHERA_PLAIN_REFLECTION(aethera::render_2d::Frequency_Trace_Sample, "frequency_trace_sample", "Frequency trace sample") -AETHERA_PLAIN_REFLECTION(aethera::render_2d::Waterfall_Row, "waterfall_row", "Waterfall row") -AETHERA_PLAIN_REFLECTION(aethera::render_2d::Constellation_Point, "constellation_point", "Constellation point") -#undef AETHERA_PLAIN_REFLECTION -#define AETHERA_MEMBER_REFLECTION(Type, Members, Id, Label, Customizer) template <> struct Reflection_Adapter : aethera::web::detail::Member_Pfr_Reflection_Adapter {}; template <> struct Type_Descriptor { static auto get(); }; -AETHERA_MEMBER_REFLECTION(aethera::render_2d::Color_Map, Color_Map_Members, "color_map", "Color map", aethera::web::detail::Editable_Field_Metadata) -AETHERA_MEMBER_REFLECTION(aethera::render_2d::Spectrum::Prop, Spectrum_Prop_Members, "spectrum_prop", "Spectrum properties", aethera::web::detail::Editable_Field_Metadata) -AETHERA_MEMBER_REFLECTION(aethera::render_2d::Frequency_Trace::Prop, Frequency_Trace_Prop_Members, "frequency_trace_prop", "Frequency trace properties", aethera::web::detail::Editable_Field_Metadata) -AETHERA_MEMBER_REFLECTION(aethera::render_2d::Sweep_Spectrum::Prop, Sweep_Spectrum_Prop_Members, "sweep_spectrum_prop", "Sweep spectrum properties", aethera::web::detail::Editable_Field_Metadata) -AETHERA_MEMBER_REFLECTION(aethera::render_2d::Afterglow::Prop, Afterglow_Prop_Members, "afterglow_prop", "Afterglow properties", aethera::web::detail::Editable_Field_Metadata) -AETHERA_MEMBER_REFLECTION(aethera::render_2d::Waterfall::Prop, Waterfall_Prop_Members, "waterfall_prop", "Waterfall properties", aethera::web::detail::Editable_Field_Metadata) -AETHERA_MEMBER_REFLECTION(aethera::render_2d::Constellation_Diagram::Prop, Constellation_Prop_Members, "constellation_prop", "Constellation properties", aethera::web::detail::Editable_Field_Metadata) -AETHERA_MEMBER_REFLECTION(aethera::render_2d::Selection_Rectangle_Overlay::Prop, Selection_Prop_Members, "selection_prop", "Selection properties", aethera::web::detail::Editable_Field_Metadata) -AETHERA_MEMBER_REFLECTION(aethera::render_2d::Spectrum::State, Spectrum_State_Members, "spectrum_state", "Spectrum state", Identity_Field_Customizer) -AETHERA_MEMBER_REFLECTION(aethera::render_2d::Frequency_Trace::State, Frequency_Trace_State_Members, "frequency_trace_state", "Frequency trace state", Identity_Field_Customizer) -AETHERA_MEMBER_REFLECTION(aethera::render_2d::Sweep_Spectrum::State, Sweep_Spectrum_State_Members, "sweep_spectrum_state", "Sweep spectrum state", Identity_Field_Customizer) -AETHERA_MEMBER_REFLECTION(aethera::render_2d::Afterglow::State, Afterglow_State_Members, "afterglow_state", "Afterglow state", Identity_Field_Customizer) -AETHERA_MEMBER_REFLECTION(aethera::render_2d::Waterfall::State, Waterfall_State_Members, "waterfall_state", "Waterfall state", Identity_Field_Customizer) -AETHERA_MEMBER_REFLECTION(aethera::render_2d::Constellation_Diagram::State, Constellation_State_Members, "constellation_state", "Constellation state", Identity_Field_Customizer) -AETHERA_MEMBER_REFLECTION(aethera::render_2d::Selection_Rectangle_Overlay::State, Selection_State_Members, "selection_state", "Selection state", Identity_Field_Customizer) -#undef AETHERA_MEMBER_REFLECTION -} -namespace aethera::web::detail { -template auto Editable_Field_Metadata::operator()(Field field) const { return field.editable(); } -template template decltype(auto) Member_Pfr_Reflection_Adapter::get(Object& value) noexcept { return value.*boost::pfr::get(Members{}); } -template template decltype(auto) Member_Pfr_Reflection_Adapter::get(const Object& value) noexcept { return value.*boost::pfr::get(Members{}); } -template template constexpr std::string_view Member_Pfr_Reflection_Adapter::name() noexcept { return boost::pfr::get_name(); } -} -namespace adminive { -template Json Value_Adapter, Json>::encode(const value_type& values) { Json result = json_array(); for (const auto& value : values) json_append(result, encode_json_value(value)); return result; } -template void Value_Adapter, Json>::decode(value_type& target, const Json& value) { if (!Json_Adapter::is_array(value)) throw std::invalid_argument("value must be an array"); value_type updated; updated.reserve(Json_Adapter::size(value)); for (std::size_t index = 0; index < Json_Adapter::size(value); ++index) { Value item{}; assign_json_value(item, Json_Adapter::at(value, index)); updated.push_back(std::move(item)); } target = std::move(updated); } -#define AETHERA_DEFINE_PLAIN_DESCRIPTOR(Type, Id, Label) inline auto Type_Descriptor::get() { return reflected_object_with(Id, Label, aethera::web::detail::Editable_Field_Metadata{}); } -AETHERA_DEFINE_PLAIN_DESCRIPTOR(aethera::Color, "color", "Color") -AETHERA_DEFINE_PLAIN_DESCRIPTOR(aethera::render_2d::Point_F, "point", "Point") -AETHERA_DEFINE_PLAIN_DESCRIPTOR(aethera::render_2d::Rect_F, "rectangle", "Rectangle") -AETHERA_DEFINE_PLAIN_DESCRIPTOR(aethera::render_2d::Axis_Range, "axis_range", "Axis range") -AETHERA_DEFINE_PLAIN_DESCRIPTOR(aethera::render_2d::Pen, "pen", "Pen") -AETHERA_DEFINE_PLAIN_DESCRIPTOR(aethera::render_2d::Brush, "brush", "Brush") -AETHERA_DEFINE_PLAIN_DESCRIPTOR(aethera::render_2d::Font, "font", "Font") -AETHERA_DEFINE_PLAIN_DESCRIPTOR(aethera::render_2d::Frequency_Trace_Sample, "frequency_trace_sample", "Frequency trace sample") -AETHERA_DEFINE_PLAIN_DESCRIPTOR(aethera::render_2d::Waterfall_Row, "waterfall_row", "Waterfall row") -AETHERA_DEFINE_PLAIN_DESCRIPTOR(aethera::render_2d::Constellation_Point, "constellation_point", "Constellation point") -#undef AETHERA_DEFINE_PLAIN_DESCRIPTOR -#define AETHERA_DEFINE_MEMBER_DESCRIPTOR(Type, Id, Label, Customizer) inline auto Type_Descriptor::get() { return reflected_object_with(Id, Label, Customizer{}); } -AETHERA_DEFINE_MEMBER_DESCRIPTOR(aethera::render_2d::Color_Map, "color_map", "Color map", aethera::web::detail::Editable_Field_Metadata) -AETHERA_DEFINE_MEMBER_DESCRIPTOR(aethera::render_2d::Spectrum::Prop, "spectrum_prop", "Spectrum properties", aethera::web::detail::Editable_Field_Metadata) -AETHERA_DEFINE_MEMBER_DESCRIPTOR(aethera::render_2d::Frequency_Trace::Prop, "frequency_trace_prop", "Frequency trace properties", aethera::web::detail::Editable_Field_Metadata) -AETHERA_DEFINE_MEMBER_DESCRIPTOR(aethera::render_2d::Sweep_Spectrum::Prop, "sweep_spectrum_prop", "Sweep spectrum properties", aethera::web::detail::Editable_Field_Metadata) -AETHERA_DEFINE_MEMBER_DESCRIPTOR(aethera::render_2d::Afterglow::Prop, "afterglow_prop", "Afterglow properties", aethera::web::detail::Editable_Field_Metadata) -AETHERA_DEFINE_MEMBER_DESCRIPTOR(aethera::render_2d::Waterfall::Prop, "waterfall_prop", "Waterfall properties", aethera::web::detail::Editable_Field_Metadata) -AETHERA_DEFINE_MEMBER_DESCRIPTOR(aethera::render_2d::Constellation_Diagram::Prop, "constellation_prop", "Constellation properties", aethera::web::detail::Editable_Field_Metadata) -AETHERA_DEFINE_MEMBER_DESCRIPTOR(aethera::render_2d::Selection_Rectangle_Overlay::Prop, "selection_prop", "Selection properties", aethera::web::detail::Editable_Field_Metadata) -AETHERA_DEFINE_MEMBER_DESCRIPTOR(aethera::render_2d::Spectrum::State, "spectrum_state", "Spectrum state", Identity_Field_Customizer) -AETHERA_DEFINE_MEMBER_DESCRIPTOR(aethera::render_2d::Frequency_Trace::State, "frequency_trace_state", "Frequency trace state", Identity_Field_Customizer) -AETHERA_DEFINE_MEMBER_DESCRIPTOR(aethera::render_2d::Sweep_Spectrum::State, "sweep_spectrum_state", "Sweep spectrum state", Identity_Field_Customizer) -AETHERA_DEFINE_MEMBER_DESCRIPTOR(aethera::render_2d::Afterglow::State, "afterglow_state", "Afterglow state", Identity_Field_Customizer) -AETHERA_DEFINE_MEMBER_DESCRIPTOR(aethera::render_2d::Waterfall::State, "waterfall_state", "Waterfall state", Identity_Field_Customizer) -AETHERA_DEFINE_MEMBER_DESCRIPTOR(aethera::render_2d::Constellation_Diagram::State, "constellation_state", "Constellation state", Identity_Field_Customizer) -AETHERA_DEFINE_MEMBER_DESCRIPTOR(aethera::render_2d::Selection_Rectangle_Overlay::State, "selection_state", "Selection state", Identity_Field_Customizer) -#undef AETHERA_DEFINE_MEMBER_DESCRIPTOR -} diff --git a/web_server/src/Graph_Session.cpp b/web_server/src/Graph_Session.cpp deleted file mode 100644 index d1044de..0000000 --- a/web_server/src/Graph_Session.cpp +++ /dev/null @@ -1,127 +0,0 @@ -#include "Graph_Session.hpp" /* 图对象异步会话。 */ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -namespace aethera::web { -namespace { -using namespace render_2d; -using namespace render_3d; -using Scene_2D = Impl; -using Frequency_Axis_Object = Impl; -using Numeric_Axis_Object = Impl; -using Time_Axis_Object = Impl; -template -std::unique_ptr build(Arguments&&... arguments) { typename Object::Builder builder(std::forward(arguments)...); auto result = builder.build(); if (!result) throw std::logic_error("gallery graph dependency graph is invalid"); return std::move(result).value(); } -template -void configure_axis(Axis* axis, Axis_Orientation orientation, Point_F position, Axis_Pixel_Length length, Size canvas) { axis->template set<&Abs_Axis::Prop::orientation>(orientation); axis->template set<&Abs_Axis::Prop::position>(position); axis->template set<&Abs_Axis::Prop::pixel_length>(length); axis->template set<&Abs_Axis::Prop::canvas_size>(canvas); } -template -void append_binary(std::string& output, Integer value) { const auto start = output.size(); output.resize(start + sizeof(Integer)); std::memcpy(output.data() + start, &value, sizeof(Integer)); } -std::string encode_frame(Image_View image, std::uint64_t sequence) { std::string output; output.reserve(24 + static_cast(image.width) * image.height * 4); append_binary(output, std::uint32_t{0x41544852}); append_binary(output, std::uint16_t{1}); append_binary(output, std::uint16_t{}); append_binary(output, static_cast(image.width)); append_binary(output, static_cast(image.height)); append_binary(output, sequence); for (int y = 0; y < image.height; ++y) { const auto* row = reinterpret_cast(image.data + static_cast(y) * image.stride); for (int x = 0; x < image.width; ++x) { const auto* pixel = row + x * 4; output.push_back(static_cast(pixel[2])); output.push_back(static_cast(pixel[1])); output.push_back(static_cast(pixel[0])); output.push_back(static_cast(pixel[3])); } } return output; } -std::string encode_frame(const render_3d::Pixel_Frame& frame, std::uint64_t sequence) { std::string output; output.reserve(24 + frame.rgba8.size()); append_binary(output, std::uint32_t{0x41544852}); append_binary(output, std::uint16_t{1}); append_binary(output, std::uint16_t{}); append_binary(output, frame.extent.width); append_binary(output, frame.extent.height); append_binary(output, sequence); output.append(reinterpret_cast(frame.rgba8.data()), frame.rgba8.size()); return output; } -struct State_Query { Graph_Session::State_Handler handler{}; }; -struct Prop_Query { Graph_Session::Prop_Handler handler{}; }; -struct Prop_Patch { nlohmann::json patch{}; Graph_Session::Prop_Handler handler{}; }; -using Session_Event = std::variant; -struct Plot_2D { - std::unique_ptr scene{}; /* 最终二维 Scene。 */ - std::unique_ptr frequency{}; /* 频率轴;不用时仍为空。 */ - std::unique_ptr horizontal{}; /* 星座图水平数值轴。 */ - std::unique_ptr vertical{}; /* 功率或星座图垂直轴。 */ - std::unique_ptr time{}; /* 时间轴;不用时为空。 */ - std::unique_ptr plot{}; /* 具体 Plottable 的唯一所有权。 */ - std::function update{}; /* 根据浏览器时钟更新权威 Prop。 */ - std::function state{}; /* 直接遍历具体图完整 State 的即时 HTTP 文档。 */ - std::function prop{}; - std::function patch_prop{}; -}; -template -void bind_prop_api(Plot_2D& plot, Object* object) { - using Prop = typename Definition::Prop; - plot.prop = [object] { return adminive::model_to_json(static_cast(object->template read_prop()), true); }; - plot.state = [object] { return adminive::model_to_json(static_cast(object->template read_state()), true); }; - plot.patch_prop = [object](const nlohmann::json& patch) { Prop updated = static_cast(object->template read_prop()); const auto result = adminive::apply_frontend_patch(updated, patch); if (!result.success) return result.to_json(); ([&] { if (object->template get() != updated.*Members) object->template set(updated.*Members); }(), ...); auto output = result.to_json(); output["prop"] = adminive::model_to_json(static_cast(object->template read_prop()), true); return output; }; -} -Plot_2D make_plot_2d(std::string_view id) { - Plot_2D result; - const Size canvas{720, 420}; - result.scene = build(); - result.scene->set<&Render_Scene_2D::Prop::viewport>(canvas); - result.scene->set<&Render_Scene_2D::Prop::background>(Color{7, 13, 24, 255}); - result.scene->activate_view(); - auto make_frequency = [&] { result.frequency = build(); configure_axis(result.frequency.get(), Axis_Orientation::horizontal, Point_F{64.0, 370.0}, 620.0, canvas); result.frequency->set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{0.0, 100.0}); }; - auto make_vertical = [&](Axis_Range range) { - result.vertical = build(); - configure_axis(result.vertical.get(), Axis_Orientation::vertical, {64.0, 370.0}, -320.0, canvas); result.vertical->set<&Numeric_Axis::Prop::coordinate_range>(range); - }; - if (id == "spectrum") { - make_frequency(); make_vertical({-110.0, 0.0}); auto object = build>(result.scene.get(), result.frequency.get(), result.vertical.get()); object->set<&Spectrum::Prop::frequency_range>(Axis_Range{0.0, 100.0}); object->set<&Spectrum::Prop::max_hold_visible>(true); auto* raw = object.get(); raw->mark_dirty(); raw->mark_dirty(); bind_prop_api(result, raw); result.update = [raw](double time) { std::array samples{}; for (std::size_t i = 0; i < samples.size(); ++i) { const double x = static_cast(i) / samples.size(); samples[i] = -92.0 + 54.0 * std::exp(-180.0 * std::pow(x - 0.28 - 0.03 * std::sin(time * 0.001), 2.0)) + 42.0 * std::exp(-260.0 * std::pow(x - 0.68, 2.0)) + 2.5 * std::sin(i * 0.31 + time * 0.004); } raw->update_samples(samples); }; result.plot = std::move(object); - } else if (id == "frequency_trace") { - result.time = build(); configure_axis(result.time.get(), Axis_Orientation::horizontal, {64.0, 370.0}, 620.0, canvas); make_vertical({-1.2, 1.2}); auto object = build>(result.scene.get(), result.time.get(), result.vertical.get()); auto* raw = object.get(); raw->mark_dirty(); raw->mark_dirty(); bind_prop_api(result, raw); auto tick = std::make_shared(); result.update = [raw, tick](double time) { raw->append_sample((*tick)++, std::sin(time * 0.0025) * 0.8 + std::sin(time * 0.0007) * 0.2); }; result.plot = std::move(object); - } else if (id == "sweep_spectrum") { - make_frequency(); make_vertical({-110.0, 0.0}); auto object = build>(result.scene.get(), result.frequency.get(), result.vertical.get()); object->set<&Sweep_Spectrum::Prop::frequency_range>(Axis_Range{0.0, 100.0}); auto* raw = object.get(); raw->mark_dirty(); raw->mark_dirty(); bind_prop_api(result, raw); result.update = [raw](double time) { std::array values{}; for (std::size_t i = 0; i < values.size(); ++i) values[i] = -90.0 + 35.0 * std::sin(i * 0.08 + time * 0.002); raw->append_block(values); }; result.plot = std::move(object); - } else if (id == "afterglow") { - make_frequency(); make_vertical({-110.0, 0.0}); auto object = build>(result.scene.get(), result.frequency.get(), result.vertical.get()); object->set<&Afterglow::Prop::frequency_range>(Axis_Range{0.0, 100.0}); object->set<&Afterglow::Prop::power_range>(Axis_Range{-110.0, 0.0}); object->set<&Afterglow::Prop::power_point_size>(96); auto* raw = object.get(); raw->mark_dirty(); raw->mark_dirty(); bind_prop_api(result, raw); result.update = [raw](double time) { std::array values{}; for (std::size_t i = 0; i < values.size(); ++i) values[i] = -95.0 + 62.0 * std::exp(-220.0 * std::pow(static_cast(i) / values.size() - 0.5 - 0.18 * std::sin(time * 0.0008), 2.0)); raw->append_spectrum(values); }; result.plot = std::move(object); - } else if (id == "waterfall") { - make_frequency(); result.time = build(); configure_axis(result.time.get(), Axis_Orientation::vertical, {64.0, 370.0}, -320.0, canvas); auto object = build>(result.scene.get(), result.frequency.get(), result.time.get()); object->set<&Waterfall::Prop::frequency_range>(Axis_Range{0.0, 100.0}); object->set<&Waterfall::Prop::power_range>(Axis_Range{-110.0, 0.0}); auto* raw = object.get(); raw->mark_dirty(); raw->mark_dirty(); bind_prop_api(result, raw); auto tick = std::make_shared(); result.update = [raw, tick](double time) { std::array values{}; for (std::size_t i = 0; i < values.size(); ++i) values[i] = -100.0 + 70.0 * std::exp(-240.0 * std::pow(static_cast(i) / values.size() - 0.5 - 0.22 * std::sin(time * 0.0006), 2.0)); raw->append_row((*tick)++, values); }; result.plot = std::move(object); - } else if (id == "constellation") { - result.horizontal = build(); configure_axis(result.horizontal.get(), Axis_Orientation::horizontal, {64.0, 370.0}, 620.0, canvas); result.horizontal->set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{-1.2, 1.2}); make_vertical({-1.2, 1.2}); auto object = build>(result.scene.get(), result.horizontal.get(), result.vertical.get()); object->set<&Constellation_Diagram::Prop::i_range>(Axis_Range{-1.2, 1.2}); object->set<&Constellation_Diagram::Prop::q_range>(Axis_Range{-1.2, 1.2}); auto* raw = object.get(); raw->mark_dirty(); raw->mark_dirty(); bind_prop_api(result, raw); result.update = [raw](double time) { const double phase = time * 0.003; raw->append_point({std::cos(phase) * 0.82 + 0.04 * std::sin(phase * 7.0), std::sin(phase) * 0.82 + 0.04 * std::cos(phase * 5.0)}); }; result.plot = std::move(object); - } else { - result.horizontal = build(); configure_axis(result.horizontal.get(), Axis_Orientation::horizontal, {64.0, 370.0}, 620.0, canvas); result.horizontal->set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{0.0, 100.0}); make_vertical({0.0, 100.0}); auto object = build>(result.scene.get(), result.horizontal.get(), result.vertical.get()); auto* raw = object.get(); raw->mark_dirty(); raw->mark_dirty(); bind_prop_api(result, raw); result.update = [](double) {}; result.plot = std::move(object); - } - return result; -} -struct Plot_3D { - std::unique_ptr> visual{}; /* 点图元权威对象。 */ - std::unique_ptr> scene{}; /* 异步 Datoviz Scene。 */ -}; -Plot_3D make_plot_3d() { Plot_3D result; result.visual = build>(); static_cast(result.visual->update_items({render_3d::Point{.position = {-0.55F, -0.2F, 0.0F}, .color = Color::red_color(), .diameter_px = 24.0F}, render_3d::Point{.position = {0.0F, 0.5F, 0.0F}, .color = Color::green_color(), .diameter_px = 30.0F}, render_3d::Point{.position = {0.55F, -0.1F, 0.0F}, .color = Color{42, 120, 255, 255}, .diameter_px = 26.0F}})); result.visual->advance(); result.scene = build>(result.visual.get()); result.scene->activate_view(); return result; } -std::variant make_plot(const Graph_Descriptor& graph) { if (graph.three_dimensional) return std::variant{std::in_place_type, make_plot_3d()}; return std::variant{std::in_place_type, make_plot_2d(graph.id)}; } -} -struct Graph_Session::Private { - asio::strand strand; /* 图对象串行访问域。 */ - asio::experimental::concurrent_channel events; /* 浏览器事件与 HTTP 状态读取队列。 */ - Graph_Descriptor descriptor; /* Catalog 中的不可变图描述。 */ - std::variant plot; /* 具体二维或三维 Scene 所有权。 */ - std::mutex handlers_mutex; /* 保护跨 Drogon 线程的连接集合。 */ - std::unordered_map handlers; /* 当前订阅该图像素帧的 WebSocket。 */ - std::atomic_uint64_t frame_sequence{}; /* 传输协议的完成帧序号。 */ - explicit Private(asio::any_io_executor executor, const Graph_Descriptor& graph) : strand(asio::make_strand(std::move(executor))), events(strand, 32), descriptor(graph), plot(make_plot(graph)) {} - void publish(std::string pixels) { std::vector outputs; { std::lock_guard lock(handlers_mutex); outputs.reserve(handlers.size()); for (const auto& [owner, handler] : handlers) outputs.push_back(handler); } for (auto& output : outputs) output(pixels); } - nlohmann::json state_document() const { if (const auto* value = std::get_if(&plot)) return value->state(); const auto& state = std::get(plot).visual->read_state(); return {{"prepare_dirty", state.prepare_dirty}, {"paint_dirty", state.paint_dirty}, {"prepare_executed", state.prepare_executed}, {"paint_executed", state.paint_executed}, {"prepare_graph_rebuilt", state.prepare_graph_rebuilt}, {"paint_graph_rebuilt", state.paint_graph_rebuilt}, {"prepare_task_count", state.prepare_task_count}, {"paint_task_count", state.paint_task_count}, {"prepare_execution_time_ns", state.prepare_execution_time_ns}, {"paint_execution_time_ns", state.paint_execution_time_ns}, {"item_count", state.item_count}, {"prepared_item_count", state.prepared_item_count}, {"prepared_revision", state.prepared_revision}}; } - nlohmann::json prop_document() const { if (const auto* value = std::get_if(&plot)) return value->prop(); return nlohmann::json::object(); } - nlohmann::json patch_prop(const nlohmann::json& patch) { if (auto* value = std::get_if(&plot)) return value->patch_prop(patch); return {{"success", true}, {"prop", nlohmann::json::object()}}; } -}; -Graph_Session::Graph_Session(std::unique_ptr private_data) : d(std::move(private_data)) {} -std::shared_ptr Graph_Session::create(asio::any_io_executor executor, const Graph_Descriptor& descriptor) { auto result = std::shared_ptr(new Graph_Session(std::make_unique(std::move(executor), descriptor))); result->start(); return result; } -Graph_Session::~Graph_Session() { d->events.close(); } -void Graph_Session::start() { auto self = shared_from_this(); if (auto* plot = std::get_if(&d->plot)) plot->scene->set_frame_callback([weak = weak_from_this()](Image_View image) { if (auto owner = weak.lock()) { const auto sequence = owner->d->frame_sequence.fetch_add(1, std::memory_order_acq_rel) + 1; owner->d->publish(encode_frame(image, sequence)); } }); else std::get(d->plot).scene->set_frame_callback([weak = weak_from_this()](std::shared_ptr frame) { if (auto owner = weak.lock()) { const auto sequence = owner->d->frame_sequence.fetch_add(1, std::memory_order_acq_rel) + 1; owner->d->publish(encode_frame(*frame, sequence)); } }); asio::co_spawn(d->strand, [self]() -> asio::awaitable { for (;;) { asio::error_code error; auto event = co_await self->d->events.async_receive(asio::redirect_error(asio::use_awaitable, error)); if (error) co_return; if (auto* state = std::get_if(&event)) { state->handler(self->d->state_document()); continue; } if (auto* prop = std::get_if(&event)) { prop->handler(self->d->prop_document()); continue; } if (auto* patch = std::get_if(&event)) { patch->handler(self->d->patch_prop(patch->patch)); continue; } const auto value = std::get(event); if (auto* plot = std::get_if(&self->d->plot)) { const Size viewport{static_cast(std::clamp(value.width, 160U, 1920U)), static_cast(std::clamp(value.height, 120U, 1080U))}; plot->scene->set<&Render_Scene_2D::Prop::viewport>(viewport); if (plot->frequency) plot->frequency->set<&Abs_Axis::Prop::canvas_size>(viewport); if (plot->horizontal) plot->horizontal->set<&Abs_Axis::Prop::canvas_size>(viewport); if (plot->vertical) plot->vertical->set<&Abs_Axis::Prop::canvas_size>(viewport); if (plot->time) plot->time->set<&Abs_Axis::Prop::canvas_size>(viewport); plot->update(value.time_milliseconds); plot->scene->render(); } else { auto& plot_3d = std::get(self->d->plot); plot_3d.scene->set<&Render_Scene_3D::Prop::viewport>(render_3d::Extent{std::clamp(value.width, 160U, 1920U), std::clamp(value.height, 120U, 1080U)}); plot_3d.scene->render(); } } }, [](std::exception_ptr exception) { if (exception) std::rethrow_exception(exception); }); } -void Graph_Session::attach(const void* owner, Frame_Handler handler) { { std::lock_guard lock(d->handlers_mutex); d->handlers.insert_or_assign(owner, std::move(handler)); } submit({}); } -void Graph_Session::detach(const void* owner) { std::lock_guard lock(d->handlers_mutex); d->handlers.erase(owner); } -void Graph_Session::submit(Graph_Event event) { static_cast(d->events.try_send(asio::error_code{}, Session_Event{event})); } -void Graph_Session::async_state(State_Handler handler) { if (!d->events.try_send(asio::error_code{}, Session_Event{State_Query{std::move(handler)}})) throw std::runtime_error("graph state queue is unavailable"); } -void Graph_Session::async_prop(Prop_Handler handler) { if (!d->events.try_send(asio::error_code{}, Session_Event{Prop_Query{std::move(handler)}})) throw std::runtime_error("graph prop queue is unavailable"); } -void Graph_Session::async_patch_prop(nlohmann::json patch, Prop_Handler handler) { if (!d->events.try_send(asio::error_code{}, Session_Event{Prop_Patch{std::move(patch), std::move(handler)}})) throw std::runtime_error("graph prop queue is unavailable"); } -const Graph_Descriptor& Graph_Session::descriptor() const noexcept { return d->descriptor; } -struct Graph_Registry::Private { asio::any_io_executor executor; std::mutex mutex; std::unordered_map> sessions; explicit Private(asio::any_io_executor value) : executor(std::move(value)) {} }; -Graph_Registry::Graph_Registry(asio::any_io_executor executor) : d(std::make_unique(std::move(executor))) {} -Graph_Registry::~Graph_Registry() = default; -std::shared_ptr Graph_Registry::acquire(std::string_view graph_id) { const auto* descriptor = find_graph(graph_id); if (!descriptor) return {}; std::lock_guard lock(d->mutex); auto& session = d->sessions[std::string(graph_id)]; if (!session) session = Graph_Session::create(d->executor, *descriptor); return session; } -} diff --git a/web_server/src/Graph_Session.hpp b/web_server/src/Graph_Session.hpp deleted file mode 100644 index 7d369f3..0000000 --- a/web_server/src/Graph_Session.hpp +++ /dev/null @@ -1,45 +0,0 @@ -#pragma once -#include "Graph_Metadata.hpp" -#include -#include -#include -#include -#include -namespace aethera::web { -struct Graph_Event { - double time_milliseconds{}; /* 浏览器 performance.now() 时钟值。 */ - std::uint32_t width{720}; /* 浏览器画布当前像素宽度。 */ - std::uint32_t height{420}; /* 浏览器画布当前像素高度。 */ -}; -class Graph_Session final : public std::enable_shared_from_this { -public: - using Frame_Handler = std::function; - using State_Handler = std::function; - using Prop_Handler = std::function; - [[nodiscard]] static std::shared_ptr create(asio::any_io_executor executor, const Graph_Descriptor& descriptor); - ~Graph_Session(); - Graph_Session(const Graph_Session&) = delete; - Graph_Session& operator=(const Graph_Session&) = delete; - void attach(const void* owner, Frame_Handler handler); - void detach(const void* owner); - void submit(Graph_Event event); - void async_state(State_Handler handler); - void async_prop(Prop_Handler handler); - void async_patch_prop(nlohmann::json patch, Prop_Handler handler); - [[nodiscard]] const Graph_Descriptor& descriptor() const noexcept; -private: - struct Private; - explicit Graph_Session(std::unique_ptr private_data); - void start(); - std::unique_ptr d; /* 单一协程事件循环及图对象所有权。 */ -}; -class Graph_Registry final { -public: - explicit Graph_Registry(asio::any_io_executor executor); - ~Graph_Registry(); - [[nodiscard]] std::shared_ptr acquire(std::string_view graph_id); -private: - struct Private; - std::unique_ptr d; /* 图标识到长期 Scene 会话的注册表。 */ -}; -} diff --git a/web_server/src/Graph_WebSocket.cpp b/web_server/src/Graph_WebSocket.cpp index 445515a..1e7d3b2 100644 --- a/web_server/src/Graph_WebSocket.cpp +++ b/web_server/src/Graph_WebSocket.cpp @@ -11,38 +11,18 @@ std::string graph_id_from_path(std::string_view path) { const auto split = path. } struct Graph_WebSocket::Private { std::weak_ptr connection; /* 不延长已关闭 Drogon 连接生命周期。 */ - std::shared_ptr session; /* 连接期间保持图会话存活。 */ + std::shared_ptr plot; /* 连接期间保持引擎桥接对象存活。 */ const void* owner{}; /* 会话订阅表使用的稳定连接身份。 */ bool attached{}; /* 是否已安装像素订阅。 */ - std::mutex callback_mutex; - std::unordered_map callbacks; }; -Graph_WebSocket::Graph_WebSocket(drogon::WebSocketConnectionPtr connection, std::shared_ptr session) : d(std::make_unique()) { d->connection = connection; d->session = std::move(session); d->owner = connection.get(); } +Graph_WebSocket::Graph_WebSocket(drogon::WebSocketConnectionPtr connection, std::shared_ptr plot) : d(std::make_unique()) { d->connection = connection; d->plot = std::move(plot); d->owner = connection.get(); } Graph_WebSocket::~Graph_WebSocket() { close(); } -void Graph_WebSocket::start() { if (d->attached) return; const auto weak = weak_from_this(); d->session->attach(d->owner, [weak](std::string pixels) { const auto socket = weak.lock(); if (!socket) return; const auto connection = socket->d->connection.lock(); if (connection && connection->connected()) connection->send(pixels.data(), pixels.size(), drogon::WebSocketMessageType::Binary); }); d->attached = true; } -void Graph_WebSocket::receive(std::string_view message) { const auto json = nlohmann::json::parse(message, nullptr, false); if (json.is_discarded() || !json.is_object()) return; Graph_Event event; if (const auto value = json.find("time"); value != json.end() && value->is_number()) event.time_milliseconds = value->get(); if (const auto value = json.find("width"); value != json.end() && value->is_number_unsigned()) event.width = std::clamp(value->get(), 160U, 1920U); if (const auto value = json.find("height"); value != json.end() && value->is_number_unsigned()) event.height = std::clamp(value->get(), 120U, 1080U); d->session->submit(event); } -void Graph_WebSocket::close() { if (!d->attached) return; d->session->detach(d->owner); d->attached = false; } -void Graph_WebSocket::register_callback(std::string key, Callback callback) { - std::lock_guard lock(d->callback_mutex); - d->callbacks.insert_or_assign(std::move(key), std::move(callback)); -} -void Graph_WebSocket::unregister_callback(std::string_view key) { - std::lock_guard lock(d->callback_mutex); - d->callbacks.erase(std::string(key)); -} -void Graph_WebSocket::send(std::string_view key, std::string_view frame) { - Callback callback; - { - std::lock_guard lock(d->callback_mutex); - const auto iterator = d->callbacks.find(std::string(key)); - if (iterator == d->callbacks.end()) return; - callback = iterator->second; - } - callback(frame); -} +void Graph_WebSocket::start() { if (d->attached) return; const auto weak = weak_from_this(); d->plot->attach(d->owner, [weak](std::string pixels) { const auto socket = weak.lock(); if (!socket) return; const auto connection = socket->d->connection.lock(); if (connection && connection->connected()) connection->send(pixels.data(), pixels.size(), drogon::WebSocketMessageType::Binary); }); d->attached = true; } +void Graph_WebSocket::receive(std::string_view message) { const auto json = nlohmann::json::parse(message, nullptr, false); if (json.is_discarded() || !json.is_object()) return; Plot_Event event; if (const auto value = json.find("time"); value != json.end() && value->is_number()) event.time_milliseconds = value->get(); if (const auto value = json.find("width"); value != json.end() && value->is_number_unsigned()) event.width = std::clamp(value->get(), 160U, 1920U); if (const auto value = json.find("height"); value != json.end() && value->is_number_unsigned()) event.height = std::clamp(value->get(), 120U, 1080U); d->plot->submit(event); } +void Graph_WebSocket::close() { if (!d->attached) return; d->plot->detach(d->owner); d->attached = false; } -Graph_WebSocket_Controller::Graph_WebSocket_Controller(std::shared_ptr value) : registry(std::move(value)) {} -void Graph_WebSocket_Controller::handleNewConnection(const drogon::HttpRequestPtr& request, const drogon::WebSocketConnectionPtr& connection) { auto session = registry->acquire(graph_id_from_path(request->path())); if (!session) { connection->shutdown(drogon::CloseCode::kViolation, "Unknown Aethera graph"); return; } auto socket = std::make_shared(connection, std::move(session)); connection->setContext(socket); connection->setPingMessage("aethera-gallery", std::chrono::seconds(20)); socket->start(); } +Graph_WebSocket_Controller::Graph_WebSocket_Controller(std::shared_ptr value) : registry(std::move(value)) {} +void Graph_WebSocket_Controller::handleNewConnection(const drogon::HttpRequestPtr& request, const drogon::WebSocketConnectionPtr& connection) { auto plot = registry->acquire(graph_id_from_path(request->path())); if (!plot) { connection->shutdown(drogon::CloseCode::kViolation, "Unknown Aethera plot"); return; } auto socket = std::make_shared(connection, std::move(plot)); connection->setContext(socket); connection->setPingMessage("aethera-gallery", std::chrono::seconds(20)); socket->start(); } void Graph_WebSocket_Controller::handleNewMessage(const drogon::WebSocketConnectionPtr& connection, std::string&& message, const drogon::WebSocketMessageType& type) { if (type != drogon::WebSocketMessageType::Text || message.size() > 64 * 1024) return; if (const auto socket = connection->getContext()) socket->receive(message); } void Graph_WebSocket_Controller::handleConnectionClosed(const drogon::WebSocketConnectionPtr& connection) { if (const auto socket = connection->getContext()) socket->close(); connection->clearContext(); } } diff --git a/web_server/src/Graph_WebSocket.hpp b/web_server/src/Graph_WebSocket.hpp index 0edbaa8..88188b3 100644 --- a/web_server/src/Graph_WebSocket.hpp +++ b/web_server/src/Graph_WebSocket.hpp @@ -1,5 +1,5 @@ #pragma once -#include "Graph_Session.hpp" +#include "Plot.hpp" #include #include #include @@ -7,31 +7,27 @@ namespace aethera::web { class Graph_WebSocket final : public std::enable_shared_from_this { public: - Graph_WebSocket(drogon::WebSocketConnectionPtr connection, std::shared_ptr session); + Graph_WebSocket(drogon::WebSocketConnectionPtr connection, std::shared_ptr plot); ~Graph_WebSocket(); Graph_WebSocket(const Graph_WebSocket&) = delete; Graph_WebSocket& operator=(const Graph_WebSocket&) = delete; - using Callback = std::function; void start(); void receive(std::string_view message); void close(); - void register_callback(std::string key, Callback callback); - void unregister_callback(std::string_view key); - void send(std::string_view key, std::string_view frame); private: struct Private; std::unique_ptr d; /* Drogon 连接与图会话绑定。 */ }; class Graph_WebSocket_Controller final : public drogon::WebSocketController { public: - explicit Graph_WebSocket_Controller(std::shared_ptr registry); + explicit Graph_WebSocket_Controller(std::shared_ptr registry); void handleNewMessage(const drogon::WebSocketConnectionPtr& connection, std::string&& message, const drogon::WebSocketMessageType& type) override; void handleNewConnection(const drogon::HttpRequestPtr& request, const drogon::WebSocketConnectionPtr& connection) override; void handleConnectionClosed(const drogon::WebSocketConnectionPtr& connection) override; WS_PATH_LIST_BEGIN - WS_ADD_PATH_VIA_REGEX("^/ws/graphs/[^/]+$"); + WS_ADD_PATH_VIA_REGEX("^/ws/plot/[^/]+$"); WS_PATH_LIST_END private: - std::shared_ptr registry; /* 所有图 WebSocket 共用的会话注册表。 */ + std::shared_ptr registry; /* 所有 WebSocket 共用的 Plot 注册表。 */ }; } diff --git a/web_server/src/Plot.cpp b/web_server/src/Plot.cpp index 83af116..876fd23 100644 --- a/web_server/src/Plot.cpp +++ b/web_server/src/Plot.cpp @@ -1,6 +1,217 @@ #include "Plot.hpp" +#include "Renderable_Adapter.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include namespace aethera::web { -Plot::Plot(std::string key_value, std::shared_ptr websocket_value) : websocket(std::move(websocket_value)), key(std::move(key_value)) { websocket->register_callback(key, [this](std::string_view event) { on_event(event); }); } -Plot::~Plot() { websocket->unregister_callback(key); } -void Plot::send_frame(std::string_view frame) { websocket->send(key, frame); } +namespace { +using namespace render_2d; +using namespace render_3d; +using Scene_2D = Impl; +using Frequency_Axis_Object = Impl; +using Numeric_Axis_Object = Impl; +using Time_Axis_Object = Impl; +template +std::unique_ptr build(Arguments&&... arguments) { typename Object::Builder builder(std::forward(arguments)...); auto result = builder.build(); if (!result) throw std::logic_error("gallery graph dependency graph is invalid"); return std::move(result).value(); } +template +void configure_axis(Axis* axis, Axis_Orientation orientation, Point_F position, Axis_Pixel_Length length, Size canvas) { axis->template set<&Abs_Axis::Prop::orientation>(orientation); axis->template set<&Abs_Axis::Prop::position>(position); axis->template set<&Abs_Axis::Prop::pixel_length>(length); axis->template set<&Abs_Axis::Prop::canvas_size>(canvas); } +template +void append_binary(std::string& output, Integer value) { const auto start = output.size(); output.resize(start + sizeof(Integer)); std::memcpy(output.data() + start, &value, sizeof(Integer)); } +std::string encode_frame(Image_View image, std::uint64_t sequence) { std::string output; output.reserve(24 + static_cast(image.width) * image.height * 4); append_binary(output, std::uint32_t{0x41544852}); append_binary(output, std::uint16_t{1}); append_binary(output, std::uint16_t{}); append_binary(output, static_cast(image.width)); append_binary(output, static_cast(image.height)); append_binary(output, sequence); for (int y = 0; y < image.height; ++y) { const auto* row = reinterpret_cast(image.data + static_cast(y) * image.stride); for (int x = 0; x < image.width; ++x) { const auto* pixel = row + x * 4; output.push_back(static_cast(pixel[2])); output.push_back(static_cast(pixel[1])); output.push_back(static_cast(pixel[0])); output.push_back(static_cast(pixel[3])); } } return output; } +std::string encode_frame(const render_3d::Pixel_Frame& frame, std::uint64_t sequence) { std::string output; output.reserve(24 + frame.rgba8.size()); append_binary(output, std::uint32_t{0x41544852}); append_binary(output, std::uint16_t{1}); append_binary(output, std::uint16_t{}); append_binary(output, frame.extent.width); append_binary(output, frame.extent.height); append_binary(output, sequence); output.append(reinterpret_cast(frame.rgba8.data()), frame.rgba8.size()); return output; } +struct Schema_Query { Plot::Json_Handler handler{}; }; +struct Prop_Write { std::string key{}; nlohmann::json value{}; Plot::Json_Handler handler{}; }; +using Plot_Input = std::variant; +struct Plot_2D { + std::unique_ptr scene{}; /* 鏈€缁堜簩缁?Scene銆?*/ + std::unique_ptr frequency{}; /* 棰戠巼杞达紱涓嶇敤鏃朵粛涓虹┖銆?*/ + std::unique_ptr horizontal{}; /* 鏄熷骇鍥炬按骞虫暟鍊艰酱銆?*/ + std::unique_ptr vertical{}; /* 鍔熺巼鎴栨槦搴у浘鍨傜洿杞淬€?*/ + std::unique_ptr time{}; /* 鏃堕棿杞达紱涓嶇敤鏃朵负绌恒€?*/ + std::unique_ptr plot{}; /* 鍏蜂綋 Plottable 鐨勫敮涓€鎵€鏈夋潈銆?*/ + std::function update{}; /* 鏍规嵁娴忚鍣ㄦ椂閽熸洿鏂版潈濞?Prop銆?*/ + std::unique_ptr descriptor{}; /* 鐩存帴璇诲啓 plot 鐨?Structive 鍗忚瑙嗗浘銆?*/ +}; +template +void bind_renderable_adapter(Holder& plot, Object& object) { + using Tag = typename Definition::Base_Tag; + using State = typename Definition::State; + using Adapter = detail::Renderable_Adapter, + detail::State_Field, + detail::State_Field, + detail::State_Field, + detail::State_Field, + detail::State_Field, + detail::State_Field, + detail::State_Field, + detail::State_Field, + detail::State_Field>; + plot.descriptor = detail::make_renderable_descriptor(Adapter{object}); +} +#define AETHERA_PROP(Type, Name) detail::Prop_Field<&Type::Prop::Name, #Name> +#define AETHERA_STATE(Type, Name) detail::State_Field +template +Plot_2D make_plot_2d() { + Plot_2D result; + const Size canvas{720, 420}; + result.scene = build(); + result.scene->set<&Render_Scene_2D::Prop::viewport>(canvas); + result.scene->set<&Render_Scene_2D::Prop::background>(Color{7, 13, 24, 255}); + result.scene->activate_view(); + auto make_frequency = [&] { result.frequency = build(); configure_axis(result.frequency.get(), Axis_Orientation::horizontal, Point_F{64.0, 370.0}, 620.0, canvas); result.frequency->set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{0.0, 100.0}); }; + auto make_vertical = [&](Axis_Range range) { + result.vertical = build(); + configure_axis(result.vertical.get(), Axis_Orientation::vertical, {64.0, 370.0}, -320.0, canvas); result.vertical->set<&Numeric_Axis::Prop::coordinate_range>(range); + }; + if constexpr (std::same_as) { + make_frequency(); make_vertical({-110.0, 0.0}); auto object = build>(result.scene.get(), result.frequency.get(), result.vertical.get()); object->set<&Spectrum::Prop::frequency_range>(Axis_Range{0.0, 100.0}); object->set<&Spectrum::Prop::max_hold_visible>(true); auto* raw = object.get(); raw->mark_dirty(); raw->mark_dirty(); bind_renderable_adapter(result, *raw); result.update = [raw](double time) { std::array samples{}; for (std::size_t i = 0; i < samples.size(); ++i) { const double x = static_cast(i) / samples.size(); samples[i] = -92.0 + 54.0 * std::exp(-180.0 * std::pow(x - 0.28 - 0.03 * std::sin(time * 0.001), 2.0)) + 42.0 * std::exp(-260.0 * std::pow(x - 0.68, 2.0)) + 2.5 * std::sin(i * 0.31 + time * 0.004); } raw->update_samples(samples); }; result.plot = std::move(object); + } else if constexpr (std::same_as) { + result.time = build(); configure_axis(result.time.get(), Axis_Orientation::horizontal, {64.0, 370.0}, 620.0, canvas); make_vertical({-1.2, 1.2}); auto object = build>(result.scene.get(), result.time.get(), result.vertical.get()); auto* raw = object.get(); raw->mark_dirty(); raw->mark_dirty(); bind_renderable_adapter(result, *raw); auto tick = std::make_shared(); result.update = [raw, tick](double time) { raw->append_sample((*tick)++, std::sin(time * 0.0025) * 0.8 + std::sin(time * 0.0007) * 0.2); }; result.plot = std::move(object); + } else if constexpr (std::same_as) { + make_frequency(); make_vertical({-110.0, 0.0}); auto object = build>(result.scene.get(), result.frequency.get(), result.vertical.get()); object->set<&Sweep_Spectrum::Prop::frequency_range>(Axis_Range{0.0, 100.0}); auto* raw = object.get(); raw->mark_dirty(); raw->mark_dirty(); bind_renderable_adapter(result, *raw); result.update = [raw](double time) { std::array values{}; for (std::size_t i = 0; i < values.size(); ++i) values[i] = -90.0 + 35.0 * std::sin(i * 0.08 + time * 0.002); raw->append_block(values); }; result.plot = std::move(object); + } else if constexpr (std::same_as) { + make_frequency(); make_vertical({-110.0, 0.0}); auto object = build>(result.scene.get(), result.frequency.get(), result.vertical.get()); object->set<&Afterglow::Prop::frequency_range>(Axis_Range{0.0, 100.0}); object->set<&Afterglow::Prop::power_range>(Axis_Range{-110.0, 0.0}); object->set<&Afterglow::Prop::power_point_size>(96); auto* raw = object.get(); raw->mark_dirty(); raw->mark_dirty(); bind_renderable_adapter(result, *raw); result.update = [raw](double time) { std::array values{}; for (std::size_t i = 0; i < values.size(); ++i) values[i] = -95.0 + 62.0 * std::exp(-220.0 * std::pow(static_cast(i) / values.size() - 0.5 - 0.18 * std::sin(time * 0.0008), 2.0)); raw->append_spectrum(values); }; result.plot = std::move(object); + } else if constexpr (std::same_as) { + make_frequency(); result.time = build(); configure_axis(result.time.get(), Axis_Orientation::vertical, {64.0, 370.0}, -320.0, canvas); auto object = build>(result.scene.get(), result.frequency.get(), result.time.get()); object->set<&Waterfall::Prop::frequency_range>(Axis_Range{0.0, 100.0}); object->set<&Waterfall::Prop::power_range>(Axis_Range{-110.0, 0.0}); auto* raw = object.get(); raw->mark_dirty(); raw->mark_dirty(); bind_renderable_adapter(result, *raw); auto tick = std::make_shared(); result.update = [raw, tick](double time) { std::array values{}; for (std::size_t i = 0; i < values.size(); ++i) values[i] = -100.0 + 70.0 * std::exp(-240.0 * std::pow(static_cast(i) / values.size() - 0.5 - 0.22 * std::sin(time * 0.0006), 2.0)); raw->append_row((*tick)++, values); }; result.plot = std::move(object); + } else if constexpr (std::same_as) { + result.horizontal = build(); configure_axis(result.horizontal.get(), Axis_Orientation::horizontal, {64.0, 370.0}, 620.0, canvas); result.horizontal->set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{-1.2, 1.2}); make_vertical({-1.2, 1.2}); auto object = build>(result.scene.get(), result.horizontal.get(), result.vertical.get()); object->set<&Constellation_Diagram::Prop::i_range>(Axis_Range{-1.2, 1.2}); object->set<&Constellation_Diagram::Prop::q_range>(Axis_Range{-1.2, 1.2}); auto* raw = object.get(); raw->mark_dirty(); raw->mark_dirty(); bind_renderable_adapter(result, *raw); result.update = [raw](double time) { const double phase = time * 0.003; raw->append_point({std::cos(phase) * 0.82 + 0.04 * std::sin(phase * 7.0), std::sin(phase) * 0.82 + 0.04 * std::cos(phase * 5.0)}); }; result.plot = std::move(object); + } else if constexpr (std::same_as) { + result.horizontal = build(); configure_axis(result.horizontal.get(), Axis_Orientation::horizontal, {64.0, 370.0}, 620.0, canvas); result.horizontal->set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{0.0, 100.0}); make_vertical({0.0, 100.0}); auto object = build>(result.scene.get(), result.horizontal.get(), result.vertical.get()); auto* raw = object.get(); raw->mark_dirty(); raw->mark_dirty(); bind_renderable_adapter(result, *raw); result.update = [](double) {}; result.plot = std::move(object); + } else { + static_assert(std::same_as, "unsupported 2D Plot definition"); + } + return result; +} +struct Plot_3D { + std::unique_ptr> visual{}; /* 鐐瑰浘鍏冩潈濞佸璞°€?*/ + std::unique_ptr> scene{}; /* 寮傛 Datoviz Scene銆?*/ + std::unique_ptr descriptor{}; /* 鐩存帴璇诲啓 visual 鐨?Structive 鍗忚瑙嗗浘銆?*/ +}; +Plot_3D make_plot_3d() { Plot_3D result; result.visual = build>(); static_cast(result.visual->update_items({render_3d::Point{.position = {-0.55F, -0.2F, 0.0F}, .color = Color::red_color(), .diameter_px = 24.0F}, render_3d::Point{.position = {0.0F, 0.5F, 0.0F}, .color = Color::green_color(), .diameter_px = 30.0F}, render_3d::Point{.position = {0.55F, -0.1F, 0.0F}, .color = Color{42, 120, 255, 255}, .diameter_px = 26.0F}})); bind_renderable_adapter(result, *result.visual); result.visual->advance(); result.scene = build>(result.visual.get()); result.scene->activate_view(); return result; } +using Plot_Engine = std::variant; +template +Plot_Engine make_plot_engine_2d() { return Plot_Engine{std::in_place_type, make_plot_2d()}; } +Plot_Engine make_plot_engine_3d() { return Plot_Engine{std::in_place_type, make_plot_3d()}; } +#undef AETHERA_STATE +#undef AETHERA_PROP +} +namespace { +struct Plot_Catalog_Entry { + std::string_view id; /* URL 与 WebSocket 共用的稳定标识。 */ + std::string_view title; /* Gallery 展示名称。 */ + std::string_view category; /* Gallery 分类。 */ + std::string_view description; /* Gallery 用途说明。 */ + std::string_view dimension; /* Gallery 维度标签。 */ + Plot_Engine (*build)(); /* 直接构造完整 Scene/Renderable/descriptor 的 typed factory。 */ +}; +constexpr std::array plot_catalog{ + Plot_Catalog_Entry{"spectrum", "Spectrum", "Curves", "Current, maximum and minimum spectrum curves with markers.", "2D", &make_plot_engine_2d}, + Plot_Catalog_Entry{"frequency_trace", "Frequency trace", "Curves", "Time ordered frequency samples rendered as a partitioned curve.", "2D", &make_plot_engine_2d}, + Plot_Catalog_Entry{"sweep_spectrum", "Sweep spectrum", "Curves", "Incremental sweep blocks composed into one frequency curve.", "2D", &make_plot_engine_2d}, + Plot_Catalog_Entry{"afterglow", "Afterglow", "Raster", "Persistent spectrum energy rendered as reusable color blocks.", "2D", &make_plot_engine_2d}, + Plot_Catalog_Entry{"waterfall", "Waterfall", "Raster", "Time ordered spectrum rows rendered as a color raster.", "2D", &make_plot_engine_2d}, + Plot_Catalog_Entry{"constellation", "Constellation", "Signals", "I/Q samples and modulation anchors.", "2D", &make_plot_engine_2d}, + Plot_Catalog_Entry{"selection_overlay", "Selection overlay", "Interaction", "Direct-paint selection rectangle over numeric axes.", "2D", &make_plot_engine_2d}, + Plot_Catalog_Entry{"datoviz_point", "Datoviz point", "3D", "Asynchronous Vulkan point visual with GPU readback.", "3D", &make_plot_engine_3d} +}; +const Plot_Catalog_Entry* find_plot(std::string_view id) { + const auto found = std::ranges::find(plot_catalog, id, &Plot_Catalog_Entry::id); + return found == plot_catalog.end() ? nullptr : &*found; +} +} + +struct Plot::Private { + asio::strand strand; /* 寮曟搸鍙?descriptor 鐨勪覆琛岃闂煙銆?*/ + asio::experimental::concurrent_channel inputs; /* UI 杈撳叆闃熷垪銆?*/ + Plot_Engine engine; /* Builder 已完成的 Scene、Renderable 与 adapter 唯一所有权。 */ + std::mutex handlers_mutex; /* 淇濇姢璺?Drogon 绾跨▼鐨勫抚璁㈤槄闆嗗悎銆?*/ + std::unordered_map handlers; /* 褰撳墠 WebSocket 甯ф秷璐硅€呫€?*/ + std::atomic_uint64_t frame_sequence{}; /* 浜岃繘鍒跺抚鍗忚搴忓彿銆?*/ + explicit Private(asio::any_io_executor executor, Plot_Engine value) : strand(asio::make_strand(std::move(executor))), inputs(strand, 32), engine(std::move(value)) {} + void publish(std::string pixels) { std::vector outputs; { std::lock_guard lock(handlers_mutex); outputs.reserve(handlers.size()); for (const auto& [owner, handler] : handlers) outputs.push_back(handler); } for (auto& output : outputs) output(pixels); } + detail::Renderable_Descriptor& descriptor() { return std::visit([](auto& value) -> detail::Renderable_Descriptor& { return *value.descriptor; }, engine); } +}; + +Plot::Plot(std::unique_ptr private_data) : d(std::move(private_data)) {} +Plot::~Plot() { d->inputs.close(); } +void Plot::start() { + auto self = shared_from_this(); + if (auto* engine_2d = std::get_if(&d->engine)) { + engine_2d->scene->set_frame_callback([weak = weak_from_this()](Image_View image) { + if (auto owner = weak.lock()) { + const auto sequence = owner->d->frame_sequence.fetch_add(1, std::memory_order_acq_rel) + 1; + owner->d->publish(encode_frame(image, sequence)); + } + }); + } else { + std::get(d->engine).scene->set_frame_callback([weak = weak_from_this()](std::shared_ptr frame) { + if (auto owner = weak.lock()) { + const auto sequence = owner->d->frame_sequence.fetch_add(1, std::memory_order_acq_rel) + 1; + owner->d->publish(encode_frame(*frame, sequence)); + } + }); + } + asio::co_spawn(d->strand, [self]() -> asio::awaitable { + for (;;) { + asio::error_code error; + auto input = co_await self->d->inputs.async_receive(asio::redirect_error(asio::use_awaitable, error)); + if (error) co_return; + if (auto* query = std::get_if(&input)) { + query->handler(self->d->descriptor().schema()); + continue; + } + if (auto* write = std::get_if(&input)) { + write->handler(self->d->descriptor().write_prop(write->key, write->value)); + continue; + } + const auto value = std::get(input); + if (auto* engine_2d = std::get_if(&self->d->engine)) { + const Size viewport{static_cast(std::clamp(value.width, 160U, 1920U)), static_cast(std::clamp(value.height, 120U, 1080U))}; + engine_2d->scene->set<&Render_Scene_2D::Prop::viewport>(viewport); + if (engine_2d->frequency) engine_2d->frequency->set<&Abs_Axis::Prop::canvas_size>(viewport); + if (engine_2d->horizontal) engine_2d->horizontal->set<&Abs_Axis::Prop::canvas_size>(viewport); + if (engine_2d->vertical) engine_2d->vertical->set<&Abs_Axis::Prop::canvas_size>(viewport); + if (engine_2d->time) engine_2d->time->set<&Abs_Axis::Prop::canvas_size>(viewport); + engine_2d->update(value.time_milliseconds); + engine_2d->scene->render(); + } else { + auto& engine_3d = std::get(self->d->engine); + engine_3d.scene->set<&Render_Scene_3D::Prop::viewport>(render_3d::Extent{std::clamp(value.width, 160U, 1920U), std::clamp(value.height, 120U, 1080U)}); + engine_3d.scene->render(); + } + } + }, [](std::exception_ptr exception) { + if (exception) std::rethrow_exception(exception); + }); +} +void Plot::attach(const void* owner, Frame_Handler handler) { { std::lock_guard lock(d->handlers_mutex); d->handlers.insert_or_assign(owner, std::move(handler)); } submit({}); } +void Plot::detach(const void* owner) { std::lock_guard lock(d->handlers_mutex); d->handlers.erase(owner); } +void Plot::submit(Plot_Event event) { static_cast(d->inputs.try_send(asio::error_code{}, Plot_Input{event})); } +void Plot::async_schema(Json_Handler handler) { if (!d->inputs.try_send(asio::error_code{}, Plot_Input{Schema_Query{std::move(handler)}})) throw std::runtime_error("plot input queue is unavailable"); } +void Plot::async_write_prop(std::string key, nlohmann::json value, Json_Handler handler) { if (!d->inputs.try_send(asio::error_code{}, Plot_Input{Prop_Write{std::move(key), std::move(value), std::move(handler)}})) throw std::runtime_error("plot input queue is unavailable"); } + +struct Plot_Registry::Private { asio::any_io_executor executor; std::mutex mutex; std::unordered_map> plots; explicit Private(asio::any_io_executor value) : executor(std::move(value)) {} }; +Plot_Registry::Plot_Registry(asio::any_io_executor executor) : d(std::make_unique(std::move(executor))) {} +Plot_Registry::~Plot_Registry() = default; +std::shared_ptr Plot_Registry::acquire(std::string_view plot_id) { const auto* entry = find_plot(plot_id); if (!entry) return {}; std::lock_guard lock(d->mutex); auto& plot = d->plots[std::string(plot_id)]; if (!plot) { plot = std::shared_ptr(new Plot(std::make_unique(d->executor, entry->build()))); plot->start(); } return plot; } +nlohmann::json Plot_Registry::catalog() const { nlohmann::json result = nlohmann::json::array(); for (const auto& entry : plot_catalog) result.push_back({{"id", entry.id}, {"title", entry.title}, {"category", entry.category}, {"description", entry.description}, {"dimension", entry.dimension}, {"websocket", "/ws/plot/" + std::string(entry.id)}, {"schema", "/plot/" + std::string(entry.id) + "/schema"}}); return result; } } diff --git a/web_server/src/Plot.hpp b/web_server/src/Plot.hpp index d89400f..b0e28a9 100644 --- a/web_server/src/Plot.hpp +++ b/web_server/src/Plot.hpp @@ -1,18 +1,48 @@ #pragma once -#include "Graph_WebSocket.hpp" +#include +#include +#include +#include #include #include +#include +#include + namespace aethera::web { -class Plot { +struct Plot_Event { + double time_milliseconds{}; /* 浏览器 performance.now() 时钟值。 */ + std::uint32_t width{720}; /* 目标帧像素宽度。 */ + std::uint32_t height{420}; /* 目标帧像素高度。 */ +}; + +class Plot final : public std::enable_shared_from_this { public: - Plot(std::string key, std::shared_ptr websocket); - virtual ~Plot(); + using Frame_Handler = std::function; + using Json_Handler = std::function; + ~Plot(); Plot(const Plot&) = delete; Plot& operator=(const Plot&) = delete; -protected: - virtual void on_event(std::string_view event) = 0; - void send_frame(std::string_view frame); - std::shared_ptr websocket; - std::string key; + void attach(const void* owner, Frame_Handler handler); + void detach(const void* owner); + void submit(Plot_Event event); + void async_schema(Json_Handler handler); + void async_write_prop(std::string key, nlohmann::json value, Json_Handler handler); +private: + struct Private; + explicit Plot(std::unique_ptr private_data); + void start(); + friend class Plot_Registry; + std::unique_ptr d; /* 引擎对象、协议 adapter、事件队列和帧订阅的唯一所有者。 */ +}; + +class Plot_Registry final { +public: + explicit Plot_Registry(asio::any_io_executor executor); + ~Plot_Registry(); + [[nodiscard]] std::shared_ptr acquire(std::string_view plot_id); + [[nodiscard]] nlohmann::json catalog() const; +private: + struct Private; + std::unique_ptr d; /* Plot id 到长生命周期引擎桥接对象的注册表。 */ }; } diff --git a/web_server/src/Renderable_Adapter.hpp b/web_server/src/Renderable_Adapter.hpp new file mode 100644 index 0000000..d7a7a1d --- /dev/null +++ b/web_server/src/Renderable_Adapter.hpp @@ -0,0 +1,46 @@ +#pragma once +#include +#include +#include +#include + +namespace aethera::web::detail { +enum class Renderable_Field_Role { + prop, + state +}; + +template +struct Renderable_Field_Role_Attribute; + +template +struct Prop_Field; + +template +struct State_Field; + +template +class Renderable_Adapter; + +class Renderable_Descriptor { +public: + virtual ~Renderable_Descriptor() = default; + [[nodiscard]] virtual nlohmann::json schema() const = 0; + [[nodiscard]] virtual nlohmann::json write_prop(std::string_view key, const nlohmann::json& value) = 0; +}; + +template +class Renderable_Descriptor_Model final : public Renderable_Descriptor { +public: + explicit Renderable_Descriptor_Model(Adapter adapter); + [[nodiscard]] nlohmann::json schema() const override; + [[nodiscard]] nlohmann::json write_prop(std::string_view key, const nlohmann::json& value) override; +private: + Adapter adapter; /* 与 Plot 内真实 Renderable 同生共死的无所有权协议视图。 */ +}; + +template +[[nodiscard]] std::unique_ptr make_renderable_descriptor(Adapter adapter); +} + +#include "Renderable_Adapter.ipp" diff --git a/web_server/src/Renderable_Adapter.ipp b/web_server/src/Renderable_Adapter.ipp new file mode 100644 index 0000000..cfd9158 --- /dev/null +++ b/web_server/src/Renderable_Adapter.ipp @@ -0,0 +1,340 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace aethera::web::detail { +template +struct Json_Member_Reflection_Adapter { + static constexpr std::size_t field_count = boost::pfr::tuple_size_v; + template static decltype(auto) get(Object& value) noexcept; + template static decltype(auto) get(const Object& value) noexcept; + template static constexpr std::string_view name() noexcept; +}; + +struct Color_Map_Json_Members { + decltype(&render_2d::Color_Map::stops) stops{&render_2d::Color_Map::stops}; /* 唯一需要显式适配的非聚合值字段。 */ +}; +} + +namespace adminive { +template +struct Value_Adapter, Json> { + using value_type = std::vector; + static constexpr std::string_view type_name{"array"}; + static Json encode(const value_type& values); + static void decode(value_type& target, const Json& value); +}; +template +struct Value_Adapter, Json> { + using value_type = std::array; + static constexpr std::string_view type_name{"array"}; + static Json encode(const value_type& values); + static void decode(value_type& target, const Json& value); +}; + +#define AETHERA_WEB_JSON_REFLECTION(Type) \ + template <> struct Reflection_Adapter : Boost_Pfr_Reflection_Adapter {}; \ + template <> struct Type_Descriptor { static auto get(); }; +AETHERA_WEB_JSON_REFLECTION(aethera::Color) +AETHERA_WEB_JSON_REFLECTION(aethera::render_2d::Point_F) +AETHERA_WEB_JSON_REFLECTION(aethera::render_2d::Rect_F) +AETHERA_WEB_JSON_REFLECTION(aethera::render_2d::Axis_Range) +AETHERA_WEB_JSON_REFLECTION(aethera::render_2d::Pen) +AETHERA_WEB_JSON_REFLECTION(aethera::render_2d::Brush) +AETHERA_WEB_JSON_REFLECTION(aethera::render_2d::Font) +AETHERA_WEB_JSON_REFLECTION(aethera::render_2d::Frequency_Trace_Sample) +AETHERA_WEB_JSON_REFLECTION(aethera::render_2d::Waterfall_Row) +AETHERA_WEB_JSON_REFLECTION(aethera::render_2d::Constellation_Point) +AETHERA_WEB_JSON_REFLECTION(aethera::render_3d::Vec3) +AETHERA_WEB_JSON_REFLECTION(aethera::render_3d::Matrix4) +AETHERA_WEB_JSON_REFLECTION(aethera::render_3d::Point) +#undef AETHERA_WEB_JSON_REFLECTION + +template <> +struct Reflection_Adapter + : aethera::web::detail::Json_Member_Reflection_Adapter {}; +template <> +struct Type_Descriptor { static auto get(); }; +} + +namespace aethera::web::detail { +struct Renderable_Field_Role_Category {}; + +template +struct Renderable_Field_Role_Attribute { + using attribute_category = Renderable_Field_Role_Category; + static constexpr bool single_valued = true; + static constexpr bool inheritable = false; + static constexpr auto value = Role; +}; + +template +inline constexpr Renderable_Field_Role_Attribute renderable_field_role{}; + +template +struct Prop_Field { + static constexpr auto member = Member; + static constexpr auto key = Key; + static constexpr auto role = Renderable_Field_Role::prop; +}; + +template +struct State_Field { + using tag_type = Tag; + static constexpr auto member = Member; + static constexpr auto key = Key; + static constexpr auto role = Renderable_Field_Role::state; +}; + +template +concept Prop_Field_Type = requires { + requires Field::role == Renderable_Field_Role::prop; +}; + +template +concept State_Field_Type = requires { + typename Field::tag_type; + requires Field::role == Renderable_Field_Role::state; +}; + +template +class Renderable_Adapter final : public structive::Property_Object, structive::No_Lock_Policy> { +public: + explicit Renderable_Adapter(Object& object); +private: + template + friend struct Renderable_Field_Accessor; + Object* object; /* Plot 拥有且析构晚于本 adapter 的真实引擎对象。 */ +}; + +template +struct Renderable_Field_Accessor; + +template +struct Renderable_Field_Accessor, Prop_Field> { + using object_type = Renderable_Adapter; + using value_type = typename structive::Member_Pointer_Traits::value_type; + using storage_identity = void; + using dependency_spec = structive::No_Property_Dependencies; + static constexpr bool readable = true; + static constexpr bool writable = true; + static constexpr bool synchronized_view_read = false; + static constexpr bool trusted_object_access = true; + [[nodiscard]] value_type read(const object_type& adapter) const; + void write(object_type& adapter, value_type value) const; +}; + +template +struct Renderable_Field_Accessor, State_Field> { + using object_type = Renderable_Adapter; + using value_type = typename structive::Member_Pointer_Traits::value_type; + using storage_identity = void; + using dependency_spec = structive::No_Property_Dependencies; + static constexpr bool readable = true; + static constexpr bool writable = false; + static constexpr bool synchronized_view_read = false; + static constexpr bool trusted_object_access = true; + [[nodiscard]] value_type read(const object_type& adapter) const; +}; + +template +constexpr auto renderable_field_descriptor(); +} + +namespace structive { +template +struct Type_Descriptor> { + static auto get(); +}; +} + +namespace aethera::web::detail { +template +Renderable_Adapter::Renderable_Adapter(Object& value) : object(&value) {} + +template +auto Renderable_Field_Accessor, Prop_Field>::read(const object_type& adapter) const -> value_type { + return adapter.object->template get(); +} + +template +void Renderable_Field_Accessor, Prop_Field>::write(object_type& adapter, value_type value) const { + adapter.object->template set(std::move(value)); +} + +template +auto Renderable_Field_Accessor, State_Field>::read(const object_type& adapter) const -> value_type { + value_type result{}; + adapter.object->template access_state([&](const auto& state) { result = state.*Member; }); + return result; +} + +template +constexpr auto renderable_field_descriptor() { + using Accessor = Renderable_Field_Accessor; + using Role = Renderable_Field_Role_Attribute; + using Key = structive::Key_Attribute; + return structive::Property_Descriptor{{}, {Key{}, Role{}}}; +} +} + +namespace structive { +template +auto Type_Descriptor>::get() { + using Adapter = aethera::web::detail::Renderable_Adapter; + return object(synchronization(sync_all_unsynchronized), aethera::web::detail::renderable_field_descriptor()...); +} +} + +namespace aethera::web::detail { +template +[[nodiscard]] std::string_view json_value_type() { + using Type = std::remove_cvref_t; + if constexpr (std::same_as) return "boolean"; + if constexpr (std::integral) return "integer"; + if constexpr (std::floating_point) return "number"; + if constexpr (std::is_enum_v) return "select"; + if constexpr (requires { typename Type::value_type; }) return "array"; + return "object"; +} + +template +[[nodiscard]] nlohmann::json adapter_schema(const Adapter& adapter) { + const auto& schema = structive::type_descriptor(); + nlohmann::json prop = nlohmann::json::array(); + nlohmann::json state = nlohmann::json::array(); + schema.for_each_property([&](auto, const auto& field) { + using Field = std::remove_cvref_t; + using Value = typename Field::value_type; + const auto role = field.template attribute().value; + nlohmann::json item{{"key", field.key()}, {"type", json_value_type()}}; + const auto value = field.accessor.read(adapter); + item["value"] = adminive::encode_json_value(value); + if constexpr (std::is_enum_v) { + item["options"] = nlohmann::json::array(); + for (const auto enum_value : adminive::enum_values()) { + const auto name = adminive::enum_name(enum_value); + item["options"].push_back({{"value", name}, {"label", name}}); + } + } + (role == Renderable_Field_Role::prop ? prop : state).push_back(std::move(item)); + }); + return {{"prop", std::move(prop)}, {"state", std::move(state)}}; +} + +template +[[nodiscard]] nlohmann::json write_adapter_prop(Adapter& adapter, std::string_view key, const nlohmann::json& input) { + const auto& schema = structive::type_descriptor(); + nlohmann::json result{{"success", false}, {"key", key}}; + const bool found = structive::visit_schema_property(schema, key, [&](auto, const auto& field) { + using Field = std::remove_cvref_t; + using Value = typename Field::value_type; + if constexpr (!Field::writable) { + result["error"] = "property is read-only"; + } else { + try { + Value value{}; + adminive::assign_json_value(value, input); + const auto status = adapter.runtime_write(key, typeid(Value), &value); + if (status != structive::Runtime_Access_Result::ok) { + result["error"] = "property write was rejected"; + return; + } + result["success"] = true; + result["value"] = adminive::encode_json_value(field.accessor.read(adapter)); + } catch (const std::exception& error) { + result["error"] = error.what(); + } + } + }); + if (!found) result["error"] = "unknown property"; + return result; +} + +template +Renderable_Descriptor_Model::Renderable_Descriptor_Model(Adapter value) : adapter(std::move(value)) {} + +template +nlohmann::json Renderable_Descriptor_Model::schema() const { return adapter_schema(adapter); } + +template +nlohmann::json Renderable_Descriptor_Model::write_prop(std::string_view key, const nlohmann::json& value) { return write_adapter_prop(adapter, key, value); } + +template +std::unique_ptr make_renderable_descriptor(Adapter adapter) { + return std::make_unique>(std::move(adapter)); +} +} + +namespace aethera::web::detail { +template +template +decltype(auto) Json_Member_Reflection_Adapter::get(Object& value) noexcept { return value.*boost::pfr::get(Members{}); } +template +template +decltype(auto) Json_Member_Reflection_Adapter::get(const Object& value) noexcept { return value.*boost::pfr::get(Members{}); } +template +template +constexpr std::string_view Json_Member_Reflection_Adapter::name() noexcept { return boost::pfr::get_name(); } +} + +namespace adminive { +template +Json Value_Adapter, Json>::encode(const value_type& values) { + Json result = json_array(); + for (const auto& value : values) json_append(result, encode_json_value(value)); + return result; +} +template +void Value_Adapter, Json>::decode(value_type& target, const Json& value) { + if (!Json_Adapter::is_array(value)) throw std::invalid_argument("value must be an array"); + value_type updated; + updated.reserve(Json_Adapter::size(value)); + for (std::size_t index = 0; index < Json_Adapter::size(value); ++index) { + Value item{}; + assign_json_value(item, Json_Adapter::at(value, index)); + updated.push_back(std::move(item)); + } + target = std::move(updated); +} +template +Json Value_Adapter, Json>::encode(const value_type& values) { + Json result = json_array(); + for (const auto& value : values) json_append(result, encode_json_value(value)); + return result; +} +template +void Value_Adapter, Json>::decode(value_type& target, const Json& value) { + if (!Json_Adapter::is_array(value) || Json_Adapter::size(value) != Size) throw std::invalid_argument("array has the wrong size"); + value_type updated{}; + for (std::size_t index = 0; index < Size; ++index) assign_json_value(updated[index], Json_Adapter::at(value, index)); + target = std::move(updated); +} + +#define AETHERA_WEB_DEFINE_JSON_DESCRIPTOR(Type, Id, Label) \ + inline auto Type_Descriptor::get() { return reflected_object_with(Id, Label, Identity_Field_Customizer{}); } +AETHERA_WEB_DEFINE_JSON_DESCRIPTOR(aethera::Color, "color", "Color") +AETHERA_WEB_DEFINE_JSON_DESCRIPTOR(aethera::render_2d::Point_F, "point", "Point") +AETHERA_WEB_DEFINE_JSON_DESCRIPTOR(aethera::render_2d::Rect_F, "rectangle", "Rectangle") +AETHERA_WEB_DEFINE_JSON_DESCRIPTOR(aethera::render_2d::Axis_Range, "axis_range", "Axis range") +AETHERA_WEB_DEFINE_JSON_DESCRIPTOR(aethera::render_2d::Pen, "pen", "Pen") +AETHERA_WEB_DEFINE_JSON_DESCRIPTOR(aethera::render_2d::Brush, "brush", "Brush") +AETHERA_WEB_DEFINE_JSON_DESCRIPTOR(aethera::render_2d::Font, "font", "Font") +AETHERA_WEB_DEFINE_JSON_DESCRIPTOR(aethera::render_2d::Frequency_Trace_Sample, "frequency_trace_sample", "Frequency trace sample") +AETHERA_WEB_DEFINE_JSON_DESCRIPTOR(aethera::render_2d::Waterfall_Row, "waterfall_row", "Waterfall row") +AETHERA_WEB_DEFINE_JSON_DESCRIPTOR(aethera::render_2d::Constellation_Point, "constellation_point", "Constellation point") +AETHERA_WEB_DEFINE_JSON_DESCRIPTOR(aethera::render_2d::Color_Map, "color_map", "Color map") +AETHERA_WEB_DEFINE_JSON_DESCRIPTOR(aethera::render_3d::Vec3, "vec3", "3D vector") +AETHERA_WEB_DEFINE_JSON_DESCRIPTOR(aethera::render_3d::Matrix4, "matrix4", "Transform") +AETHERA_WEB_DEFINE_JSON_DESCRIPTOR(aethera::render_3d::Point, "point_3d", "3D point") +#undef AETHERA_WEB_DEFINE_JSON_DESCRIPTOR +} diff --git a/web_server/src/Web_Server.cpp b/web_server/src/Web_Server.cpp index 9402a64..a04a3b3 100644 --- a/web_server/src/Web_Server.cpp +++ b/web_server/src/Web_Server.cpp @@ -1,9 +1,9 @@ #include "Web_Server.hpp" -#include "Graph_Metadata.hpp" /* 按图请求描述数据。 */ -#include "Graph_Session.hpp" #include "Graph_WebSocket.hpp" +#include "Plot.hpp" #include #include +#include #include #include #include @@ -16,14 +16,12 @@ drogon::HttpResponsePtr error_response(drogon::HttpStatusCode status, std::strin int run_web_server(std::uint16_t port, const std::filesystem::path& asset_root) { const auto hardware_threads = std::max(2U, std::thread::hardware_concurrency()); auto graph_pool = std::make_shared(std::min(8U, hardware_threads)); - auto registry = std::make_shared(graph_pool->get_executor()); + auto registry = std::make_shared(graph_pool->get_executor()); auto websocket = std::make_shared(registry); auto& app = drogon::app(); - app.registerHandler("/api/graphs", [](const drogon::HttpRequestPtr&, std::function&& callback) { callback(json_response(graph_catalog_json())); }, {drogon::Get}); - app.registerHandler("/api/graphs/{1}/descriptor", [](const drogon::HttpRequestPtr&, std::function&& callback, std::string graph_id) { if (!find_graph(graph_id)) { callback(error_response(drogon::k404NotFound, "unknown graph")); return; } callback(json_response({{"prop", graph_prop_descriptor_json(graph_id)}, {"state", graph_state_descriptor_json(graph_id)}, {"state_api", "/api/graphs/" + graph_id + "/state"}, {"prop_api", "/api/graphs/" + graph_id + "/prop"}})); }, {drogon::Get}); - app.registerHandler("/api/graphs/{1}/state", [registry](const drogon::HttpRequestPtr&, std::function&& callback, std::string graph_id) { auto session = registry->acquire(graph_id); if (!session) { callback(error_response(drogon::k404NotFound, "unknown graph")); return; } auto output = std::make_shared>(std::move(callback)); session->async_state([output](nlohmann::json state) { (*output)(json_response(std::move(state))); }); }, {drogon::Get}); - app.registerHandler("/api/graphs/{1}/prop", [registry](const drogon::HttpRequestPtr&, std::function&& callback, std::string graph_id) { auto session = registry->acquire(graph_id); if (!session) { callback(error_response(drogon::k404NotFound, "unknown graph")); return; } auto output = std::make_shared>(std::move(callback)); session->async_prop([output](nlohmann::json prop) { (*output)(json_response(std::move(prop))); }); }, {drogon::Get}); - app.registerHandler("/api/graphs/{1}/prop", [registry](const drogon::HttpRequestPtr& request, std::function&& callback, std::string graph_id) { auto session = registry->acquire(graph_id); if (!session) { callback(error_response(drogon::k404NotFound, "unknown graph")); return; } nlohmann::json patch; try { patch = nlohmann::json::parse(request->body()); } catch (const nlohmann::json::exception&) { callback(error_response(drogon::k400BadRequest, "invalid JSON patch")); return; } auto output = std::make_shared>(std::move(callback)); session->async_patch_prop(std::move(patch), [output](nlohmann::json result) { (*output)(json_response(std::move(result))); }); }, {drogon::Patch}); + app.registerHandler("/plot", [registry](const drogon::HttpRequestPtr&, std::function&& callback) { callback(json_response(registry->catalog())); }, {drogon::Get}); + app.registerHandler("/plot/{1}/schema", [registry](const drogon::HttpRequestPtr&, std::function&& callback, std::string plot_id) { auto plot = registry->acquire(plot_id); if (!plot) { callback(error_response(drogon::k404NotFound, "unknown plot")); return; } auto output = std::make_shared>(std::move(callback)); plot->async_schema([output](nlohmann::json schema) { (*output)(json_response(std::move(schema))); }); }, {drogon::Get}); + app.registerHandler("/plot/{1}/prop/{2}", [registry](const drogon::HttpRequestPtr& request, std::function&& callback, std::string plot_id, std::string key) { auto plot = registry->acquire(plot_id); if (!plot) { callback(error_response(drogon::k404NotFound, "unknown plot")); return; } nlohmann::json value; try { value = nlohmann::json::parse(request->body()); } catch (const nlohmann::json::exception&) { callback(error_response(drogon::k400BadRequest, "invalid JSON value")); return; } auto output = std::make_shared>(std::move(callback)); plot->async_write_prop(std::move(key), std::move(value), [output](nlohmann::json result) { (*output)(json_response(std::move(result))); }); }, {drogon::Put}); app.registerController(websocket).setDocumentRoot(asset_root.string()).setHomePage("index.html").setStaticFileHeaders({{"Cache-Control", "no-store"}}).addListener("127.0.0.1", port).setThreadNum(std::min(8U, hardware_threads)).setIdleConnectionTimeout(90).run(); graph_pool->stop(); graph_pool->join(); diff --git a/web_server/src/main.cpp b/web_server/src/main.cpp index 3f6805c..3bdf3ac 100644 --- a/web_server/src/main.cpp +++ b/web_server/src/main.cpp @@ -6,6 +6,21 @@ #include #include namespace { -std::uint16_t parse_port(int argc, char** argv) { constexpr std::uint16_t fallback = 8848; if (argc == 1) return fallback; if (argc != 3 || std::string_view(argv[1]) != "--port") return 0; unsigned value{}; const std::string_view text(argv[2]); const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value); return error == std::errc{} && end == text.data() + text.size() && value > 0 && value <= 65535 ? static_cast(value) : 0; } +std::uint16_t parse_port(int argc, char** argv) { + constexpr std::uint16_t fallback = 8848; + if (argc == 1) return fallback; + if (argc != 3 || std::string_view(argv[1]) != "--port") return 0; + unsigned value{}; + const std::string_view text(argv[2]); + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value); + return error == std::errc{} && end == text.data() + text.size() && value > 0 && value <= 65535 ? static_cast(value) : 0; +} +} +int main(int argc, char** argv) { + const auto port = parse_port(argc, argv); + if (port == 0) return 2; + aethera::initialize_runtime(); + const auto executable = std::filesystem::absolute(argv[0]); + std::cout << "Aethera Gallery http://127.0.0.1:" << port << std::endl; + return aethera::web::run_web_server(port, executable.parent_path() / "webapp_gallery"); } -int main(int argc, char** argv) { const auto port = parse_port(argc, argv); if (port == 0) return 2; aethera::initialize_runtime(); const auto executable = std::filesystem::absolute(argv[0]); std::cout << "Aethera Gallery http://127.0.0.1:" << port << std::endl; return aethera::web::run_web_server(port, executable.parent_path() / "webapp_gallery"); } diff --git a/webapp_gallery/src/app.tsx b/webapp_gallery/src/app.tsx index f7e65cb..00766de 100644 --- a/webapp_gallery/src/app.tsx +++ b/webapp_gallery/src/app.tsx @@ -1,14 +1,120 @@ import {memo, useEffect, useMemo, useRef, useState} from "react"; -type Graph = {id: string; title: string; category: string; description: string; dimension: "2D" | "3D"; websocket: string; state: string; descriptor: string}; -type Field = {name: string; value_type: string; editable: boolean; children?: Field[]; presentation: {label: string; description?: string; control: string; options?: Array<{value: string; label: string}>}}; -type Descriptor = {state: {fields: Field[]}; prop: {fields: Field[]}; state_api: string; prop_api: string}; + +type Plot = { + id: string; + title: string; + category: string; + description: string; + dimension: "2D" | "3D"; + websocket: string; + schema: string; +}; +type Option = {value: string; label: string}; +type Field = {key: string; type: "boolean" | "integer" | "number" | "select" | "array" | "object"; value: unknown; options?: Option[]}; +type Schema = {prop: Field[]; state: Field[]}; + const protocolHeaderSize = 24; -function socketUrl(path: string) { return `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${path}`; } -function drawPixels(canvas: HTMLCanvasElement, bytes: ArrayBuffer) { if (bytes.byteLength < protocolHeaderSize) return; const view = new DataView(bytes); if (view.getUint32(0, true) !== 0x41544852 || view.getUint16(4, true) !== 1) return; const width = view.getUint32(8, true); const height = view.getUint32(12, true); const pixels = new Uint8ClampedArray(bytes, protocolHeaderSize); if (pixels.byteLength !== width * height * 4) return; if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } canvas.getContext("2d", {alpha: false})?.putImageData(new ImageData(pixels, width, height), 0, 0); } -function useGraphStream(graph: Graph, canvasRef: React.RefObject) { const statusRef = useRef(null); useEffect(() => { let stopped = false; let animation = 0; const socket = new WebSocket(socketUrl(graph.websocket)); socket.binaryType = "arraybuffer"; socket.onopen = () => { if (statusRef.current) statusRef.current.textContent = "LIVE"; }; socket.onclose = () => { if (statusRef.current) statusRef.current.textContent = "OFFLINE"; }; socket.onmessage = event => { if (event.data instanceof ArrayBuffer && canvasRef.current) drawPixels(canvasRef.current, event.data); }; const tick = (time: number) => { const canvas = canvasRef.current; if (!stopped && socket.readyState === WebSocket.OPEN && canvas) socket.send(JSON.stringify({time, width: Math.max(320, canvas.clientWidth * devicePixelRatio), height: Math.max(220, canvas.clientHeight * devicePixelRatio)})); if (!stopped) animation = requestAnimationFrame(tick); }; animation = requestAnimationFrame(tick); return () => { stopped = true; cancelAnimationFrame(animation); socket.close(); }; }, [graph.websocket, canvasRef]); return statusRef; } -function JsonControl({field, value, onChange}: {field: Field; value: unknown; onChange: (value: unknown) => void}) { const [draft, setDraft] = useState(() => JSON.stringify(value, null, 2)); const [invalid, setInvalid] = useState(false); useEffect(() => setDraft(JSON.stringify(value, null, 2)), [value]); const apply = () => { try { onChange(JSON.parse(draft)); setInvalid(false); } catch { setInvalid(true); } }; return