#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 #include #include namespace aethera::web { namespace { using namespace render_2d; using namespace render_3d; using Scene_2D = Impl; using Scene_3D = Impl; using Frequency_Axis_Object = Impl; using Numeric_Axis_Object = Impl; using Time_Axis_Object = Impl; using Selection_Object = Impl; constexpr std::uint16_t frame_protocol_version{4}; enum class Frame_Pacing_Mode : std::uint32_t { manual, fixed_rate, minimum_latency, maximum_rate }; struct Frame_Pacing_Properties { Frame_Pacing_Mode mode{Frame_Pacing_Mode::fixed_rate}; /* 控制后继 render 请求节奏的策略。 */ double fixed_rate_fps{30.0}; /* 固定频率策略的目标帧率,单位为 FPS。 */ double minimum_latency_headroom{1.25}; /* 最低延迟策略相对 P95 生成耗时的安全系数。 */ }; class Frame_Policy final { public: [[nodiscard]] Frame_Pacing_Properties snapshot() const; [[nodiscard]] nlohmann::json schema() const; [[nodiscard]] nlohmann::json write_prop(std::string_view key, const nlohmann::json& value); private: mutable std::mutex mutex; /* 保护异步帧发布与 HTTP 属性编辑读取。 */ Frame_Pacing_Properties pacing{}; /* 可编辑帧调度属性的唯一权威来源。 */ }; std::string_view pacing_mode_name(Frame_Pacing_Mode mode) { switch (mode) { case Frame_Pacing_Mode::manual: return "manual"; case Frame_Pacing_Mode::fixed_rate: return "fixed_rate"; case Frame_Pacing_Mode::minimum_latency: return "minimum_latency"; case Frame_Pacing_Mode::maximum_rate: return "maximum_rate"; } throw std::logic_error("unknown frame pacing mode"); } std::optional parse_pacing_mode(std::string_view value) { if (value == "manual") return Frame_Pacing_Mode::manual; if (value == "fixed_rate") return Frame_Pacing_Mode::fixed_rate; if (value == "minimum_latency") return Frame_Pacing_Mode::minimum_latency; if (value == "maximum_rate") return Frame_Pacing_Mode::maximum_rate; return std::nullopt; } Frame_Pacing_Properties Frame_Policy::snapshot() const { std::lock_guard lock(mutex); return pacing; } nlohmann::json Frame_Policy::schema() const { std::lock_guard lock(mutex); nlohmann::json fields = nlohmann::json::array(); fields.push_back({{"key", "pacing_mode"}, {"label", "帧刷新策略"}, {"editor", "select"}, {"editable", true}, {"description", "选择浏览器如何安排下一次 render 调用。"}, {"technical_description", "Controls client-side render cadence using end-to-end samples computed by the browser."}, {"value", pacing_mode_name(pacing.mode)}, {"options", nlohmann::json::array({{{"value", "manual"}, {"label", "手动刷新"}}, {{"value", "fixed_rate"}, {"label", "固定频率"}}, {{"value", "minimum_latency"}, {"label", "最低延迟"}}, {{"value", "maximum_rate"}, {"label", "最高频率"}}})}}); fields.push_back({{"key", "fixed_rate_fps"}, {"label", "固定目标帧率"}, {"editor", "number"}, {"editable", true}, {"description", "固定频率策略下每秒发起的 render 次数。"}, {"technical_description", "Target render request rate used by fixed_rate pacing, in frames per second."}, {"value", pacing.fixed_rate_fps}}); fields.push_back({{"key", "minimum_latency_headroom"}, {"label", "最低延迟余量"}, {"editor", "number"}, {"editable", true}, {"description", "最低延迟策略使用的浏览器端 P95 端到端耗时安全系数。"}, {"technical_description", "Multiplier applied to browser-computed P95 request-to-pixel latency before scheduling the next render request."}, {"value", pacing.minimum_latency_headroom}}); return {{"id", "frame-runtime"}, {"label", "帧策略与诊断"}, {"kind", "runtime"}, {"fields", std::move(fields)}, {"state", nlohmann::json::object()}}; } nlohmann::json Frame_Policy::write_prop(std::string_view key, const nlohmann::json& value) { std::lock_guard lock(mutex); if (key == "pacing_mode") { if (!value.is_string()) return {{"success", false}, {"error", "pacing_mode requires a string"}}; const auto parsed = parse_pacing_mode(value.get_ref()); if (!parsed) return {{"success", false}, {"error", "unknown frame pacing mode"}}; pacing.mode = *parsed; return {{"success", true}, {"component", "frame-runtime"}, {"key", key}, {"value", pacing_mode_name(pacing.mode)}}; } if (key == "fixed_rate_fps") { if (!value.is_number()) return {{"success", false}, {"error", "fixed_rate_fps requires a number"}}; const double next = value.get(); if (!std::isfinite(next) || next < 0.1 || next > 240.0) return {{"success", false}, {"error", "fixed_rate_fps must be between 0.1 and 240"}}; pacing.fixed_rate_fps = next; return {{"success", true}, {"component", "frame-runtime"}, {"key", key}, {"value", pacing.fixed_rate_fps}}; } if (key == "minimum_latency_headroom") { if (!value.is_number()) return {{"success", false}, {"error", "minimum_latency_headroom requires a number"}}; const double next = value.get(); if (!std::isfinite(next) || next < 1.0 || next > 4.0) return {{"success", false}, {"error", "minimum_latency_headroom must be between 1 and 4"}}; pacing.minimum_latency_headroom = next; return {{"success", true}, {"component", "frame-runtime"}, {"key", key}, {"value", pacing.minimum_latency_headroom}}; } return {{"success", false}, {"error", "unknown frame runtime property"}}; } nlohmann::json frame_metadata(Render_Frame& frame, std::uint32_t width, std::uint32_t height, std::size_t byte_length, const Frame_Pacing_Properties& pacing) { nlohmann::json markers = nlohmann::json::object(); for (const auto& point : frame.trace_points()) markers[std::string(magic_enum::enum_name(point.marker))] = point.elapsed_ns; nlohmann::json measurements = nlohmann::json::object(); for (const auto& value : frame.trace_values()) measurements[std::string(magic_enum::enum_name(value.measurement))] = value.value_ns; const auto identity = frame.identity(); return { {"kind", "frame_metadata"}, {"protocol", "aethera.frame"}, {"version", frame_protocol_version}, {"sequence", identity.sequence}, {"correlation_id", identity.correlation_id}, {"created_time_unix_ms", static_cast(frame.created_time_unix_ns()) / 1'000'000.0}, {"pixel", {{"width", width}, {"height", height}, {"format", "rgba8"}, {"byte_length", byte_length}}}, {"pacing", {{"mode", pacing_mode_name(pacing.mode)}, {"fixed_rate_fps", pacing.fixed_rate_fps}, {"minimum_latency_headroom", pacing.minimum_latency_headroom}}}, {"trace", {{"clock", "steady_elapsed_ns"}, {"markers", std::move(markers)}, {"measurements", std::move(measurements)}}} }; } std::shared_ptr encode_frame(Frame_2D* frame, const Frame_Pacing_Properties& pacing) { frame->mark(Frame_Trace_Marker::websocket_publish_started); const Image_View image = frame->image(); std::string output; output.reserve(static_cast(image.width) * image.height * 4); 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])); } } frame->mark(Frame_Trace_Marker::websocket_publish_finished); auto message = std::make_shared(); message->pixels = std::move(output); message->metadata = frame_metadata(*frame, static_cast(image.width), static_cast(image.height), message->pixels.size(), pacing).dump(); return message; } std::shared_ptr encode_frame(Frame_3D* frame, const Frame_Pacing_Properties& pacing) { frame->mark(Frame_Trace_Marker::websocket_publish_started); const auto pixels = frame->pixels(); auto message = std::make_shared(); message->pixels.assign(reinterpret_cast(pixels.data()), pixels.size()); frame->mark(Frame_Trace_Marker::websocket_publish_finished); const auto extent = frame->extent(); message->metadata = frame_metadata(*frame, extent.width, extent.height, message->pixels.size(), pacing).dump(); return message; } struct Schema_Query { Plot::Json_Handler handler; }; struct Frame_Submission { const void* owner{}; /* 只向发起 render 的 WebSocket 连接返回完成帧。 */ Plot_Frame_Request request{}; /* 浏览器帧请求及关联标识。 */ }; struct Prop_Write { std::string component; std::string key; nlohmann::json value; Plot::Json_Handler handler; }; using Plot_Input = std::variant; template class Scene_View_Model final : public Plot::Scene_View { public: Scene_View_Model(std::vector> value_descriptors, std::function value_update, Owned_Objects... owned_objects) : descriptors(std::move(value_descriptors)), update_scene(std::move(value_update)), objects(std::move(owned_objects)...) {} nlohmann::json schema() const override { nlohmann::json components = nlohmann::json::array(); for (const auto& descriptor : descriptors) components.push_back(descriptor->schema()); return {{"protocol", "aethera.plot.inspector"}, {"version", 2}, {"components", std::move(components)}}; } nlohmann::json write_prop(std::string_view component, std::string_view key, const nlohmann::json& value) override { const auto found = std::ranges::find_if(descriptors, [&](const auto& item) { return item->id() == component; }); if (found == descriptors.end()) return {{"success", false}, {"error", "unknown component"}}; auto result = (*found)->write_prop(key, value); result["component"] = component; return result; } void update(const Plot_Frame_Request& request) override { update_scene(request); } private: std::vector> descriptors; std::function update_scene; std::tuple objects; }; template using Prop_Field = detail::Prop_Field; template using State_Field = detail::State_Field; template std::unique_ptr make_renderable_component( std::string id, std::string label, std::string kind, Object& object) { using Definition = typename Object::Attached_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>; return detail::make_renderable_descriptor(std::move(id), std::move(label), std::move(kind), Adapter{object}); } template std::unique_ptr make_scene_component(Scene_Object& scene) { using Definition = typename Scene_Object::Attached_Object; using Prop = typename Definition::Prop; using Adapter = detail::Renderable_Adapter, detail::Prop_Field<&Prop::background, "background", "Scene clear color.">, detail::Prop_Field<&Prop::view_active, "view_active", "Whether the scene publishes rendered frames.">, detail::State_Field, detail::State_Field, detail::State_Field, detail::State_Field, detail::State_Field, detail::State_Field, detail::State_Field>; return detail::make_renderable_descriptor("scene", "场景", "scene", Adapter{scene}); } template <> std::unique_ptr make_scene_component(Scene_3D& scene) { using Adapter = detail::Renderable_Adapter, detail::Prop_Field<&Render_Scene_3D::Prop::view_active, "view_active", "Whether the scene publishes rendered frames.">, detail::State_Field, detail::State_Field, detail::State_Field, detail::State_Field, detail::State_Field, detail::State_Field, detail::State_Field>; return detail::make_renderable_descriptor("scene", "场景", "scene", Adapter{scene}); } template std::unique_ptr make_axis_component( std::string id, std::string label, Axis_Object& axis) { return make_renderable_component, Prop_Field<&Abs_Axis::Prop::pixel_length, "pixel_length", "Signed axis length in pixels.">, Prop_Field<&Abs_Axis::Prop::orientation, "orientation", "Axis orientation.">, Prop_Field<&Abs_Axis::Prop::tick_length, "tick_length", "Major tick length.">, Prop_Field<&Abs_Axis::Prop::sub_tick_length, "sub_tick_length", "Minor tick length.">, Prop_Field<&Abs_Axis::Prop::axis_pen, "axis_pen", "Axis line and tick style.">, Prop_Field<&Abs_Axis::Prop::unit_text, "unit_text", "Axis unit label.">, Prop_Field<&Abs_Axis::Prop::unit_text_font, "unit_text_font", "Axis label font.">, Prop_Field<&Abs_Axis::Prop::unit_text_pen, "unit_text_pen", "Axis label foreground.">, Prop_Field<&Abs_Axis::Prop::unit_text_background_brush, "unit_text_background_brush", "Axis label background.">, Prop_Field<&Abs_Axis::Prop::label_rotation_degrees, "label_rotation_degrees", "Tick label rotation.">, Prop_Field<&Numeric_Axis::Prop::coordinate_range, "coordinate_range", "Visible coordinate range.">, Prop_Field<&Numeric_Axis::Prop::precision, "precision", "Maximum decimal precision.">, Prop_Field<&Numeric_Axis::Prop::locale, "locale", "Numeric label locale.">, Prop_Field<&Numeric_Axis::Prop::wheel_enabled, "wheel_enabled", "Allows wheel zoom.">, Prop_Field<&Numeric_Axis::Prop::drag_enabled, "drag_enabled", "Allows pointer drag panning.">>( std::move(id), std::move(label), "axis", axis); } template <> std::unique_ptr make_axis_component( std::string id, std::string label, Time_Axis_Object& axis) { return make_renderable_component, Prop_Field<&Abs_Axis::Prop::pixel_length, "pixel_length", "Signed axis length in pixels.">, Prop_Field<&Abs_Axis::Prop::orientation, "orientation", "Axis orientation.">, Prop_Field<&Abs_Axis::Prop::tick_length, "tick_length", "Major tick length.">, Prop_Field<&Abs_Axis::Prop::sub_tick_length, "sub_tick_length", "Minor tick length.">, Prop_Field<&Abs_Axis::Prop::axis_pen, "axis_pen", "Axis line and tick style.">, Prop_Field<&Abs_Axis::Prop::unit_text, "unit_text", "Axis unit label.">, Prop_Field<&Abs_Axis::Prop::unit_text_font, "unit_text_font", "Axis label font.">, Prop_Field<&Abs_Axis::Prop::unit_text_pen, "unit_text_pen", "Axis label foreground.">, Prop_Field<&Abs_Axis::Prop::unit_text_background_brush, "unit_text_background_brush", "Axis label background.">, Prop_Field<&Abs_Axis::Prop::label_rotation_degrees, "label_rotation_degrees", "Tick label rotation.">, Prop_Field<&Time_Axis::Prop::visible_count, "visible_count", "Maximum visible time samples.">, Prop_Field<&Time_Axis::Prop::tick_label_spacing_px, "tick_label_spacing_px", "Spacing between time labels.">, Prop_Field<&Time_Axis::Prop::estimated_label_width_px, "estimated_label_width_px", "Estimated time label width.">, Prop_Field<&Time_Axis::Prop::format, "format", "Time label format.">, Prop_Field<&Time_Axis::Prop::newest_at_start, "newest_at_start", "Places the newest time at the range origin.">, State_Field, State_Field>( std::move(id), std::move(label), "axis", axis); } template std::unique_ptr make_scene_view( Object& object, Scene_2D& scene, std::function update, Owned_Objects&&... owned_objects) { using Definition = typename Object::Attached_Object; using Tag = typename Definition::Base_Tag; using State = typename Definition::State; std::vector> components; components.push_back(make_scene_component(scene)); components.push_back(make_renderable_component("plot", "主绘图组件", "renderable", object)); std::size_t axis_index{}; const auto append_owned = [&](const auto& owned) { using Owned = std::remove_cvref_t; if constexpr (std::same_as || std::same_as || std::same_as) { const auto id = axis_index++ == 0 ? "axis-x" : "axis-y"; components.push_back(make_axis_component(id, id == std::string_view{"axis-x"} ? "横向坐标轴" : "纵向坐标轴", *owned)); } else if constexpr (std::same_as && !std::same_as) { components.push_back(make_renderable_component, Prop_Field<&Selection_Rectangle_Overlay::Prop::label_pen, "label_pen", "Pen used to draw selected-region label text.">, Prop_Field<&Selection_Rectangle_Overlay::Prop::selection_brush, "selection_brush", "Brush used to fill selected rectangular regions.">, Prop_Field<&Selection_Rectangle_Overlay::Prop::selection_border_pen, "selection_border_pen", "Pen used to draw selected-region borders.">, Prop_Field<&Selection_Rectangle_Overlay::Prop::selected_regions, "selected_regions", "Collection of selected rectangles expressed in axis coordinates.">, State_Field>("selection", "矩形选区", "overlay", *owned)); } }; (append_owned(owned_objects), ...); return std::make_unique...>>( std::move(components), std::move(update), std::forward(owned_objects)...); } std::unique_ptr make_frequency_axis() { auto result = Frequency_Axis_Object::Builder{} .set(&Abs_Axis::Prop::orientation, Axis_Orientation::horizontal) .set(&Abs_Axis::Prop::position, Point_F{64.0, 370.0}) .set(&Abs_Axis::Prop::pixel_length, 620.0) .set(&Numeric_Axis::Prop::coordinate_range, Axis_Range{0.0, 100.0}) .build(); if (!result) throw std::logic_error("frequency axis dependency graph is invalid"); return std::move(result).value(); } std::unique_ptr make_numeric_axis( Axis_Orientation orientation, Point_F position, Axis_Pixel_Length length, Axis_Range range) { auto result = Numeric_Axis_Object::Builder{} .set(&Abs_Axis::Prop::orientation, orientation) .set(&Abs_Axis::Prop::position, position) .set(&Abs_Axis::Prop::pixel_length, length) .set(&Numeric_Axis::Prop::coordinate_range, range) .build(); if (!result) throw std::logic_error("numeric axis dependency graph is invalid"); return std::move(result).value(); } std::unique_ptr make_time_axis( Axis_Orientation orientation, Point_F position, Axis_Pixel_Length length) { auto result = Time_Axis_Object::Builder{} .set(&Abs_Axis::Prop::orientation, orientation) .set(&Abs_Axis::Prop::position, position) .set(&Abs_Axis::Prop::pixel_length, length) .build(); if (!result) throw std::logic_error("time axis dependency graph is invalid"); return std::move(result).value(); } template std::unique_ptr selection_overlay(Horizontal_Axis* horizontal_axis, Vertical_Axis* vertical_axis) { auto result = Selection_Object::Builder(horizontal_axis, vertical_axis).build(); if (!result) throw std::logic_error("selection overlay axes form an invalid dependency graph"); return std::move(result).value(); } template void place_selection_over(Scene_2D* scene, Selection_Object* selection, Plot_Object* plot) { const auto result = scene->template edit_dependency_graph([&](auto& paint) { paint.add_dependency(selection, plot); }); if (!result) throw std::logic_error("selection overlay paint order is invalid"); } template void resize_axes(Scene_2D* scene, Size viewport, Axes*... axes) { const Size previous_viewport = scene->template read_prop().viewport; if (previous_viewport == viewport || previous_viewport.empty()) return; const auto resize_axis = [&](auto* axis) { const auto layout = axis->template read_prop(); const double horizontal_scale = static_cast(viewport.width) / previous_viewport.width; const double vertical_scale = static_cast(viewport.height) / previous_viewport.height; axis->template set<&Abs_Axis::Prop::position>(Point_F{ layout.position.x * horizontal_scale, layout.position.y * vertical_scale }); axis->template set<&Abs_Axis::Prop::pixel_length>(layout.pixel_length * (layout.orientation == Axis_Orientation::horizontal ? horizontal_scale : vertical_scale)); }; (resize_axis(axes), ...); } template void dispatch_plot_input(Scene_Object& scene, const Plot_Input_Event& input) { const auto dispatch = [&](auto event) { scene.dispatch_event(std::move(event)); }; const auto apply_pointer = [&](auto& event) { event.position = input.position; event.global_position = input.global_position; event.button = input.button; event.buttons = input.buttons; event.modifiers = input.modifiers; }; switch (input.type) { case Event_Type::pointer_move: case Event_Type::pointer_press: case Event_Type::pointer_release: { auto event = std::make_unique>(input.type); apply_pointer(*event); dispatch(std::move(event)); break; } case Event_Type::wheel: { auto event = std::make_unique>(); apply_pointer(*event); event->pixel_delta_x = input.pixel_delta_x; event->pixel_delta_y = input.pixel_delta_y; event->angle_delta_x = input.angle_delta_x; event->angle_delta_y = input.angle_delta_y; dispatch(std::move(event)); break; } case Event_Type::key_press: case Event_Type::key_release: { auto event = std::make_unique(input.type); event->key = input.key; event->native_key = input.native_key; event->modifiers = input.modifiers; event->auto_repeat = input.auto_repeat; dispatch(std::move(event)); break; } default: { dispatch(std::make_unique(input.type)); break; } } } } std::shared_ptr make_axes_plot(asio::any_io_executor executor) { constexpr Size canvas{720, 420}; auto frequency = make_frequency_axis(); frequency->template set<&Abs_Axis::Prop::unit_text>("Hz"); auto numeric = make_numeric_axis( Axis_Orientation::vertical, {64.0, 370.0}, -320.0, {-100.0, 0.0}); numeric->template set<&Abs_Axis::Prop::unit_text>("dB"); auto time = make_time_axis( Axis_Orientation::horizontal, {64.0, 190.0}, 620.0); time->template set<&Abs_Axis::Prop::unit_text>("Time"); auto scene = *Scene_2D::Builder{} .set(&Render_Scene_2D::Prop::viewport, canvas) .set(&Render_Scene_2D::Prop::background, Color{7, 13, 24, 255}) .set(&Render_Scene_2D::Prop::view_active, true) .build(); const auto topology = scene->template edit_dependency_graph([&](auto& prepare, auto& paint) { prepare.add(frequency.get()); prepare.add(numeric.get()); prepare.add(time.get()); paint.add(frequency.get()); paint.add(numeric.get()); paint.add(time.get()); }); if (!topology) throw std::logic_error("axis gallery topology is invalid"); auto update = [scene = scene.get(), frequency = frequency.get(), numeric = numeric.get(), time = time.get()](const Plot_Frame_Request& event) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, frequency, numeric, time); constexpr double day_milliseconds = 86'400'000.0; time->append_time(Time_Of_Day{ static_cast( std::fmod(std::max(0.0, event.time_milliseconds), day_milliseconds)) }); }; std::vector> components; components.push_back(make_scene_component(*scene)); components.push_back(make_axis_component("axis-frequency", "频率轴", *frequency)); components.push_back(make_axis_component("axis-value", "数值轴", *numeric)); components.push_back(make_axis_component("axis-time", "时间轴", *time)); auto view = std::make_unique>( std::move(components), std::move(update), std::move(frequency), std::move(numeric), std::move(time)); return std::make_shared(std::move(executor), std::move(scene), std::move(view)); } std::shared_ptr make_spectrum_plot(asio::any_io_executor executor) { constexpr Size canvas{720, 420}; auto frequency = make_frequency_axis(); auto vertical = make_numeric_axis(Axis_Orientation::vertical, {64.0, 370.0}, -320.0, {-110.0, 0.0}); auto spectrum = *Impl::Builder(frequency.get(), vertical.get()) .set(&Spectrum::Prop::frequency_range, Axis_Range{0.0, 100.0}) .set(&Spectrum::Prop::max_hold_visible, true) .build(); auto selection = selection_overlay(frequency.get(), vertical.get()); auto scene = *Scene_2D::Builder() .set(&Render_Scene_2D::Prop::viewport, canvas) .set(&Render_Scene_2D::Prop::background, Color{7, 13, 24, 255}) .set(&Render_Scene_2D::Prop::view_active, true) .add_renderable(selection.get()) .build(); place_selection_over(scene.get(), selection.get(), spectrum.get()); auto update = [scene = scene.get(), raw = spectrum.get(), frequency = frequency.get(), vertical = vertical.get()](const Plot_Frame_Request& event) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, frequency, vertical); 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(event.time_milliseconds * 0.001), 2.0)) + 42.0 * std::exp(-260.0 * std::pow(x - 0.68, 2.0)) + 2.5 * std::sin(i * 0.31 + event.time_milliseconds * 0.004); } raw->update_samples(samples); }; auto view = make_scene_view< Prop_Field<&Spectrum::Prop::center_frequency, "center_frequency", "Frequency placed at the visual center of the spectrum axis.">, Prop_Field<&Spectrum::Prop::partition_count, "partition_count", "Number of partitions used to prepare and render spectrum samples.">, Prop_Field<&Spectrum::Prop::max_hold_visible, "max_hold_visible", "Shows the accumulated maximum-hold spectrum curve when enabled.">, Prop_Field<&Spectrum::Prop::min_hold_visible, "min_hold_visible", "Shows the accumulated minimum-hold spectrum curve when enabled.">, Prop_Field<&Spectrum::Prop::max_marker_visible, "max_marker_visible", "Displays the marker attached to the strongest visible sample.">, Prop_Field<&Spectrum::Prop::min_marker_visible, "min_marker_visible", "Displays the marker attached to the weakest visible sample.">, Prop_Field<&Spectrum::Prop::sweep_region_visible, "sweep_region_visible", "Highlights the configured sweep-frequency interval on the plot.">, Prop_Field<&Spectrum::Prop::visible_range_only, "visible_range_only", "Restricts sample preparation to the frequency range currently visible on the axis.">, Prop_Field<&Spectrum::Prop::frequency_range, "frequency_range", "Maps the complete input sample span onto frequency coordinates.">, Prop_Field<&Spectrum::Prop::sweep_frequency_range, "sweep_frequency_range", "Defines the frequency interval rendered as the sweep region.">, Prop_Field<&Spectrum::Prop::partition_mode, "partition_mode", "Selects how samples are divided between preparation tasks.">, Prop_Field<&Spectrum::Prop::interpolation_mode, "interpolation_mode", "Selects the interpolation algorithm used between adjacent spectrum samples.">, Prop_Field<&Spectrum::Prop::max_brush, "max_brush", "Fill brush used for the maximum-hold area.">, Prop_Field<&Spectrum::Prop::current_brush, "current_brush", "Fill brush used for the current spectrum area.">, Prop_Field<&Spectrum::Prop::min_brush, "min_brush", "Fill brush used for the minimum-hold area.">, Prop_Field<&Spectrum::Prop::max_pen, "max_pen", "Stroke style used for the maximum-hold curve.">, Prop_Field<&Spectrum::Prop::current_pen, "current_pen", "Stroke style used for the current spectrum curve.">, Prop_Field<&Spectrum::Prop::min_pen, "min_pen", "Stroke style used for the minimum-hold curve.">, Prop_Field<&Spectrum::Prop::selected_marker_pen, "selected_marker_pen", "Stroke style used to emphasize the currently selected marker.">, Prop_Field<&Spectrum::Prop::marker_pen, "marker_pen", "Default stroke style used for unselected spectrum markers.">, Prop_Field<&Spectrum::Prop::middle_frequency_pen, "middle_frequency_pen", "Stroke style used for the center-frequency indicator.">, Prop_Field<&Spectrum::Prop::sweep_region_brush, "sweep_region_brush", "Fill brush used to highlight the sweep-frequency interval.">, Prop_Field<&Spectrum::Prop::custom_markers, "custom_markers", "User-defined marker positions and presentation data.">, Prop_Field<&Spectrum::Prop::selected_marker, "selected_marker", "Index of the custom marker currently selected for interaction.">, State_Field, State_Field, State_Field>( *spectrum, *scene, std::move(update), std::move(frequency), std::move(vertical), std::move(spectrum), std::move(selection)); return std::make_shared(std::move(executor), std::move(scene), std::move(view)); } std::shared_ptr make_frequency_trace_plot(asio::any_io_executor executor) { constexpr Size canvas{720, 420}; auto time = make_time_axis( Axis_Orientation::horizontal, {64.0, 370.0}, 620.0); auto vertical = make_numeric_axis( Axis_Orientation::vertical, {64.0, 370.0}, -320.0, {-1.2, 1.2}); auto trace = *Impl::Builder(time.get(), vertical.get()).build(); auto selection = selection_overlay(time.get(), vertical.get()); auto scene = *Scene_2D::Builder{} .set(&Render_Scene_2D::Prop::viewport, canvas) .set(&Render_Scene_2D::Prop::background, Color{7, 13, 24, 255}) .set(&Render_Scene_2D::Prop::view_active, true) .add_renderable(trace.get()) .add_renderable(selection.get()) .build(); place_selection_over(scene.get(), selection.get(), trace.get()); auto update = [scene = scene.get(), raw = trace.get(), time = time.get(), vertical = vertical.get()](const Plot_Frame_Request& event) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, time, vertical); constexpr double day_milliseconds = 86'400'000.0; const auto tick = time->append_time(Time_Of_Day{ static_cast( std::fmod(std::max(0.0, event.time_milliseconds), day_milliseconds)) }); raw->append_sample(tick, std::sin(event.time_milliseconds * 0.0025) * 0.8 + std::sin(event.time_milliseconds * 0.0007) * 0.2); }; auto view = make_scene_view< Prop_Field<&Frequency_Trace::Prop::partition_count, "partition_count", "Number of partitions used to prepare the time-ordered trace.">, Prop_Field<&Frequency_Trace::Prop::pen, "pen", "Stroke style used to draw the frequency trace.">, Prop_Field<&Frequency_Trace::Prop::partition_mode, "partition_mode", "Selects how trace samples are divided between preparation tasks.">, Prop_Field<&Frequency_Trace::Prop::samples, "samples", "Complete time-ordered collection of frequency trace samples.">, State_Field, State_Field>( *trace, *scene, std::move(update), std::move(time), std::move(vertical), std::move(trace), std::move(selection)); return std::make_shared(std::move(executor), std::move(scene), std::move(view)); } std::shared_ptr make_sweep_spectrum_plot(asio::any_io_executor executor) { constexpr Size canvas{720, 420}; auto frequency = make_frequency_axis(); auto vertical = make_numeric_axis( Axis_Orientation::vertical, {64.0, 370.0}, -320.0, {-110.0, 0.0}); auto sweep = *Impl::Builder(frequency.get(), vertical.get()) .set(&Sweep_Spectrum::Prop::frequency_range, Axis_Range{0.0, 100.0}) .set(&Sweep_Spectrum::Prop::bins_per_block, std::size_t{8}) .set(&Sweep_Spectrum::Prop::block_count, std::size_t{64}) .build(); auto selection = selection_overlay(frequency.get(), vertical.get()); auto scene = *Scene_2D::Builder{} .set(&Render_Scene_2D::Prop::viewport, canvas) .set(&Render_Scene_2D::Prop::background, Color{7, 13, 24, 255}) .set(&Render_Scene_2D::Prop::view_active, true) .add_renderable(sweep.get()) .add_renderable(selection.get()) .build(); place_selection_over(scene.get(), selection.get(), sweep.get()); auto update = [scene = scene.get(), raw = sweep.get(), frequency = frequency.get(), vertical = vertical.get()](const Plot_Frame_Request& event) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, frequency, vertical); const auto& state = raw->template read_prop(); const std::size_t block_count = std::max(1, state.block_count); const std::size_t bins_per_block = std::max(1, state.bins_per_block); const std::size_t block_index = state.blocks.size() < block_count ? state.blocks.size() : state.next_block_index % block_count; std::vector values(bins_per_block); for (std::size_t i = 0; i < values.size(); ++i) { const auto sweep_index = block_index * values.size() + i; values[i] = -90.0 + 35.0 * std::sin(sweep_index * 0.08 + event.time_milliseconds * 0.002); } raw->append_block(values); }; auto view = make_scene_view< Prop_Field<&Sweep_Spectrum::Prop::bins_per_block, "bins_per_block", "Number of frequency bins stored in each incoming sweep block.">, Prop_Field<&Sweep_Spectrum::Prop::block_count, "block_count", "Number of blocks required to compose one complete sweep.">, Prop_Field<&Sweep_Spectrum::Prop::partition_count, "partition_count", "Number of partitions used during sweep preparation.">, Prop_Field<&Sweep_Spectrum::Prop::visible_range_only, "visible_range_only", "Restricts preparation to the frequency interval visible on the axis.">, Prop_Field<&Sweep_Spectrum::Prop::frequency_range, "frequency_range", "Maps the complete sweep span onto frequency coordinates.">, Prop_Field<&Sweep_Spectrum::Prop::partition_mode, "partition_mode", "Selects how sweep blocks are divided between preparation tasks.">, Prop_Field<&Sweep_Spectrum::Prop::pen, "pen", "Stroke style used for the completed sweep curve.">, Prop_Field<&Sweep_Spectrum::Prop::current_frequency_pen, "current_frequency_pen", "Stroke style used for the current sweep-frequency indicator.">, Prop_Field<&Sweep_Spectrum::Prop::interpolation_mode, "interpolation_mode", "Selects interpolation between adjacent sweep bins.">, Prop_Field<&Sweep_Spectrum::Prop::blocks, "blocks", "Latest data stored in each fixed frequency-segment slot.">, State_Field, State_Field, State_Field>( *sweep, *scene, std::move(update), std::move(frequency), std::move(vertical), std::move(sweep), std::move(selection)); return std::make_shared(std::move(executor), std::move(scene), std::move(view)); } std::shared_ptr make_afterglow_plot(asio::any_io_executor executor) { constexpr Size canvas{720, 420}; auto frequency = make_frequency_axis(); auto vertical = make_numeric_axis( Axis_Orientation::vertical, {64.0, 370.0}, -320.0, {-110.0, 0.0}); auto afterglow = *Impl::Builder(frequency.get(), vertical.get()) .set(&Afterglow::Prop::frequency_range, Axis_Range{0.0, 100.0}) .set(&Afterglow::Prop::power_range, Axis_Range{-110.0, 0.0}) .set(&Afterglow::Prop::power_point_size, 96) .build(); auto selection = selection_overlay(frequency.get(), vertical.get()); auto scene = *Scene_2D::Builder{} .set(&Render_Scene_2D::Prop::viewport, canvas) .set(&Render_Scene_2D::Prop::background, Color{7, 13, 24, 255}) .set(&Render_Scene_2D::Prop::view_active, true) .add_renderable(afterglow.get()) .add_renderable(selection.get()) .build(); place_selection_over(scene.get(), selection.get(), afterglow.get()); auto update = [scene = scene.get(), raw = afterglow.get(), frequency = frequency.get(), vertical = vertical.get()](const Plot_Frame_Request& event) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, frequency, vertical); 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(event.time_milliseconds * 0.0008), 2.0)); raw->append_spectrum(values); }; auto view = make_scene_view< Prop_Field<&Afterglow::Prop::frequency_point_size, "frequency_point_size", "Number of frequency cells allocated across each afterglow row.">, Prop_Field<&Afterglow::Prop::power_point_size, "power_point_size", "Number of power cells allocated along the vertical afterglow range.">, Prop_Field<&Afterglow::Prop::partition_count, "partition_count", "Number of partitions used to prepare afterglow history.">, Prop_Field<&Afterglow::Prop::interpolate, "interpolate", "Enables interpolation when mapping samples into the afterglow grid.">, Prop_Field<&Afterglow::Prop::attenuation_rate, "attenuation_rate", "Controls how quickly historical energy fades between updates.">, Prop_Field<&Afterglow::Prop::frequency_range, "frequency_range", "Maps input samples onto the afterglow frequency axis.">, Prop_Field<&Afterglow::Prop::power_range, "power_range", "Defines the minimum and maximum power represented by the color grid.">, Prop_Field<&Afterglow::Prop::partition_mode, "partition_mode", "Selects how afterglow cells are divided between preparation tasks.">, Prop_Field<&Afterglow::Prop::color_map, "color_map", "Maps accumulated energy values to rendered colors.">, Prop_Field<&Afterglow::Prop::spectra, "spectra", "Spectrum history currently retained for afterglow rendering.">, State_Field, State_Field, State_Field>( *afterglow, *scene, std::move(update), std::move(frequency), std::move(vertical), std::move(afterglow), std::move(selection)); return std::make_shared(std::move(executor), std::move(scene), std::move(view)); } std::shared_ptr make_waterfall_plot(asio::any_io_executor executor) { constexpr Size canvas{720, 420}; auto frequency = make_frequency_axis(); auto time = make_time_axis( Axis_Orientation::vertical, {64.0, 370.0}, -320.0); auto waterfall = *Impl::Builder(frequency.get(), time.get()) .set(&Waterfall::Prop::frequency_range, Axis_Range{0.0, 100.0}) .set(&Waterfall::Prop::power_range, Axis_Range{-110.0, 0.0}) .build(); auto selection = selection_overlay(frequency.get(), time.get()); auto scene = *Scene_2D::Builder{} .set(&Render_Scene_2D::Prop::viewport, canvas) .set(&Render_Scene_2D::Prop::background, Color{7, 13, 24, 255}) .set(&Render_Scene_2D::Prop::view_active, true) .add_renderable(waterfall.get()) .add_renderable(selection.get()) .build(); place_selection_over(scene.get(), selection.get(), waterfall.get()); auto update = [scene = scene.get(), raw = waterfall.get(), frequency = frequency.get(), time = time.get()](const Plot_Frame_Request& event) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, frequency, 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(event.time_milliseconds * 0.0006), 2.0)); constexpr double day_milliseconds = 86'400'000.0; const auto tick = time->append_time(Time_Of_Day{ static_cast( std::fmod(std::max(0.0, event.time_milliseconds), day_milliseconds)) }); raw->append_row(tick, values); }; auto view = make_scene_view< Prop_Field<&Waterfall::Prop::tooltip_enabled, "tooltip_enabled", "Enables value inspection tooltips over waterfall cells.">, Prop_Field<&Waterfall::Prop::tooltip_font, "tooltip_font", "Font used to render waterfall tooltip text.">, Prop_Field<&Waterfall::Prop::tooltip_text_pen, "tooltip_text_pen", "Pen used to draw tooltip text and its foreground color.">, Prop_Field<&Waterfall::Prop::tooltip_background_brush, "tooltip_background_brush", "Brush used to fill the tooltip background panel.">, Prop_Field<&Waterfall::Prop::frequency_bin_count, "frequency_bin_count", "Number of frequency bins expected in each waterfall row.">, Prop_Field<&Waterfall::Prop::partition_count, "partition_count", "Number of partitions used to prepare waterfall cells.">, Prop_Field<&Waterfall::Prop::visible_range_only, "visible_range_only", "Restricts preparation to frequencies visible on the current axis.">, Prop_Field<&Waterfall::Prop::frequency_range, "frequency_range", "Maps row samples onto waterfall frequency coordinates.">, Prop_Field<&Waterfall::Prop::power_range, "power_range", "Defines the power interval mapped through the waterfall color map.">, Prop_Field<&Waterfall::Prop::partition_mode, "partition_mode", "Selects how waterfall rows are divided between preparation tasks.">, Prop_Field<&Waterfall::Prop::interpolation_mode, "interpolation_mode", "Selects interpolation when samples are mapped to raster cells.">, Prop_Field<&Waterfall::Prop::color_map, "color_map", "Maps sample power values to waterfall colors.">, Prop_Field<&Waterfall::Prop::rows, "rows", "Time-ordered collection of spectrum rows retained by the waterfall.">, State_Field, State_Field, State_Field>( *waterfall, *scene, std::move(update), std::move(frequency), std::move(time), std::move(waterfall), std::move(selection)); return std::make_shared(std::move(executor), std::move(scene), std::move(view)); } std::shared_ptr make_constellation_plot(asio::any_io_executor executor) { constexpr Size canvas{720, 420}; auto horizontal = make_numeric_axis( Axis_Orientation::horizontal, {64.0, 370.0}, 620.0, {-1.2, 1.2}); auto vertical = make_numeric_axis( Axis_Orientation::vertical, {64.0, 370.0}, -320.0, {-1.2, 1.2}); auto constellation = *Impl::Builder(horizontal.get(), vertical.get()) .set(&Constellation_Diagram::Prop::i_range, Axis_Range{-1.2, 1.2}) .set(&Constellation_Diagram::Prop::q_range, Axis_Range{-1.2, 1.2}) .build(); auto selection = selection_overlay(horizontal.get(), vertical.get()); auto scene = *Scene_2D::Builder{} .set(&Render_Scene_2D::Prop::viewport, canvas) .set(&Render_Scene_2D::Prop::background, Color{7, 13, 24, 255}) .set(&Render_Scene_2D::Prop::view_active, true) .add_renderable(constellation.get()) .add_renderable(selection.get()) .build(); place_selection_over(scene.get(), selection.get(), constellation.get()); auto update = [scene = scene.get(), raw = constellation.get(), horizontal = horizontal.get(), vertical = vertical.get()](const Plot_Frame_Request& event) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, horizontal, vertical); const auto& state = raw->template read_prop(); const int anchor_count = static_cast(state.type); const double radius = std::min(state.i_range.size(), state.q_range.size()) * 0.4; const double phase = event.time_milliseconds * 0.001; for (int index = 0; index < anchor_count; ++index) { const double angle = state.phase_offset_radians + 2.0 * std::numbers::pi * static_cast(index) / anchor_count; const double noise_i = 0.025 * std::sin(phase * 11.0 + index * 1.73) + 0.012 * std::cos(phase * 23.0 + index * 0.61); const double noise_q = 0.025 * std::cos(phase * 13.0 + index * 1.37) + 0.012 * std::sin(phase * 19.0 + index * 0.47); raw->append_point({ state.i_range.center() + std::cos(angle) * radius + noise_i, state.q_range.center() + std::sin(angle) * radius + noise_q }); } }; auto view = make_scene_view< Prop_Field<&Constellation_Diagram::Prop::point_lifetime_ms, "point_lifetime_ms", "Time in milliseconds that an appended constellation point remains visible.">, Prop_Field<&Constellation_Diagram::Prop::type, "type", "Selects the modulation constellation used to generate reference anchors.">, Prop_Field<&Constellation_Diagram::Prop::phase_offset_radians, "phase_offset_radians", "Rotates constellation points and anchors by the specified phase angle.">, Prop_Field<&Constellation_Diagram::Prop::i_range, "i_range", "Defines the horizontal in-phase coordinate interval.">, Prop_Field<&Constellation_Diagram::Prop::q_range, "q_range", "Defines the vertical quadrature coordinate interval.">, Prop_Field<&Constellation_Diagram::Prop::point_color, "point_color", "Color used to render received I/Q samples.">, Prop_Field<&Constellation_Diagram::Prop::anchor_color, "anchor_color", "Color used to render ideal modulation anchors.">, Prop_Field<&Constellation_Diagram::Prop::points, "points", "Current time-stamped collection of received I/Q samples.">, State_Field>( *constellation, *scene, std::move(update), std::move(horizontal), std::move(vertical), std::move(constellation), std::move(selection)); return std::make_shared(std::move(executor), std::move(scene), std::move(view)); } std::shared_ptr make_selection_overlay_plot(asio::any_io_executor executor) { constexpr Size canvas{720, 420}; auto horizontal = make_numeric_axis( Axis_Orientation::horizontal, {64.0, 370.0}, 620.0, {0.0, 100.0}); auto vertical = make_numeric_axis( Axis_Orientation::vertical, {64.0, 370.0}, -320.0, {0.0, 100.0}); auto selection = *Impl::Builder(horizontal.get(), vertical.get()).build(); auto scene = *Scene_2D::Builder{} .set(&Render_Scene_2D::Prop::viewport, canvas) .set(&Render_Scene_2D::Prop::background, Color{7, 13, 24, 255}) .set(&Render_Scene_2D::Prop::view_active, true) .add_renderable(selection.get()) .build(); auto update = [scene = scene.get(), horizontal = horizontal.get(), vertical = vertical.get()](const Plot_Frame_Request& event) { resize_axes(scene, {static_cast(event.width), static_cast(event.height)}, horizontal, vertical); }; auto view = make_scene_view< Prop_Field<&Selection_Rectangle_Overlay::Prop::label_font, "label_font", "Font used for labels attached to selected regions.">, Prop_Field<&Selection_Rectangle_Overlay::Prop::label_pen, "label_pen", "Pen used to draw selected-region label text.">, Prop_Field<&Selection_Rectangle_Overlay::Prop::selection_brush, "selection_brush", "Brush used to fill selected rectangular regions.">, Prop_Field<&Selection_Rectangle_Overlay::Prop::selection_border_pen, "selection_border_pen", "Pen used to draw selected-region borders.">, Prop_Field<&Selection_Rectangle_Overlay::Prop::selected_regions, "selected_regions", "Collection of selected rectangles expressed in axis coordinates.">, State_Field>( *selection, *scene, std::move(update), std::move(horizontal), std::move(vertical), std::move(selection)); return std::make_shared(std::move(executor), std::move(scene), std::move(view)); } struct Plot::Private { using Scene = std::variant, std::unique_ptr>; using Frame = std::variant, std::unique_ptr>; struct Managed_Frame { const void* owner{}; /* 发起本帧的 WebSocket 连接身份。 */ Plot_Frame_Request request{}; /* 创建本帧的浏览器请求及 viewport。 */ Frame frame{}; /* Web 层唯一拥有并传给 Scene 的外部帧。 */ }; asio::strand strand; /* 串行执行帧请求、属性写入和 Schema 查询。 */ asio::experimental::concurrent_channel inputs; /* 不承载输入事件的异步命令通道。 */ std::unique_ptr view; /* 使用层数据推进及反射描述实现;拥有 Scene 引用的图元。 */ Scene scene; /* 当前 Plot 唯一拥有的 2D 或 3D Scene;析构先于 view 所有图元。 */ std::once_flag start_once; /* 保证回调与协程只安装一次。 */ std::mutex handlers_mutex; /* 保护跨 Drogon 连接线程修改的订阅表。 */ std::unordered_map handlers; /* 以连接身份索引的完成帧订阅。 */ std::uint64_t next_frame_sequence{1}; /* 下一外部帧使用的单调序号;只在 strand 访问。 */ Frame_Policy frame_policy{}; /* 仅保存可编辑刷新策略,不保存衍生统计。 */ std::optional active_frame{}; /* 当前由 Scene/异步后端借用指针的外部帧。 */ std::deque pending_order{}; /* 按首次等待顺序保存连接身份,避免连接间饥饿。 */ std::unordered_map pending_frames{}; /* 每个连接只保留最新一个尚未提交 Scene 的外部帧。 */ bool frame_in_flight{}; /* Scene 是否已有一次尚未完成回调的帧。 */ template Private(asio::any_io_executor executor, std::unique_ptr value_scene, std::unique_ptr value_view) : strand(asio::make_strand(std::move(executor))), inputs(strand, 32), view(std::move(value_view)), scene(std::move(value_scene)) {} void publish(const void* owner, std::shared_ptr frame) { Frame_Handler output; { std::lock_guard lock(handlers_mutex); const auto found = handlers.find(owner); if (found != handlers.end()) output = found->second; } if (output) output(std::move(frame)); } [[nodiscard]] nlohmann::json schema() const; [[nodiscard]] Managed_Frame make_frame(Frame_Submission submission); void request_frame(Frame_Submission submission); void render_frame(Managed_Frame frame); void publish_completed_frame(Render_Frame* frame); void frame_completed(); }; nlohmann::json Plot::Private::schema() const { auto result = view->schema(); result["components"].push_back(frame_policy.schema()); return result; } Plot::Private::Managed_Frame Plot::Private::make_frame(Frame_Submission submission) { const Frame_Identity identity{next_frame_sequence++, submission.request.correlation_id}; if (std::holds_alternative>(scene)) return {submission.owner, std::move(submission.request), std::make_unique(identity)}; return {submission.owner, std::move(submission.request), std::make_unique(identity)}; } void Plot::Private::request_frame(Frame_Submission submission) { auto frame = make_frame(std::move(submission)); if (frame_in_flight) { const auto found = pending_frames.find(frame.owner); if (found != pending_frames.end()) found->second = std::move(frame); else { const auto owner = frame.owner; pending_order.push_back(owner); pending_frames.emplace(owner, std::move(frame)); } return; } render_frame(std::move(frame)); } void Plot::Private::render_frame(Managed_Frame frame) { frame_in_flight = true; active_frame = std::move(frame); auto& request = active_frame->request; request.width = std::clamp(request.width, 160U, 1920U); request.height = std::clamp(request.height, 120U, 1080U); view->update(request); if (auto* scene_2d = std::get_if>(&scene)) { (*scene_2d)->set<&Render_Scene_2D::Prop::viewport>(Size{static_cast(request.width), static_cast(request.height)}); const auto result = (*scene_2d)->render(std::get>(active_frame->frame).get()); if (result != Render_Scene_2D::Render_Result::completed) asio::post(strand, [this] { frame_completed(); }); return; } auto& scene_3d = std::get>(scene); scene_3d->set<&Render_Scene_3D::Prop::viewport>(Extent{request.width, request.height}); const auto result = scene_3d->render(std::get>(active_frame->frame).get()); if (result != Render_Scene_3D::Render_Result::submitted) asio::post(strand, [this] { frame_completed(); }); } void Plot::Private::publish_completed_frame(Render_Frame* frame) { if (!active_frame) throw std::logic_error("frame callback has no externally owned active frame"); const auto pacing = frame_policy.snapshot(); if (auto* frame_2d = std::get_if>(&active_frame->frame); frame_2d && frame_2d->get() == frame) publish(active_frame->owner, encode_frame(frame_2d->get(), pacing)); else if (auto* frame_3d = std::get_if>(&active_frame->frame); frame_3d && frame_3d->get() == frame) publish(active_frame->owner, encode_frame(frame_3d->get(), pacing)); else throw std::logic_error("frame callback does not match the externally owned active frame"); frame_completed(); } void Plot::Private::frame_completed() { active_frame.reset(); frame_in_flight = false; while (!pending_order.empty()) { const auto owner = pending_order.front(); pending_order.pop_front(); auto next = pending_frames.extract(owner); if (next.empty()) continue; render_frame(std::move(next.mapped())); return; } } Plot::Plot(asio::any_io_executor executor, std::unique_ptr scene, std::unique_ptr view) : d(std::make_unique(std::move(executor), std::move(scene), std::move(view))) {} Plot::Plot(asio::any_io_executor executor, std::unique_ptr scene, std::unique_ptr view) : d(std::make_unique(std::move(executor), std::move(scene), std::move(view))) {} Plot::~Plot() { d->inputs.close(); } void Plot::ensure_started() { std::call_once(d->start_once, [this] { auto self = shared_from_this(); if (auto* scene = std::get_if>(&d->scene)) { (*scene)->set_frame_callback([weak = weak_from_this()](Frame_2D* frame) { if (auto owner = weak.lock()) { frame->mark(Frame_Trace_Marker::callback_finished); asio::post(owner->d->strand, [weak, frame] { if (auto next_owner = weak.lock()) next_owner->d->publish_completed_frame(frame); }); } }); } else { std::get>(d->scene)->set_frame_callback( [weak = weak_from_this()](Frame_3D* frame) { if (auto owner = weak.lock()) { frame->mark(Frame_Trace_Marker::callback_finished); asio::post(owner->d->strand, [weak, frame] { if (auto next_owner = weak.lock()) next_owner->d->publish_completed_frame(frame); }); } }); } 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->schema()); continue; } if (auto* write = std::get_if(&input)) { write->handler(write->component == "frame-runtime" ? self->d->frame_policy.write_prop(write->key, write->value) : self->d->view->write_prop(write->component, write->key, write->value)); continue; } auto submission = std::get(input); self->d->request_frame(std::move(submission)); } }, [](std::exception_ptr exception) { if (exception) std::rethrow_exception(exception); }); }); } void Plot::attach(const void* owner, Frame_Handler handler) { ensure_started(); { std::lock_guard lock(d->handlers_mutex); d->handlers.insert_or_assign(owner, std::move(handler)); } } void Plot::detach(const void* owner) { std::lock_guard lock(d->handlers_mutex); d->handlers.erase(owner); } void Plot::submit_frame(const void* owner, Plot_Frame_Request request) { ensure_started(); static_cast(d->inputs.try_send(asio::error_code{}, Plot_Input{Frame_Submission{owner, std::move(request)}})); } void Plot::submit_input(Plot_Input_Event event) { ensure_started(); if (auto* scene = std::get_if>(&d->scene)) dispatch_plot_input(**scene, event); else dispatch_plot_input(*std::get>(d->scene), event); } void Plot::async_schema(Json_Handler handler) { ensure_started(); 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 component, std::string key, nlohmann::json value, Json_Handler handler) { ensure_started(); if (!d->inputs.try_send(asio::error_code{}, Plot_Input{ Prop_Write{std::move(component), std::move(key), std::move(value), std::move(handler)} })) throw std::runtime_error("plot input queue is unavailable"); } }