From feb7c72e948e61ef0a9ed32386ed5180a19c5433 Mon Sep 17 00:00:00 2001 From: wyc <1104749580@qq.com> Date: Tue, 11 Aug 2026 06:21:51 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BD=91=E9=A1=B5=E5=8D=87=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Core2/axis/Axis.cpp | 1 + Core2/axis/Axis.h | 1 + Core2/plot/Plot_Core.cpp | 356 +++- Core2/plot/Plot_Core.h | 42 + Core2/plottable/Heatmaps.cpp | 63 + Core2/plottable/Overlays.cpp | 32 + Core2/plottable/Plottables.h | 14 + Core2/plottable/Spectrum.cpp | 16 + Core2/tests/Core2_Frame_Pipeline_Tests.cpp | 263 +++ Core2/tests/Core2_Integration_Tests.cpp | 384 ++++ .../low_latency/Low_Latency_Strategy.inl | 5 +- .../manual/Manual_Refresh_Strategy.hpp | 4 +- .../manual/Manual_Refresh_Strategy.inl | 16 +- .../low_latency/Low_Latency_Pipeline_Test.cpp | 229 +++ .../Low_Latency_Strategy_Test_Types.hpp | 15 +- .../manual/Manual_Refresh_Strategy_Test.cpp | 20 + main.cmake | 11 + web.cmake | 21 +- web_server/Gallery_Plot_Session.cpp | 1721 +++++++++++++++++ web_server/Gallery_Plot_Session.h | 25 + web_server/Gallery_Protocol.cpp | 979 ++++++++++ web_server/Gallery_Protocol.h | 88 + web_server/Gallery_WebSocket_Controller.cpp | 75 + web_server/Gallery_WebSocket_Controller.h | 22 + web_server/Pixel_Frame.cpp | 101 +- web_server/Pixel_Frame.h | 15 + web_server/Renderive_WebSocket_Controller.cpp | 9 +- web_server/Web_Event.h | 11 +- web_server/Web_Event_Adapter.cpp | 14 +- web_server/Web_Performance_Log.cpp | 50 + web_server/Web_Performance_Log.h | 12 + web_server/Web_Plot_Session.cpp | 25 +- web_server/Web_Plot_Session.h | 9 +- web_server/Web_Server.cpp | 34 +- web_server/Web_Server.h | 3 +- web_server/main.cpp | 12 +- web_server/tests/Web_Bridge_Tests.cpp | 1105 ++++++++++- webapp_gallery/app.js | 554 ++++++ webapp_gallery/index.html | 147 ++ webapp_gallery/styles.css | 84 + 40 files changed, 6490 insertions(+), 98 deletions(-) create mode 100644 Core2/tests/Core2_Frame_Pipeline_Tests.cpp create mode 100644 Kernel/tests/renderive/frame_control/strategy/low_latency/Low_Latency_Pipeline_Test.cpp create mode 100644 web_server/Gallery_Plot_Session.cpp create mode 100644 web_server/Gallery_Plot_Session.h create mode 100644 web_server/Gallery_Protocol.cpp create mode 100644 web_server/Gallery_Protocol.h create mode 100644 web_server/Gallery_WebSocket_Controller.cpp create mode 100644 web_server/Gallery_WebSocket_Controller.h create mode 100644 web_server/Web_Performance_Log.cpp create mode 100644 web_server/Web_Performance_Log.h create mode 100644 webapp_gallery/app.js create mode 100644 webapp_gallery/index.html create mode 100644 webapp_gallery/styles.css diff --git a/Core2/axis/Axis.cpp b/Core2/axis/Axis.cpp index 5a91cbd..862727a 100644 --- a/Core2/axis/Axis.cpp +++ b/Core2/axis/Axis.cpp @@ -341,6 +341,7 @@ Font Time_Axis::font() const { return unit_text_font(); } void Time_Axis::set_font(Font value) { set_unit_text_font(value); } bool Time_Axis::newest_at_axis_start() const { std::lock_guard lock(time_mutex_); return time_state_.newest_at_start; } void Time_Axis::set_newest_at_axis_start(bool value) { { std::lock_guard lock(time_mutex_); time_state_.newest_at_start = value; } changed(); } +std::size_t Time_Axis::time_point_count() const { std::lock_guard lock(time_mutex_); return time_state_.samples.size(); } int Time_Axis::append_time(Time_Of_Day time) { int tick{}; diff --git a/Core2/axis/Axis.h b/Core2/axis/Axis.h index dbd55ad..e367e73 100644 --- a/Core2/axis/Axis.h +++ b/Core2/axis/Axis.h @@ -226,6 +226,7 @@ public: void set_font(Font value); [[nodiscard]] bool newest_at_axis_start() const; void set_newest_at_axis_start(bool value); + [[nodiscard]] std::size_t time_point_count() const; int append_time(Time_Of_Day time); [[nodiscard]] Time_Of_Day tick_to_time(int tick) const; [[nodiscard]] Range coord_range() const override; diff --git a/Core2/plot/Plot_Core.cpp b/Core2/plot/Plot_Core.cpp index dd636fc..e97b2e5 100644 --- a/Core2/plot/Plot_Core.cpp +++ b/Core2/plot/Plot_Core.cpp @@ -5,20 +5,185 @@ #include "../renderable/Renderable.h" #include +#include +#include +#include #include #include #include #include +#include #include #include +#include #include +#include namespace renderive { namespace { -using Plot_Frame_Control = ::Low_Latency_Strategy<::Scene2D_Frame_Data>; -using Plot_Scene = ::Scene2D_Context; +struct Frame_Observer_Data { + mutable std::mutex mutex; + std::uint32_t last_event{}; + std::uint32_t limit_state{}; + bool has_limit_state{}; + double frequency_hz{}; + std::uint64_t observation_count{}; + std::uint64_t produced_frame_count{}; + std::uint64_t consumed_frame_count{}; + std::uint64_t dropped_frame_count{}; + std::uint64_t failed_operation_count{}; + std::uint64_t pending_frame_count{}; + std::uint64_t latest_sequence{}; + std::uint64_t paint_duration_ns{}; + std::uint64_t render_duration_ns{}; + std::uint64_t target_interval_ns{}; + std::uint64_t bottleneck_duration_ns{}; + std::uint64_t next_refresh_interval_ns{}; + std::uint64_t paint_lease_wait_ns{}; + std::uint64_t paint_state_wait_ns{}; + std::uint64_t publish_state_wait_ns{}; + std::uint64_t ready_wait_ns{}; + std::uint64_t frame_age_at_render_ns{}; + std::uint64_t render_lease_wait_ns{}; + std::uint64_t render_state_wait_ns{}; + std::uint64_t render_finish_state_wait_ns{}; + std::uint64_t queue_wait_ns{}; + std::uint64_t end_to_end_ns{}; +}; + +struct Frame_Observer { + static constexpr bool enabled = true; + std::shared_ptr data; + + template + void observe(const Observation& observation) noexcept { + std::lock_guard lock(data->mutex); + data->last_event = static_cast(observation.event); + ++data->observation_count; + if (observation.statistics.sequence != 0) + data->latest_sequence = observation.statistics.sequence; + if constexpr (requires { observation.statistics.timing.paint_duration_ns; }) { + if (observation.statistics.sequence != 0) { + const auto& timing = observation.statistics.timing; + data->paint_lease_wait_ns = timing.paint_lease_wait_ns; + data->paint_state_wait_ns = timing.paint_state_wait_ns; + data->publish_state_wait_ns = timing.publish_state_wait_ns; + data->ready_wait_ns = timing.ready_wait_ns; + data->frame_age_at_render_ns = timing.frame_age_at_render_ns; + data->render_lease_wait_ns = timing.render_lease_wait_ns; + data->render_state_wait_ns = timing.render_state_wait_ns; + data->render_finish_state_wait_ns = timing.render_finish_state_wait_ns; + } + } else { + if (observation.statistics.sequence != 0) { + data->paint_duration_ns = observation.statistics.paint_duration_ns; + data->render_duration_ns = observation.statistics.render_duration_ns; + } + } + if constexpr (requires { observation.statistics.queue_wait_ns; }) { + if (observation.statistics.sequence != 0) + data->queue_wait_ns = observation.statistics.queue_wait_ns; + } + + if constexpr (requires { observation.state.prepared_frame_count; }) { + data->produced_frame_count = observation.state.prepared_frame_count; + data->consumed_frame_count = observation.state.render_count; + data->dropped_frame_count = observation.state.replaced_prepared_frame_count + + observation.state.discarded_prepared_frame_count; + data->failed_operation_count = observation.state.failed_refresh_count; + data->pending_frame_count = observation.state.pending_frame ? 1U : 0U; + } else if constexpr (requires { observation.state.limit_state; }) { + data->has_limit_state = true; + data->limit_state = static_cast(observation.state.limit_state); + data->frequency_hz = observation.state.frequency_hz; + data->target_interval_ns = observation.state.target_interval_ns; + data->paint_duration_ns = observation.state.paint_duration_ns; + data->render_duration_ns = observation.state.render_duration_ns; + data->bottleneck_duration_ns = observation.state.bottleneck_duration_ns; + data->next_refresh_interval_ns = observation.state.next_refresh_interval_ns; + data->end_to_end_ns = observation.state.end_to_end_ns; + data->produced_frame_count = observation.statistics.counters.published_frame_count; + data->consumed_frame_count = observation.state.completed_lifecycle_count; + data->dropped_frame_count = + observation.statistics.counters.abandoned_frame_count + + observation.statistics.counters.manually_discarded_frame_count; + data->failed_operation_count = observation.statistics.counters.swap_failure_count; + const auto retired_frame_count = + observation.state.completed_lifecycle_count + + observation.statistics.counters.abandoned_frame_count + + observation.statistics.counters.manually_discarded_frame_count; + data->pending_frame_count = + observation.statistics.counters.published_frame_count > retired_frame_count ? 1U : 0U; + } else { + data->produced_frame_count = observation.state.enqueued_frame_count; + data->consumed_frame_count = observation.state.rendered_frame_count; + data->failed_operation_count = observation.state.empty_acquire_count; + data->pending_frame_count = observation.state.pending_frame_count; + } + } +}; + +using Frame_Observer_State = ::Observer_State; +using Manual_Frame_Control = + ::Manual_Refresh_Strategy<::Scene2D_Frame_Data, std::mutex, Frame_Observer_State>; +using Low_Latency_Frame_Control = + ::Low_Latency_Strategy<::Scene2D_Frame_Data, std::mutex, Frame_Observer_State>; +using Playback_Frame_Control = + ::Flow_Refresh_Strategy<::Scene2D_Frame_Data, std::mutex, Frame_Observer_State>; +using Manual_Plot_Scene = + ::Scene2D_Context; +using Low_Latency_Plot_Scene = + ::Scene2D_Context; +using Playback_Plot_Scene = + ::Scene2D_Context; +using Plot_Scene = std::variant, + std::unique_ptr, + std::unique_ptr>; + +template +decltype(auto) with_scene(Plot_Scene& scene, Function&& function) { + return std::visit( + [&function](auto& concrete) -> decltype(auto) { + return std::forward(function)(*concrete); + }, + scene); +} + +template +decltype(auto) with_scene(const Plot_Scene& scene, Function&& function) { + return std::visit( + [&function](const auto& concrete) -> decltype(auto) { + return std::forward(function)(*concrete); + }, + scene); +} + +std::string observer_event_name(Frame_Control_Mode mode, std::uint32_t event) { + if (mode == Frame_Control_Mode::Manual) { + static constexpr const char* names[]{"prepared", "prepared_replaced", "refresh_succeeded", + "refresh_failed", "rendered", "real_time_data_updated", + "manually_discarded"}; + return event < std::size(names) ? names[event] : "none"; + } + if (mode == Frame_Control_Mode::Playback) { + static constexpr const char* names[]{"enqueued", "dequeued", "queue_empty", "rendered", + "real_time_data_updated"}; + return event < std::size(names) ? names[event] : "none"; + } + static constexpr const char* names[]{"published", "abandoned", "manually_discarded", + "swap_failed", "rendered", "lifecycle_completed", + "real_time_data_updated"}; + return event < std::size(names) ? names[event] : "none"; +} + +std::string limit_state_name(bool available, std::uint32_t state) { + if (!available) + return "not_applicable"; + static constexpr const char* names[]{"frequency_limited", "paint_limited", "render_limited"}; + return state < std::size(names) ? names[state] : "unknown"; +} Rect full_rect(Size size) { return {0, 0, std::max(0, size.width), std::max(0, size.height)}; @@ -35,9 +200,27 @@ private: } // namespace struct Plot_Core::Impl { - Impl() - : scene(*memory_resource(Memory_Domain::Plot_Frame)) {} + explicit Impl(Frame_Control_Mode selected_mode) + : mode(selected_mode), observer(std::make_shared()), + scene(make_scene(selected_mode, observer)) {} + static Plot_Scene make_scene(Frame_Control_Mode mode, + const std::shared_ptr& observer) { + auto& resource = *memory_resource(Memory_Domain::Plot_Frame); + Frame_Observer_State observer_state(Frame_Observer{observer}); + switch (mode) { + case Frame_Control_Mode::Manual: + return std::make_unique(resource, std::move(observer_state)); + case Frame_Control_Mode::Playback: + return std::make_unique(resource, std::move(observer_state)); + case Frame_Control_Mode::Low_Latency: + return std::make_unique(resource, std::move(observer_state)); + } + throw std::invalid_argument("unknown frame control mode"); + } + + Frame_Control_Mode mode; + std::shared_ptr observer; Plot_Scene scene; mutable std::mutex mutex; std::mutex initialization_mutex; @@ -50,7 +233,10 @@ struct Plot_Core::Impl { }; Plot_Core::Plot_Core() - : impl_(std::make_unique()) {} + : Plot_Core(Frame_Control_Mode::Low_Latency) {} + +Plot_Core::Plot_Core(Frame_Control_Mode mode) + : impl_(std::make_unique(mode)) {} Plot_Core::~Plot_Core() = default; @@ -60,12 +246,14 @@ void Plot_Core::init() { return; auto root = renderive::make_shared(Memory_Domain::Renderable, *this, true); root->set_object_name("root"); - impl_->scene.attach_renderable(root); + with_scene(impl_->scene, [&root](auto& scene) { scene.attach_renderable(root); }); notify_model_dirty(); } std::shared_ptr Plot_Core::root_renderable() const { - const auto topology = impl_->scene.topology_snapshot(); + const auto topology = with_scene(impl_->scene, [](const auto& scene) { + return scene.topology_snapshot(); + }); for (const auto& relationship : topology.display) { if (relationship.parent) continue; @@ -96,12 +284,14 @@ void Plot_Core::attach_renderable(const std::shared_ptr& renderable, throw std::logic_error("Plot_Core::init must be called before adding renderables"); parent_pointer = root.get(); } - impl_->scene.attach_renderable(renderable); + with_scene(impl_->scene, [&renderable](auto& scene) { scene.attach_renderable(renderable); }); try { - impl_->scene.set_display_parent(*renderable, parent_pointer); - impl_->scene.set_dependency_parent(*renderable, parent_pointer); + with_scene(impl_->scene, [&renderable, parent_pointer](auto& scene) { + scene.set_display_parent(*renderable, parent_pointer); + scene.set_dependency_parent(*renderable, parent_pointer); + }); } catch (...) { - impl_->scene.detach_renderable(*renderable); + with_scene(impl_->scene, [&renderable](auto& scene) { scene.detach_renderable(*renderable); }); throw; } notify_model_dirty(); @@ -110,7 +300,7 @@ void Plot_Core::attach_renderable(const std::shared_ptr& renderable, void Plot_Core::remove_renderable(const std::shared_ptr& renderable) { if (!renderable || renderable == root_renderable()) return; - impl_->scene.detach_renderable(*renderable); + with_scene(impl_->scene, [&renderable](auto& scene) { scene.detach_renderable(*renderable); }); notify_model_dirty(); } @@ -138,7 +328,9 @@ void Plot_Core::set_viewport_size(Size size) { return; impl_->viewport = size; } - const auto topology = impl_->scene.topology_snapshot(); + const auto topology = with_scene(impl_->scene, [](const auto& scene) { + return scene.topology_snapshot(); + }); for (const auto& renderable : topology.renderables) const_cast<::Renderable_Base&>(*renderable).invalidate_cache(); notify_model_dirty(); @@ -150,7 +342,9 @@ Size Plot_Core::viewport_size() const noexcept { } void Plot_Core::dispatch_event(const Event& event) { - const auto topology = impl_->scene.topology_snapshot(); + const auto topology = with_scene(impl_->scene, [](const auto& scene) { + return scene.topology_snapshot(); + }); for (auto iterator = topology.display.rbegin(); iterator != topology.display.rend(); ++iterator) { auto base = std::const_pointer_cast<::Renderable_Base>(iterator->child); if (auto renderable = std::dynamic_pointer_cast(base)) { @@ -168,6 +362,45 @@ void Plot_Core::notify_model_dirty() noexcept { impl_->dirty.store(true, std::memory_order_release); } +bool Plot_Core::prepare_frame() { + return with_scene(impl_->scene, [](auto& scene) { + auto paint_frame = scene.frame_control.acquire_painter(); + if (!paint_frame) + return false; + auto& scene_state = static_cast::Scene_State_Strategy&>(scene); + scene_state.template set<&::Scene2D_State::revision>(scene_state.state_revision() + 1); + scene_state.publish(); + return true; + }); +} + +bool Plot_Core::refresh_manual_frame() { + if (impl_->mode != Frame_Control_Mode::Manual) + return false; + return std::get>(impl_->scene)->frame_control.refresh(); +} + +bool Plot_Core::render_prepared_frame() { + return with_scene(impl_->scene, [](auto& scene) { + auto render_frame = scene.frame_control.acquire_renderer(); + if (!render_frame) + return false; + scene.render(); + scene.wait_for_render(); + return true; + }); +} + +bool Plot_Core::discard_pending_frame() { + return with_scene(impl_->scene, [](auto& scene) { + using Scene = std::remove_reference_t; + if constexpr (std::is_same_v) + return false; + else + return scene.frame_control.discard_pending_frame(); + }); +} + bool Plot_Core::render_frame(bool force) { if (!view_active() && !force) return false; @@ -179,17 +412,12 @@ bool Plot_Core::render_frame(bool force) { const auto started = std::chrono::steady_clock::now(); try { - { - auto paint_frame = impl_->scene.frame_control.acquire_painter(); - auto& scene_state = static_cast(impl_->scene); - scene_state.template set<&::Scene2D_State::revision>(scene_state.state_revision() + 1); - scene_state.publish(); - } - { - auto render_frame = impl_->scene.frame_control.acquire_renderer(); - impl_->scene.render(); - impl_->scene.wait_for_render(); - } + if (!prepare_frame()) + return false; + if (impl_->mode == Frame_Control_Mode::Manual && !refresh_manual_frame()) + return false; + if (!render_prepared_frame()) + return false; } catch (...) { notify_model_dirty(); throw; @@ -205,7 +433,11 @@ bool Plot_Core::render_frame(bool force) { overlay = impl_->performance; } if (overlay) - overlay->record_frame(duration_ms, diagnostics(), impl_->scene.renderable_count(), viewport); + overlay->record_frame(duration_ms, diagnostics(), + with_scene(impl_->scene, [](const auto& scene) { + return scene.renderable_count(); + }), + viewport); if (sink) sink->request_present(full_rect(viewport)); return true; @@ -214,8 +446,10 @@ bool Plot_Core::render_frame(bool force) { void Plot_Core::with_frame(const std::function& consumer) { if (!consumer) return; - impl_->scene.with_final_color_cache([&consumer](const detail::Blend2D_Color_Cache& cache) { - consumer(cache.view()); + with_scene(impl_->scene, [&consumer](auto& scene) { + scene.with_final_color_cache([&consumer](const detail::Blend2D_Color_Cache& cache) { + consumer(cache.view()); + }); }); } @@ -235,26 +469,70 @@ bool Plot_Core::view_active() const noexcept { void Plot_Core::set_max_render_fps(double fps) { if (!std::isfinite(fps) || fps <= 0.0) throw std::invalid_argument("maximum render FPS must be finite and positive"); - impl_->scene.frame_control.set_frequency_hz(fps); + if (impl_->mode != Frame_Control_Mode::Low_Latency) + throw std::logic_error("maximum render FPS is only available in low-latency mode"); + std::get>(impl_->scene)->frame_control.set_frequency_hz(fps); notify_model_dirty(); } double Plot_Core::max_render_fps() const noexcept { - return impl_->scene.frame_control.state().frequency_hz; + if (impl_->mode != Frame_Control_Mode::Low_Latency) + return std::numeric_limits::quiet_NaN(); + return std::get>(impl_->scene)->frame_control.state().frequency_hz; } Low_Latency_Diagnostics Plot_Core::diagnostics() const { - const auto state = impl_->scene.frame_control.state(); - return { - view_active(), - {state.frequency_hz, state.completed_lifecycle_count, state.next_refresh_interval_ns} - }; + if (impl_->mode == Frame_Control_Mode::Low_Latency) { + const auto state = std::get>(impl_->scene)->frame_control.state(); + return {view_active(), + {state.frequency_hz, state.completed_lifecycle_count, + state.next_refresh_interval_ns}}; + } + const auto observed = frame_observer_snapshot(); + return {view_active(), + {std::numeric_limits::quiet_NaN(), observed.consumed_frame_count, 0}}; } Refresh_Control_Snapshot Plot_Core::refresh_feedback_snapshot() const { return diagnostics().refresh; } +Frame_Control_Mode Plot_Core::frame_control_mode() const noexcept { + return impl_->mode; +} + +Frame_Observer_Snapshot Plot_Core::frame_observer_snapshot() const { + std::lock_guard lock(impl_->observer->mutex); + return {impl_->mode, + impl_->observer->observation_count == 0 + ? std::string("none") + : observer_event_name(impl_->mode, impl_->observer->last_event), + limit_state_name(impl_->observer->has_limit_state, impl_->observer->limit_state), + impl_->observer->frequency_hz, + impl_->observer->observation_count, + impl_->observer->produced_frame_count, + impl_->observer->consumed_frame_count, + impl_->observer->dropped_frame_count, + impl_->observer->failed_operation_count, + impl_->observer->pending_frame_count, + impl_->observer->latest_sequence, + impl_->observer->paint_duration_ns, + impl_->observer->render_duration_ns, + impl_->observer->target_interval_ns, + impl_->observer->bottleneck_duration_ns, + impl_->observer->next_refresh_interval_ns, + impl_->observer->paint_lease_wait_ns, + impl_->observer->paint_state_wait_ns, + impl_->observer->publish_state_wait_ns, + impl_->observer->ready_wait_ns, + impl_->observer->frame_age_at_render_ns, + impl_->observer->render_lease_wait_ns, + impl_->observer->render_state_wait_ns, + impl_->observer->render_finish_state_wait_ns, + impl_->observer->queue_wait_ns, + impl_->observer->end_to_end_ns}; +} + void Plot_Core::set_presentation_sink(std::weak_ptr sink) { std::lock_guard lock(impl_->mutex); impl_->presentation_sink = std::move(sink); @@ -274,12 +552,14 @@ void Plot_Core::set_performance_overlay(std::shared_ptr ove } ::Scene_Base& Plot_Core::kernel_scene() const noexcept { - return impl_->scene; + return with_scene(impl_->scene, [](auto& scene) -> ::Scene_Base& { return scene; }); } void Plot_Core::set_renderable_cache(Renderable& renderable, Renderable_Cache_Mode mode) { - impl_->scene.set_renderable_configuration( - renderable, {.cache_enabled = mode == Renderable_Cache_Mode::Local_Pixel}); + with_scene(impl_->scene, [&renderable, mode](auto& scene) { + scene.set_renderable_configuration( + renderable, {.cache_enabled = mode == Renderable_Cache_Mode::Local_Pixel}); + }); notify_model_dirty(); } diff --git a/Core2/plot/Plot_Core.h b/Core2/plot/Plot_Core.h index 67cde93..92ead55 100644 --- a/Core2/plot/Plot_Core.h +++ b/Core2/plot/Plot_Core.h @@ -17,6 +17,12 @@ class Performance_Overlay; struct Performance_Overlay_Options; class Renderable; +enum class Frame_Control_Mode : std::uint8_t { + Manual, + Low_Latency, + Playback +}; + struct Refresh_Control_Snapshot { double frequency_hz{}; std::uint64_t frame_count{}; @@ -28,6 +34,35 @@ struct Low_Latency_Diagnostics { Refresh_Control_Snapshot refresh; }; +struct Frame_Observer_Snapshot { + Frame_Control_Mode mode = Frame_Control_Mode::Low_Latency; + std::string last_event; + std::string limit_state; + double frequency_hz{}; + std::uint64_t observation_count{}; + std::uint64_t produced_frame_count{}; + std::uint64_t consumed_frame_count{}; + std::uint64_t dropped_frame_count{}; + std::uint64_t failed_operation_count{}; + std::uint64_t pending_frame_count{}; + std::uint64_t latest_sequence{}; + std::uint64_t paint_duration_ns{}; + std::uint64_t render_duration_ns{}; + std::uint64_t target_interval_ns{}; + std::uint64_t bottleneck_duration_ns{}; + std::uint64_t next_refresh_interval_ns{}; + std::uint64_t paint_lease_wait_ns{}; + std::uint64_t paint_state_wait_ns{}; + std::uint64_t publish_state_wait_ns{}; + std::uint64_t ready_wait_ns{}; + std::uint64_t frame_age_at_render_ns{}; + std::uint64_t render_lease_wait_ns{}; + std::uint64_t render_state_wait_ns{}; + std::uint64_t render_finish_state_wait_ns{}; + std::uint64_t queue_wait_ns{}; + std::uint64_t end_to_end_ns{}; +}; + class Presentation_Sink { public: virtual ~Presentation_Sink() = default; @@ -37,6 +72,7 @@ public: class LIB_DECL Plot_Core { public: Plot_Core(); + explicit Plot_Core(Frame_Control_Mode mode); ~Plot_Core(); Plot_Core(const Plot_Core&) = delete; Plot_Core& operator=(const Plot_Core&) = delete; @@ -64,6 +100,10 @@ public: void dispatch_event(const Event& event); void notify_model_dirty() noexcept; + [[nodiscard]] bool prepare_frame(); + [[nodiscard]] bool refresh_manual_frame(); + [[nodiscard]] bool render_prepared_frame(); + [[nodiscard]] bool discard_pending_frame(); [[nodiscard]] bool render_frame(bool force = false); void with_frame(const std::function& consumer); @@ -75,6 +115,8 @@ public: [[nodiscard]] double max_render_fps() const noexcept; [[nodiscard]] Low_Latency_Diagnostics diagnostics() const; [[nodiscard]] Refresh_Control_Snapshot refresh_feedback_snapshot() const; + [[nodiscard]] Frame_Control_Mode frame_control_mode() const noexcept; + [[nodiscard]] Frame_Observer_Snapshot frame_observer_snapshot() const; void set_presentation_sink(std::weak_ptr sink); [[nodiscard]] std::shared_ptr performance_overlay() const; diff --git a/Core2/plottable/Heatmaps.cpp b/Core2/plottable/Heatmaps.cpp index 88821b8..7ab1658 100644 --- a/Core2/plottable/Heatmaps.cpp +++ b/Core2/plottable/Heatmaps.cpp @@ -145,6 +145,40 @@ void Waterfall::set_frequency_bin_count(int value) { changed(); } +std::size_t Waterfall::row_count() const { + std::lock_guard lock(mutex_); + return state_.rows.size(); +} + +std::size_t Waterfall::stored_point_count() const { + std::lock_guard lock(mutex_); + std::size_t count{}; + for (const auto& row : state_.rows) + count += row.values.size(); + return count; +} + +std::size_t Waterfall::rendered_cell_count() const { + const State state = state_snapshot(); + if (!state.frequency_axis || state.rows.empty()) + return 0; + const int source_width = std::min( + state.bin_count, + static_cast(std::min_element( + state.rows.begin(), state.rows.end(), + [](const Row& left, const Row& right) { + return left.values.size() < right.values.size(); + })->values.size())); + if (source_width <= 0) + return 0; + const auto columns = frequency_columns(state.frequency_range, + state.frequency_axis->coord_range(), + source_width, state.visible_only); + return columns ? static_cast(columns->last - columns->first + 1) * + state.rows.size() + : 0; +} + Waterfall::State Waterfall::state_snapshot() const { std::lock_guard lock(mutex_); return state_; } void Waterfall::handle_event(const Event& event) { update_hover(event); } @@ -247,6 +281,11 @@ void Frequency_Trace::append_sample(Time_Of_Day time, double value) { std::shared_ptr Frequency_Trace::time_axis() const { std::lock_guard lock(mutex_); return time_axis_; } std::shared_ptr Frequency_Trace::value_axis() const { std::lock_guard lock(mutex_); return value_axis_; } +std::size_t Frequency_Trace::sample_count() const { std::lock_guard lock(mutex_); return samples_.size(); } +std::size_t Frequency_Trace::rendered_point_count() const { + std::lock_guard lock(mutex_); + return time_axis_ && value_axis_ && samples_.size() >= 2 ? samples_.size() : 0; +} void Frequency_Trace::paint(detail::Painter& painter) { std::shared_ptr time_axis; @@ -338,6 +377,30 @@ void Afterglow::set_attenuation_rate(double value) { changed(); } +std::size_t Afterglow::history_count() const { + std::lock_guard lock(mutex_); + return state_.history.size(); +} + +std::size_t Afterglow::latest_spectrum_point_count() const { + std::lock_guard lock(mutex_); + return state_.history.empty() ? 0 : state_.history.back().size(); +} + +std::size_t Afterglow::rendered_cell_count() const { + const State state = state_snapshot(); + if (!state.power_axis || state.history.empty()) + return 0; + const int width = std::min(state.frequency_count, + static_cast(state.history.back().size())); + const int height = state.power_count > 0 + ? state.power_count + : std::max(1, static_cast(state.power_axis->pixel_length())); + return width > 0 && height > 0 + ? static_cast(width) * static_cast(height) + : 0; +} + void Afterglow::append_spectrum(std::span values) { { std::lock_guard lock(mutex_); diff --git a/Core2/plottable/Overlays.cpp b/Core2/plottable/Overlays.cpp index 7e6ad50..79982b8 100644 --- a/Core2/plottable/Overlays.cpp +++ b/Core2/plottable/Overlays.cpp @@ -87,6 +87,33 @@ void Sweep_Spectrum::set_block_count(int value) { changed(); } +std::size_t Sweep_Spectrum::stored_block_count() const { + std::lock_guard lock(mutex_); + return state_.data.size(); +} + +std::size_t Sweep_Spectrum::stored_point_count() const { + std::lock_guard lock(mutex_); + std::size_t count{}; + for (const auto& block : state_.data) + count += block.size(); + return count; +} + +std::size_t Sweep_Spectrum::rendered_point_count() const { + const State state = state_snapshot(); + if (!state.frequency_axis || !state.power_axis) + return 0; + std::vector values; + for (const auto& block : state.data) + values.insert(values.end(), block.begin(), block.end()); + return detail::curve_points(values, state.frequency_range, + state.frequency_axis->transform(), + state.power_axis->transform(), state.visible_only, + state.interpolation) + .size(); +} + Sweep_Spectrum::State Sweep_Spectrum::state_snapshot() const { std::lock_guard lock(mutex_); return state_; } void Sweep_Spectrum::paint(detail::Painter& painter) { @@ -335,6 +362,11 @@ void Constellation_Diagram::set_point_lifetime_ms(int value) { changed(); } +std::size_t Constellation_Diagram::point_count() const { + std::lock_guard lock(mutex_); + return state_.points.size(); +} + void Constellation_Diagram::fit_square_to_axes() { auto horizontal = i_axis(); auto vertical = q_axis(); diff --git a/Core2/plottable/Plottables.h b/Core2/plottable/Plottables.h index c3ef931..b7085dd 100644 --- a/Core2/plottable/Plottables.h +++ b/Core2/plottable/Plottables.h @@ -69,6 +69,8 @@ public: template void update_samples(const Values& values) { update_samples(std::span(values.data(), values.size())); } + [[nodiscard]] std::size_t sample_count() const; + [[nodiscard]] std::size_t rendered_point_count() const; [[nodiscard]] double power_at(double frequency, bool& ok) const; void add_custom_marker(double frequency); void add_custom_line_marker(double frequency); @@ -180,6 +182,9 @@ public: void set_power_range(Range value); [[nodiscard]] int frequency_bin_count() const; void set_frequency_bin_count(int value); + [[nodiscard]] std::size_t row_count() const; + [[nodiscard]] std::size_t stored_point_count() const; + [[nodiscard]] std::size_t rendered_cell_count() const; [[nodiscard]] bool visible_range_only() const; void set_visible_range_only(bool value); [[nodiscard]] Image_Interpolation_Mode interpolation_mode() const; @@ -240,6 +245,8 @@ public: void append_sample(Time_Of_Day time, double value); [[nodiscard]] std::shared_ptr time_axis() const; [[nodiscard]] std::shared_ptr value_axis() const; + [[nodiscard]] std::size_t sample_count() const; + [[nodiscard]] std::size_t rendered_point_count() const; class Builder { public: @@ -284,6 +291,9 @@ public: void set_interpolate(bool value); [[nodiscard]] double attenuation_rate() const; void set_attenuation_rate(double value); + [[nodiscard]] std::size_t history_count() const; + [[nodiscard]] std::size_t latest_spectrum_point_count() const; + [[nodiscard]] std::size_t rendered_cell_count() const; void append_spectrum(std::span values); void append_spectrum(std::pmr::vector&& values); template void append_spectrum(const Values& values) { @@ -354,6 +364,9 @@ public: void set_bins_per_block(int value); [[nodiscard]] int block_count() const; void set_block_count(int value); + [[nodiscard]] std::size_t stored_block_count() const; + [[nodiscard]] std::size_t stored_point_count() const; + [[nodiscard]] std::size_t rendered_point_count() const; [[nodiscard]] Pen pen() const; void set_pen(Pen value); [[nodiscard]] Pen cur_frequency_pen() const; @@ -482,6 +495,7 @@ public: void set_anchor_color(Color value); [[nodiscard]] int point_lifetime_ms() const; void set_point_lifetime_ms(int value); + [[nodiscard]] std::size_t point_count() const; void fit_square_to_axes(); class Builder { diff --git a/Core2/plottable/Spectrum.cpp b/Core2/plottable/Spectrum.cpp index 51e9eb3..1d566a3 100644 --- a/Core2/plottable/Spectrum.cpp +++ b/Core2/plottable/Spectrum.cpp @@ -117,6 +117,22 @@ void Spectrum::update_samples(std::pmr::vector&& values) { update_samples(std::span(values.data(), values.size())); } +std::size_t Spectrum::sample_count() const { + std::lock_guard lock(mutex_); + return state_.samples.size(); +} + +std::size_t Spectrum::rendered_point_count() const { + const State state = state_snapshot(); + if (!state.frequency_axis || !state.power_axis) + return 0; + return detail::curve_points(state.samples, state.frequency_range, + state.frequency_axis->transform(), + state.power_axis->transform(), state.visible_only, + state.interpolation) + .size(); +} + double Spectrum::power_at(double frequency, bool& ok) const { std::lock_guard lock(mutex_); ok = false; diff --git a/Core2/tests/Core2_Frame_Pipeline_Tests.cpp b/Core2/tests/Core2_Frame_Pipeline_Tests.cpp new file mode 100644 index 0000000..22c4964 --- /dev/null +++ b/Core2/tests/Core2_Frame_Pipeline_Tests.cpp @@ -0,0 +1,263 @@ +#include "Core2/export.h" + +#include + +#include +#include +#include +#include + +namespace renderive { +namespace { + +TEST(Renderive_Core2_Frame_Pipeline, ManualLifecycleSeparatesPrepareRefreshAndRender) { + Plot_Core plot(Frame_Control_Mode::Manual); + plot.init(); + plot.set_viewport_size({96, 54}); + + ASSERT_TRUE(plot.prepare_frame()); + auto snapshot = plot.frame_observer_snapshot(); + EXPECT_EQ(snapshot.mode, Frame_Control_Mode::Manual); + EXPECT_EQ(snapshot.last_event, "prepared"); + EXPECT_EQ(snapshot.limit_state, "not_applicable"); + EXPECT_EQ(snapshot.produced_frame_count, 1u); + EXPECT_EQ(snapshot.consumed_frame_count, 0u); + EXPECT_EQ(snapshot.pending_frame_count, 1u); + EXPECT_EQ(snapshot.latest_sequence, 1u); + + ASSERT_TRUE(plot.refresh_manual_frame()); + snapshot = plot.frame_observer_snapshot(); + EXPECT_EQ(snapshot.last_event, "refresh_succeeded"); + EXPECT_EQ(snapshot.produced_frame_count, 1u); + EXPECT_EQ(snapshot.consumed_frame_count, 0u); + EXPECT_EQ(snapshot.pending_frame_count, 0u); + EXPECT_EQ(snapshot.latest_sequence, 1u); + + ASSERT_TRUE(plot.render_prepared_frame()); + snapshot = plot.frame_observer_snapshot(); + EXPECT_EQ(snapshot.last_event, "rendered"); + EXPECT_EQ(snapshot.produced_frame_count, 1u); + EXPECT_EQ(snapshot.consumed_frame_count, 1u); + EXPECT_EQ(snapshot.pending_frame_count, 0u); + EXPECT_EQ(snapshot.latest_sequence, 1u); + EXPECT_EQ(plot.diagnostics().refresh.frame_count, 1u); +} + +TEST(Renderive_Core2_Frame_Pipeline, HighFrequencyLowLatencyLifecycleExposesKernelTiming) { + Plot_Core plot(Frame_Control_Mode::Low_Latency); + plot.init(); + plot.set_viewport_size({96, 54}); + plot.set_max_render_fps(1'000'000'000.0); + + ASSERT_TRUE(plot.prepare_frame()); + auto snapshot = plot.frame_observer_snapshot(); + EXPECT_EQ(snapshot.last_event, "published"); + EXPECT_EQ(snapshot.limit_state, "frequency_limited"); + EXPECT_DOUBLE_EQ(snapshot.frequency_hz, 1'000'000'000.0); + EXPECT_EQ(snapshot.target_interval_ns, 1u); + EXPECT_EQ(snapshot.produced_frame_count, 1u); + EXPECT_EQ(snapshot.consumed_frame_count, 0u); + EXPECT_EQ(snapshot.pending_frame_count, 1u); + + ASSERT_TRUE(plot.render_prepared_frame()); + snapshot = plot.frame_observer_snapshot(); + EXPECT_EQ(snapshot.last_event, "lifecycle_completed"); + EXPECT_DOUBLE_EQ(snapshot.frequency_hz, 1'000'000'000.0); + EXPECT_EQ(snapshot.target_interval_ns, 1u); + EXPECT_EQ(snapshot.produced_frame_count, 1u); + EXPECT_EQ(snapshot.consumed_frame_count, 1u); + EXPECT_EQ(snapshot.pending_frame_count, 0u); + EXPECT_EQ(snapshot.bottleneck_duration_ns, + std::max(snapshot.paint_duration_ns, snapshot.render_duration_ns)); + EXPECT_EQ(snapshot.next_refresh_interval_ns, + std::max(snapshot.target_interval_ns, snapshot.bottleneck_duration_ns)); + EXPECT_GE(snapshot.end_to_end_ns, snapshot.paint_duration_ns); + EXPECT_GE(snapshot.end_to_end_ns, snapshot.render_duration_ns); + + const auto refresh = plot.refresh_feedback_snapshot(); + EXPECT_DOUBLE_EQ(refresh.frequency_hz, snapshot.frequency_hz); + EXPECT_EQ(refresh.frame_count, snapshot.consumed_frame_count); + EXPECT_EQ(refresh.next_refresh_interval_ns, snapshot.next_refresh_interval_ns); +} + +TEST(Renderive_Core2_Frame_Pipeline, LowLatencyReplacementRetiresOldFrameWithoutPhantomPendingFrame) { + Plot_Core plot(Frame_Control_Mode::Low_Latency); + plot.init(); + plot.set_viewport_size({64, 40}); + + ASSERT_TRUE(plot.prepare_frame()); + ASSERT_TRUE(plot.prepare_frame()); + auto snapshot = plot.frame_observer_snapshot(); + EXPECT_EQ(snapshot.last_event, "published"); + EXPECT_EQ(snapshot.produced_frame_count, 2u); + EXPECT_EQ(snapshot.dropped_frame_count, 1u); + EXPECT_EQ(snapshot.pending_frame_count, 1u); + EXPECT_EQ(snapshot.latest_sequence, 2u); + + ASSERT_TRUE(plot.render_prepared_frame()); + snapshot = plot.frame_observer_snapshot(); + EXPECT_EQ(snapshot.last_event, "lifecycle_completed"); + EXPECT_EQ(snapshot.produced_frame_count, 2u); + EXPECT_EQ(snapshot.consumed_frame_count, 1u); + EXPECT_EQ(snapshot.dropped_frame_count, 1u); + EXPECT_EQ(snapshot.pending_frame_count, 0u); + EXPECT_EQ(snapshot.latest_sequence, 2u); +} + +TEST(Renderive_Core2_Frame_Pipeline, LowLatencyDiscardAndEmptyRenderRemainObservable) { + Plot_Core plot(Frame_Control_Mode::Low_Latency); + plot.init(); + plot.set_viewport_size({64, 40}); + + ASSERT_TRUE(plot.prepare_frame()); + ASSERT_TRUE(plot.discard_pending_frame()); + auto snapshot = plot.frame_observer_snapshot(); + EXPECT_EQ(snapshot.last_event, "manually_discarded"); + EXPECT_EQ(snapshot.produced_frame_count, 1u); + EXPECT_EQ(snapshot.consumed_frame_count, 0u); + EXPECT_EQ(snapshot.dropped_frame_count, 1u); + EXPECT_EQ(snapshot.pending_frame_count, 0u); + EXPECT_EQ(snapshot.latest_sequence, 1u); + + EXPECT_FALSE(plot.render_prepared_frame()); + snapshot = plot.frame_observer_snapshot(); + EXPECT_EQ(snapshot.last_event, "swap_failed"); + EXPECT_EQ(snapshot.failed_operation_count, 1u); + EXPECT_EQ(snapshot.pending_frame_count, 0u); + EXPECT_EQ(snapshot.latest_sequence, 1u); +} + +TEST(Renderive_Core2_Frame_Pipeline, ManualDiscardAndFailedRefreshUpdateObserverState) { + Plot_Core plot(Frame_Control_Mode::Manual); + plot.init(); + plot.set_viewport_size({64, 40}); + + ASSERT_TRUE(plot.prepare_frame()); + ASSERT_TRUE(plot.discard_pending_frame()); + auto snapshot = plot.frame_observer_snapshot(); + EXPECT_EQ(snapshot.last_event, "manually_discarded"); + EXPECT_EQ(snapshot.produced_frame_count, 1u); + EXPECT_EQ(snapshot.dropped_frame_count, 1u); + EXPECT_EQ(snapshot.pending_frame_count, 0u); + EXPECT_EQ(snapshot.latest_sequence, 1u); + const auto last_paint_duration_ns = snapshot.paint_duration_ns; + + EXPECT_FALSE(plot.refresh_manual_frame()); + snapshot = plot.frame_observer_snapshot(); + EXPECT_EQ(snapshot.last_event, "refresh_failed"); + EXPECT_EQ(snapshot.failed_operation_count, 1u); + EXPECT_EQ(snapshot.pending_frame_count, 0u); + EXPECT_EQ(snapshot.latest_sequence, 1u); + EXPECT_EQ(snapshot.paint_duration_ns, last_paint_duration_ns); +} + +TEST(Renderive_Core2_Frame_Pipeline, PlaybackPreservesQueueDepthAndEmptyAcquireFailure) { + Plot_Core plot(Frame_Control_Mode::Playback); + plot.init(); + plot.set_viewport_size({64, 40}); + + ASSERT_TRUE(plot.prepare_frame()); + ASSERT_TRUE(plot.prepare_frame()); + ASSERT_TRUE(plot.prepare_frame()); + auto snapshot = plot.frame_observer_snapshot(); + EXPECT_EQ(snapshot.produced_frame_count, 3u); + EXPECT_EQ(snapshot.pending_frame_count, 3u); + EXPECT_EQ(snapshot.latest_sequence, 3u); + + ASSERT_TRUE(plot.render_prepared_frame()); + ASSERT_TRUE(plot.render_prepared_frame()); + ASSERT_TRUE(plot.render_prepared_frame()); + snapshot = plot.frame_observer_snapshot(); + EXPECT_EQ(snapshot.last_event, "rendered"); + EXPECT_EQ(snapshot.consumed_frame_count, 3u); + EXPECT_EQ(snapshot.pending_frame_count, 0u); + EXPECT_EQ(snapshot.latest_sequence, 3u); + const auto last_paint_duration_ns = snapshot.paint_duration_ns; + const auto last_render_duration_ns = snapshot.render_duration_ns; + const auto last_queue_wait_ns = snapshot.queue_wait_ns; + + EXPECT_FALSE(plot.render_prepared_frame()); + snapshot = plot.frame_observer_snapshot(); + EXPECT_EQ(snapshot.last_event, "queue_empty"); + EXPECT_EQ(snapshot.failed_operation_count, 1u); + EXPECT_EQ(snapshot.pending_frame_count, 0u); + EXPECT_EQ(snapshot.latest_sequence, 3u); + EXPECT_EQ(snapshot.paint_duration_ns, last_paint_duration_ns); + EXPECT_EQ(snapshot.render_duration_ns, last_render_duration_ns); + EXPECT_EQ(snapshot.queue_wait_ns, last_queue_wait_ns); +} + +TEST(Renderive_Core2_Frame_Pipeline, PlaybackFullCyclePreservesPreviouslyQueuedFrames) { + Plot_Core plot(Frame_Control_Mode::Playback); + plot.init(); + plot.set_viewport_size({96, 54}); + + ASSERT_TRUE(plot.prepare_frame()); + ASSERT_TRUE(plot.prepare_frame()); + ASSERT_TRUE(plot.prepare_frame()); + auto snapshot = plot.frame_observer_snapshot(); + ASSERT_EQ(snapshot.pending_frame_count, 3u); + ASSERT_EQ(snapshot.produced_frame_count, 3u); + + ASSERT_TRUE(plot.render_frame(true)); + snapshot = plot.frame_observer_snapshot(); + EXPECT_EQ(snapshot.mode, Frame_Control_Mode::Playback); + EXPECT_EQ(snapshot.last_event, "rendered"); + EXPECT_EQ(snapshot.produced_frame_count, 4u); + EXPECT_EQ(snapshot.consumed_frame_count, 1u); + EXPECT_EQ(snapshot.pending_frame_count, 3u); + // The cycle enqueues sequence 4 but must consume the FIFO head (sequence 1). + EXPECT_EQ(snapshot.latest_sequence, 1u); + EXPECT_FALSE(plot.discard_pending_frame()); + EXPECT_EQ(plot.frame_observer_snapshot().pending_frame_count, 3u); +} + +TEST(Renderive_Core2_Frame_Pipeline, EveryModeHonorsActiveDirtyAndForceRenderGates) { + constexpr std::array modes{Frame_Control_Mode::Manual, + Frame_Control_Mode::Low_Latency, + Frame_Control_Mode::Playback}; + for (const auto mode : modes) { + Plot_Core plot(mode); + plot.init(); + plot.set_viewport_size({80, 45}); + EXPECT_FALSE(plot.view_active()); + EXPECT_FALSE(plot.render_frame(false)); + EXPECT_EQ(plot.frame_observer_snapshot().observation_count, 0u); + + plot.activate_view(); + ASSERT_TRUE(plot.render_frame(false)); + const auto completed = plot.frame_observer_snapshot(); + EXPECT_EQ(completed.mode, mode); + EXPECT_EQ(completed.consumed_frame_count, 1u); + EXPECT_FALSE(plot.render_frame(false)); + EXPECT_EQ(plot.frame_observer_snapshot().consumed_frame_count, 1u); + + plot.deactivate_view(); + plot.set_background_color(Color{8, 16, 24, 255}); + EXPECT_FALSE(plot.render_frame(false)); + ASSERT_TRUE(plot.render_frame(true)); + EXPECT_EQ(plot.frame_observer_snapshot().consumed_frame_count, 2u); + } +} + +TEST(Renderive_Core2_Frame_Pipeline, FrequencyConfigurationRejectsInvalidValuesAndWrongModes) { + Plot_Core low_latency(Frame_Control_Mode::Low_Latency); + EXPECT_THROW(low_latency.set_max_render_fps(0.0), std::invalid_argument); + EXPECT_THROW(low_latency.set_max_render_fps(-1.0), std::invalid_argument); + EXPECT_THROW(low_latency.set_max_render_fps(std::numeric_limits::quiet_NaN()), + std::invalid_argument); + EXPECT_THROW(low_latency.set_max_render_fps(std::numeric_limits::infinity()), + std::invalid_argument); + low_latency.set_max_render_fps(1'000.0); + EXPECT_DOUBLE_EQ(low_latency.max_render_fps(), 1'000.0); + + Plot_Core manual(Frame_Control_Mode::Manual); + Plot_Core playback(Frame_Control_Mode::Playback); + EXPECT_THROW(manual.set_max_render_fps(60.0), std::logic_error); + EXPECT_THROW(playback.set_max_render_fps(60.0), std::logic_error); + EXPECT_FALSE(playback.refresh_manual_frame()); + EXPECT_FALSE(playback.discard_pending_frame()); +} + +} // namespace +} // namespace renderive diff --git a/Core2/tests/Core2_Integration_Tests.cpp b/Core2/tests/Core2_Integration_Tests.cpp index 6a1be1c..f048d84 100644 --- a/Core2/tests/Core2_Integration_Tests.cpp +++ b/Core2/tests/Core2_Integration_Tests.cpp @@ -6,6 +6,7 @@ #include #include +#include #include namespace renderive { @@ -215,5 +216,388 @@ TEST(Renderive_Core2, PerformanceOverlayConsumesKernelFrameDiagnostics) { EXPECT_DOUBLE_EQ(plot.max_render_fps(), 120.0); } +TEST(Renderive_Core2, AllFrameControlModesUseKernelStrategiesAndExposeObservers) { + Plot_Core manual(Frame_Control_Mode::Manual); + manual.init(); + manual.set_viewport_size({64, 40}); + EXPECT_EQ(manual.frame_control_mode(), Frame_Control_Mode::Manual); + ASSERT_TRUE(manual.prepare_frame()); + auto manual_observer = manual.frame_observer_snapshot(); + EXPECT_EQ(manual_observer.last_event, "prepared"); + EXPECT_EQ(manual_observer.pending_frame_count, 1u); + ASSERT_TRUE(manual.refresh_manual_frame()); + ASSERT_TRUE(manual.render_prepared_frame()); + manual_observer = manual.frame_observer_snapshot(); + EXPECT_EQ(manual_observer.last_event, "rendered"); + EXPECT_EQ(manual_observer.consumed_frame_count, 1u); + + Plot_Core low_latency(Frame_Control_Mode::Low_Latency); + low_latency.init(); + low_latency.set_viewport_size({64, 40}); + low_latency.activate_view(); + ASSERT_TRUE(low_latency.render_frame(true)); + const auto low_observer = low_latency.frame_observer_snapshot(); + EXPECT_EQ(low_latency.frame_control_mode(), Frame_Control_Mode::Low_Latency); + EXPECT_EQ(low_observer.last_event, "lifecycle_completed"); + EXPECT_EQ(low_observer.consumed_frame_count, 1u); + + Plot_Core playback(Frame_Control_Mode::Playback); + playback.init(); + playback.set_viewport_size({64, 40}); + ASSERT_TRUE(playback.prepare_frame()); + ASSERT_TRUE(playback.prepare_frame()); + ASSERT_TRUE(playback.prepare_frame()); + auto playback_observer = playback.frame_observer_snapshot(); + EXPECT_EQ(playback.frame_control_mode(), Frame_Control_Mode::Playback); + EXPECT_EQ(playback_observer.last_event, "enqueued"); + EXPECT_EQ(playback_observer.pending_frame_count, 3u); + ASSERT_TRUE(playback.render_prepared_frame()); + playback_observer = playback.frame_observer_snapshot(); + EXPECT_EQ(playback_observer.last_event, "rendered"); + EXPECT_EQ(playback_observer.pending_frame_count, 2u); + EXPECT_EQ(playback_observer.consumed_frame_count, 1u); +} + +TEST(Renderive_Core2, RetainedAxisAndTimeAxisApisRoundTripWithoutWebAdapters) { + Plot_Core plot; + plot.init(); + const auto root = plot.root_renderable(); + ASSERT_TRUE(root); + + const auto axis = Axis::Builder(root, Orientation::Vertical) + .set_x(31) + .set_y(17) + .set_pixel_length(240) + .set_tick_length(13) + .set_sub_tick_length(7) + .set_color({10, 20, 30, 255}) + .set_unit_text("dB") + .set_unit_text_font({15.0, 650, true}) + .set_unit_text_pen({Color{40, 50, 60, 255}, 2.0}) + .set_unit_text_background_brush( + {Color{3, 4, 5, 255}, Brush_Style::Solid}) + .set_label_rotation_degrees(27) + .set_coord_range({-120.0, -20.0}) + .set_label_precision(4) + .set_use_wheel(true) + .set_use_drag(true) + .build(); + ASSERT_TRUE(axis); + EXPECT_EQ(axis->x(), 31); + EXPECT_EQ(axis->y(), 17); + EXPECT_EQ(axis->orientation(), Orientation::Vertical); + EXPECT_EQ(axis->pixel_length(), 240U); + EXPECT_EQ(axis->tick_length(), 13); + EXPECT_EQ(axis->sub_tick_length(), 7); + EXPECT_EQ(axis->color(), (Color{10, 20, 30, 255})); + EXPECT_EQ(axis->unit_text(), "dB"); + EXPECT_EQ(axis->unit_text_font(), (Font{15.0, 650, true})); + EXPECT_EQ(axis->unit_text_pen(), (Pen{Color{40, 50, 60, 255}, 2.0})); + EXPECT_EQ(axis->unit_text_background_brush(), + (Brush{Color{3, 4, 5, 255}, Brush_Style::Solid})); + EXPECT_EQ(axis->label_rotation_degrees(), 27); + EXPECT_EQ(axis->coord_range(), (Range{-120.0, -20.0})); + EXPECT_EQ(axis->label_precision(), 4); + EXPECT_TRUE(axis->use_wheel()); + EXPECT_TRUE(axis->use_drag()); + + axis->set_locale({','}); + axis->set_coord_start(-100.0); + axis->set_coord_length(80.0); + EXPECT_EQ(axis->locale(), (Number_Locale{','})); + EXPECT_EQ(axis->coord_range(), (Range{-100.0, -20.0})); + EXPECT_DOUBLE_EQ(axis->pixel_to_coord(axis->coord_to_pixel(-60.0)), -60.0); + EXPECT_GT(axis->pixel_sample_count(), 0); + EXPECT_GT(axis->tick_step(axis->coord_range()), 0.0); + EXPECT_GE(axis->sub_tick_count(axis->tick_step(axis->coord_range())), 0); + + const auto time_axis = Time_Axis::Builder(root, Orientation::Horizontal) + .set_x(12) + .set_y(280) + .set_pixel_length(300) + .set_visible_time_point_count(32) + .set_tick_label_spacing_px(19) + .set_time_format("hh:mm:ss.zzz") + .set_font({16.0, 700, true}) + .set_newest_at_axis_start(true) + .build(); + ASSERT_TRUE(time_axis); + const int first = time_axis->append_time({3'723'004}); + const int second = time_axis->append_time({3'724'005}); + EXPECT_EQ(time_axis->visible_time_point_count(), 32); + EXPECT_EQ(time_axis->tick_label_spacing_px(), 19); + EXPECT_EQ(time_axis->time_format(), "hh:mm:ss.zzz"); + EXPECT_EQ(time_axis->font(), (Font{16.0, 700, true})); + EXPECT_TRUE(time_axis->newest_at_axis_start()); + EXPECT_EQ(time_axis->time_point_count(), 2U); + EXPECT_EQ(time_axis->tick_to_time(first), (Time_Of_Day{3'723'004})); + EXPECT_EQ(time_axis->tick_to_time(second), (Time_Of_Day{3'724'005})); + EXPECT_EQ(time_axis->tick_label(first), "01:02:03.004"); +} + +TEST(Renderive_Core2, RetainedSpectrumApisRoundTripAndMarkersRemainObservable) { + Plot_Core plot; + plot.init(); + plot.set_viewport_size({360, 240}); + plot.activate_view(); + const auto root = plot.root_renderable(); + const auto frequency = Frequency_Axis::Builder(root, Orientation::Horizontal) + .set_x(30).set_y(210).set_pixel_length(300) + .set_coord_range({90.0, 110.0}).build(); + const auto power = Axis::Builder(root, Orientation::Vertical) + .set_x(30).set_y(20).set_pixel_length(190) + .set_coord_range({-20.0, -120.0}).build(); + auto spectrum = Spectrum::Builder(root, frequency, power) + .set_frequency_range({90.0, 110.0}) + .set_frequency_point_size(4) + .set_center_frequency(100.0) + .set_sweep_frequency_range({96.0, 104.0}) + .set_max_hold_visible(true) + .set_min_hold_visible(true) + .set_max_marker_visible(true) + .set_use_min_marker(true) + .set_sweep_region_visible(true) + .set_visible_range_only(false) + .set_interpolation_mode(Line_Interpolation_Mode::Cubic_Value) + .build(); + ASSERT_TRUE(spectrum); + + spectrum->set_max_brush({Color{1, 2, 3, 80}, Brush_Style::Solid}); + spectrum->set_current_brush({Color{4, 5, 6, 90}, Brush_Style::Solid}); + spectrum->set_min_brush({Color{7, 8, 9, 100}, Brush_Style::Solid}); + spectrum->set_max_pen({Color{10, 20, 30, 255}, 2.0}); + spectrum->set_current_pen({Color{40, 50, 60, 255}, 2.5}); + spectrum->set_min_pen({Color{70, 80, 90, 255}, 3.0}); + spectrum->set_selected_marker_pen({Color{11, 22, 33, 255}, 4.0}); + spectrum->set_marker_pen({Color{44, 55, 66, 255}, 1.5}); + spectrum->set_middle_frequency_pen({Color{77, 88, 99, 255}, 1.25}); + spectrum->set_sweep_region_brush({Color{12, 34, 56, 70}, Brush_Style::Solid}); + + EXPECT_EQ(spectrum->frequency_axis(), frequency); + EXPECT_EQ(spectrum->power_axis(), power); + EXPECT_EQ(spectrum->frequency_point_size(), 4); + EXPECT_EQ(spectrum->frequency_range(), (Range{90.0, 110.0})); + EXPECT_DOUBLE_EQ(spectrum->center_frequency(), 100.0); + EXPECT_EQ(spectrum->sweep_frequency_range(), (Range{96.0, 104.0})); + EXPECT_TRUE(spectrum->max_hold_visible()); + EXPECT_TRUE(spectrum->min_hold_visible()); + EXPECT_TRUE(spectrum->max_marker_visible()); + EXPECT_TRUE(spectrum->use_min_marker()); + EXPECT_TRUE(spectrum->sweep_region_visible()); + EXPECT_FALSE(spectrum->visible_range_only()); + EXPECT_EQ(spectrum->interpolation_mode(), Line_Interpolation_Mode::Cubic_Value); + EXPECT_EQ(spectrum->max_brush(), (Brush{Color{1, 2, 3, 80}, Brush_Style::Solid})); + EXPECT_EQ(spectrum->current_pen(), (Pen{Color{40, 50, 60, 255}, 2.5})); + EXPECT_EQ(spectrum->sweep_region_brush(), + (Brush{Color{12, 34, 56, 70}, Brush_Style::Solid})); + + std::pmr::vector samples{std::pmr::get_default_resource()}; + samples.assign({-100.0, -80.0, -70.0, -50.0}); + spectrum->update_samples(std::move(samples)); + EXPECT_EQ(spectrum->sample_count(), 4U); + EXPECT_GT(spectrum->rendered_point_count(), 4U); + bool valid{}; + EXPECT_DOUBLE_EQ(spectrum->power_at(100.0, valid), -75.0); + EXPECT_TRUE(valid); + EXPECT_DOUBLE_EQ(spectrum->power_at(200.0, valid), 0.0); + EXPECT_FALSE(valid); + + spectrum->add_custom_marker(96.0); + spectrum->add_custom_line_marker(102.0); + EXPECT_EQ(spectrum->selectable_line_marker_count(), 2); + spectrum->select_next_marker(); + EXPECT_EQ(spectrum->selected_marker_index(), 0); + spectrum->set_current_marker_frequency(97.0); + EXPECT_DOUBLE_EQ(spectrum->marker_frequency(0), 97.0); + spectrum->select_previous_marker(); + EXPECT_EQ(spectrum->selected_marker_index(), 1); + spectrum->remove_selected_marker(); + EXPECT_EQ(spectrum->selectable_line_marker_count(), 1); + spectrum->remove_custom_marker(97.1); + EXPECT_EQ(spectrum->selectable_line_marker_count(), 0); + spectrum->clear_custom_markers(); + EXPECT_EQ(spectrum->selected_marker_index(), -1); + EXPECT_TRUE(plot.render_frame(true)); +} + +TEST(Renderive_Core2, RetainedHeatmapSweepAndTraceApisPreserveDataShapes) { + Plot_Core plot; + plot.init(); + plot.set_viewport_size({420, 280}); + plot.activate_view(); + const auto root = plot.root_renderable(); + const auto frequency = Frequency_Axis::Builder(root, Orientation::Horizontal) + .set_x(40).set_y(250).set_pixel_length(340) + .set_coord_range({0.0, 4.0}).build(); + const auto power = Axis::Builder(root, Orientation::Vertical) + .set_x(40).set_y(20).set_pixel_length(230) + .set_coord_range({0.0, -120.0}).build(); + const auto time = Time_Axis::Builder(root, Orientation::Vertical) + .set_x(40).set_y(20).set_pixel_length(230) + .set_visible_time_point_count(8).build(); + + auto waterfall = Waterfall::Builder(root, frequency, time) + .set_frequency_range({0.0, 4.0}) + .set_power_range({-120.0, 0.0}) + .set_frequency_bin_count(4) + .set_visible_range_only(false) + .set_interpolation_mode(Image_Interpolation_Mode::Bicubic) + .build(); + ASSERT_TRUE(waterfall); + EXPECT_EQ(waterfall->frequency_axis(), frequency); + EXPECT_EQ(waterfall->time_axis(), time); + EXPECT_EQ(waterfall->frequency_range(), (Range{0.0, 4.0})); + EXPECT_EQ(waterfall->power_range(), (Range{-120.0, 0.0})); + EXPECT_EQ(waterfall->frequency_bin_count(), 4); + EXPECT_FALSE(waterfall->visible_range_only()); + EXPECT_EQ(waterfall->interpolation_mode(), Image_Interpolation_Mode::Bicubic); + const std::array row1{-100.0, -80.0, -60.0, -40.0}; + waterfall->append_row(Time_Of_Day{1000}, row1); + std::pmr::vector row2{std::pmr::get_default_resource()}; + row2.assign({-90.0, -70.0, -50.0, -30.0}); + waterfall->append_row(Time_Of_Day{2000}, std::move(row2)); + EXPECT_EQ(waterfall->row_count(), 2U); + EXPECT_EQ(waterfall->stored_point_count(), 8U); + EXPECT_EQ(waterfall->rendered_cell_count(), 8U); + + auto afterglow = Afterglow::Builder(root, frequency, power) + .set_frequency_range({0.0, 4.0}) + .set_power_range({-120.0, 0.0}) + .set_frequency_bin_count(4) + .set_power_bin_count(8) + .set_interpolate_power_bins(false) + .set_decay_rate(0.35) + .build(); + ASSERT_TRUE(afterglow); + EXPECT_EQ(afterglow->frequency_axis(), frequency); + EXPECT_EQ(afterglow->power_axis(), power); + EXPECT_EQ(afterglow->frequency_range(), (Range{0.0, 4.0})); + EXPECT_EQ(afterglow->power_range(), (Range{-120.0, 0.0})); + EXPECT_EQ(afterglow->frequency_point_size(), 4); + EXPECT_EQ(afterglow->power_point_size(), 8); + EXPECT_FALSE(afterglow->interpolate()); + EXPECT_DOUBLE_EQ(afterglow->attenuation_rate(), 0.35); + afterglow->append_spectrum(row1); + std::pmr::vector glow2{std::pmr::get_default_resource()}; + glow2.assign({-95.0, -75.0, -55.0, -35.0}); + afterglow->append_spectrum(std::move(glow2)); + EXPECT_EQ(afterglow->history_count(), 2U); + EXPECT_EQ(afterglow->latest_spectrum_point_count(), 4U); + EXPECT_EQ(afterglow->rendered_cell_count(), 32U); + afterglow->set_attenuation_rate(2.0); + EXPECT_DOUBLE_EQ(afterglow->attenuation_rate(), 1.0); + + auto sweep = Sweep_Spectrum::Builder(root, frequency, power) + .set_frequency_range({0.0, 8.0}) + .set_bins_per_block(4) + .set_block_num(2) + .set_pen({Color{1, 120, 220, 255}, 2.0}) + .set_current_frequency_pen({Color{240, 80, 20, 255}, 3.0}) + .set_visible_range_only(false) + .set_interpolation_mode(Line_Interpolation_Mode::Step_Right) + .build(); + ASSERT_TRUE(sweep); + EXPECT_EQ(sweep->frequency_axis(), frequency); + EXPECT_EQ(sweep->power_axis(), power); + EXPECT_EQ(sweep->frequency_range(), (Range{0.0, 8.0})); + EXPECT_EQ(sweep->bins_per_block(), 4); + EXPECT_EQ(sweep->block_count(), 2); + EXPECT_FALSE(sweep->visible_range_only()); + EXPECT_EQ(sweep->interpolation_mode(), Line_Interpolation_Mode::Step_Right); + sweep->append_block(row1); + sweep->append_block(row1); + std::pmr::vector block3{std::pmr::get_default_resource()}; + block3.assign(row1.begin(), row1.end()); + sweep->append_block(std::move(block3)); + EXPECT_EQ(sweep->stored_block_count(), 2U); + EXPECT_EQ(sweep->stored_point_count(), 8U); + EXPECT_GT(sweep->rendered_point_count(), 0U); + + auto trace = Frequency_Trace::Builder(root, time, power) + .set_pen({Color{120, 240, 80, 255}, 2.0}) + .build(); + ASSERT_TRUE(trace); + trace->append_sample(Time_Of_Day{3000}, -80.0); + trace->append_sample(Time_Of_Day{4000}, -70.0); + EXPECT_EQ(trace->time_axis(), time); + EXPECT_EQ(trace->value_axis(), power); + EXPECT_EQ(trace->sample_count(), 2U); + EXPECT_EQ(trace->rendered_point_count(), 2U); + EXPECT_TRUE(plot.render_frame(true)); +} + +TEST(Renderive_Core2, RetainedSelectionAndConstellationApisDriveInteractionAndLayout) { + Plot_Core plot; + plot.init(); + plot.set_viewport_size({360, 260}); + plot.activate_view(); + const auto root = plot.root_renderable(); + const auto horizontal = Axis::Builder(root, Orientation::Horizontal) + .set_x(30).set_y(230).set_pixel_length(300) + .set_coord_range({-3.0, 3.0}).build(); + const auto vertical = Axis::Builder(root, Orientation::Vertical) + .set_x(30).set_y(20).set_pixel_length(210) + .set_coord_range({3.0, -3.0}).build(); + auto selection = Selection_Rectangle_Overlay::Builder(root, horizontal, vertical) + .set_font({14.0, 600, true}) + .set_font_pen({Color{20, 220, 180, 255}, 2.0}) + .set_rect_brush({Color{20, 80, 220, 60}, Brush_Style::Solid}) + .set_border_pen({Color{240, 200, 60, 255}, 2.0, Line_Style::Dash}) + .build(); + ASSERT_TRUE(selection); + EXPECT_EQ(selection->label_font(), (Font{14.0, 600, true})); + EXPECT_EQ(selection->label_pen(), (Pen{Color{20, 220, 180, 255}, 2.0})); + EXPECT_EQ(selection->selection_brush(), + (Brush{Color{20, 80, 220, 60}, Brush_Style::Solid})); + EXPECT_EQ(selection->selection_border_pen(), + (Pen{Color{240, 200, 60, 255}, 2.0, Line_Style::Dash})); + + Pointer_Event press(Event_Type::Pointer_Press); + press.position = {80.0, 70.0}; + press.button = Mouse_Button::Left; + selection->handle_event(press); + EXPECT_TRUE(press.is_accepted()); + Pointer_Event move(Event_Type::Pointer_Move); + move.position = {260.0, 180.0}; + selection->handle_event(move); + EXPECT_TRUE(move.is_accepted()); + Pointer_Event release(Event_Type::Pointer_Release); + release.position = {260.0, 180.0}; + release.button = Mouse_Button::Left; + selection->handle_event(release); + EXPECT_TRUE(release.is_accepted()); + ASSERT_EQ(selection->selected_regions().size(), 1U); + EXPECT_FALSE(selection->selected_regions().front().empty()); + selection->clear_selected_regions(); + EXPECT_TRUE(selection->selected_regions().empty()); + selection->set_horizontal_axis(horizontal); + selection->set_vertical_axis(vertical); + + auto constellation = Constellation_Diagram::Builder(root, horizontal, vertical) + .set_i_range({-2.0, 2.0}) + .set_q_range({-3.0, 3.0}) + .set_point_color({80, 220, 255, 255}) + .set_anchor_color({255, 180, 40, 255}) + .set_point_lifetime_ms(2000) + .set_type(Constellation_Diagram_Type::Psk16) + .set_phase_offset_radians(0.25) + .build(); + ASSERT_TRUE(constellation); + EXPECT_EQ(constellation->i_axis(), horizontal); + EXPECT_EQ(constellation->q_axis(), vertical); + EXPECT_EQ(constellation->i_range(), (Range{-2.0, 2.0})); + EXPECT_EQ(constellation->q_range(), (Range{-3.0, 3.0})); + EXPECT_EQ(constellation->point_color(), (Color{80, 220, 255, 255})); + EXPECT_EQ(constellation->anchor_color(), (Color{255, 180, 40, 255})); + EXPECT_EQ(constellation->point_lifetime_ms(), 2000); + constellation->append_point({0.5, -0.5}); + constellation->append_point({-0.5, 0.5}); + EXPECT_EQ(constellation->point_count(), 2U); + constellation->fit_square_to_axes(); + EXPECT_EQ(horizontal->coord_range(), (Range{-3.0, 3.0})); + EXPECT_EQ(vertical->coord_range(), (Range{3.0, -3.0})); + EXPECT_TRUE(plot.render_frame(true)); +} + } // namespace } // namespace renderive diff --git a/Kernel/src/renderive/frame_control/strategy/low_latency/Low_Latency_Strategy.inl b/Kernel/src/renderive/frame_control/strategy/low_latency/Low_Latency_Strategy.inl index 22cb659..f87c1b9 100644 --- a/Kernel/src/renderive/frame_control/strategy/low_latency/Low_Latency_Strategy.inl +++ b/Kernel/src/renderive/frame_control/strategy/low_latency/Low_Latency_Strategy.inl @@ -261,7 +261,10 @@ void Low_Latency_Strategy::on_real_time_data_updat ++real_time_data_update_sequence_; last_real_time_data_update_ = observation.state; state_.real_time_data_update_sequence = real_time_data_update_sequence_; - strategy_observation = {Observation_Event::real_time_data_updated, {}, state_, last_real_time_data_update_}; + Statistics statistics; + statistics.real_time_data_update_sequence = real_time_data_update_sequence_; + statistics.counters = counters_; + strategy_observation = {Observation_Event::real_time_data_updated, statistics, state_, last_real_time_data_update_}; } observe(strategy_observation); } diff --git a/Kernel/src/renderive/frame_control/strategy/manual/Manual_Refresh_Strategy.hpp b/Kernel/src/renderive/frame_control/strategy/manual/Manual_Refresh_Strategy.hpp index 8ce804f..a4eea44 100644 --- a/Kernel/src/renderive/frame_control/strategy/manual/Manual_Refresh_Strategy.hpp +++ b/Kernel/src/renderive/frame_control/strategy/manual/Manual_Refresh_Strategy.hpp @@ -15,7 +15,8 @@ public: refresh_succeeded, refresh_failed, rendered, - real_time_data_updated + real_time_data_updated, + manually_discarded }; struct Statistics { std::uint64_t sequence{}; @@ -30,6 +31,7 @@ public: struct State { std::uint64_t prepared_frame_count{}; std::uint64_t replaced_prepared_frame_count{}; + std::uint64_t discarded_prepared_frame_count{}; std::uint64_t successful_refresh_count{}; std::uint64_t failed_refresh_count{}; std::uint64_t render_count{}; diff --git a/Kernel/src/renderive/frame_control/strategy/manual/Manual_Refresh_Strategy.inl b/Kernel/src/renderive/frame_control/strategy/manual/Manual_Refresh_Strategy.inl index 80e6a11..cf117b2 100644 --- a/Kernel/src/renderive/frame_control/strategy/manual/Manual_Refresh_Strategy.inl +++ b/Kernel/src/renderive/frame_control/strategy/manual/Manual_Refresh_Strategy.inl @@ -181,12 +181,18 @@ bool Manual_Refresh_Strategy::refresh() { } template bool Manual_Refresh_Strategy::discard_pending_frame() { - std::lock_guard lock(state_mutex_); - if (!state_.pending_frame) { - return false; + Observation observation; + { + std::lock_guard lock(state_mutex_); + if (!state_.pending_frame) { + return false; + } + state_.pending_frame = false; + ++state_.discarded_prepared_frame_count; + update_frame_control_state(Frame_Control_Strategy_Base::invalid_frequency_hz(), 0); + observation = {Observation_Event::manually_discarded, pending_->statistics, state_, last_real_time_data_update_}; } - state_.pending_frame = false; - update_frame_control_state(Frame_Control_Strategy_Base::invalid_frequency_hz(), 0); + observe(observation); return true; } template diff --git a/Kernel/tests/renderive/frame_control/strategy/low_latency/Low_Latency_Pipeline_Test.cpp b/Kernel/tests/renderive/frame_control/strategy/low_latency/Low_Latency_Pipeline_Test.cpp new file mode 100644 index 0000000..16bf589 --- /dev/null +++ b/Kernel/tests/renderive/frame_control/strategy/low_latency/Low_Latency_Pipeline_Test.cpp @@ -0,0 +1,229 @@ +#include + +#include +#include + +#include "Low_Latency_Strategy_Test_Types.hpp" + +namespace { + +void complete_pipeline(Low_Latency_Test_Strategy& strategy, + const Low_Latency_Test_Time_Source& time_source, + int value, + std::uint64_t paint_duration_ns, + std::uint64_t ready_wait_ns, + std::uint64_t render_duration_ns) { + { + auto frame = strategy.acquire_painter(); + ASSERT_TRUE(frame); + frame->value = value; + time_source.advance_ns(paint_duration_ns); + } + time_source.advance_ns(ready_wait_ns); + { + auto frame = strategy.acquire_renderer(); + ASSERT_TRUE(frame); + EXPECT_EQ(frame->value, value); + time_source.advance_ns(render_duration_ns); + } +} + +} // namespace + +TEST(low_latency_pipeline_test, classifies_frequency_paint_and_render_limits_across_lifecycles) { + Low_Latency_Test_Time_Source time_source; + Low_Latency_Test_Observer observer; + auto strategy = make_low_latency_test_strategy(time_source, observer); + + strategy.set_frequency_hz(100.0); + complete_pipeline(strategy, time_source, 1, 2'000'000, 500'000, 3'000'000); + auto state = strategy.state(); + EXPECT_EQ(state.limit_state, Low_Latency_Test_Strategy::Limit_State::frequency_limited); + EXPECT_EQ(state.target_interval_ns, 10'000'000u); + EXPECT_EQ(state.bottleneck_duration_ns, 3'000'000u); + EXPECT_EQ(state.next_refresh_interval_ns, 10'000'000u); + + strategy.set_frequency_hz(1'000.0); + complete_pipeline(strategy, time_source, 2, 4'000'000, 250'000, 2'000'000); + state = strategy.state(); + EXPECT_EQ(state.limit_state, Low_Latency_Test_Strategy::Limit_State::paint_limited); + EXPECT_EQ(state.paint_duration_ns, 4'000'000u); + EXPECT_EQ(state.render_duration_ns, 2'000'000u); + EXPECT_EQ(state.next_refresh_interval_ns, 4'000'000u); + + complete_pipeline(strategy, time_source, 3, 2'000'000, 125'000, 5'000'000); + state = strategy.state(); + EXPECT_EQ(state.limit_state, Low_Latency_Test_Strategy::Limit_State::render_limited); + EXPECT_EQ(state.paint_duration_ns, 2'000'000u); + EXPECT_EQ(state.render_duration_ns, 5'000'000u); + EXPECT_EQ(state.next_refresh_interval_ns, 5'000'000u); + EXPECT_EQ(state.completed_lifecycle_count, 3u); + EXPECT_EQ(state.frame_sequence, 3u); +} + +TEST(low_latency_pipeline_test, applies_frequency_and_equal_bottleneck_boundaries_deterministically) { + Low_Latency_Test_Time_Source time_source; + Low_Latency_Test_Observer observer; + auto strategy = make_low_latency_test_strategy(time_source, observer); + strategy.set_frequency_hz(1'000.0); + + complete_pipeline(strategy, time_source, 1, 1'000'000, 0, 999'999); + auto state = strategy.state(); + EXPECT_EQ(state.limit_state, Low_Latency_Test_Strategy::Limit_State::frequency_limited); + EXPECT_EQ(state.bottleneck_duration_ns, state.target_interval_ns); + + complete_pipeline(strategy, time_source, 2, 2'000'000, 0, 2'000'000); + state = strategy.state(); + EXPECT_EQ(state.limit_state, Low_Latency_Test_Strategy::Limit_State::paint_limited); + EXPECT_EQ(state.paint_duration_ns, state.render_duration_ns); + EXPECT_EQ(state.bottleneck_duration_ns, 2'000'000u); +} + +TEST(low_latency_pipeline_test, accepts_one_billion_hz_and_uses_the_measured_bottleneck) { + Low_Latency_Test_Time_Source time_source; + Low_Latency_Test_Observer observer; + auto strategy = make_low_latency_test_strategy(time_source, observer); + strategy.set_frequency_hz(1'000'000'000.0); + + complete_pipeline(strategy, time_source, 7, 20, 10, 30); + const auto state = strategy.state(); + EXPECT_DOUBLE_EQ(state.frequency_hz, 1'000'000'000.0); + EXPECT_EQ(state.target_interval_ns, 1u); + EXPECT_EQ(state.limit_state, Low_Latency_Test_Strategy::Limit_State::render_limited); + EXPECT_EQ(state.next_refresh_interval_ns, 30u); +} + +TEST(low_latency_pipeline_test, reports_exact_pipeline_timing_and_state_to_observers) { + Low_Latency_Test_Time_Source time_source; + Low_Latency_Test_Observer observer; + auto data = observer.data; + auto strategy = make_low_latency_test_strategy(time_source, observer); + strategy.set_frequency_hz(500.0); + + complete_pipeline(strategy, time_source, 42, 3'000'000, 700'000, 4'000'000); + const auto observations = low_latency_observations(data); + ASSERT_EQ(observations.size(), 3u); + EXPECT_EQ(observations[0].event, + static_cast(Low_Latency_Test_Strategy::Observation_Event::published)); + EXPECT_EQ(observations[1].event, + static_cast(Low_Latency_Test_Strategy::Observation_Event::rendered)); + EXPECT_EQ(observations[2].event, + static_cast(Low_Latency_Test_Strategy::Observation_Event::lifecycle_completed)); + + const auto& completed = observations.back(); + EXPECT_EQ(completed.sequence, 1u); + EXPECT_EQ(completed.frame_render_count, 1u); + EXPECT_EQ(completed.paint_duration_ns, 3'000'000u); + EXPECT_EQ(completed.ready_wait_ns, 700'000u); + EXPECT_EQ(completed.frame_age_at_render_ns, 700'000u); + EXPECT_EQ(completed.render_duration_ns, 4'000'000u); + EXPECT_EQ(completed.end_to_end_ns, 7'700'000u); + EXPECT_EQ(completed.published_frame_count, 1u); + EXPECT_EQ(completed.render_pass_count, 1u); + EXPECT_EQ(completed.target_interval_ns, 2'000'000u); + EXPECT_EQ(completed.bottleneck_duration_ns, 4'000'000u); + EXPECT_EQ(completed.next_refresh_interval_ns, 4'000'000u); + EXPECT_EQ(completed.limit_state, + static_cast(Low_Latency_Test_Strategy::Limit_State::render_limited)); + EXPECT_EQ(completed.completed_lifecycle_count, 1u); +} + +TEST(low_latency_pipeline_test, replaces_only_the_unconsumed_frame_and_renders_the_latest) { + Low_Latency_Test_Time_Source time_source; + Low_Latency_Test_Observer observer; + auto data = observer.data; + auto strategy = make_low_latency_test_strategy(time_source, observer); + + { + auto frame = strategy.acquire_painter(); + frame->value = 1; + time_source.advance_ns(100); + } + { + auto frame = strategy.acquire_painter(); + frame->value = 2; + time_source.advance_ns(200); + } + { + auto frame = strategy.acquire_renderer(); + ASSERT_TRUE(frame); + EXPECT_EQ(frame->value, 2); + time_source.advance_ns(300); + } + + const auto counters = strategy.counter_statistics(); + EXPECT_EQ(counters.published_frame_count, 2u); + EXPECT_EQ(counters.abandoned_frame_count, 1u); + EXPECT_EQ(counters.render_pass_count, 1u); + EXPECT_EQ(strategy.state().completed_lifecycle_count, 1u); + EXPECT_EQ(strategy.state().frame_sequence, 2u); + + const auto observations = low_latency_observations(data); + ASSERT_EQ(observations.size(), 5u); + EXPECT_EQ(observations[1].event, + static_cast(Low_Latency_Test_Strategy::Observation_Event::abandoned)); + EXPECT_EQ(observations.back().abandoned_frame_count, 1u); +} + +TEST(low_latency_pipeline_test, rerender_without_new_frame_reports_failure_without_new_lifecycle) { + Low_Latency_Test_Time_Source time_source; + Low_Latency_Test_Observer observer; + auto data = observer.data; + auto strategy = make_low_latency_test_strategy(time_source, observer); + complete_pipeline(strategy, time_source, 9, 100, 0, 200); + + { + auto frame = strategy.acquire_renderer(); + ASSERT_TRUE(frame); + EXPECT_EQ(frame->value, 9); + time_source.advance_ns(400); + } + + const auto state = strategy.state(); + const auto counters = strategy.counter_statistics(); + EXPECT_EQ(state.completed_lifecycle_count, 1u); + EXPECT_EQ(counters.render_pass_count, 2u); + EXPECT_EQ(counters.swap_failure_count, 1u); + + const auto observations = low_latency_observations(data); + ASSERT_EQ(observations.size(), 5u); + EXPECT_EQ(observations[3].event, + static_cast(Low_Latency_Test_Strategy::Observation_Event::swap_failed)); + EXPECT_EQ(observations[4].event, + static_cast(Low_Latency_Test_Strategy::Observation_Event::rendered)); + EXPECT_EQ(observations[4].render_pass_count, 2u); + EXPECT_EQ(observations[4].completed_lifecycle_count, 1u); +} + +TEST(low_latency_pipeline_test, completed_frame_keeps_the_real_time_revision_captured_at_paint) { + Low_Latency_Test_Time_Source time_source; + Low_Latency_Test_Observer observer; + auto data = observer.data; + auto strategy = make_low_latency_test_strategy(time_source, observer); + strategy.on_real_time_data_update( + {Real_Time_Data_Observation_Event::updated, + {nullptr, Real_Time_Data_Retention::latest, 10, 100, 1, 1}}); + { + auto frame = strategy.acquire_painter(); + frame->value = 1; + time_source.advance_ns(100); + } + strategy.on_real_time_data_update( + {Real_Time_Data_Observation_Event::updated, + {nullptr, Real_Time_Data_Retention::latest, 11, 110, 2, 1}}); + auto observations = low_latency_observations(data); + ASSERT_EQ(observations.size(), 3u); + EXPECT_EQ(observations.back().event, + static_cast(Low_Latency_Test_Strategy::Observation_Event::real_time_data_updated)); + EXPECT_EQ(observations.back().published_frame_count, 1u); + EXPECT_EQ(observations.back().real_time_data_update_sequence, 2u); + { + auto frame = strategy.acquire_renderer(); + ASSERT_TRUE(frame); + time_source.advance_ns(200); + } + + const auto state = strategy.state(); + EXPECT_EQ(state.real_time_data_update_sequence, 2u); + EXPECT_EQ(state.completed_frame_real_time_data_update_sequence, 1u); +} diff --git a/Kernel/tests/renderive/frame_control/strategy/low_latency/Low_Latency_Strategy_Test_Types.hpp b/Kernel/tests/renderive/frame_control/strategy/low_latency/Low_Latency_Strategy_Test_Types.hpp index 2a9ad12..b6dbd39 100644 --- a/Kernel/tests/renderive/frame_control/strategy/low_latency/Low_Latency_Strategy_Test_Types.hpp +++ b/Kernel/tests/renderive/frame_control/strategy/low_latency/Low_Latency_Strategy_Test_Types.hpp @@ -12,11 +12,14 @@ struct Low_Latency_Test_Frame { struct Low_Latency_Test_Time_Source { std::shared_ptr> time_ns{std::make_shared>(100)}; std::uint64_t now_ns() const noexcept { - return time_ns->fetch_add(10, std::memory_order_relaxed); + return time_ns->load(std::memory_order_relaxed); } void advance_ns(std::uint64_t duration_ns) const noexcept { time_ns->fetch_add(duration_ns, std::memory_order_relaxed); } + void set_ns(std::uint64_t value_ns) const noexcept { + time_ns->store(value_ns, std::memory_order_relaxed); + } }; struct Low_Latency_Recorded_Observation { int event{}; @@ -25,6 +28,9 @@ struct Low_Latency_Recorded_Observation { std::uint64_t real_time_data_update_sequence{}; std::uint64_t paint_duration_ns{}; std::uint64_t render_duration_ns{}; + std::uint64_t ready_wait_ns{}; + std::uint64_t frame_age_at_render_ns{}; + std::uint64_t end_to_end_ns{}; std::uint64_t published_frame_count{}; std::uint64_t render_pass_count{}; std::uint64_t abandoned_frame_count{}; @@ -32,6 +38,8 @@ struct Low_Latency_Recorded_Observation { std::uint64_t swap_failure_count{}; double frequency_hz{}; int limit_state{}; + std::uint64_t target_interval_ns{}; + std::uint64_t bottleneck_duration_ns{}; std::uint64_t next_refresh_interval_ns{}; std::uint64_t completed_lifecycle_count{}; }; @@ -54,6 +62,9 @@ struct Low_Latency_Test_Observer { statistics.real_time_data_update_sequence, statistics.timing.paint_duration_ns, statistics.timing.render_duration_ns, + statistics.timing.ready_wait_ns, + statistics.timing.frame_age_at_render_ns, + statistics.timing.end_to_end_ns, counters.published_frame_count, counters.render_pass_count, counters.abandoned_frame_count, @@ -61,6 +72,8 @@ struct Low_Latency_Test_Observer { counters.swap_failure_count, observation.state.frequency_hz, static_cast(observation.state.limit_state), + observation.state.target_interval_ns, + observation.state.bottleneck_duration_ns, observation.state.next_refresh_interval_ns, observation.state.completed_lifecycle_count }); diff --git a/Kernel/tests/renderive/frame_control/strategy/manual/Manual_Refresh_Strategy_Test.cpp b/Kernel/tests/renderive/frame_control/strategy/manual/Manual_Refresh_Strategy_Test.cpp index 0be7fb1..88a4cc3 100644 --- a/Kernel/tests/renderive/frame_control/strategy/manual/Manual_Refresh_Strategy_Test.cpp +++ b/Kernel/tests/renderive/frame_control/strategy/manual/Manual_Refresh_Strategy_Test.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include "renderive/frame_control/Frame_Control.hpp" struct Manual_Refresh_Test_Frame { int value{}; @@ -84,3 +85,22 @@ TEST(manual_refresh_strategy_test, refresh_observer_can_acquire_renderer_without EXPECT_TRUE(strategy.refresh()); EXPECT_EQ(observed_value.load(std::memory_order_acquire), 42); } + +TEST(manual_refresh_strategy_test, discard_is_counted_and_observed) { + Manual_Refresh_Reentrant_Observer observer; + auto data = observer.data; + Manual_Refresh_Reentrant_Strategy strategy{Manual_Refresh_Reentrant_Observer_State(observer)}; + std::vector events; + data->callback = [&events](int event) { events.push_back(event); }; + { + auto frame = strategy.acquire_painter(); + frame->value = 21; + } + ASSERT_TRUE(strategy.discard_pending_frame()); + EXPECT_FALSE(strategy.discard_pending_frame()); + const auto state = strategy.state(); + EXPECT_FALSE(state.pending_frame); + EXPECT_EQ(state.discarded_prepared_frame_count, 1u); + ASSERT_EQ(events.size(), 2u); + EXPECT_EQ(events.back(), static_cast(Manual_Refresh_Reentrant_Strategy::Observation_Event::manually_discarded)); +} diff --git a/main.cmake b/main.cmake index fd6b9b6..de2fb55 100644 --- a/main.cmake +++ b/main.cmake @@ -9,6 +9,7 @@ set(renderive_dependencies ) rcl_load_dependency_environment(${renderive_dependencies}) find_package(Drogon) +find_package(spdlog CONFIG REQUIRED) rcl_add_dependency_action_targets(renderive_env ${renderive_dependencies}) library_is_installed_with_rely(renderive_dependencies_installed ${renderive_dependencies}) if (NOT renderive_dependencies_installed) @@ -110,6 +111,7 @@ add_test(NAME Renderive_Kernel_Tests COMMAND Renderive_Kernel_Tests) add_executable(Renderive_Core2_Tests EXCLUDE_FROM_ALL "${root_dir}/Kernel/tests/main.cpp" "${root_dir}/Core2/tests/Core2_Integration_Tests.cpp" + "${root_dir}/Core2/tests/Core2_Frame_Pipeline_Tests.cpp" ) target_link_libraries(Renderive_Core2_Tests PRIVATE Renderive_Core2 @@ -138,4 +140,13 @@ if (MSVC) target_compile_options(Renderive_Core2 PRIVATE /utf-8) target_compile_options(Renderive_Qt PRIVATE /utf-8) endif () +if (NOT TARGET Adminive::Nlohmann) + set(renderive_saved_build_testing "${BUILD_TESTING}") + set(BUILD_TESTING OFF) + add_subdirectory("${root_dir}/../Adminive" + "${CMAKE_CURRENT_BINARY_DIR}/third_party/Adminive" + EXCLUDE_FROM_ALL) + set(BUILD_TESTING "${renderive_saved_build_testing}") + unset(renderive_saved_build_testing) +endif () include(${CMAKE_CURRENT_LIST_DIR}/web.cmake) diff --git a/web.cmake b/web.cmake index 6a0c24b..bc8c85a 100644 --- a/web.cmake +++ b/web.cmake @@ -1,7 +1,11 @@ set(renderive_web_sources + "${root_dir}/web_server/Gallery_Plot_Session.cpp" + "${root_dir}/web_server/Gallery_Protocol.cpp" + "${root_dir}/web_server/Gallery_WebSocket_Controller.cpp" "${root_dir}/web_server/Pixel_Frame.cpp" "${root_dir}/web_server/Renderive_WebSocket_Controller.cpp" "${root_dir}/web_server/Web_Event_Adapter.cpp" + "${root_dir}/web_server/Web_Performance_Log.cpp" "${root_dir}/web_server/Web_Plot_Session.cpp" "${root_dir}/web_server/Web_Server.cpp" ) @@ -13,23 +17,34 @@ target_compile_definitions(Renderive_Web PRIVATE NOMINMAX) target_link_libraries(Renderive_Web PRIVATE Renderive_Core2 Renderive_Kernel + Adminive::Nlohmann Drogon::Drogon + spdlog::spdlog ) add_executable(Renderive_Web_Server "${root_dir}/web_server/main.cpp") target_compile_features(Renderive_Web_Server PRIVATE cxx_std_20) target_link_libraries(Renderive_Web_Server PRIVATE Renderive_Web) -add_custom_command(TARGET Renderive_Web_Server POST_BUILD +add_custom_target(Renderive_Web_Assets COMMAND "${CMAKE_COMMAND}" -E copy_directory "${root_dir}/webapp" - "$/webapp" + "${CMAKE_CURRENT_BINARY_DIR}/webapp" + COMMAND "${CMAKE_COMMAND}" -E copy_directory + "${root_dir}/webapp_gallery" + "${CMAKE_CURRENT_BINARY_DIR}/webapp_gallery" + COMMENT "Synchronizing Renderive Web assets" ) +add_dependencies(Renderive_Web_Server Renderive_Web_Assets) add_executable(Renderive_Web_Tests EXCLUDE_FROM_ALL "${root_dir}/Kernel/tests/main.cpp" "${root_dir}/web_server/tests/Web_Bridge_Tests.cpp" ) -target_link_libraries(Renderive_Web_Tests PRIVATE Renderive_Web GTest::GTest) +target_link_libraries(Renderive_Web_Tests PRIVATE + Renderive_Web + Adminive::Nlohmann + GTest::GTest +) add_test(NAME Renderive_Web_Tests COMMAND Renderive_Web_Tests) if (MSVC) diff --git a/web_server/Gallery_Plot_Session.cpp b/web_server/Gallery_Plot_Session.cpp new file mode 100644 index 0000000..2ba9bbe --- /dev/null +++ b/web_server/Gallery_Plot_Session.cpp @@ -0,0 +1,1721 @@ +#include "Gallery_Plot_Session.h" + +#include "Gallery_Protocol.h" +#include "Pixel_Frame.h" +#include "Web_Performance_Log.h" +#include "Core2/export.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace renderive::web { +namespace { + +template +const T& state_value(const Gallery_State& state, std::string_view id) { + return std::get(state.values.at(std::string(id))); +} + +double number_value(const Gallery_State& state, std::string_view id) { + return state_value(state, id); +} + +int integer_value(const Gallery_State& state, std::string_view id) { + return static_cast(std::lround(number_value(state, id))); +} + +bool bool_value(const Gallery_State& state, std::string_view id) { + return state_value(state, id); +} + +const std::string& string_value(const Gallery_State& state, std::string_view id) { + return state_value(state, id); +} + +bool has_value(const Gallery_State& state, std::string_view id) { + return state.values.contains(id); +} + +int hex_digit(char value) { + if (value >= '0' && value <= '9') + return value - '0'; + if (value >= 'a' && value <= 'f') + return value - 'a' + 10; + return value - 'A' + 10; +} + +Color color_from_hex(std::string_view value, std::uint8_t alpha = 255) { + const auto channel = [value](std::size_t offset) { + return static_cast(hex_digit(value[offset]) * 16 + + hex_digit(value[offset + 1])); + }; + return {channel(1), channel(3), channel(5), alpha}; +} + +Color blend(Color first, Color second, double amount) { + const auto channel = [amount](std::uint8_t a, std::uint8_t b) { + return static_cast(std::clamp( + std::lround(a + (b - a) * amount), 0L, 255L)); + }; + return {channel(first.r, second.r), channel(first.g, second.g), + channel(first.b, second.b), channel(first.a, second.a)}; +} + +Color_Map gallery_color_map(std::string_view palette) { + std::array stops{ + Color{5, 10, 25, 255}, Color{20, 52, 112, 255}, Color{39, 196, 181, 255}, + Color{242, 201, 76, 255}, Color{255, 76, 106, 255} + }; + if (palette == "Ember") { + stops = {Color{10, 5, 8, 255}, Color{62, 18, 30, 255}, Color{153, 47, 39, 255}, + Color{244, 132, 48, 255}, Color{255, 238, 166, 255}}; + } else if (palette == "Grayscale") { + stops = {Color{0, 0, 0, 255}, Color{52, 52, 52, 255}, Color{112, 112, 112, 255}, + Color{188, 188, 188, 255}, Color{255, 255, 255, 255}}; + } + std::vector colors; + colors.reserve(256); + for (int index = 0; index < 256; ++index) { + const double position = index / 255.0 * (stops.size() - 1); + const auto first = static_cast(std::floor(position)); + const auto second = std::min(first + 1, stops.size() - 1); + colors.push_back(premultiply(blend(stops[first], stops[second], position - first))); + } + Color_Map result; + result.set_colors(std::move(colors)); + return result; +} + +Time_Of_Day current_time_of_day() { + constexpr std::int64_t day_ms = 24LL * 60LL * 60LL * 1000LL; + const auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + return {now % day_ms}; +} + +double gaussian(double x, double center, double width) { + const double normalized = (x - center) / width; + return std::exp(-0.5 * normalized * normalized); +} + +Line_Interpolation_Mode line_mode(std::string_view value) { + if (value == "Nearest_Sample") + return Line_Interpolation_Mode::Nearest_Sample; + if (value == "Linear_Power_Domain") + return Line_Interpolation_Mode::Linear_Power_Domain; + if (value == "Step_Left") + return Line_Interpolation_Mode::Step_Left; + if (value == "Step_Right") + return Line_Interpolation_Mode::Step_Right; + if (value == "Cubic_Value") + return Line_Interpolation_Mode::Cubic_Value; + return Line_Interpolation_Mode::Linear_Value; +} + +Image_Interpolation_Mode image_mode(std::string_view value) { + if (value == "Bilinear") + return Image_Interpolation_Mode::Bilinear; + if (value == "Bicubic") + return Image_Interpolation_Mode::Bicubic; + return Image_Interpolation_Mode::Nearest; +} + +Constellation_Diagram_Type constellation_mode(std::string_view value) { + if (value == "PSK4") + return Constellation_Diagram_Type::Psk4; + if (value == "PSK16") + return Constellation_Diagram_Type::Psk16; + return Constellation_Diagram_Type::Psk8; +} + +std::optional action_number(const Gallery_Action_Request& request) { + if (!request.argument || !std::holds_alternative(*request.argument)) + return std::nullopt; + return std::get(*request.argument); +} + +Frame_Control_Mode core_frame_mode(Gallery_Frame_Mode mode) { + switch (mode) { + case Gallery_Frame_Mode::Manual: + return Frame_Control_Mode::Manual; + case Gallery_Frame_Mode::Playback: + return Frame_Control_Mode::Playback; + case Gallery_Frame_Mode::Low_Latency: + return Frame_Control_Mode::Low_Latency; + } + return Frame_Control_Mode::Low_Latency; +} + +std::string_view frame_mode_name(Gallery_Frame_Mode mode) { + switch (mode) { + case Gallery_Frame_Mode::Manual: + return "manual"; + case Gallery_Frame_Mode::Playback: + return "playback"; + case Gallery_Frame_Mode::Low_Latency: + return "low_latency"; + } + return "low_latency"; +} + +} // namespace + +struct Gallery_Pixel_Copy { + Pixel_Frame_Copy image; + Color background; +}; + +class Gallery_Scene final { +public: + Gallery_Scene(std::uint64_t session_id, std::string case_id, Gallery_State state, + Gallery_Frame_Mode frame_mode, bool automatic_low_latency) + : session_id_(session_id), case_id_(std::move(case_id)), state_(std::move(state)), + frame_mode_(frame_mode), automatic_low_latency_(automatic_low_latency), + plot_(core_frame_mode(frame_mode)), performance_started_(std::chrono::steady_clock::now()) { + plot_.init(); + plot_.set_viewport_size({560, 320}); + root_ = plot_.root_renderable(); + axes_node_ = plot_.create_renderable_node(root_, "Gallery_Axes"); + data_node_ = plot_.create_renderable_node(root_, "Gallery_Data"); + overlay_node_ = plot_.create_renderable_node(root_, "Gallery_Overlay"); + axes_node_->set_cache_mode(Renderable_Cache_Mode::Local_Pixel); + attach_performance_overlay(plot_); + build_axes(); + build_primary(); + apply_state(); + plot_.activate_view(); + update_model(); + (void)plot_.render_frame(true); + } + + ~Gallery_Scene() { plot_.deactivate_view(); } + + Gallery_Scene(const Gallery_Scene&) = delete; + Gallery_Scene& operator=(const Gallery_Scene&) = delete; + + [[nodiscard]] const std::string& case_id() const noexcept { return case_id_; } + [[nodiscard]] const Gallery_State& state() const noexcept { return state_; } + [[nodiscard]] Gallery_Frame_Mode frame_mode() const noexcept { return frame_mode_; } + [[nodiscard]] bool can_render_automatically() const noexcept { + return automatic_low_latency_ && frame_mode_ == Gallery_Frame_Mode::Low_Latency && + plot_.view_active(); + } + [[nodiscard]] std::uint64_t next_refresh_interval_ns() const { + return plot_.refresh_feedback_snapshot().next_refresh_interval_ns; + } + + void set_client_metrics(double transport_fps, double presentation_fps, + std::uint64_t buffered_bytes, + std::uint64_t changed_pixel_frames, + std::uint64_t duplicate_pixel_frames, + double last_pixel_change_age_ms) noexcept { + client_transport_fps_ = std::isfinite(transport_fps) ? + std::clamp(transport_fps, 0.0, 100000.0) : 0.0; + client_presentation_fps_ = std::isfinite(presentation_fps) ? + std::clamp(presentation_fps, 0.0, 100000.0) : 0.0; + client_buffered_bytes_ = buffered_bytes; + client_changed_pixel_frames_ = changed_pixel_frames; + client_duplicate_pixel_frames_ = duplicate_pixel_frames; + client_last_pixel_change_age_ms_ = std::isfinite(last_pixel_change_age_ms) ? + std::max(0.0, last_pixel_change_age_ms) : 0.0; + } + + [[nodiscard]] bool requires_rebuild(const Gallery_State& candidate) const { + const auto changed = [this, &candidate](std::string_view id) { + const auto current = state_.values.find(id); + const auto next = candidate.values.find(id); + return current != state_.values.end() && next != candidate.values.end() && + current->second != next->second; + }; + if (case_id_ == "frequency_trace") + return changed("trace_pen") || changed("trace_pen_width"); + if (case_id_ == "constellation") + return changed("constellation_type") || changed("phase_offset"); + if (case_id_ == "waterfall" || case_id_ == "afterglow") + return changed("color_map"); + return false; + } + + void set_state(Gallery_State state) { + state_ = std::move(state); + apply_state(); + } + + void resize(Size size) { + const Size previous = plot_.viewport_size(); + plot_.set_viewport_size(size); + apply_layout(); + Resize_Event event; + event.old_size = previous; + event.new_size = size; + plot_.dispatch_event(event); + } + + void dispatch(const Event& event) { + if (event.type == Event_Type::Show) + plot_.activate_view(); + else if (event.type == Event_Type::Hide) + plot_.deactivate_view(); + plot_.dispatch_event(event); + } + + void remove_primary() { + if (primary_) + plot_.remove_renderable(primary_); + } + + bool render_latest_frame() { + if (!plot_.view_active()) + return false; + update_model(); + const auto started = std::chrono::steady_clock::now(); + const bool rendered = plot_.render_frame(true); + record_performance(started, rendered); + if (rendered) + rendered_since_last_pixel_ = true; + return rendered; + } + + [[nodiscard]] std::optional copy_latest_pixels() { + if (!plot_.view_active()) + return std::nullopt; + if (!rendered_since_last_pixel_ && !can_render_automatically() && + !render_latest_frame()) + return std::nullopt; + rendered_since_last_pixel_ = false; + Gallery_Pixel_Copy copy; + copy.background = plot_.background_color(); + plot_.with_frame([©](Image_View image) { + copy.image = copy_pixel_frame(image); + }); + return copy.image.empty() ? std::nullopt : + std::optional(std::move(copy)); + } + + void record_pixel_response(std::chrono::steady_clock::time_point request_started, + std::chrono::steady_clock::time_point encode_started, + std::chrono::steady_clock::time_point encode_finished, + std::size_t pixel_bytes) { + record_pixel_performance(request_started, encode_started, encode_finished, pixel_bytes); + } + + [[nodiscard]] std::string action(const Gallery_Action_Request& request, + bool& recognized) { + last_action_result_.clear(); + recognized = true; + if (request.id == "mode_prepare" || request.id == "mode_enqueue") { + update_model(); + const bool prepared = plot_.prepare_frame(); + last_action_result_ = prepared ? "frame prepared" : "prepare rejected"; + return prepared ? "Kernel 帧已准备/入队" : "Kernel 拒绝准备帧"; + } + if (request.id == "mode_enqueue_burst") { + const auto argument = action_number(request); + if (!argument) + return invalid_argument(recognized); + const int count = std::clamp(static_cast(std::lround(*argument)), 1, 256); + int prepared{}; + for (int index = 0; index < count; ++index) { + update_model(); + prepared += plot_.prepare_frame() ? 1 : 0; + } + last_action_result_ = "enqueued=" + std::to_string(prepared); + return "回放帧已批量压入 Flow 队列"; + } + if (request.id == "mode_refresh") { + const bool refreshed = plot_.refresh_manual_frame(); + last_action_result_ = refreshed ? "manual refresh succeeded" : "manual refresh failed"; + return refreshed ? "Manual 待处理帧已提交刷新" : "当前没有可刷新的 Manual 帧"; + } + if (request.id == "mode_render" || request.id == "mode_dequeue") { + const auto started = std::chrono::steady_clock::now(); + const bool rendered = plot_.render_prepared_frame(); + record_performance(started, rendered); + rendered_since_last_pixel_ = rendered; + last_action_result_ = rendered ? "prepared frame rendered" : "no prepared frame"; + return rendered ? "Kernel 已渲染准备帧" : "当前没有可消费的准备帧"; + } + if (request.id == "mode_discard") { + const bool discarded = plot_.discard_pending_frame(); + last_action_result_ = discarded ? "pending frame discarded" : "no pending frame"; + return discarded ? "待处理帧已丢弃" : "当前没有可丢弃帧"; + } + if (request.id == "mode_cycle") { + update_model(); + const auto started = std::chrono::steady_clock::now(); + const bool rendered = plot_.render_frame(true); + record_performance(started, rendered); + rendered_since_last_pixel_ = rendered; + last_action_result_ = rendered ? "full frame cycle rendered" : "frame cycle skipped"; + return rendered ? "完整帧控制周期已执行" : "完整帧控制周期未产生图像"; + } + if (request.id == "read_data_shape") { + last_action_result_ = "data shape refreshed in observer telemetry"; + return "输入点数、实际绘制点/单元数已刷新到观察者与性能面板"; + } + if (request.id == "toggle_view") { + if (plot_.view_active()) + plot_.deactivate_view(); + else + plot_.activate_view(); + return plot_.view_active() ? "Plot view 已激活" : "Plot view 已停用;可再次执行恢复"; + } + if (request.id == "axis_set_start") { + if (numeric_domain_axis_) + numeric_domain_axis_->set_coord_start(number_value(state_, "coord_origin")); + return "已通过 Axis::set_coord_start 应用当前起点"; + } + if (request.id == "axis_set_length") { + if (numeric_domain_axis_) + numeric_domain_axis_->set_coord_length(number_value(state_, "coord_target") - + number_value(state_, "coord_origin")); + return "已通过 Axis::set_coord_length 应用当前跨度"; + } + if (request.id == "axis_probe") + return "坐标映射、刻度步长、次刻度数与标签结果已刷新到遥测区"; + if (request.id == "append_time" && time_axis_) { + const int tick = time_axis_->append_time(current_time_of_day()); + last_action_result_ = "tick=" + std::to_string(tick) + + ", ms=" + std::to_string(time_axis_->tick_to_time(tick).milliseconds); + return "Time_Axis 时间点已追加并回读"; + } + if (spectrum_) { + if (request.id == "push_samples") { + push_spectrum_samples(); + return "Spectrum 样本已手动推送"; + } + if (request.id == "power_at") { + const auto argument = action_number(request); + if (!argument) + return invalid_argument(recognized); + bool ok{}; + const double power = spectrum_->power_at(*argument, ok); + last_action_result_ = ok ? std::to_string(power) + " dB" : "频率不在样本范围"; + return "Spectrum::power_at 查询完成"; + } + if (request.id == "add_marker" || request.id == "add_line_marker" || + request.id == "remove_marker" || request.id == "set_marker_frequency") { + const auto argument = action_number(request); + if (!argument) + return invalid_argument(recognized); + if (request.id == "add_marker") + spectrum_->add_custom_marker(*argument); + else if (request.id == "add_line_marker") + spectrum_->add_custom_line_marker(*argument); + else if (request.id == "remove_marker") + spectrum_->remove_custom_marker(*argument); + else { + const int selected = spectrum_->selected_marker_index(); + if (selected >= 0) + spectrum_->set_marker_frequency(selected, *argument); + spectrum_->set_current_marker_frequency(*argument); + } + return "Marker API 已执行"; + } + if (request.id == "remove_selected_marker") { + spectrum_->remove_selected_marker(); + return "选中 Marker 已删除"; + } + if (request.id == "clear_markers") { + spectrum_->clear_custom_markers(); + return "全部 Marker 已清空"; + } + if (request.id == "select_marker") { + const auto argument = action_number(request); + if (!argument) + return invalid_argument(recognized); + spectrum_->set_selected_marker_index(static_cast(std::lround(*argument))); + return "Marker 索引已选择"; + } + if (request.id == "select_next_marker") { + spectrum_->select_next_marker(); + return "已选择下一个 Marker"; + } + if (request.id == "select_previous_marker") { + spectrum_->select_previous_marker(); + return "已选择上一个 Marker"; + } + if (request.id == "clear_marker_selection") { + spectrum_->clear_marker_selection(); + return "Marker 选择已清除"; + } + if (request.id == "rebind_axes") { + spectrum_->set_frequency_axis(spectrum_->frequency_axis()); + spectrum_->set_power_axis(spectrum_->power_axis()); + return "Spectrum 的 shared_ptr 轴已回读并重新绑定"; + } + } + if (selection_) { + if (request.id == "clear_selection") { + selection_->clear_selected_regions(); + return "框选区域已清空"; + } + if (request.id == "rebind_selection_axes") { + selection_->set_horizontal_axis(numeric_domain_axis_); + selection_->set_vertical_axis(value_axis_); + return "Selection Overlay 的两根轴已重新绑定"; + } + } + if (waterfall_) { + if (request.id == "append_row") { + push_waterfall_row(); + return "Waterfall 行已手动追加"; + } + if (request.id == "append_tick_row") { + const int tick = time_axis_->append_time(current_time_of_day()); + waterfall_->append_row(tick, spectrum_values(waterfall_->frequency_bin_count(), + waterfall_->power_range())); + return "Waterfall 已通过 int tick 重载追加一行"; + } + if (request.id == "rebind_axes") { + last_action_result_ = waterfall_->frequency_axis() && waterfall_->time_axis() + ? "frequency_axis/time_axis valid" : "axis invalid"; + return "Waterfall 轴绑定已回读"; + } + } + if (afterglow_) { + if (request.id == "append_spectrum") { + push_afterglow_spectrum(); + return "Afterglow 频谱已手动追加"; + } + if (request.id == "rebind_axes") { + last_action_result_ = afterglow_->frequency_axis() && afterglow_->power_axis() + ? "frequency_axis/power_axis valid" : "axis invalid"; + return "Afterglow 轴绑定已回读"; + } + } + if (sweep_) { + if (request.id == "append_block") { + push_sweep_block(); + return "Sweep_Spectrum 扫频块已手动追加"; + } + if (request.id == "rebind_axes") { + last_action_result_ = sweep_->frequency_axis() && sweep_->power_axis() + ? "frequency_axis/power_axis valid" : "axis invalid"; + return "Sweep_Spectrum 轴绑定已回读"; + } + } + if (trace_) { + if (request.id == "append_sample") { + push_trace_sample(); + return "Frequency_Trace 样本已手动追加"; + } + if (request.id == "append_tick_sample") { + const int tick = time_axis_->append_time(current_time_of_day()); + trace_->append_sample(tick, std::sin(frame_index_ * 0.12)); + return "Frequency_Trace 已通过 int tick 重载追加样本"; + } + if (request.id == "read_axes") { + last_action_result_ = trace_->time_axis() && trace_->value_axis() + ? "time_axis/value_axis valid" : "axis invalid"; + return "Frequency_Trace 轴绑定已回读"; + } + } + if (constellation_) { + if (request.id == "append_points") { + push_constellation_points(); + return "Constellation IQ 点已手动追加"; + } + if (request.id == "fit_square") { + constellation_->fit_square_to_axes(); + return "Constellation 已按轴拟合正方形"; + } + if (request.id == "read_axes") { + last_action_result_ = constellation_->i_axis() && constellation_->q_axis() + ? "i_axis/q_axis valid" : "axis invalid"; + return "Constellation 轴绑定已回读"; + } + } + recognized = false; + return {}; + } + + void record_action_notice_if_empty(std::string_view notice) { + if (last_action_result_.empty()) + last_action_result_ = notice; + } + + [[nodiscard]] std::string telemetry_json() const { + const auto observer = plot_.frame_observer_snapshot(); + const auto telemetry_now = std::chrono::steady_clock::now(); + const double elapsed_seconds = std::max( + std::chrono::duration(telemetry_now - performance_started_).count(), + 1e-9); + const double render_fps = recent_rate(render_history_, telemetry_now); + const double pixel_fps = recent_rate(pixel_history_, telemetry_now); + nlohmann::json telemetry{ + {"case", case_id_}, + {"frame_mode", frame_mode_name(frame_mode_)}, + {"frame_index", frame_index_}, + {"viewport", {{"width", plot_.viewport_size().width}, + {"height", plot_.viewport_size().height}}}, + {"view_active", plot_.view_active()}, + {"kernel_frame_count", plot_.diagnostics().refresh.frame_count}, + {"performance", { + {"render_attempt_count", render_attempt_count_}, + {"successful_render_count", successful_render_count_}, + {"failed_render_count", render_attempt_count_ - successful_render_count_}, + {"measured_fps", render_fps}, + {"lifetime_average_fps", static_cast(successful_render_count_) / elapsed_seconds}, + {"last_render_ms", last_render_ms_}, + {"average_render_ms", successful_render_count_ == 0 ? 0.0 : + total_render_ms_ / static_cast(successful_render_count_)}, + {"maximum_render_ms", maximum_render_ms_}, + {"pixel_response_fps", pixel_fps}, + {"last_pixel_snapshot_ms", last_pixel_snapshot_ms_}, + {"last_pixel_encode_ms", last_pixel_encode_ms_}, + {"average_pixel_encode_ms", pixel_frame_count_ == 0 ? 0.0 : + total_pixel_encode_ms_ / static_cast(pixel_frame_count_)}, + {"maximum_pixel_encode_ms", maximum_pixel_encode_ms_}, + {"last_pixel_request_ms", last_pixel_request_ms_}, + {"last_pixel_bytes", last_pixel_bytes_}, + {"pixel_payload_megabytes_per_second", + pixel_fps * static_cast(last_pixel_bytes_) / 1e6}, + {"automatic_low_latency_scheduler", + automatic_low_latency_ && frame_mode_ == Gallery_Frame_Mode::Low_Latency}, + {"kernel_paint_ms", static_cast(observer.paint_duration_ns) / 1e6}, + {"kernel_render_ms", static_cast(observer.render_duration_ns) / 1e6}, + {"kernel_bottleneck_ms", static_cast(observer.bottleneck_duration_ns) / 1e6}, + {"kernel_end_to_end_ms", static_cast(observer.end_to_end_ns) / 1e6} + }}, + {"kernel_observer", { + {"mode", frame_mode_name(frame_mode_)}, + {"last_event", observer.last_event}, + {"limit_state", observer.limit_state}, + {"configured_frequency_hz", observer.frequency_hz}, + {"observation_count", observer.observation_count}, + {"produced_frame_count", observer.produced_frame_count}, + {"consumed_frame_count", observer.consumed_frame_count}, + {"dropped_frame_count", observer.dropped_frame_count}, + {"failed_operation_count", observer.failed_operation_count}, + {"pending_frame_count", observer.pending_frame_count}, + {"latest_sequence", observer.latest_sequence}, + {"paint_duration_ns", observer.paint_duration_ns}, + {"render_duration_ns", observer.render_duration_ns}, + {"target_interval_ns", observer.target_interval_ns}, + {"bottleneck_duration_ns", observer.bottleneck_duration_ns}, + {"next_refresh_interval_ns", observer.next_refresh_interval_ns}, + {"paint_lease_wait_ns", observer.paint_lease_wait_ns}, + {"paint_state_wait_ns", observer.paint_state_wait_ns}, + {"publish_state_wait_ns", observer.publish_state_wait_ns}, + {"ready_wait_ns", observer.ready_wait_ns}, + {"frame_age_at_render_ns", observer.frame_age_at_render_ns}, + {"render_lease_wait_ns", observer.render_lease_wait_ns}, + {"render_state_wait_ns", observer.render_state_wait_ns}, + {"render_finish_state_wait_ns", observer.render_finish_state_wait_ns}, + {"queue_wait_ns", observer.queue_wait_ns}, + {"end_to_end_ns", observer.end_to_end_ns} + }}, + {"client_performance", { + {"transport_fps", client_transport_fps_}, + {"presentation_fps", client_presentation_fps_}, + {"websocket_buffered_bytes", client_buffered_bytes_}, + {"changed_pixel_frames", client_changed_pixel_frames_}, + {"duplicate_pixel_frames", client_duplicate_pixel_frames_}, + {"last_pixel_change_age_ms", client_last_pixel_change_age_ms_} + }}, + {"last_action_result", last_action_result_} + }; + if (frame_mode_ == Gallery_Frame_Mode::Low_Latency) { + telemetry["render_fps"] = plot_.max_render_fps(); + telemetry["refresh_feedback_hz"] = plot_.refresh_feedback_snapshot().frequency_hz; + telemetry["low_latency_limit"] = { + {"current", observer.limit_state}, + {"frequency_limited", observer.limit_state == "frequency_limited"}, + {"paint_limited", observer.limit_state == "paint_limited"}, + {"render_limited", observer.limit_state == "render_limited"}, + {"configured_frequency_hz", observer.frequency_hz}, + {"target_interval_ns", observer.target_interval_ns}, + {"paint_duration_ns", observer.paint_duration_ns}, + {"render_duration_ns", observer.render_duration_ns}, + {"bottleneck_duration_ns", observer.bottleneck_duration_ns}, + {"next_refresh_interval_ns", observer.next_refresh_interval_ns} + }; + } + if (primary_) { + telemetry["renderable"] = { + {"object_name", primary_->object_name()}, + {"visible", primary_->is_visible()}, + {"cache_mode", primary_->get_cache_mode() == Renderable_Cache_Mode::Direct + ? "Direct" : "Local_Pixel"} + }; + } + if (const auto overlay = plot_.performance_overlay()) { + telemetry["performance_overlay_enabled"] = overlay->enabled(); + if (const auto snapshot = overlay->display_snapshot()) + telemetry["performance_overlay_lines"] = snapshot->lines.size(); + } + if (numeric_domain_axis_) { + const Range range = numeric_domain_axis_->coord_range(); + const double center = range.center(); + telemetry["axis"] = { + {"origin", range.origin}, {"target", range.target}, + {"start_coord", numeric_domain_axis_->start_coord()}, + {"end_coord", numeric_domain_axis_->end_coord()}, + {"center_pixel", numeric_domain_axis_->coord_to_pixel(center)}, + {"center_roundtrip", numeric_domain_axis_->pixel_to_coord( + numeric_domain_axis_->coord_to_pixel(center))}, + {"pixel_samples", numeric_domain_axis_->pixel_sample_count()}, + {"tick_step", numeric_domain_axis_->tick_step(range)}, + {"sub_tick_count", numeric_domain_axis_->sub_tick_count( + numeric_domain_axis_->tick_step(range))}, + {"center_label", numeric_domain_axis_->tick_label(center)} + }; + } + if (spectrum_) { + telemetry["spectrum"] = { + {"selectable_line_markers", spectrum_->selectable_line_marker_count()}, + {"selected_marker_index", spectrum_->selected_marker_index()}, + {"configured_frequency_points", spectrum_->frequency_point_size()}, + {"input_sample_count", spectrum_->sample_count()}, + {"rendered_point_count", spectrum_->rendered_point_count()} + }; + const int selected = spectrum_->selected_marker_index(); + if (selected >= 0 && selected < spectrum_->selectable_line_marker_count()) + telemetry["spectrum"]["selected_marker_frequency"] = + spectrum_->marker_frequency(selected); + } + if (selection_) + telemetry["selection_regions"] = selection_->selected_regions().size(); + if (waterfall_) { + telemetry["waterfall"] = { + {"configured_frequency_bins", waterfall_->frequency_bin_count()}, + {"row_count", waterfall_->row_count()}, + {"stored_point_count", waterfall_->stored_point_count()}, + {"rendered_cell_count", waterfall_->rendered_cell_count()}, + {"time_axis_point_count", time_axis_ ? time_axis_->time_point_count() : 0} + }; + } + if (afterglow_) { + telemetry["afterglow"] = { + {"configured_frequency_points", afterglow_->frequency_point_size()}, + {"configured_power_points", afterglow_->power_point_size()}, + {"history_frame_count", afterglow_->history_count()}, + {"latest_input_point_count", afterglow_->latest_spectrum_point_count()}, + {"rendered_cell_count", afterglow_->rendered_cell_count()} + }; + } + if (sweep_) { + telemetry["sweep_spectrum"] = { + {"configured_bins_per_block", sweep_->bins_per_block()}, + {"configured_block_count", sweep_->block_count()}, + {"stored_block_count", sweep_->stored_block_count()}, + {"stored_point_count", sweep_->stored_point_count()}, + {"rendered_point_count", sweep_->rendered_point_count()} + }; + } + if (trace_) { + telemetry["frequency_trace"] = { + {"sample_count", trace_->sample_count()}, + {"rendered_point_count", trace_->rendered_point_count()}, + {"time_axis_point_count", time_axis_ ? time_axis_->time_point_count() : 0} + }; + } + if (constellation_) { + const std::string& constellation_type = string_value(state_, "constellation_type"); + const int anchor_count = constellation_type == "PSK4" ? 4 : + constellation_type == "PSK16" ? 16 : 8; + telemetry["constellation"] = { + {"point_count", constellation_->point_count()}, + {"anchor_count", anchor_count}, + {"rendered_point_count", constellation_->point_count() + + static_cast(anchor_count)}, + {"point_lifetime_ms", constellation_->point_lifetime_ms()} + }; + } + if (case_id_ == "axis_lab") { + telemetry["axis_lab"] = { + {"domain_axis_pixel_samples", numeric_domain_axis_ ? + numeric_domain_axis_->pixel_sample_count() : 0}, + {"time_axis_point_count", time_axis_ ? time_axis_->time_point_count() : 0} + }; + } + + if (spectrum_) + telemetry["data_shape"] = {{"input_points", spectrum_->sample_count()}, + {"rendered_elements", spectrum_->rendered_point_count()}, + {"unit", "points"}}; + else if (waterfall_) + telemetry["data_shape"] = {{"input_points", waterfall_->stored_point_count()}, + {"rendered_elements", waterfall_->rendered_cell_count()}, + {"unit", "cells"}}; + else if (afterglow_) + telemetry["data_shape"] = {{"input_points", afterglow_->latest_spectrum_point_count()}, + {"rendered_elements", afterglow_->rendered_cell_count()}, + {"unit", "cells"}}; + else if (sweep_) + telemetry["data_shape"] = {{"input_points", sweep_->stored_point_count()}, + {"rendered_elements", sweep_->rendered_point_count()}, + {"unit", "points"}}; + else if (trace_) + telemetry["data_shape"] = {{"input_points", trace_->sample_count()}, + {"rendered_elements", trace_->rendered_point_count()}, + {"unit", "points"}}; + else if (constellation_) { + const std::string& constellation_type = string_value(state_, "constellation_type"); + const std::size_t anchors = constellation_type == "PSK4" ? 4U : + constellation_type == "PSK16" ? 16U : 8U; + telemetry["data_shape"] = {{"input_points", constellation_->point_count()}, + {"rendered_elements", constellation_->point_count() + anchors}, + {"unit", "points"}}; + } + else + telemetry["data_shape"] = {{"input_points", 0}, + {"rendered_elements", numeric_domain_axis_ ? + numeric_domain_axis_->pixel_sample_count() : 0}, + {"unit", "axis samples"}}; + return telemetry.dump(); + } + +private: + using Performance_Clock = std::chrono::steady_clock; + + static void record_timestamp(std::deque& history, + Performance_Clock::time_point now) { + history.push_back(now); + const auto oldest = now - std::chrono::seconds(1); + while (history.size() > 2 && history.front() < oldest) + history.pop_front(); + } + + static double recent_rate(const std::deque& history, + Performance_Clock::time_point now) { + if (history.size() < 2 || now - history.back() > std::chrono::seconds(1)) + return 0.0; + const double seconds = + std::chrono::duration(history.back() - history.front()).count(); + return seconds > 0.0 ? static_cast(history.size() - 1) / seconds : 0.0; + } + + void record_performance(std::chrono::steady_clock::time_point started, bool rendered) { + const auto finished = std::chrono::steady_clock::now(); + ++render_attempt_count_; + const double duration_ms = + std::chrono::duration(finished - started).count(); + last_render_ms_ = duration_ms; + maximum_render_ms_ = std::max(maximum_render_ms_, duration_ms); + if (rendered) { + ++successful_render_count_; + total_render_ms_ += duration_ms; + record_timestamp(render_history_, finished); + } + maybe_log_performance(finished); + } + + void record_pixel_performance(Performance_Clock::time_point request_started, + Performance_Clock::time_point encode_started, + Performance_Clock::time_point encode_finished, + std::size_t pixel_bytes) { + last_pixel_snapshot_ms_ = + std::chrono::duration(encode_started - request_started).count(); + last_pixel_encode_ms_ = + std::chrono::duration(encode_finished - encode_started).count(); + last_pixel_request_ms_ = + std::chrono::duration(encode_finished - request_started).count(); + maximum_pixel_encode_ms_ = std::max(maximum_pixel_encode_ms_, last_pixel_encode_ms_); + total_pixel_encode_ms_ += last_pixel_encode_ms_; + last_pixel_bytes_ = pixel_bytes; + ++pixel_frame_count_; + record_timestamp(pixel_history_, encode_finished); + } + + void maybe_log_performance(Performance_Clock::time_point now) { + if (now - last_performance_log_ < std::chrono::seconds(1)) + return; + last_performance_log_ = now; + const auto observer = plot_.frame_observer_snapshot(); + const auto unix_ms = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + const double pixel_fps = recent_rate(pixel_history_, now); + const nlohmann::json line{ + {"event", "gallery_performance"}, + {"unix_ms", unix_ms}, + {"session_id", session_id_}, + {"case", case_id_}, + {"frame_mode", frame_mode_name(frame_mode_)}, + {"configured_fps", frame_mode_ == Gallery_Frame_Mode::Low_Latency ? + plot_.max_render_fps() : 0.0}, + {"backend_render_fps", recent_rate(render_history_, now)}, + {"pixel_response_fps", pixel_fps}, + {"client_transport_fps", client_transport_fps_}, + {"client_presentation_fps", client_presentation_fps_}, + {"client_changed_pixel_frames", client_changed_pixel_frames_}, + {"client_duplicate_pixel_frames", client_duplicate_pixel_frames_}, + {"client_last_pixel_change_age_ms", client_last_pixel_change_age_ms_}, + {"last_render_ms", last_render_ms_}, + {"last_pixel_snapshot_ms", last_pixel_snapshot_ms_}, + {"last_pixel_encode_ms", last_pixel_encode_ms_}, + {"last_pixel_request_ms", last_pixel_request_ms_}, + {"pixel_payload_bytes", last_pixel_bytes_}, + {"pixel_payload_megabytes_per_second", + pixel_fps * static_cast(last_pixel_bytes_) / 1e6}, + {"websocket_buffered_bytes", client_buffered_bytes_}, + {"automatic_low_latency_scheduler", + automatic_low_latency_ && frame_mode_ == Gallery_Frame_Mode::Low_Latency}, + {"successful_render_count", successful_render_count_}, + {"failed_render_count", render_attempt_count_ - successful_render_count_}, + {"pixel_frame_count", pixel_frame_count_}, + {"last_event", observer.last_event}, + {"limit_state", observer.limit_state}, + {"observation_count", observer.observation_count}, + {"latest_sequence", observer.latest_sequence}, + {"produced_frame_count", observer.produced_frame_count}, + {"consumed_frame_count", observer.consumed_frame_count}, + {"target_interval_ns", observer.target_interval_ns}, + {"paint_duration_ns", observer.paint_duration_ns}, + {"render_duration_ns", observer.render_duration_ns}, + {"bottleneck_duration_ns", observer.bottleneck_duration_ns}, + {"next_refresh_interval_ns", observer.next_refresh_interval_ns}, + {"paint_lease_wait_ns", observer.paint_lease_wait_ns}, + {"paint_state_wait_ns", observer.paint_state_wait_ns}, + {"publish_state_wait_ns", observer.publish_state_wait_ns}, + {"ready_wait_ns", observer.ready_wait_ns}, + {"frame_age_at_render_ns", observer.frame_age_at_render_ns}, + {"render_lease_wait_ns", observer.render_lease_wait_ns}, + {"render_state_wait_ns", observer.render_state_wait_ns}, + {"render_finish_state_wait_ns", observer.render_finish_state_wait_ns}, + {"queue_wait_ns", observer.queue_wait_ns}, + {"end_to_end_ns", observer.end_to_end_ns}, + {"pending_frames", observer.pending_frame_count}, + {"dropped_frames", observer.dropped_frame_count}, + {"failed_operations", observer.failed_operation_count} + }; + write_web_performance_log(line.dump()); + } + + void build_axes() { + constexpr Color axis_color{118, 145, 184, 255}; + const Range coordinate_range{number_value(state_, "coord_origin"), + number_value(state_, "coord_target")}; + if (case_id_ == "frequency_trace") { + time_axis_ = Time_Axis::Builder(axes_node_, Orientation::Horizontal) + .set_visible_time_point_count(integer_value(state_, "time_visible_count")) + .set_tick_label_spacing_px(integer_value(state_, "time_label_spacing")) + .set_time_format(string_value(state_, "time_format")) + .set_color(axis_color) + .build(); + } else if (case_id_ == "sweep_spectrum" || case_id_ == "constellation") { + numeric_domain_axis_ = Axis::Builder(axes_node_, Orientation::Horizontal) + .set_coord_range(coordinate_range) + .set_use_wheel(true) + .set_use_drag(true) + .set_color(axis_color) + .build(); + } else { + numeric_domain_axis_ = Frequency_Axis::Builder(axes_node_, Orientation::Horizontal) + .set_coord_range(coordinate_range) + .set_use_wheel(true) + .set_use_drag(true) + .set_color(axis_color) + .build(); + } + + if (case_id_ == "waterfall") { + time_axis_ = Time_Axis::Builder(axes_node_, Orientation::Vertical) + .set_visible_time_point_count(integer_value(state_, "time_visible_count")) + .set_tick_label_spacing_px(integer_value(state_, "time_label_spacing")) + .set_time_format(string_value(state_, "time_format")) + .set_color(axis_color) + .build(); + } else { + Range value_range{-120.0, -20.0}; + if (case_id_ == "frequency_trace") + value_range = {number_value(state_, "trace_value_min"), + number_value(state_, "trace_value_max")}; + else if (case_id_ == "constellation") + value_range = {number_value(state_, "q_origin"), + number_value(state_, "q_target")}; + value_axis_ = Axis::Builder(axes_node_, Orientation::Vertical) + .set_coord_range(value_range) + .set_label_precision(case_id_ == "constellation" ? 2 : 0) + .set_color(axis_color) + .build(); + } + + if (case_id_ == "axis_lab") { + time_axis_ = Time_Axis::Builder(axes_node_, Orientation::Horizontal) + .set_visible_time_point_count(integer_value(state_, "time_visible_count")) + .set_tick_label_spacing_px(integer_value(state_, "time_label_spacing")) + .set_time_format(string_value(state_, "time_format")) + .set_color({69, 221, 190, 255}) + .build(); + } + } + + void build_primary() { + if (case_id_ == "axis_lab") { + primary_ = numeric_domain_axis_; + return; + } + if (case_id_ == "spectrum" || case_id_ == "selection_overlay") { + spectrum_ = Spectrum::Builder(data_node_, + std::static_pointer_cast(numeric_domain_axis_), + value_axis_) + .set_frequency_range({number_value(state_, "frequency_origin"), + number_value(state_, "frequency_target")}) + .set_frequency_point_size(integer_value(state_, "frequency_point_size")) + .set_center_frequency(number_value(state_, "center_frequency")) + .set_sweep_frequency_range({number_value(state_, "sweep_origin"), + number_value(state_, "sweep_target")}) + .build(); + primary_ = spectrum_; + if (case_id_ == "selection_overlay") { + selection_ = Selection_Rectangle_Overlay::Builder( + overlay_node_, numeric_domain_axis_, value_axis_) + .build(); + primary_ = selection_; + } + return; + } + if (case_id_ == "waterfall") { + waterfall_ = Waterfall::Builder(data_node_, + std::static_pointer_cast(numeric_domain_axis_), + time_axis_) + .set_frequency_range({number_value(state_, "frequency_origin"), + number_value(state_, "frequency_target")}) + .set_power_range({number_value(state_, "power_origin"), + number_value(state_, "power_target")}) + .set_frequency_bin_count(integer_value(state_, "frequency_bin_count")) + .set_color_map(gallery_color_map(string_value(state_, "color_map"))) + .build(); + primary_ = waterfall_; + return; + } + if (case_id_ == "afterglow") { + afterglow_ = Afterglow::Builder(data_node_, + std::static_pointer_cast(numeric_domain_axis_), + value_axis_) + .set_frequency_range({number_value(state_, "frequency_origin"), + number_value(state_, "frequency_target")}) + .set_power_range({number_value(state_, "power_origin"), + number_value(state_, "power_target")}) + .set_frequency_bin_count(integer_value(state_, "frequency_point_size")) + .set_power_bin_count(integer_value(state_, "power_point_size")) + .set_color_map(gallery_color_map(string_value(state_, "color_map"))) + .build(); + primary_ = afterglow_; + return; + } + if (case_id_ == "sweep_spectrum") { + sweep_ = Sweep_Spectrum::Builder(data_node_, numeric_domain_axis_, value_axis_) + .set_frequency_range({number_value(state_, "frequency_origin"), + number_value(state_, "frequency_target")}) + .set_bins_per_block(integer_value(state_, "bins_per_block")) + .set_block_num(integer_value(state_, "block_count")) + .build(); + primary_ = sweep_; + return; + } + if (case_id_ == "frequency_trace") { + trace_ = Frequency_Trace::Builder(data_node_, time_axis_, value_axis_) + .set_pen({color_from_hex(string_value(state_, "trace_pen")), + number_value(state_, "trace_pen_width"), Line_Style::Solid, + Line_Cap::Round, Line_Join::Round}) + .build(); + primary_ = trace_; + return; + } + if (case_id_ == "constellation") { + constellation_ = Constellation_Diagram::Builder(data_node_, numeric_domain_axis_, value_axis_) + .set_i_range({number_value(state_, "i_origin"), + number_value(state_, "i_target")}) + .set_q_range({number_value(state_, "q_origin"), + number_value(state_, "q_target")}) + .set_type(constellation_mode(string_value(state_, "constellation_type"))) + .set_phase_offset_radians(number_value(state_, "phase_offset")) + .build(); + primary_ = constellation_; + } + } + + std::shared_ptr horizontal_axis() const { + return numeric_domain_axis_ ? std::static_pointer_cast(numeric_domain_axis_) + : std::static_pointer_cast(time_axis_); + } + + std::shared_ptr vertical_axis() const { + if (case_id_ == "waterfall") + return time_axis_; + return value_axis_; + } + + void apply_layout() { + const Size viewport = plot_.viewport_size(); + const int left = viewport.width < 440 ? 48 : 62; + const int right = 24; + const int top = case_id_ == "axis_lab" ? 48 : 18; + const int bottom = 46; + const int width = std::max(1, viewport.width - left - right); + const int height = std::max(1, viewport.height - top - bottom); + const int x_offset = integer_value(state_, "axis_x_offset"); + const int y_offset = integer_value(state_, "axis_y_offset"); + const double length_rate = number_value(state_, "axis_length_percent") / 100.0; + + if (const auto horizontal = horizontal_axis()) { + horizontal->set_x(left + x_offset); + horizontal->set_y(top + height + y_offset); + horizontal->set_pixel_length(static_cast( + std::max(1, static_cast(std::lround(width * length_rate))))); + } + if (const auto vertical = vertical_axis()) { + vertical->set_x(left + x_offset); + vertical->set_y(top + y_offset); + vertical->set_pixel_length(static_cast( + std::max(1, static_cast(std::lround(height * length_rate))))); + } + if (case_id_ == "axis_lab" && time_axis_) { + time_axis_->set_x(left); + time_axis_->set_y(26); + time_axis_->set_pixel_length(static_cast(width)); + } + } + + void apply_axis_style(const std::shared_ptr& axis) { + if (!axis) + return; + axis->set_tick_length(integer_value(state_, "tick_length")); + axis->set_sub_tick_length(integer_value(state_, "sub_tick_length")); + axis->set_color(color_from_hex(string_value(state_, "axis_color"))); + axis->set_locale({string_value(state_, "decimal_separator").front()}); + axis->set_unit_text(string_value(state_, "unit_text")); + axis->set_unit_text_font({number_value(state_, "unit_font_size"), + integer_value(state_, "unit_font_weight"), + bool_value(state_, "unit_font_italic")}); + axis->set_unit_text_pen({color_from_hex(string_value(state_, "unit_pen_color"))}); + axis->set_unit_text_background_brush( + {color_from_hex(string_value(state_, "unit_background_color")), Brush_Style::Solid}); + axis->set_label_rotation_degrees(integer_value(state_, "label_rotation")); + } + + void apply_axes() { + const bool horizontal = string_value(state_, "axis_orientation") == "Horizontal"; + if (const auto main = horizontal_axis()) + main->set_orientation(horizontal ? Orientation::Horizontal : Orientation::Vertical); + if (const auto other = vertical_axis()) + other->set_orientation(horizontal ? Orientation::Vertical : Orientation::Horizontal); + + apply_axis_style(horizontal_axis()); + apply_axis_style(vertical_axis()); + if (case_id_ == "axis_lab") + apply_axis_style(time_axis_); + + if (numeric_domain_axis_) { + numeric_domain_axis_->set_coord_range({number_value(state_, "coord_origin"), + number_value(state_, "coord_target")}); + numeric_domain_axis_->set_label_precision(integer_value(state_, "label_precision")); + numeric_domain_axis_->set_use_wheel(bool_value(state_, "use_wheel")); + numeric_domain_axis_->set_use_drag(bool_value(state_, "use_drag")); + } + if (time_axis_) { + time_axis_->set_visible_time_point_count(integer_value(state_, "time_visible_count")); + time_axis_->set_tick_label_spacing_px(integer_value(state_, "time_label_spacing")); + time_axis_->set_time_format(string_value(state_, "time_format")); + time_axis_->set_font({number_value(state_, "time_font_size"), 500, false}); + time_axis_->set_newest_at_axis_start(bool_value(state_, "time_newest_at_start")); + } + apply_layout(); + } + + void apply_state() { + plot_.set_background_color(color_from_hex(string_value(state_, "background_color"))); + if (frame_mode_ == Gallery_Frame_Mode::Low_Latency) + plot_.set_max_render_fps(number_value(state_, "max_render_fps")); + set_performance_plot_name(plot_, case_id_ + "/" + std::string(frame_mode_name(frame_mode_))); + set_performance_overlay_enabled(plot_, bool_value(state_, "performance_overlay")); + if (primary_) { + primary_->set_visible(bool_value(state_, "renderable_visible")); + primary_->set_cache_mode(string_value(state_, "cache_mode") == "Direct" + ? Renderable_Cache_Mode::Direct + : Renderable_Cache_Mode::Local_Pixel); + primary_->set_object_name(string_value(state_, "object_name")); + } + apply_axes(); + apply_specific_state(); + plot_.notify_model_dirty(); + } + + void apply_specific_state() { + if (spectrum_) { + spectrum_->set_frequency_point_size(integer_value(state_, "frequency_point_size")); + spectrum_->set_frequency_range({number_value(state_, "frequency_origin"), + number_value(state_, "frequency_target")}); + spectrum_->set_center_frequency(number_value(state_, "center_frequency")); + spectrum_->set_sweep_frequency_range({number_value(state_, "sweep_origin"), + number_value(state_, "sweep_target")}); + spectrum_->set_max_hold_visible(bool_value(state_, "max_hold_visible")); + spectrum_->set_min_hold_visible(bool_value(state_, "min_hold_visible")); + spectrum_->set_max_marker_visible(bool_value(state_, "max_marker_visible")); + spectrum_->set_use_min_marker(bool_value(state_, "use_min_marker")); + spectrum_->set_sweep_region_visible(bool_value(state_, "sweep_region_visible")); + spectrum_->set_visible_range_only(bool_value(state_, "visible_range_only")); + spectrum_->set_interpolation_mode(line_mode(string_value(state_, "line_interpolation"))); + spectrum_->set_max_brush({color_from_hex(string_value(state_, "max_brush"), 76), Brush_Style::Solid}); + spectrum_->set_current_brush({color_from_hex(string_value(state_, "current_brush"), 76), Brush_Style::Solid}); + spectrum_->set_min_brush({color_from_hex(string_value(state_, "min_brush"), 76), Brush_Style::Solid}); + spectrum_->set_max_pen({color_from_hex(string_value(state_, "max_pen")), 1.2}); + spectrum_->set_current_pen({color_from_hex(string_value(state_, "current_pen")), 2.0, + Line_Style::Solid, Line_Cap::Round, Line_Join::Round}); + spectrum_->set_min_pen({color_from_hex(string_value(state_, "min_pen")), 1.2}); + spectrum_->set_selected_marker_pen({color_from_hex(string_value(state_, "selected_marker_pen")), 2.0}); + spectrum_->set_marker_pen({color_from_hex(string_value(state_, "marker_pen")), 1.2}); + spectrum_->set_middle_frequency_pen({color_from_hex(string_value(state_, "middle_frequency_pen")), + 1.0, Line_Style::Dash}); + spectrum_->set_sweep_region_brush({color_from_hex(string_value(state_, "sweep_region_brush"), 80), + Brush_Style::Solid}); + spectrum_->set_use_hover_info(bool_value(state_, "hover_enabled")); + spectrum_->set_hover_tooltip_font({number_value(state_, "hover_font_size"), 500, false}); + spectrum_->set_tooltip_text_pen({color_from_hex(string_value(state_, "hover_text_color"))}); + spectrum_->set_hover_tooltip_background_brush( + {color_from_hex(string_value(state_, "hover_background"), 230), Brush_Style::Solid}); + } + if (selection_) { + selection_->set_label_font({number_value(state_, "selection_font_size"), 500, false}); + selection_->set_label_pen({color_from_hex(string_value(state_, "selection_label_pen"))}); + selection_->set_selection_brush({color_from_hex(string_value(state_, "selection_brush"), 70), + Brush_Style::Solid}); + selection_->set_selection_border_pen({color_from_hex(string_value(state_, "selection_border")), + 1.0, Line_Style::Dash}); + } + if (waterfall_) { + waterfall_->set_frequency_range({number_value(state_, "frequency_origin"), + number_value(state_, "frequency_target")}); + waterfall_->set_power_range({number_value(state_, "power_origin"), + number_value(state_, "power_target")}); + waterfall_->set_frequency_bin_count(integer_value(state_, "frequency_bin_count")); + waterfall_->set_visible_range_only(bool_value(state_, "visible_range_only")); + waterfall_->set_interpolation_mode(image_mode(string_value(state_, "image_interpolation"))); + waterfall_->set_use_hover_info(bool_value(state_, "hover_enabled")); + waterfall_->set_hover_tooltip_font({number_value(state_, "hover_font_size"), 500, false}); + waterfall_->set_tooltip_text_pen({color_from_hex(string_value(state_, "hover_text_color"))}); + waterfall_->set_hover_tooltip_background_brush( + {color_from_hex(string_value(state_, "hover_background"), 230), Brush_Style::Solid}); + } + if (afterglow_) { + afterglow_->set_frequency_range({number_value(state_, "frequency_origin"), + number_value(state_, "frequency_target")}); + afterglow_->set_power_range({number_value(state_, "power_origin"), + number_value(state_, "power_target")}); + afterglow_->set_frequency_point_size(integer_value(state_, "frequency_point_size")); + afterglow_->set_power_point_size(integer_value(state_, "power_point_size")); + afterglow_->set_interpolate(bool_value(state_, "interpolate_power")); + afterglow_->set_attenuation_rate(number_value(state_, "attenuation_rate")); + } + if (sweep_) { + sweep_->set_frequency_range({number_value(state_, "frequency_origin"), + number_value(state_, "frequency_target")}); + sweep_->set_bins_per_block(integer_value(state_, "bins_per_block")); + sweep_->set_block_count(integer_value(state_, "block_count")); + sweep_->set_pen({color_from_hex(string_value(state_, "sweep_pen")), 1.8}); + sweep_->set_cur_frequency_pen({color_from_hex(string_value(state_, "current_frequency_pen")), 2.0}); + sweep_->set_visible_range_only(bool_value(state_, "visible_range_only")); + sweep_->set_interpolation_mode(line_mode(string_value(state_, "line_interpolation"))); + } + if (trace_ && value_axis_) + value_axis_->set_coord_range({number_value(state_, "trace_value_min"), + number_value(state_, "trace_value_max")}); + if (constellation_) { + constellation_->set_i_range({number_value(state_, "i_origin"), number_value(state_, "i_target")}); + constellation_->set_q_range({number_value(state_, "q_origin"), number_value(state_, "q_target")}); + constellation_->set_point_color(color_from_hex(string_value(state_, "point_color"))); + constellation_->set_anchor_color(color_from_hex(string_value(state_, "anchor_color"))); + constellation_->set_point_lifetime_ms(integer_value(state_, "point_lifetime_ms")); + if (numeric_domain_axis_) + numeric_domain_axis_->set_coord_range({number_value(state_, "i_origin"), + number_value(state_, "i_target")}); + if (value_axis_) + value_axis_->set_coord_range({number_value(state_, "q_origin"), + number_value(state_, "q_target")}); + } + } + + std::vector spectrum_values(int count, Range power_range = {-120.0, -20.0}) const { + count = std::max(count, 2); + std::vector values(static_cast(count)); + const double time = static_cast(frame_index_) * 0.09; + for (int index = 0; index < count; ++index) { + const double x = static_cast(index) / (count - 1); + const double noise = std::sin(index * 11.71 + time * 2.9) * + std::sin(index * 0.19 + time * 0.7) * 4.0; + const double signal = 58.0 * gaussian(x, 0.32 + std::sin(time) * 0.04, 0.035) + + 45.0 * gaussian(x, 0.57, 0.07) + + 32.0 * gaussian(x, 0.78, 0.018); + values[static_cast(index)] = + std::clamp(-112.0 + noise + signal, std::min(power_range.origin, power_range.target), + std::max(power_range.origin, power_range.target)); + } + return values; + } + + void push_spectrum_samples() { + spectrum_->update_samples(spectrum_values(spectrum_->frequency_point_size())); + } + + void push_waterfall_row() { + waterfall_->append_row(current_time_of_day(), + spectrum_values(waterfall_->frequency_bin_count(), waterfall_->power_range())); + } + + void push_afterglow_spectrum() { + afterglow_->append_spectrum(spectrum_values(afterglow_->frequency_point_size(), + afterglow_->power_range())); + } + + void push_sweep_block() { + const int count = sweep_->bins_per_block(); + std::vector block(static_cast(std::max(count, 1))); + for (int index = 0; index < count; ++index) { + const double x = static_cast(index) / std::max(1, count - 1); + block[static_cast(index)] = -104.0 + 68.0 * gaussian(x, 0.5, 0.16) + + std::sin(frame_index_ * 0.2 + index) * 4.0; + } + sweep_->append_block(block); + } + + void push_trace_sample() { + const double lower = number_value(state_, "trace_value_min"); + const double upper = number_value(state_, "trace_value_max"); + const double center = (lower + upper) * 0.5; + const double amplitude = std::abs(upper - lower) * 0.42; + trace_->append_sample(current_time_of_day(), center + amplitude * std::sin(frame_index_ * 0.12)); + } + + void push_constellation_points() { + const std::string& mode = string_value(state_, "constellation_type"); + const int count = mode == "PSK4" ? 4 : mode == "PSK16" ? 16 : 8; + const double phase = number_value(state_, "phase_offset"); + for (int index = 0; index < 24; ++index) { + const int anchor = static_cast((frame_index_ * 7 + index * 5) % count); + const double angle = phase + 2.0 * std::numbers::pi * anchor / count; + const double noise = std::sin(frame_index_ * 0.31 + index * 1.73) * 0.055; + constellation_->append_point({std::cos(angle) + noise, + std::sin(angle) - noise * 0.7}); + } + } + + void update_model() { + if (spectrum_) + push_spectrum_samples(); + if (waterfall_) + push_waterfall_row(); + if (afterglow_) + push_afterglow_spectrum(); + if (sweep_) + push_sweep_block(); + if (trace_) + push_trace_sample(); + if (constellation_) + push_constellation_points(); + if (case_id_ == "axis_lab" && time_axis_) + time_axis_->append_time(current_time_of_day()); + ++frame_index_; + } + + static std::string invalid_argument(bool& recognized) { + recognized = false; + return {}; + } + + std::uint64_t session_id_{}; + std::string case_id_; + Gallery_State state_; + Gallery_Frame_Mode frame_mode_ = Gallery_Frame_Mode::Low_Latency; + bool automatic_low_latency_{}; + Plot_Core plot_; + std::chrono::steady_clock::time_point performance_started_; + std::chrono::steady_clock::time_point last_performance_log_; + std::shared_ptr root_; + std::shared_ptr axes_node_; + std::shared_ptr data_node_; + std::shared_ptr overlay_node_; + std::shared_ptr numeric_domain_axis_; + std::shared_ptr value_axis_; + std::shared_ptr time_axis_; + std::shared_ptr primary_; + std::shared_ptr spectrum_; + std::shared_ptr waterfall_; + std::shared_ptr trace_; + std::shared_ptr afterglow_; + std::shared_ptr sweep_; + std::shared_ptr selection_; + std::shared_ptr constellation_; + std::uint64_t frame_index_{}; + std::uint64_t render_attempt_count_{}; + std::uint64_t successful_render_count_{}; + double last_render_ms_{}; + double total_render_ms_{}; + double maximum_render_ms_{}; + std::deque render_history_; + std::uint64_t pixel_frame_count_{}; + double last_pixel_snapshot_ms_{}; + double last_pixel_encode_ms_{}; + double total_pixel_encode_ms_{}; + double maximum_pixel_encode_ms_{}; + double last_pixel_request_ms_{}; + std::size_t last_pixel_bytes_{}; + std::deque pixel_history_; + double client_transport_fps_{}; + double client_presentation_fps_{}; + std::uint64_t client_buffered_bytes_{}; + std::uint64_t client_changed_pixel_frames_{}; + std::uint64_t client_duplicate_pixel_frames_{}; + double client_last_pixel_change_age_ms_{}; + bool rendered_since_last_pixel_{}; + std::string last_action_result_; +}; + +struct Gallery_Plot_Session::Impl { + explicit Impl(bool enable_automatic_low_latency) + : automatic_low_latency(enable_automatic_low_latency), + session_id(next_session_id()) { + if (automatic_low_latency) { + render_worker = std::jthread( + [this](std::stop_token stop) { run_automatic_renderer(stop); }); + } + } + + ~Impl() { + render_worker.request_stop(); + scheduler_condition.notify_all(); + } + + static std::uint64_t next_session_id() noexcept { + static std::atomic next{1}; + return next.fetch_add(1, std::memory_order_relaxed); + } + + void update_client_metrics(std::string_view message) { + if (!scene) + return; + const auto root = nlohmann::json::parse(message, nullptr, false); + if (root.is_discarded() || !root.is_object() || !root.contains("client_metrics") || + !root["client_metrics"].is_object()) + return; + const auto& metrics = root["client_metrics"]; + const auto finite_metric = [&metrics](std::string_view name) { + const auto iterator = metrics.find(std::string(name)); + if (iterator == metrics.end() || !iterator->is_number()) + return 0.0; + const double value = iterator->get(); + return std::isfinite(value) ? value : 0.0; + }; + std::uint64_t buffered_bytes{}; + if (const auto iterator = metrics.find("websocket_buffered_bytes"); + iterator != metrics.end() && iterator->is_number_unsigned()) { + buffered_bytes = iterator->get(); + } + const auto unsigned_metric = [&metrics](std::string_view name) { + const auto iterator = metrics.find(std::string(name)); + if (iterator == metrics.end() || !iterator->is_number_integer()) + return std::uint64_t{}; + const auto value = iterator->get(); + return value > 0 ? static_cast(value) : std::uint64_t{}; + }; + scene->set_client_metrics(finite_metric("transport_fps"), + finite_metric("presentation_fps"), buffered_bytes, + unsigned_metric("changed_pixel_frames"), + unsigned_metric("duplicate_pixel_frames"), + finite_metric("last_pixel_change_age_ms")); + } + + [[nodiscard]] std::unique_lock acquire_foreground_lock() { + foreground_waiters.fetch_add(1, std::memory_order_release); + scheduler_condition.notify_all(); + std::unique_lock lock(mutex); + foreground_waiters.fetch_sub(1, std::memory_order_release); + scheduler_condition.notify_all(); + return lock; + } + + void run_automatic_renderer(std::stop_token stop) { + using Clock = std::chrono::steady_clock; + std::unique_lock lock(mutex); + auto next_deadline = Clock::now(); + std::uint64_t active_revision = std::numeric_limits::max(); + while (!stop.stop_requested()) { + scheduler_condition.wait(lock, [this, &stop] { + return stop.stop_requested() || + (scene && scene->can_render_automatically()); + }); + if (stop.stop_requested()) + break; + if (foreground_waiters.load(std::memory_order_acquire) != 0) { + scheduler_condition.wait(lock, [this, &stop] { + return stop.stop_requested() || + foreground_waiters.load(std::memory_order_acquire) == 0; + }); + continue; + } + + const auto revision = scheduler_revision; + const auto cycle_started = Clock::now(); + if (active_revision != revision) { + active_revision = revision; + next_deadline = cycle_started; + } + (void)scene->render_latest_frame(); + const auto interval = std::chrono::nanoseconds( + std::max(1, scene->next_refresh_interval_ns())); + if (next_deadline + interval < cycle_started) + next_deadline = cycle_started; + next_deadline += interval; + const auto deadline = next_deadline; + // A render that already consumed the whole interval used to let the + // worker immediately reacquire the session mutex. Heavy scenes + // (notably Constellation) could then starve pixel pulls, observer + // requests and shutdown indefinitely. Explicitly hand the mutex to + // registered foreground waiters; otherwise yield without using a + // timed wait (which Windows may round to ~15 ms). + if (Clock::now() >= deadline) { + if (foreground_waiters.load(std::memory_order_acquire) != 0) { + scheduler_condition.wait(lock, [this, &stop] { + return stop.stop_requested() || + foreground_waiters.load(std::memory_order_acquire) == 0; + }); + } else { + lock.unlock(); + std::this_thread::yield(); + lock.lock(); + } + continue; + } + const auto spin_window = interval <= std::chrono::milliseconds(10) ? + std::chrono::duration_cast( + std::chrono::milliseconds(2)) : + std::chrono::duration_cast( + std::chrono::microseconds(250)); + const auto coarse_deadline = deadline > cycle_started + spin_window ? + deadline - spin_window : cycle_started; + if (Clock::now() < coarse_deadline) { + const bool interrupted = scheduler_condition.wait_until( + lock, coarse_deadline, [this, &stop, revision] { + return stop.stop_requested() || scheduler_revision != revision || + foreground_waiters.load(std::memory_order_acquire) != 0; + }); + if (interrupted) + continue; + } + + lock.unlock(); + while (!stop.stop_requested() && Clock::now() < deadline) { + } + lock.lock(); + } + } + + static bool affects_render_schedule(const Web_Event& event) { + return std::visit( + [](const auto& value) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + return false; + } else if constexpr (std::is_same_v) { + return value.kind != Gallery_Request_Kind::Catalog && + value.kind != Gallery_Request_Kind::Observe; + } else { + return true; + } + }, + event); + } + + std::unique_ptr scene; + std::mutex mutex; + std::condition_variable scheduler_condition; + std::uint64_t scheduler_revision{}; + std::uint64_t scene_generation{}; + bool automatic_low_latency{}; + std::uint64_t session_id{}; + std::atomic foreground_waiters{}; + std::jthread render_worker; + + std::optional handle_gallery(const Gallery_Request& request) { + if (request.kind == Gallery_Request_Kind::Catalog) + return Web_Response{Web_Response_Type::Json, Gallery_Protocol::catalog_json()}; + if (request.kind == Gallery_Request_Kind::Open) { + const auto open = Gallery_Protocol::open_request(request.message); + if (!open) + return Web_Response{Web_Response_Type::Json, + Gallery_Protocol::error_json("未知的 gallery case 或 frame_mode", "case")}; + scene = std::make_unique( + session_id, open->case_id, + Gallery_Protocol::default_state(open->case_id, open->frame_mode), + open->frame_mode, automatic_low_latency); + ++scene_generation; + return Web_Response{Web_Response_Type::Json, + Gallery_Protocol::case_json(scene->case_id(), scene->state(), + scene->telemetry_json(), + "Core2/Kernel 独立画布已创建", + scene->frame_mode())}; + } + if (!scene) + return Web_Response{Web_Response_Type::Json, + Gallery_Protocol::error_json("请先发送 gallery_open")}; + if (request.kind == Gallery_Request_Kind::Observe) { + update_client_metrics(request.message); + return Web_Response{Web_Response_Type::Json, + Gallery_Protocol::observer_json( + scene->case_id(), scene->frame_mode(), scene->telemetry_json())}; + } + if (request.kind == Gallery_Request_Kind::Patch) { + auto patch = Gallery_Protocol::apply_patch(scene->case_id(), scene->state(), + request.message, scene->frame_mode()); + if (!patch.candidate) + return Web_Response{Web_Response_Type::Json, std::move(patch.response_json)}; + if (scene->requires_rebuild(*patch.candidate)) { + const std::string id = scene->case_id(); + const auto mode = scene->frame_mode(); + scene->remove_primary(); + scene = std::make_unique(session_id, id, + std::move(*patch.candidate), mode, + automatic_low_latency); + ++scene_generation; + } else { + scene->set_state(std::move(*patch.candidate)); + } + return Web_Response{Web_Response_Type::Json, + Gallery_Protocol::case_json(scene->case_id(), scene->state(), + scene->telemetry_json(), + "控件 API 已由后端应用", + scene->frame_mode())}; + } + const auto action = Gallery_Protocol::action_request(request.message); + if (!action) + return Web_Response{Web_Response_Type::Json, + Gallery_Protocol::error_json("gallery_action 格式无效")}; + if (!Gallery_Protocol::action_available(scene->case_id(), scene->frame_mode(), + action->id)) + return Web_Response{Web_Response_Type::Json, + Gallery_Protocol::error_json( + "动作不属于当前画布或帧策略", "action")}; + if (action->id == "reset") { + const std::string id = scene->case_id(); + const auto mode = scene->frame_mode(); + scene = std::make_unique( + session_id, id, Gallery_Protocol::default_state(id, mode), mode, + automatic_low_latency); + ++scene_generation; + return Web_Response{Web_Response_Type::Json, + Gallery_Protocol::case_json(id, scene->state(), + scene->telemetry_json(), + "本图已恢复后端默认值", mode)}; + } + if (action->id == "rebuild_renderable") { + const std::string id = scene->case_id(); + const auto mode = scene->frame_mode(); + Gallery_State state = scene->state(); + scene->remove_primary(); + scene = std::make_unique(session_id, id, std::move(state), mode, + automatic_low_latency); + ++scene_generation; + return Web_Response{Web_Response_Type::Json, + Gallery_Protocol::case_json(id, scene->state(), + scene->telemetry_json(), + "Renderable 已移除并通过 Builder 重建", mode)}; + } + bool recognized{}; + const std::string notice = scene->action(*action, recognized); + if (!recognized) + return Web_Response{Web_Response_Type::Json, + Gallery_Protocol::error_json("动作不属于当前画布或参数无效", + "action")}; + scene->record_action_notice_if_empty(notice); + return Web_Response{Web_Response_Type::Json, + Gallery_Protocol::case_json(scene->case_id(), scene->state(), + scene->telemetry_json(), notice, + scene->frame_mode())}; + } + + std::optional handle_frame_request() { + const auto request_started = std::chrono::steady_clock::now(); + std::optional copy; + std::uint64_t generation{}; + { + auto lock = acquire_foreground_lock(); + if (!scene) + return std::nullopt; + generation = scene_generation; + copy = scene->copy_latest_pixels(); + } + if (!copy) + return std::nullopt; + + const auto encode_started = std::chrono::steady_clock::now(); + std::string pixels = encode_pixel_frame(copy->image, copy->background); + const auto encode_finished = std::chrono::steady_clock::now(); + { + auto lock = acquire_foreground_lock(); + if (scene && scene_generation == generation) + scene->record_pixel_response(request_started, encode_started, encode_finished, + pixels.size()); + } + return pixels.empty() ? std::nullopt : + std::optional(Web_Response{ + Web_Response_Type::Pixels, std::move(pixels)}); + } + + std::optional handle(const Web_Event& event) { + if (std::holds_alternative(event)) + return handle_frame_request(); + const bool reschedule = affects_render_schedule(event); + std::optional response; + { + auto lock = acquire_foreground_lock(); + response = std::visit( + [this](const auto& value) -> std::optional { + using T = std::decay_t; + if constexpr (std::is_same_v) { + return handle_gallery(value); + } else if constexpr (std::is_same_v) { + return std::nullopt; + } else if constexpr (std::is_same_v) { + if (scene) + scene->resize(value.size); + } else if constexpr (std::is_same_v) { + if (scene) + scene->dispatch(value); + } else if constexpr (std::is_base_of_v) { + if (scene) + scene->dispatch(value); + } + return std::nullopt; + }, + event); + if (reschedule) + ++scheduler_revision; + } + if (reschedule) + scheduler_condition.notify_all(); + return response; + } +}; + +Gallery_Plot_Session::Gallery_Plot_Session() : Gallery_Plot_Session(false) {} + +Gallery_Plot_Session::Gallery_Plot_Session(bool automatic_low_latency) + : impl_(std::make_unique(automatic_low_latency)) {} +Gallery_Plot_Session::~Gallery_Plot_Session() = default; + +std::optional Gallery_Plot_Session::handle(const Web_Event& event) { + return impl_->handle(event); +} + +} // namespace renderive::web diff --git a/web_server/Gallery_Plot_Session.h b/web_server/Gallery_Plot_Session.h new file mode 100644 index 0000000..6bf2678 --- /dev/null +++ b/web_server/Gallery_Plot_Session.h @@ -0,0 +1,25 @@ +#pragma once + +#include "Web_Plot_Session.h" + +#include +#include + +namespace renderive::web { + +class Gallery_Plot_Session final { +public: + Gallery_Plot_Session(); + explicit Gallery_Plot_Session(bool automatic_low_latency); + ~Gallery_Plot_Session(); + Gallery_Plot_Session(const Gallery_Plot_Session&) = delete; + Gallery_Plot_Session& operator=(const Gallery_Plot_Session&) = delete; + + [[nodiscard]] std::optional handle(const Web_Event& event); + +private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace renderive::web diff --git a/web_server/Gallery_Protocol.cpp b/web_server/Gallery_Protocol.cpp new file mode 100644 index 0000000..abcb522 --- /dev/null +++ b/web_server/Gallery_Protocol.cpp @@ -0,0 +1,979 @@ +#include "Gallery_Protocol.h" + +#include "adminive/adminive.hpp" +#include "adminive/adapters/nlohmann_json.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace renderive::web::gallery_detail { + +using Json = nlohmann::json; + +struct Case_Model { + std::string id; + std::string title; + std::string component; + std::string category; + std::string description; + int order{}; + int preferred_width = 560; + int preferred_height = 320; +}; + +struct Control_Model { + std::string id; + std::string label; + std::string api; + std::string description; + std::string group; + std::string input; + bool integer{}; + double minimum{}; + double maximum{}; + double step{}; +}; + +struct Action_Model { + std::string id; + std::string label; + std::string api; + std::string description; + std::string group; + std::string argument_input; + std::string argument_label; + double argument_default{}; +}; + +struct Control_Definition { + Control_Model model; + Gallery_Value initial; + std::vector options; +}; + +struct Action_Definition { + Action_Model model; +}; + +const std::vector& cases() { + static const std::vector value{ + {"axis_lab", "坐标轴实验室", "Axis / Frequency_Axis / Time_Axis", "基础控件", + "普通轴、频率轴和时间轴的布局、格式化、滚轮与拖拽 API。", 10}, + {"spectrum", "实时频谱", "Spectrum", "三种频率图", + "频率控制模式 1:曲线、保持线、扫频区、峰值和自定义 Marker。", 20}, + {"afterglow", "余辉频谱", "Afterglow", "三种频率图", + "频率控制模式 2:二维功率密度、衰减和功率 bin 插值。", 30}, + {"sweep_spectrum", "扫频频谱", "Sweep_Spectrum", "三种频率图", + "频率控制模式 3:分块扫频、当前频率游标和六种线插值。", 40}, + {"waterfall", "瀑布图", "Waterfall", "热力图控件", + "逐行时频热力图;Nearest、Bilinear、Bicubic 三种图像模式均可切换。", 50}, + {"frequency_trace", "时间轨迹", "Frequency_Trace", "曲线控件", + "Time_Axis 驱动的连续轨迹,覆盖时间格式和 Builder 笔刷 API。", 60}, + {"selection_overlay", "框选叠层", "Selection_Rectangle_Overlay", "交互控件", + "鼠标框选、多区域保留、标注样式、清空与轴重新绑定。", 70}, + {"constellation", "星座图", "Constellation_Diagram", "点图控件", + "PSK4/PSK8/PSK16 Builder 模式、相位、寿命、坐标范围和方形拟合。", 80} + }; + return value; +} + +Control_Definition flag(std::string id, std::string label, std::string api, + bool initial, std::string group, std::string description = {}) { + return {{std::move(id), std::move(label), std::move(api), std::move(description), + std::move(group), "boolean"}, initial, {}}; +} + +Control_Definition number(std::string id, std::string label, std::string api, + double initial, double minimum, double maximum, double step, + std::string group, bool integer = false, + std::string description = {}) { + return {{std::move(id), std::move(label), std::move(api), std::move(description), + std::move(group), "number", integer, minimum, maximum, step}, initial, {}}; +} + +Control_Definition text_control(std::string id, std::string label, std::string api, + std::string initial, std::string group, + std::string description = {}) { + return {{std::move(id), std::move(label), std::move(api), std::move(description), + std::move(group), "text"}, std::move(initial), {}}; +} + +Control_Definition color(std::string id, std::string label, std::string api, + std::string initial, std::string group, + std::string description = {}) { + auto result = text_control(std::move(id), std::move(label), std::move(api), + std::move(initial), std::move(group), std::move(description)); + result.model.input = "color"; + return result; +} + +Control_Definition select(std::string id, std::string label, std::string api, + std::string initial, std::vector options, + std::string group, std::string description = {}) { + return {{std::move(id), std::move(label), std::move(api), std::move(description), + std::move(group), "select"}, std::move(initial), std::move(options)}; +} + +void append_common(std::vector& result, Gallery_Frame_Mode frame_mode) { + result.push_back(color("background_color", "背景颜色", "Plot_Core::set_background_color", + "#07111f", "Plot_Core")); + if (frame_mode == Gallery_Frame_Mode::Low_Latency) { + result.push_back(number("max_render_fps", "最大渲染 FPS", "Plot_Core::set_max_render_fps", + 30, 0.01, 1'000'000'000, 1, "Low_Latency_Strategy", false, + "Kernel 接受任意有限正频率;高目标用于触发并观察 Painter 或后台 Render 瓶颈。")); + result.push_back(number("pixel_stream_fps", "WebSocket 像素 FPS", + "WebSocket_Pixel_Stream::set_pull_frequency", 30, 1, 60, 1, + "Web Transport", false, + "只限制浏览器拉取完整 RGBA 像素图的频率,不限制 Kernel 后台渲染频率。")); + } + result.push_back(flag("performance_overlay", "性能叠层", "set_performance_overlay_enabled", + false, "Plot_Core")); + result.push_back(flag("renderable_visible", "控件可见", "Renderable::set_visible", + true, "Renderable")); + result.push_back(select("cache_mode", "缓存模式", "Renderable::set_cache_mode", "Local_Pixel", + {"Direct", "Local_Pixel"}, "Renderable")); + result.push_back(text_control("object_name", "对象名称", "Renderable::set_object_name", + "Gallery_Renderable", "Renderable")); +} + +void append_axis(std::vector& result) { + result.push_back(select("axis_orientation", "主轴方向", "Abs_Axis::set_orientation", "Horizontal", + {"Horizontal", "Vertical"}, "Abs_Axis")); + result.push_back(number("axis_x_offset", "X 偏移", "Abs_Axis::set_x", 0, -160, 160, 1, + "Abs_Axis", true)); + result.push_back(number("axis_y_offset", "Y 偏移", "Abs_Axis::set_y", 0, -160, 160, 1, + "Abs_Axis", true)); + result.push_back(number("axis_length_percent", "轴长百分比", "Abs_Axis::set_pixel_length", 100, + 25, 125, 1, "Abs_Axis", true)); + result.push_back(number("tick_length", "主刻度长度", "Abs_Axis::set_tick_length", 8, + -24, 24, 1, "Abs_Axis", true)); + result.push_back(number("sub_tick_length", "次刻度长度", "Abs_Axis::set_sub_tick_length", 4, + -16, 16, 1, "Abs_Axis", true)); + result.push_back(color("axis_color", "轴颜色", "Abs_Axis::set_color", "#7691b8", "Abs_Axis")); + result.push_back(select("decimal_separator", "小数点", "Abs_Axis::set_locale", ".", + {".", ","}, "Abs_Axis")); + result.push_back(text_control("unit_text", "单位文本", "Abs_Axis::set_unit_text", "Hz", "Abs_Axis")); + result.push_back(number("unit_font_size", "单位字号", "Abs_Axis::set_unit_text_font", 12, + 6, 36, 1, "Abs_Axis")); + result.push_back(number("unit_font_weight", "单位字重", "Abs_Axis::set_unit_text_font", 500, + 100, 900, 100, "Abs_Axis", true)); + result.push_back(flag("unit_font_italic", "单位斜体", "Abs_Axis::set_unit_text_font", false, + "Abs_Axis")); + result.push_back(color("unit_pen_color", "单位文字颜色", "Abs_Axis::set_unit_text_pen", + "#dce9ff", "Abs_Axis")); + result.push_back(color("unit_background_color", "单位背景颜色", + "Abs_Axis::set_unit_text_background_brush", "#07111f", "Abs_Axis")); + result.push_back(number("label_rotation", "标签旋转角度", "Abs_Axis::set_label_rotation_degrees", + 0, -90, 90, 1, "Abs_Axis", true)); + result.push_back(number("coord_origin", "坐标起点", "Axis::set_coord_range", 88'000'000, + -1e10, 1e10, 1000, "Axis")); + result.push_back(number("coord_target", "坐标终点", "Axis::set_coord_range", 108'000'000, + -1e10, 1e10, 1000, "Axis")); + result.push_back(number("label_precision", "标签精度", "Axis::set_label_precision", 2, + 0, 9, 1, "Axis", true)); + result.push_back(flag("use_wheel", "启用滚轮缩放", "Axis::set_use_wheel", true, "Axis")); + result.push_back(flag("use_drag", "启用拖拽平移", "Axis::set_use_drag", true, "Axis")); +} + +void append_time_axis(std::vector& result) { + result.push_back(number("time_visible_count", "可见时间点", "Time_Axis::set_visible_time_point_count", + 80, 8, 400, 1, "Time_Axis", true)); + result.push_back(number("time_label_spacing", "时间标签间距", "Time_Axis::set_tick_label_spacing_px", + 28, 0, 120, 1, "Time_Axis", true)); + result.push_back(text_control("time_format", "时间格式", "Time_Axis::set_time_format", + "mm:ss.zzz", "Time_Axis")); + result.push_back(number("time_font_size", "时间字体", "Time_Axis::set_font", 11, + 6, 32, 1, "Time_Axis")); + result.push_back(flag("time_newest_at_start", "最新数据位于轴起点", + "Time_Axis::set_newest_at_axis_start", false, "Time_Axis")); +} + +void append_hover(std::vector& result) { + result.push_back(flag("hover_enabled", "悬浮信息", "Hover_Tooltip_Mixin::set_use_hover_info", + true, "Hover Tooltip")); + result.push_back(number("hover_font_size", "悬浮字体", "Hover_Tooltip_Mixin::set_hover_tooltip_font", + 12, 6, 32, 1, "Hover Tooltip")); + result.push_back(color("hover_text_color", "悬浮文字", "Hover_Tooltip_Mixin::set_tooltip_text_pen", + "#ffffff", "Hover Tooltip")); + result.push_back(color("hover_background", "悬浮背景", + "Hover_Tooltip_Mixin::set_hover_tooltip_background_brush", + "#111827", "Hover Tooltip")); +} + +std::vector controls(std::string_view case_id, + Gallery_Frame_Mode frame_mode) { + std::vector result; + append_common(result, frame_mode); + append_axis(result); + + const auto set_initial = [&result](std::string_view id, Gallery_Value value) { + const auto found = std::find_if(result.begin(), result.end(), [id](const auto& item) { + return item.model.id == id; + }); + if (found != result.end()) + found->initial = std::move(value); + }; + if (case_id == "sweep_spectrum") { + set_initial("coord_origin", 0.0); + set_initial("coord_target", 300.0); + set_initial("unit_text", std::string("MHz")); + } else if (case_id == "frequency_trace") { + set_initial("coord_origin", -1.0); + set_initial("coord_target", 1.0); + set_initial("unit_text", std::string("value")); + } else if (case_id == "constellation") { + set_initial("coord_origin", -1.25); + set_initial("coord_target", 1.25); + set_initial("unit_text", std::string("I / Q")); + } + + if (case_id == "axis_lab") { + append_time_axis(result); + return result; + } + if (case_id == "spectrum" || case_id == "selection_overlay") { + result.push_back(number("frequency_point_size", "频率点数", "Spectrum::set_frequency_point_size", + 512, 16, 4096, 16, "Spectrum", true)); + result.push_back(number("frequency_origin", "数据频率起点", "Spectrum::set_frequency_range", + 88e6, -1e10, 1e10, 1000, "Spectrum")); + result.push_back(number("frequency_target", "数据频率终点", "Spectrum::set_frequency_range", + 108e6, -1e10, 1e10, 1000, "Spectrum")); + result.push_back(number("center_frequency", "中心频率", "Spectrum::set_center_frequency", + 98e6, -1e10, 1e10, 1000, "Spectrum")); + result.push_back(number("sweep_origin", "扫频区起点", "Spectrum::set_sweep_frequency_range", + 94e6, -1e10, 1e10, 1000, "Spectrum")); + result.push_back(number("sweep_target", "扫频区终点", "Spectrum::set_sweep_frequency_range", + 102e6, -1e10, 1e10, 1000, "Spectrum")); + result.push_back(flag("max_hold_visible", "最大保持线", "Spectrum::set_max_hold_visible", true, "Spectrum")); + result.push_back(flag("min_hold_visible", "最小保持线", "Spectrum::set_min_hold_visible", false, "Spectrum")); + result.push_back(flag("max_marker_visible", "峰值 Marker", "Spectrum::set_max_marker_visible", true, "Spectrum")); + result.push_back(flag("use_min_marker", "最小值 Marker", "Spectrum::set_use_min_marker", false, "Spectrum")); + result.push_back(flag("sweep_region_visible", "扫频区域", "Spectrum::set_sweep_region_visible", true, "Spectrum")); + result.push_back(flag("visible_range_only", "仅绘制可见范围", "Spectrum::set_visible_range_only", true, "Spectrum")); + result.push_back(select("line_interpolation", "线插值模式", "Spectrum::set_interpolation_mode", + "Linear_Value", {"Nearest_Sample", "Linear_Value", "Linear_Power_Domain", + "Step_Left", "Step_Right", "Cubic_Value"}, "Spectrum")); + result.push_back(color("max_brush", "最大保持填充", "Spectrum::set_max_brush", "#332810", "Spectrum")); + result.push_back(color("current_brush", "当前曲线填充", "Spectrum::set_current_brush", "#0e3a35", "Spectrum")); + result.push_back(color("min_brush", "最小保持填充", "Spectrum::set_min_brush", "#19213a", "Spectrum")); + result.push_back(color("max_pen", "最大保持线颜色", "Spectrum::set_max_pen", "#ffd166", "Spectrum")); + result.push_back(color("current_pen", "当前曲线颜色", "Spectrum::set_current_pen", "#35e6b2", "Spectrum")); + result.push_back(color("min_pen", "最小保持线颜色", "Spectrum::set_min_pen", "#7aa2ff", "Spectrum")); + result.push_back(color("selected_marker_pen", "选中 Marker", "Spectrum::set_selected_marker_pen", "#ff4d8d", "Spectrum")); + result.push_back(color("marker_pen", "Marker 颜色", "Spectrum::set_marker_pen", "#ff7a59", "Spectrum")); + result.push_back(color("middle_frequency_pen", "中心频率线", "Spectrum::set_middle_frequency_pen", "#6bc8ff", "Spectrum")); + result.push_back(color("sweep_region_brush", "扫频区填充", "Spectrum::set_sweep_region_brush", "#293257", "Spectrum")); + append_hover(result); + if (case_id == "selection_overlay") { + result.push_back(number("selection_font_size", "框选标签字体", "Selection_Rectangle_Overlay::set_label_font", + 12, 6, 32, 1, "Selection Overlay")); + result.push_back(color("selection_label_pen", "框选标签颜色", "Selection_Rectangle_Overlay::set_label_pen", + "#ffffff", "Selection Overlay")); + result.push_back(color("selection_brush", "框选填充", "Selection_Rectangle_Overlay::set_selection_brush", + "#173b66", "Selection Overlay")); + result.push_back(color("selection_border", "框选边框", "Selection_Rectangle_Overlay::set_selection_border_pen", + "#7dd3fc", "Selection Overlay")); + } + return result; + } + if (case_id == "waterfall") { + append_time_axis(result); + result.push_back(number("frequency_origin", "频率起点", "Waterfall::set_frequency_range", 88e6, + -1e10, 1e10, 1000, "Waterfall")); + result.push_back(number("frequency_target", "频率终点", "Waterfall::set_frequency_range", 108e6, + -1e10, 1e10, 1000, "Waterfall")); + result.push_back(number("power_origin", "功率起点", "Waterfall::set_power_range", -120, + -240, 120, 1, "Waterfall")); + result.push_back(number("power_target", "功率终点", "Waterfall::set_power_range", -20, + -240, 120, 1, "Waterfall")); + result.push_back(number("frequency_bin_count", "频率 Bin", "Waterfall::set_frequency_bin_count", + 256, 16, 2048, 16, "Waterfall", true)); + result.push_back(flag("visible_range_only", "仅绘制可见范围", "Waterfall::set_visible_range_only", true, "Waterfall")); + result.push_back(select("image_interpolation", "图像插值(三模式)", "Waterfall::set_interpolation_mode", + "Nearest", {"Nearest", "Bilinear", "Bicubic"}, "Waterfall")); + result.push_back(select("color_map", "颜色映射(重建 Builder)", + "Waterfall::Builder::set_color_map / Color_Map::set_colors", + "Spectrum", {"Spectrum", "Ember", "Grayscale"}, "Waterfall")); + append_hover(result); + return result; + } + if (case_id == "afterglow") { + result.push_back(number("frequency_origin", "频率起点", "Afterglow::set_frequency_range", 88e6, + -1e10, 1e10, 1000, "Afterglow")); + result.push_back(number("frequency_target", "频率终点", "Afterglow::set_frequency_range", 108e6, + -1e10, 1e10, 1000, "Afterglow")); + result.push_back(number("power_origin", "功率起点", "Afterglow::set_power_range", -120, + -240, 120, 1, "Afterglow")); + result.push_back(number("power_target", "功率终点", "Afterglow::set_power_range", -20, + -240, 120, 1, "Afterglow")); + result.push_back(number("frequency_point_size", "频率格点", "Afterglow::set_frequency_point_size", + 192, 16, 1024, 8, "Afterglow", true)); + result.push_back(number("power_point_size", "功率格点", "Afterglow::set_power_point_size", + 96, 8, 512, 8, "Afterglow", true)); + result.push_back(flag("interpolate_power", "功率 Bin 插值", "Afterglow::set_interpolate", true, "Afterglow")); + result.push_back(number("attenuation_rate", "衰减率", "Afterglow::set_attenuation_rate", 0.18, + 0.01, 1, 0.01, "Afterglow")); + result.push_back(select("color_map", "颜色映射(重建 Builder)", + "Afterglow::Builder::set_color_map / Color_Map::set_colors", + "Spectrum", {"Spectrum", "Ember", "Grayscale"}, "Afterglow")); + return result; + } + if (case_id == "sweep_spectrum") { + result.push_back(number("frequency_origin", "扫频起点", "Sweep_Spectrum::set_frequency_range", 0, + -1e10, 1e10, 1, "Sweep Spectrum")); + result.push_back(number("frequency_target", "扫频终点", "Sweep_Spectrum::set_frequency_range", 300, + -1e10, 1e10, 1, "Sweep Spectrum")); + result.push_back(number("bins_per_block", "每块 Bin", "Sweep_Spectrum::set_bins_per_block", 64, + 2, 1024, 1, "Sweep Spectrum", true)); + result.push_back(number("block_count", "块数量", "Sweep_Spectrum::set_block_count", 8, + 1, 128, 1, "Sweep Spectrum", true)); + result.push_back(color("sweep_pen", "扫频曲线", "Sweep_Spectrum::set_pen", "#ffd166", "Sweep Spectrum")); + result.push_back(color("current_frequency_pen", "当前频率游标", "Sweep_Spectrum::set_cur_frequency_pen", + "#ff5d73", "Sweep Spectrum")); + result.push_back(flag("visible_range_only", "仅绘制可见范围", "Sweep_Spectrum::set_visible_range_only", true, "Sweep Spectrum")); + result.push_back(select("line_interpolation", "线插值模式", "Sweep_Spectrum::set_interpolation_mode", + "Linear_Value", {"Nearest_Sample", "Linear_Value", "Linear_Power_Domain", + "Step_Left", "Step_Right", "Cubic_Value"}, "Sweep Spectrum")); + return result; + } + if (case_id == "frequency_trace") { + append_time_axis(result); + result.push_back(color("trace_pen", "轨迹颜色(重建 Builder)", "Frequency_Trace::Builder::set_pen", + "#35e6b2", "Frequency Trace")); + result.push_back(number("trace_pen_width", "轨迹宽度(重建 Builder)", "Frequency_Trace::Builder::set_pen", + 2, 0.25, 12, 0.25, "Frequency Trace")); + result.push_back(number("trace_value_min", "数值下限", "Axis::set_coord_range", -1, + -1e6, 1e6, 0.1, "Frequency Trace")); + result.push_back(number("trace_value_max", "数值上限", "Axis::set_coord_range", 1, + -1e6, 1e6, 0.1, "Frequency Trace")); + return result; + } + if (case_id == "constellation") { + result.push_back(number("i_origin", "I 起点", "Constellation_Diagram::set_i_range", -1.25, + -1000, 1000, 0.05, "Constellation")); + result.push_back(number("i_target", "I 终点", "Constellation_Diagram::set_i_range", 1.25, + -1000, 1000, 0.05, "Constellation")); + result.push_back(number("q_origin", "Q 起点", "Constellation_Diagram::set_q_range", -1.25, + -1000, 1000, 0.05, "Constellation")); + result.push_back(number("q_target", "Q 终点", "Constellation_Diagram::set_q_range", 1.25, + -1000, 1000, 0.05, "Constellation")); + result.push_back(color("point_color", "采样点颜色", "Constellation_Diagram::set_point_color", + "#49e6c3", "Constellation")); + result.push_back(color("anchor_color", "锚点颜色", "Constellation_Diagram::set_anchor_color", + "#ffd166", "Constellation")); + result.push_back(number("point_lifetime_ms", "采样点寿命", "Constellation_Diagram::set_point_lifetime_ms", + 1800, 50, 20000, 50, "Constellation", true)); + result.push_back(select("constellation_type", "星座模式(重建 Builder)", + "Constellation_Diagram::Builder::set_type", "PSK8", + {"PSK4", "PSK8", "PSK16"}, "Constellation")); + result.push_back(number("phase_offset", "相位偏移(重建 Builder)", + "Constellation_Diagram::Builder::set_phase_offset_radians", 0, + -6.283185307, 6.283185307, 0.05, "Constellation")); + return result; + } + return result; +} + +Action_Definition action(std::string id, std::string label, std::string api, + std::string group, std::string description = {}, + std::string argument_input = {}, std::string argument_label = {}, + double argument_default = 0) { + return {{std::move(id), std::move(label), std::move(api), std::move(description), + std::move(group), std::move(argument_input), std::move(argument_label), + argument_default}}; +} + +std::vector actions(std::string_view case_id, + Gallery_Frame_Mode frame_mode) { + std::vector result{ + action("reset", "恢复本图默认值", "Gallery_Scene::rebuild", "会话"), + action("rebuild_renderable", "移除并重建控件", "Plot_Core::remove_renderable / Builder::build", "会话"), + action("toggle_view", "切换 View 生命周期", "Plot_Core::activate_view / deactivate_view", "会话"), + action("axis_set_start", "通过 set_coord_start 应用起点", "Axis::set_coord_start", "Axis"), + action("axis_set_length", "通过 set_coord_length 应用跨度", "Axis::set_coord_length", "Axis"), + action("axis_probe", "读取坐标映射与刻度", "coord_to_pixel / pixel_to_coord / tick_step / sub_tick_count / tick_label", "Axis") + }; + if (frame_mode == Gallery_Frame_Mode::Manual) { + result.push_back(action("mode_prepare", "准备帧", "Manual_Refresh_Strategy::acquire_painter / Plot_Core::prepare_frame", + "Manual_Refresh_Strategy")); + result.push_back(action("mode_refresh", "提交手动刷新", "Manual_Refresh_Strategy::refresh / Plot_Core::refresh_manual_frame", + "Manual_Refresh_Strategy")); + result.push_back(action("mode_render", "渲染已刷新帧", "Manual_Refresh_Strategy::acquire_renderer / Plot_Core::render_prepared_frame", + "Manual_Refresh_Strategy")); + result.push_back(action("mode_discard", "丢弃待刷新帧", "Plot_Core::discard_pending_frame", + "Manual_Refresh_Strategy")); + result.push_back(action("mode_cycle", "执行完整手动帧周期", "Plot_Core::render_frame", + "Manual_Refresh_Strategy")); + } else if (frame_mode == Gallery_Frame_Mode::Low_Latency) { + result.push_back(action("mode_prepare", "发布低延迟帧", "Low_Latency_Strategy::acquire_painter / Plot_Core::prepare_frame", + "Low_Latency_Strategy")); + result.push_back(action("mode_render", "消费最新帧", "Low_Latency_Strategy::acquire_renderer / Plot_Core::render_prepared_frame", + "Low_Latency_Strategy")); + result.push_back(action("mode_discard", "丢弃陈旧帧", "Plot_Core::discard_pending_frame", + "Low_Latency_Strategy")); + result.push_back(action("mode_cycle", "执行低延迟帧周期", "Plot_Core::render_frame", + "Low_Latency_Strategy")); + } else { + result.push_back(action("mode_enqueue", "压入一帧", "Flow_Refresh_Strategy::acquire_painter / Plot_Core::prepare_frame", + "Flow_Refresh_Strategy")); + result.push_back(action("mode_enqueue_burst", "批量压入回放队列", "Flow_Refresh_Strategy::acquire_painter / Plot_Core::prepare_frame × N", + "Flow_Refresh_Strategy", {}, "number", "帧数", 8)); + result.push_back(action("mode_dequeue", "消费队首帧", "Flow_Refresh_Strategy::acquire_renderer / Plot_Core::render_prepared_frame", + "Flow_Refresh_Strategy")); + result.push_back(action("mode_cycle", "执行回放帧周期", "Plot_Core::render_frame", + "Flow_Refresh_Strategy")); + } + if (case_id == "axis_lab") { + result.push_back(action("append_time", "追加时间点", "Time_Axis::append_time / tick_to_time", "Time_Axis")); + result.push_back(action("read_data_shape", "读取轴采样点状态", "Abs_Axis::pixel_sample_count / Time_Axis::time_point_count", "观察者")); + } else if (case_id == "spectrum" || case_id == "selection_overlay") { + result.push_back(action("push_samples", "推送一帧样本", "Spectrum::update_samples", "Spectrum")); + result.push_back(action("power_at", "查询指定频率功率", "Spectrum::power_at", "Spectrum", {}, "number", "频率", 98e6)); + result.push_back(action("add_marker", "添加点 Marker", "Spectrum::add_custom_marker", "Marker", {}, "number", "频率", 96e6)); + result.push_back(action("add_line_marker", "添加线 Marker", "Spectrum::add_custom_line_marker", "Marker", {}, "number", "频率", 100e6)); + result.push_back(action("remove_marker", "按频率删除 Marker", "Spectrum::remove_custom_marker", "Marker", {}, "number", "频率", 96e6)); + result.push_back(action("remove_selected_marker", "删除选中 Marker", "Spectrum::remove_selected_marker", "Marker")); + result.push_back(action("clear_markers", "清空 Marker", "Spectrum::clear_custom_markers", "Marker")); + result.push_back(action("select_marker", "选择 Marker 索引", "Spectrum::set_selected_marker_index", "Marker", {}, "number", "索引", 0)); + result.push_back(action("select_next_marker", "选择下一个 Marker", "Spectrum::select_next_marker", "Marker")); + result.push_back(action("select_previous_marker", "选择上一个 Marker", "Spectrum::select_previous_marker", "Marker")); + result.push_back(action("clear_marker_selection", "清除 Marker 选择", "Spectrum::clear_marker_selection", "Marker")); + result.push_back(action("set_marker_frequency", "修改选中 Marker 频率", + "Spectrum::set_marker_frequency / set_current_marker_frequency", + "Marker", {}, "number", "频率", 99e6)); + result.push_back(action("rebind_axes", "重新绑定频率轴和功率轴", "Spectrum::set_frequency_axis / set_power_axis", "Spectrum")); + result.push_back(action("read_data_shape", "读取输入与绘制点数", "Spectrum::sample_count / rendered_point_count", "观察者")); + if (case_id == "selection_overlay") { + result.push_back(action("clear_selection", "清空框选区域", "Selection_Rectangle_Overlay::clear_selected_regions", "Selection Overlay")); + result.push_back(action("rebind_selection_axes", "重新绑定框选轴", "Selection_Rectangle_Overlay::set_horizontal_axis / set_vertical_axis", "Selection Overlay")); + } + } else if (case_id == "waterfall") { + result.push_back(action("append_row", "追加一行", "Waterfall::append_row", "Waterfall")); + result.push_back(action("append_tick_row", "按 tick 追加一行", "Time_Axis::append_time / Waterfall::append_row(int, span)", "Waterfall")); + result.push_back(action("rebind_axes", "读取并重新绑定轴", "Waterfall::frequency_axis / time_axis", "Waterfall")); + result.push_back(action("read_data_shape", "读取行、点与渲染单元", "Waterfall::row_count / stored_point_count / rendered_cell_count", "观察者")); + } else if (case_id == "afterglow") { + result.push_back(action("append_spectrum", "追加一帧频谱", "Afterglow::append_spectrum", "Afterglow")); + result.push_back(action("rebind_axes", "读取轴绑定", "Afterglow::frequency_axis / power_axis", "Afterglow")); + result.push_back(action("read_data_shape", "读取历史帧与渲染网格", "Afterglow::history_count / latest_spectrum_point_count / rendered_cell_count", "观察者")); + } else if (case_id == "sweep_spectrum") { + result.push_back(action("append_block", "追加一个扫频块", "Sweep_Spectrum::append_block", "Sweep Spectrum")); + result.push_back(action("rebind_axes", "读取轴绑定", "Sweep_Spectrum::frequency_axis / power_axis", "Sweep Spectrum")); + result.push_back(action("read_data_shape", "读取块与绘制点数", "Sweep_Spectrum::stored_block_count / stored_point_count / rendered_point_count", "观察者")); + } else if (case_id == "frequency_trace") { + result.push_back(action("append_sample", "追加一个样本", "Frequency_Trace::append_sample", "Frequency Trace")); + result.push_back(action("append_tick_sample", "按 tick 追加样本", "Time_Axis::append_time / Frequency_Trace::append_sample(int, double)", "Frequency Trace")); + result.push_back(action("read_axes", "读取时间轴与值轴", "Frequency_Trace::time_axis / value_axis", "Frequency Trace")); + result.push_back(action("read_data_shape", "读取轨迹点数", "Frequency_Trace::sample_count / rendered_point_count / Time_Axis::time_point_count", "观察者")); + } else if (case_id == "constellation") { + result.push_back(action("append_points", "追加一组 IQ 点", "Constellation_Diagram::append_point", "Constellation")); + result.push_back(action("fit_square", "按轴拟合正方形", "Constellation_Diagram::fit_square_to_axes", "Constellation")); + result.push_back(action("read_axes", "读取 I/Q 轴", "Constellation_Diagram::i_axis / q_axis", "Constellation")); + result.push_back(action("read_data_shape", "读取星座点与锚点数", "Constellation_Diagram::point_count", "观察者")); + } + return result; +} + +const Case_Model* find_case(std::string_view id) { + const auto& all = cases(); + const auto found = std::find_if(all.begin(), all.end(), + [id](const Case_Model& item) { return item.id == id; }); + return found == all.end() ? nullptr : &*found; +} + +const Control_Definition* find_control(const std::vector& all, + std::string_view id) { + const auto found = std::find_if(all.begin(), all.end(), + [id](const Control_Definition& item) { + return item.model.id == id; + }); + return found == all.end() ? nullptr : &*found; +} + +bool valid_color(std::string_view value) { + if (value.size() != 7 || value.front() != '#') + return false; + return std::all_of(value.begin() + 1, value.end(), [](char character) { + return std::isxdigit(static_cast(character)) != 0; + }); +} + +Json value_json(const Gallery_Value& value) { + return std::visit([](const auto& item) { return Json(item); }, value); +} + +std::optional parse_value(const Json& value, + const Control_Definition& definition, + std::string& error) { + if (definition.model.input == "boolean") { + if (!value.is_boolean()) { + error = "必须是布尔值"; + return std::nullopt; + } + return Gallery_Value(value.get()); + } + if (definition.model.input == "number") { + if (!value.is_number()) { + error = "必须是数字"; + return std::nullopt; + } + const double number_value = value.get(); + if (!std::isfinite(number_value) || number_value < definition.model.minimum || + number_value > definition.model.maximum) { + error = "数值超出后端允许范围"; + return std::nullopt; + } + if (definition.model.integer && std::floor(number_value) != number_value) { + error = "必须是整数"; + return std::nullopt; + } + return Gallery_Value(number_value); + } + if (!value.is_string()) { + error = "必须是字符串"; + return std::nullopt; + } + std::string string_value = value.get(); + if (definition.model.input == "color" && !valid_color(string_value)) { + error = "颜色必须使用 #RRGGBB"; + return std::nullopt; + } + if (definition.model.input == "select" && + std::find(definition.options.begin(), definition.options.end(), string_value) == + definition.options.end()) { + error = "枚举值不在后端选项中"; + return std::nullopt; + } + if (string_value.size() > 160) { + error = "文本过长"; + return std::nullopt; + } + return Gallery_Value(std::move(string_value)); +} + +double numeric(const Gallery_State& state, std::string_view id) { + const auto found = state.values.find(id); + return found == state.values.end() ? 0.0 : std::get(found->second); +} + +std::optional range_error(const Gallery_State& state) { + constexpr std::pair pairs[] = { + {"coord_origin", "coord_target"}, {"frequency_origin", "frequency_target"}, + {"power_origin", "power_target"}, {"sweep_origin", "sweep_target"}, + {"trace_value_min", "trace_value_max"}, {"i_origin", "i_target"}, + {"q_origin", "q_target"} + }; + for (const auto& [origin, target] : pairs) { + if (state.values.contains(origin) && state.values.contains(target) && + numeric(state, origin) == numeric(state, target)) { + return std::string(origin) + " 与 " + std::string(target) + " 不能相等"; + } + } + return std::nullopt; +} + +Json parse_request(std::string_view message) { + return Json::parse(message.begin(), message.end()); +} + +adminive::Table_View control_view(); +adminive::Table_View action_view(); + +} // namespace renderive::web::gallery_detail + +namespace adminive { + +template <> +struct Type_Descriptor { + static auto get() { + using T = renderive::web::gallery_detail::Case_Model; + return object("renderive_gallery_case", + ADMINIVE_FIELD_LABEL(T, id, "标识"), + ADMINIVE_FIELD_LABEL(T, title, "标题"), + ADMINIVE_FIELD_LABEL(T, component, "Core2 控件"), + ADMINIVE_FIELD_LABEL(T, category, "分类"), + ADMINIVE_FIELD_LABEL(T, description, "说明"), + ADMINIVE_FIELD_LABEL(T, order, "顺序"), + ADMINIVE_FIELD_LABEL(T, preferred_width, "建议宽度"), + ADMINIVE_FIELD_LABEL(T, preferred_height, "建议高度")) + .label("Renderive 控件用例"); + } +}; + +template <> +struct Type_Descriptor { + static auto get() { + using T = renderive::web::gallery_detail::Control_Model; + return object("renderive_gallery_control", + ADMINIVE_FIELD_LABEL(T, id, "字段"), + ADMINIVE_FIELD_LABEL(T, label, "控件"), + ADMINIVE_FIELD_LABEL(T, api, "Core2 API"), + ADMINIVE_FIELD_LABEL(T, description, "说明"), + ADMINIVE_FIELD_LABEL(T, group, "分组"), + ADMINIVE_FIELD_LABEL(T, input, "输入类型"), + ADMINIVE_FIELD_LABEL(T, integer, "整数"), + ADMINIVE_FIELD_LABEL(T, minimum, "最小值"), + ADMINIVE_FIELD_LABEL(T, maximum, "最大值"), + ADMINIVE_FIELD_LABEL(T, step, "步长")) + .label("后端控制菜单字段"); + } +}; + +template <> +struct Type_Descriptor { + static auto get() { + using T = renderive::web::gallery_detail::Action_Model; + return object("renderive_gallery_action", + ADMINIVE_FIELD_LABEL(T, id, "动作"), + ADMINIVE_FIELD_LABEL(T, label, "名称"), + ADMINIVE_FIELD_LABEL(T, api, "Core2 API"), + ADMINIVE_FIELD_LABEL(T, description, "说明"), + ADMINIVE_FIELD_LABEL(T, group, "分组"), + ADMINIVE_FIELD_LABEL(T, argument_input, "参数类型"), + ADMINIVE_FIELD_LABEL(T, argument_label, "参数名称"), + ADMINIVE_FIELD_LABEL(T, argument_default, "参数默认值")) + .label("后端动作菜单"); + } +}; + +} // namespace adminive + +namespace renderive::web::gallery_detail { + +adminive::Table_View control_view() { + using T = Control_Model; + return adminive::table_view( + adminive::column<&T::label>("控件"), + adminive::column<&T::group>("分组"), + adminive::column<&T::api>("Core2 API"), + adminive::column<&T::input>("输入类型")) + .titled("后端控制菜单"); +} + +adminive::Table_View action_view() { + using T = Action_Model; + return adminive::table_view( + adminive::column<&T::label>("动作"), + adminive::column<&T::group>("分组"), + adminive::column<&T::api>("Core2 API")) + .titled("保留 API 动作"); +} + +} // namespace renderive::web::gallery_detail + +namespace renderive::web { +namespace { + +using gallery_detail::Json; + +Json protocol_base() { + return {{"category", "gallery"}, + {"protocol", "renderive.control-gallery"}, + {"protocol_version", 3}}; +} + +Json case_contract(std::string_view case_id) { + const auto* item = gallery_detail::find_case(case_id); + return item ? adminive::to_frontend_json(*item) : Json::object(); +} + +std::string_view frame_mode_id(Gallery_Frame_Mode mode) { + switch (mode) { + case Gallery_Frame_Mode::Manual: + return "manual"; + case Gallery_Frame_Mode::Low_Latency: + return "low_latency"; + case Gallery_Frame_Mode::Playback: + return "playback"; + } + return "low_latency"; +} + +std::optional parse_frame_mode(std::string_view value) { + if (value == "manual") + return Gallery_Frame_Mode::Manual; + if (value == "low_latency") + return Gallery_Frame_Mode::Low_Latency; + if (value == "playback") + return Gallery_Frame_Mode::Playback; + return std::nullopt; +} + +Json frame_mode_contract(Gallery_Frame_Mode mode) { + switch (mode) { + case Gallery_Frame_Mode::Manual: + return {{"id", "manual"}, {"title", "手动刷新"}, + {"strategy", "Manual_Refresh_Strategy"}, + {"description", "显式准备、刷新和渲染;页面不会自动拉取像素。"}, + {"automatic", false}, {"order", 10}}; + case Gallery_Frame_Mode::Low_Latency: + return {{"id", "low_latency"}, {"title", "低延迟"}, + {"strategy", "Low_Latency_Strategy"}, + {"description", "以最大 FPS 自动发布并消费最新帧,可观测丢帧与端到端延迟。"}, + {"automatic", true}, {"order", 20}}; + case Gallery_Frame_Mode::Playback: + return {{"id", "playback"}, {"title", "回放队列"}, + {"strategy", "Flow_Refresh_Strategy"}, + {"description", "所有帧按队列顺序入队和消费,可观测队深与排队时间。"}, + {"automatic", true}, {"order", 30}}; + } + return Json::object(); +} + +} // namespace + +std::string Gallery_Protocol::catalog_json() { + Json result = protocol_base(); + result["type"] = "catalog"; + result["transport"] = {{"events", "websocket-text"}, {"pixels", "websocket-binary-rvp1"}, + {"http_api", false}, {"socket_per_canvas", true}}; + result["case_descriptor"] = + adminive::to_descriptor_json(); + result["frame_modes"] = Json::array(); + constexpr Gallery_Frame_Mode frame_modes[]{Gallery_Frame_Mode::Manual, + Gallery_Frame_Mode::Low_Latency, + Gallery_Frame_Mode::Playback}; + for (const auto mode : frame_modes) + result["frame_modes"].push_back(frame_mode_contract(mode)); + result["cases"] = Json::array(); + std::size_t controls_total{}; + std::size_t actions_total{}; + for (const auto& item : gallery_detail::cases()) { + Json entry = adminive::to_frontend_json(item); + entry["control_count_by_mode"] = Json::object(); + entry["action_count_by_mode"] = Json::object(); + for (const auto mode : frame_modes) { + const std::string key(frame_mode_id(mode)); + const std::size_t mode_controls = control_count(item.id, mode); + const std::size_t mode_actions = action_count(item.id, mode); + entry["control_count_by_mode"][key] = mode_controls; + entry["action_count_by_mode"][key] = mode_actions; + controls_total += mode_controls; + actions_total += mode_actions; + } + entry["control_count"] = control_count(item.id, Gallery_Frame_Mode::Low_Latency); + entry["action_count"] = action_count(item.id, Gallery_Frame_Mode::Low_Latency); + result["cases"].push_back(std::move(entry)); + } + result["coverage"] = {{"case_count", gallery_detail::cases().size()}, + {"page_count", std::size(frame_modes)}, + {"canvas_count", gallery_detail::cases().size() * std::size(frame_modes)}, + {"manual_control_count", controls_total}, + {"manual_action_count", actions_total}, + {"frequency_modes", Json::array({"spectrum", "afterglow", "sweep_spectrum"})}, + {"image_interpolation_modes", Json::array({"Nearest", "Bilinear", "Bicubic"})}}; + return result.dump(); +} + +bool Gallery_Protocol::is_case(std::string_view case_id) { + return gallery_detail::find_case(case_id) != nullptr; +} + +Gallery_State Gallery_Protocol::default_state(std::string_view case_id, + Gallery_Frame_Mode frame_mode) { + Gallery_State state; + for (const auto& definition : gallery_detail::controls(case_id, frame_mode)) + state.values.emplace(definition.model.id, definition.initial); + return state; +} + +Gallery_State Gallery_Protocol::default_state(std::string_view case_id) { + return default_state(case_id, Gallery_Frame_Mode::Low_Latency); +} + +std::string Gallery_Protocol::case_json(std::string_view case_id, + const Gallery_State& state, + std::string_view telemetry_json, + std::string_view notice, + Gallery_Frame_Mode frame_mode) { + Json result = protocol_base(); + result["type"] = "case_state"; + result["case"] = case_contract(case_id); + result["frame_mode"] = frame_mode_contract(frame_mode); + result["controls"] = { + {"descriptor", adminive::to_descriptor_json()}, + {"view", adminive::to_view_json( + gallery_detail::control_view())}, + {"data", Json::array()} + }; + for (const auto& definition : gallery_detail::controls(case_id, frame_mode)) { + Json item = adminive::to_frontend_json(definition.model); + const auto found = state.values.find(definition.model.id); + item["value"] = found == state.values.end() + ? gallery_detail::value_json(definition.initial) + : gallery_detail::value_json(found->second); + item["options"] = definition.options; + result["controls"]["data"].push_back(std::move(item)); + } + result["actions"] = { + {"descriptor", adminive::to_descriptor_json()}, + {"view", adminive::to_view_json( + gallery_detail::action_view())}, + {"data", Json::array()} + }; + for (const auto& definition : gallery_detail::actions(case_id, frame_mode)) + result["actions"]["data"].push_back( + adminive::to_frontend_json(definition.model)); + try { + result["telemetry"] = Json::parse(telemetry_json.begin(), telemetry_json.end()); + } catch (const std::exception&) { + result["telemetry"] = Json::object(); + } + if (!notice.empty()) + result["notice"] = notice; + return result.dump(); +} + +std::string Gallery_Protocol::case_json(std::string_view case_id, + const Gallery_State& state, + std::string_view telemetry_json, + std::string_view notice) { + return case_json(case_id, state, telemetry_json, notice, + Gallery_Frame_Mode::Low_Latency); +} + +std::string Gallery_Protocol::error_json(std::string_view message, + std::string_view field) { + Json result = protocol_base(); + result["type"] = "error"; + result["message"] = message; + result["field_errors"] = Json::object(); + if (!field.empty()) + result["field_errors"][std::string(field)] = message; + return result.dump(); +} + +std::string Gallery_Protocol::observer_json(std::string_view case_id, + Gallery_Frame_Mode frame_mode, + std::string_view telemetry_json) { + Json result = protocol_base(); + result["type"] = "observer_state"; + result["case_id"] = case_id; + result["frame_mode"] = frame_mode_contract(frame_mode); + try { + result["telemetry"] = Json::parse(telemetry_json.begin(), telemetry_json.end()); + } catch (const std::exception&) { + result["telemetry"] = Json::object(); + } + return result.dump(); +} + +Gallery_Patch_Result Gallery_Protocol::apply_patch(std::string_view case_id, + const Gallery_State& current, + std::string_view message, + Gallery_Frame_Mode frame_mode) { + try { + const Json request = gallery_detail::parse_request(message); + if (!request.is_object() || request.value("category", "") != "event" || + request.value("type", "") != "gallery_patch" || + !request.contains("patch") || !request.at("patch").is_object()) { + return {std::nullopt, error_json("gallery_patch 格式无效")}; + } + const auto definitions = gallery_detail::controls(case_id, frame_mode); + Gallery_State candidate = current; + for (const auto& [id, value] : request.at("patch").items()) { + const auto* definition = gallery_detail::find_control(definitions, id); + if (!definition) + return {std::nullopt, error_json("该控件不属于当前画布", id)}; + std::string validation_error; + auto parsed = gallery_detail::parse_value(value, *definition, validation_error); + if (!parsed) + return {std::nullopt, error_json(validation_error, id)}; + candidate.values[id] = std::move(*parsed); + } + if (const auto error = gallery_detail::range_error(candidate)) + return {std::nullopt, error_json(*error)}; + return {std::move(candidate), {}}; + } catch (const std::exception& error) { + return {std::nullopt, error_json(error.what())}; + } +} + +Gallery_Patch_Result Gallery_Protocol::apply_patch(std::string_view case_id, + const Gallery_State& current, + std::string_view message) { + return apply_patch(case_id, current, message, Gallery_Frame_Mode::Low_Latency); +} + +std::optional Gallery_Protocol::open_request(std::string_view message) { + try { + const Json request = gallery_detail::parse_request(message); + if (!request.is_object() || request.value("category", "") != "event" || + request.value("type", "") != "gallery_open" || + !request.contains("case") || !request.at("case").is_string()) + return std::nullopt; + const std::string value = request.at("case").get(); + if (!is_case(value) || !request.contains("frame_mode") || + !request.at("frame_mode").is_string()) + return std::nullopt; + const auto mode = parse_frame_mode(request.at("frame_mode").get()); + return mode ? std::optional(Gallery_Open_Request{value, *mode}) + : std::nullopt; + } catch (const std::exception&) { + return std::nullopt; + } +} + +std::optional Gallery_Protocol::action_request( + std::string_view message) { + try { + const Json request = gallery_detail::parse_request(message); + if (!request.is_object() || request.value("category", "") != "event" || + request.value("type", "") != "gallery_action" || + !request.contains("action") || !request.at("action").is_string()) + return std::nullopt; + Gallery_Action_Request result{request.at("action").get()}; + if (request.contains("argument")) { + const Json& argument = request.at("argument"); + if (argument.is_boolean()) + result.argument = argument.get(); + else if (argument.is_number()) { + const double value = argument.get(); + if (!std::isfinite(value)) + return std::nullopt; + result.argument = value; + } else if (argument.is_string()) + result.argument = argument.get(); + else + return std::nullopt; + } + return result; + } catch (const std::exception&) { + return std::nullopt; + } +} + +std::size_t Gallery_Protocol::control_count(std::string_view case_id, + Gallery_Frame_Mode frame_mode) { + return gallery_detail::controls(case_id, frame_mode).size(); +} + +std::size_t Gallery_Protocol::control_count(std::string_view case_id) { + return control_count(case_id, Gallery_Frame_Mode::Low_Latency); +} + +std::size_t Gallery_Protocol::action_count(std::string_view case_id, + Gallery_Frame_Mode frame_mode) { + return gallery_detail::actions(case_id, frame_mode).size(); +} + +std::size_t Gallery_Protocol::action_count(std::string_view case_id) { + return action_count(case_id, Gallery_Frame_Mode::Low_Latency); +} + +bool Gallery_Protocol::action_available(std::string_view case_id, + Gallery_Frame_Mode frame_mode, + std::string_view action_id) { + const auto available = gallery_detail::actions(case_id, frame_mode); + return std::any_of(available.begin(), available.end(), + [action_id](const gallery_detail::Action_Definition& item) { + return item.model.id == action_id; + }); +} + +} // namespace renderive::web diff --git a/web_server/Gallery_Protocol.h b/web_server/Gallery_Protocol.h new file mode 100644 index 0000000..d28713d --- /dev/null +++ b/web_server/Gallery_Protocol.h @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace renderive::web { + +enum class Gallery_Frame_Mode : std::uint8_t { + Manual, + Low_Latency, + Playback +}; + +struct Gallery_Open_Request { + std::string case_id; + Gallery_Frame_Mode frame_mode = Gallery_Frame_Mode::Low_Latency; +}; + +using Gallery_Value = std::variant; + +struct Gallery_State { + std::map> values; +}; + +struct Gallery_Patch_Result { + std::optional candidate; + std::string response_json; +}; + +struct Gallery_Action_Request { + std::string id; + std::optional argument; +}; + +class Gallery_Protocol final { +public: + [[nodiscard]] static std::string catalog_json(); + [[nodiscard]] static bool is_case(std::string_view case_id); + [[nodiscard]] static Gallery_State default_state(std::string_view case_id); + [[nodiscard]] static Gallery_State default_state( + std::string_view case_id, Gallery_Frame_Mode frame_mode); + [[nodiscard]] static std::string case_json( + std::string_view case_id, + const Gallery_State& state, + std::string_view telemetry_json = "{}", + std::string_view notice = {}); + [[nodiscard]] static std::string case_json( + std::string_view case_id, + const Gallery_State& state, + std::string_view telemetry_json, + std::string_view notice, + Gallery_Frame_Mode frame_mode); + [[nodiscard]] static std::string error_json( + std::string_view message, + std::string_view field = {}); + [[nodiscard]] static std::string observer_json( + std::string_view case_id, + Gallery_Frame_Mode frame_mode, + std::string_view telemetry_json); + [[nodiscard]] static Gallery_Patch_Result apply_patch( + std::string_view case_id, + const Gallery_State& current, + std::string_view message); + [[nodiscard]] static Gallery_Patch_Result apply_patch( + std::string_view case_id, + const Gallery_State& current, + std::string_view message, + Gallery_Frame_Mode frame_mode); + [[nodiscard]] static std::optional open_request( + std::string_view message); + [[nodiscard]] static std::optional action_request( + std::string_view message); + [[nodiscard]] static std::size_t control_count(std::string_view case_id); + [[nodiscard]] static std::size_t control_count( + std::string_view case_id, Gallery_Frame_Mode frame_mode); + [[nodiscard]] static std::size_t action_count(std::string_view case_id); + [[nodiscard]] static std::size_t action_count( + std::string_view case_id, Gallery_Frame_Mode frame_mode); + [[nodiscard]] static bool action_available( + std::string_view case_id, Gallery_Frame_Mode frame_mode, + std::string_view action_id); +}; + +} // namespace renderive::web diff --git a/web_server/Gallery_WebSocket_Controller.cpp b/web_server/Gallery_WebSocket_Controller.cpp new file mode 100644 index 0000000..e4d640b --- /dev/null +++ b/web_server/Gallery_WebSocket_Controller.cpp @@ -0,0 +1,75 @@ +#include "Gallery_WebSocket_Controller.h" + +#include "Gallery_Plot_Session.h" +#include "Web_Event_Adapter.h" + +#include + +#include +#include + +namespace renderive::web { + +void Gallery_WebSocket_Controller::handleNewConnection( + const drogon::HttpRequestPtr&, + const drogon::WebSocketConnectionPtr& connection) { + connection->setContext(std::make_shared(true)); + connection->setPingMessage("renderive-gallery", std::chrono::seconds(20)); + LOG_INFO << "Renderive Gallery WebSocket connected: " + << connection->peerAddr().toIpPort(); +} + +void Gallery_WebSocket_Controller::handleNewMessage( + const drogon::WebSocketConnectionPtr& connection, + std::string&& message, + const drogon::WebSocketMessageType& type) { + if (type == drogon::WebSocketMessageType::Ping || + type == drogon::WebSocketMessageType::Pong || + type == drogon::WebSocketMessageType::Close) { + return; + } + if (type != drogon::WebSocketMessageType::Text) { + connection->shutdown(drogon::CloseCode::kInvalidMessage, + "Renderive Gallery accepts text events only"); + return; + } + if (message.size() > 64 * 1024) { + connection->shutdown(drogon::CloseCode::kMessageTooBig, + "Renderive Gallery event is too large"); + return; + } + const auto event = Web_Event_Adapter::decode(message); + if (!event) { + connection->shutdown(drogon::CloseCode::kWrongMessageContent, + "Invalid Renderive Gallery event"); + return; + } + const auto session = connection->getContext(); + if (!session) { + connection->shutdown(drogon::CloseCode::kUnexpectedCondition, + "Renderive Gallery session is unavailable"); + return; + } + try { + if (auto response = session->handle(*event)) { + const auto message_type = response->type == Web_Response_Type::Pixels + ? drogon::WebSocketMessageType::Binary + : drogon::WebSocketMessageType::Text; + connection->send(response->payload.data(), response->payload.size(), + message_type); + } + } catch (const std::exception& error) { + LOG_ERROR << "Renderive Gallery session failed: " << error.what(); + connection->shutdown(drogon::CloseCode::kUnexpectedCondition, + "Renderive Gallery rendering failed"); + } +} + +void Gallery_WebSocket_Controller::handleConnectionClosed( + const drogon::WebSocketConnectionPtr& connection) { + LOG_INFO << "Renderive Gallery WebSocket closed: " + << connection->peerAddr().toIpPort(); + connection->clearContext(); +} + +} // namespace renderive::web diff --git a/web_server/Gallery_WebSocket_Controller.h b/web_server/Gallery_WebSocket_Controller.h new file mode 100644 index 0000000..7ae209b --- /dev/null +++ b/web_server/Gallery_WebSocket_Controller.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +namespace renderive::web { + +class Gallery_WebSocket_Controller final + : public drogon::WebSocketController { +public: + 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_PATH_ADD("/renderive/gallery"); + WS_PATH_LIST_END +}; + +} // namespace renderive::web diff --git a/web_server/Pixel_Frame.cpp b/web_server/Pixel_Frame.cpp index f736d39..8c3ac38 100644 --- a/web_server/Pixel_Frame.cpp +++ b/web_server/Pixel_Frame.cpp @@ -3,6 +3,9 @@ #include #include #include +#if defined(_M_X64) || defined(__x86_64__) +#include +#endif namespace renderive::web { namespace { @@ -23,6 +26,24 @@ std::uint8_t flatten(std::uint8_t premultiplied, std::uint8_t alpha, } // namespace +Pixel_Frame_Copy copy_pixel_frame(Image_View image) { + Pixel_Frame_Copy copy; + if (image.empty() || image.format != Pixel_Format::Premultiplied_32 || + image.stride < image.width * static_cast(sizeof(Pixel))) { + return copy; + } + copy.width = image.width; + copy.height = image.height; + copy.pixels.resize(static_cast(image.width) * + static_cast(image.height)); + for (int y = 0; y < image.height; ++y) { + const auto* source = image.data + static_cast(y) * image.stride; + std::memcpy(copy.pixels.data() + static_cast(y) * image.width, + source, static_cast(image.width) * sizeof(Pixel)); + } + return copy; +} + std::string encode_pixel_frame(Image_View image, Color background) { if (image.empty() || image.format != Pixel_Format::Premultiplied_32 || image.stride < image.width * static_cast(sizeof(Pixel))) { @@ -46,23 +67,75 @@ std::string encode_pixel_frame(Image_View image, Color background) { write_u32_le(frame.data() + 12, static_cast(image.width * static_cast(sizeof(Pixel)))); - auto* output = reinterpret_cast(frame.data() + pixel_frame_header_size); - for (int y = 0; y < image.height; ++y) { - const auto* row = image.data + static_cast(y) * image.stride; - for (int x = 0; x < image.width; ++x) { - Pixel pixel{}; - std::memcpy(&pixel, row + static_cast(x) * sizeof(Pixel), sizeof(pixel)); - const auto alpha = static_cast((pixel >> 24U) & 0xffU); - const auto red = static_cast((pixel >> 16U) & 0xffU); - const auto green = static_cast((pixel >> 8U) & 0xffU); - const auto blue = static_cast(pixel & 0xffU); - *output++ = flatten(red, alpha, background.r); - *output++ = flatten(green, alpha, background.g); - *output++ = flatten(blue, alpha, background.b); - *output++ = 255; + auto* output = reinterpret_cast(frame.data() + pixel_frame_header_size); + const std::uint32_t opaque_background = + static_cast(background.r) | + (static_cast(background.g) << 8U) | + (static_cast(background.b) << 16U) | + 0xff000000U; + const auto convert_pixel = [background, opaque_background](Pixel pixel) { + const auto alpha = static_cast((pixel >> 24U) & 0xffU); + if (alpha == 0) + return opaque_background; + const auto red = static_cast((pixel >> 16U) & 0xffU); + const auto green = static_cast((pixel >> 8U) & 0xffU); + const auto blue = static_cast(pixel & 0xffU); + if (alpha == 255) { + return static_cast(red) | + (static_cast(green) << 8U) | + (static_cast(blue) << 16U) | + 0xff000000U; } + return static_cast(flatten(red, alpha, background.r)) | + (static_cast(flatten(green, alpha, background.g)) << 8U) | + (static_cast(flatten(blue, alpha, background.b)) << 16U) | + 0xff000000U; + }; +#if defined(_M_X64) || defined(__x86_64__) + const __m128i rgba_shuffle = _mm_setr_epi8( + 2, 1, 0, 3, 6, 5, 4, 7, 10, 9, 8, 11, 14, 13, 12, 15); + const __m128i background_pixels = _mm_set1_epi32(static_cast(opaque_background)); +#endif + for (int y = 0; y < image.height; ++y) { + const auto* row = reinterpret_cast( + image.data + static_cast(y) * image.stride); + int x{}; +#if defined(_M_X64) || defined(__x86_64__) + for (; x + 4 <= image.width; x += 4) { + const Pixel alpha_union = row[x] | row[x + 1] | row[x + 2] | row[x + 3]; + if ((alpha_union & 0xff000000U) == 0) { + _mm_storeu_si128(reinterpret_cast<__m128i*>(output), background_pixels); + output += 4; + continue; + } + const Pixel alpha_intersection = row[x] & row[x + 1] & row[x + 2] & row[x + 3]; + if ((alpha_intersection & 0xff000000U) == 0xff000000U) { + const __m128i bgra = + _mm_loadu_si128(reinterpret_cast(row + x)); + _mm_storeu_si128(reinterpret_cast<__m128i*>(output), + _mm_shuffle_epi8(bgra, rgba_shuffle)); + output += 4; + continue; + } + *output++ = convert_pixel(row[x]); + *output++ = convert_pixel(row[x + 1]); + *output++ = convert_pixel(row[x + 2]); + *output++ = convert_pixel(row[x + 3]); + } +#endif + for (; x < image.width; ++x) + *output++ = convert_pixel(row[x]); } return frame; } +std::string encode_pixel_frame(const Pixel_Frame_Copy& image, Color background) { + if (image.empty()) + return {}; + return encode_pixel_frame( + {reinterpret_cast(image.pixels.data()), image.width, image.height, + image.width * static_cast(sizeof(Pixel)), Pixel_Format::Premultiplied_32}, + background); +} + } // namespace renderive::web diff --git a/web_server/Pixel_Frame.h b/web_server/Pixel_Frame.h index 563103e..25d5669 100644 --- a/web_server/Pixel_Frame.h +++ b/web_server/Pixel_Frame.h @@ -4,12 +4,27 @@ #include #include +#include namespace renderive::web { inline constexpr std::size_t pixel_frame_header_size = 16; +struct Pixel_Frame_Copy { + int width{}; + int height{}; + std::vector pixels; + + [[nodiscard]] bool empty() const noexcept { + return width <= 0 || height <= 0 || pixels.empty(); + } +}; + +[[nodiscard]] Pixel_Frame_Copy copy_pixel_frame(Image_View image); + [[nodiscard]] std::string encode_pixel_frame(Image_View image, Color background = Color::black()); +[[nodiscard]] std::string encode_pixel_frame(const Pixel_Frame_Copy& image, + Color background = Color::black()); } // namespace renderive::web diff --git a/web_server/Renderive_WebSocket_Controller.cpp b/web_server/Renderive_WebSocket_Controller.cpp index 6b89c9c..b0a0ef3 100644 --- a/web_server/Renderive_WebSocket_Controller.cpp +++ b/web_server/Renderive_WebSocket_Controller.cpp @@ -50,9 +50,12 @@ void Renderive_WebSocket_Controller::handleNewMessage( return; } try { - if (auto pixels = session->handle(*event)) { - connection->send(pixels->data(), pixels->size(), - drogon::WebSocketMessageType::Binary); + if (auto response = session->handle(*event)) { + const auto message_type = response->type == Web_Response_Type::Pixels + ? drogon::WebSocketMessageType::Binary + : drogon::WebSocketMessageType::Text; + connection->send(response->payload.data(), response->payload.size(), + message_type); } } catch (const std::exception& error) { LOG_ERROR << "Renderive WebSocket session failed: " << error.what(); diff --git a/web_server/Web_Event.h b/web_server/Web_Event.h index 7ee5c30..194c13e 100644 --- a/web_server/Web_Event.h +++ b/web_server/Web_Event.h @@ -2,6 +2,7 @@ #include "Core2/event/Event.h" +#include #include namespace renderive::web { @@ -40,6 +41,13 @@ struct Set_Smoothing { struct Clear_Selection {}; +enum class Gallery_Request_Kind : std::uint8_t { Catalog, Open, Patch, Action, Observe }; + +struct Gallery_Request { + Gallery_Request_Kind kind = Gallery_Request_Kind::Catalog; + std::string message; +}; + using Web_Event = std::variant; + Clear_Selection, + Gallery_Request>; } // namespace renderive::web diff --git a/web_server/Web_Event_Adapter.cpp b/web_server/Web_Event_Adapter.cpp index b171b0c..7de67eb 100644 --- a/web_server/Web_Event_Adapter.cpp +++ b/web_server/Web_Event_Adapter.cpp @@ -184,8 +184,8 @@ std::optional Web_Event_Adapter::decode(std::string_view message) { const auto height = finite_number(root, "height"); if (!width || !height) return std::nullopt; - return Viewport_Resize{{std::clamp(static_cast(*width), 240, 1920), - std::clamp(static_cast(*height), 180, 1200)}}; + return Viewport_Resize{{static_cast(std::clamp(*width, 240.0, 1920.0)), + static_cast(std::clamp(*height, 180.0, 1200.0))}}; } if (type == "show") return Event(Event_Type::Show); @@ -207,6 +207,16 @@ std::optional Web_Event_Adapter::decode(std::string_view message) { return key_event(root, Event_Type::Key_Release); if (type == "control") return control_event(root); + if (type == "gallery_catalog") + return Gallery_Request{Gallery_Request_Kind::Catalog, std::string(message)}; + if (type == "gallery_open") + return Gallery_Request{Gallery_Request_Kind::Open, std::string(message)}; + if (type == "gallery_patch") + return Gallery_Request{Gallery_Request_Kind::Patch, std::string(message)}; + if (type == "gallery_action") + return Gallery_Request{Gallery_Request_Kind::Action, std::string(message)}; + if (type == "gallery_observe") + return Gallery_Request{Gallery_Request_Kind::Observe, std::string(message)}; return std::nullopt; } diff --git a/web_server/Web_Performance_Log.cpp b/web_server/Web_Performance_Log.cpp new file mode 100644 index 0000000..da35d52 --- /dev/null +++ b/web_server/Web_Performance_Log.cpp @@ -0,0 +1,50 @@ +#include "Web_Performance_Log.h" + +#include +#include + +#include +#include +#include +#include + +namespace renderive::web { +namespace { + +std::mutex log_mutex; +std::shared_ptr performance_logger; +std::filesystem::path performance_log_path; + +} // namespace + +void initialize_web_performance_log(const std::filesystem::path& log_directory) { + std::lock_guard lock(log_mutex); + std::filesystem::create_directories(log_directory); + performance_log_path = log_directory / "renderive-web-performance.jsonl"; + auto sink = std::make_shared( + performance_log_path.string(), 20U * 1024U * 1024U, 5U, false); + performance_logger = std::make_shared("renderive_web_performance", sink); + performance_logger->set_pattern("%v"); + performance_logger->set_level(spdlog::level::info); + performance_logger->flush_on(spdlog::level::info); +} + +void write_web_performance_log(std::string_view json_line) noexcept { + try { + std::shared_ptr logger; + { + std::lock_guard lock(log_mutex); + logger = performance_logger; + } + if (logger) + logger->info("{}", json_line); + } catch (...) { + } +} + +std::filesystem::path web_performance_log_path() { + std::lock_guard lock(log_mutex); + return performance_log_path; +} + +} // namespace renderive::web diff --git a/web_server/Web_Performance_Log.h b/web_server/Web_Performance_Log.h new file mode 100644 index 0000000..32d75eb --- /dev/null +++ b/web_server/Web_Performance_Log.h @@ -0,0 +1,12 @@ +#pragma once + +#include +#include + +namespace renderive::web { + +void initialize_web_performance_log(const std::filesystem::path& log_directory); +void write_web_performance_log(std::string_view json_line) noexcept; +[[nodiscard]] std::filesystem::path web_performance_log_path(); + +} // namespace renderive::web diff --git a/web_server/Web_Plot_Session.cpp b/web_server/Web_Plot_Session.cpp index 8405162..e4528f9 100644 --- a/web_server/Web_Plot_Session.cpp +++ b/web_server/Web_Plot_Session.cpp @@ -26,11 +26,8 @@ Color blend(Color first, Color second, double amount) { Color_Map radio_color_map() { constexpr Color stops[] = { - {3, 7, 18, 255}, - {16, 52, 105, 255}, - {23, 163, 184, 255}, - {238, 210, 91, 255}, - {239, 68, 68, 255} + {3, 7, 18, 255}, {16, 52, 105, 255}, {23, 163, 184, 255}, + {238, 210, 91, 255}, {239, 68, 68, 255} }; std::vector colors; colors.reserve(256); @@ -180,7 +177,6 @@ struct Web_Plot_Session::Impl { spectrum_power_axis->set_x(left); spectrum_power_axis->set_y(top); spectrum_power_axis->set_pixel_length(static_cast(spectrum_height)); - waterfall_frequency_axis->set_x(left); waterfall_frequency_axis->set_y(waterfall_y + waterfall_height); waterfall_frequency_axis->set_pixel_length(static_cast(content_width)); @@ -220,7 +216,8 @@ struct Web_Plot_Session::Impl { 28.0 * gaussian(x, 0.32, 0.025); break; } - samples[index] = std::clamp(-110.0 + noise * 4.5 + signal + gain_db, -120.0, -20.0); + samples[index] = std::clamp(-110.0 + noise * 4.5 + signal + gain_db, + -120.0, -20.0); } spectrum->update_samples(samples); if ((frame_index & 1U) == 0U) @@ -237,7 +234,7 @@ struct Web_Plot_Session::Impl { spectrum->set_frequency_range(range); spectrum->set_center_frequency(center); spectrum->set_sweep_frequency_range({center - bandwidth * 0.125, - center + bandwidth * 0.125}); + center + bandwidth * 0.125}); waterfall->set_frequency_range(range); } @@ -249,7 +246,7 @@ struct Web_Plot_Session::Impl { waterfall_frequency_axis->set_coord_range(range); spectrum->set_frequency_range(range); spectrum->set_sweep_frequency_range({center - bandwidth * 0.125, - center + bandwidth * 0.125}); + center + bandwidth * 0.125}); waterfall->set_frequency_range(range); } @@ -267,13 +264,15 @@ struct Web_Plot_Session::Impl { return pixels.empty() ? std::nullopt : std::optional(std::move(pixels)); } - std::optional handle(const Web_Event& event) { + std::optional handle(const Web_Event& event) { std::lock_guard lock(mutex); return std::visit( - [this](const auto& value) -> std::optional { + [this](const auto& value) -> std::optional { using T = std::decay_t; if constexpr (std::is_same_v) { - return render_pixels(); + auto pixels = render_pixels(); + if (pixels) + return Web_Response{Web_Response_Type::Pixels, std::move(*pixels)}; } else if constexpr (std::is_same_v) { const Size previous = plot.viewport_size(); plot.set_viewport_size(value.size); @@ -321,7 +320,7 @@ struct Web_Plot_Session::Impl { Web_Plot_Session::Web_Plot_Session() : impl_(std::make_unique()) {} Web_Plot_Session::~Web_Plot_Session() = default; -std::optional Web_Plot_Session::handle(const Web_Event& event) { +std::optional Web_Plot_Session::handle(const Web_Event& event) { return impl_->handle(event); } diff --git a/web_server/Web_Plot_Session.h b/web_server/Web_Plot_Session.h index 6f6af09..d76fef1 100644 --- a/web_server/Web_Plot_Session.h +++ b/web_server/Web_Plot_Session.h @@ -8,6 +8,13 @@ namespace renderive::web { +enum class Web_Response_Type : std::uint8_t { Pixels, Json }; + +struct Web_Response { + Web_Response_Type type = Web_Response_Type::Pixels; + std::string payload; +}; + class Web_Plot_Session final { public: Web_Plot_Session(); @@ -15,7 +22,7 @@ public: Web_Plot_Session(const Web_Plot_Session&) = delete; Web_Plot_Session& operator=(const Web_Plot_Session&) = delete; - [[nodiscard]] std::optional handle(const Web_Event& event); + [[nodiscard]] std::optional handle(const Web_Event& event); private: struct Impl; diff --git a/web_server/Web_Server.cpp b/web_server/Web_Server.cpp index bbdee97..d9129e3 100644 --- a/web_server/Web_Server.cpp +++ b/web_server/Web_Server.cpp @@ -1,24 +1,52 @@ #include "Web_Server.h" +#include "Gallery_WebSocket_Controller.h" #include "Renderive_WebSocket_Controller.h" +#include "Web_Performance_Log.h" #include #include +#include +#include #include #include #include namespace renderive::web { -int run_web_server(std::uint16_t port) { +int run_web_server(std::uint16_t port, const std::filesystem::path& asset_root) { + const std::filesystem::path gallery_root = asset_root / "webapp_gallery"; + const std::filesystem::path gallery_index = gallery_root / "index.html"; + if (!std::filesystem::is_regular_file(gallery_index)) { + std::cerr << "Renderive Gallery assets not found: " << gallery_index << '\n'; + return 3; + } + initialize_web_performance_log(asset_root / "logs"); const auto controller = std::make_shared(); + const auto gallery_controller = std::make_shared(); const auto hardware_threads = std::max(2U, std::thread::hardware_concurrency()); std::cout << "Renderive WebSocket backend: ws://127.0.0.1:" << port << "/renderive\n" - << "Open webapp/index.html directly; no HTTP application endpoint is used.\n"; - drogon::app() + << "Renderive control gallery: ws://127.0.0.1:" << port + << "/renderive/gallery\n" + << "Renderive hosted gallery: http://127.0.0.1:" << port << "/\n" + << "Renderive performance log: " << web_performance_log_path().string() << "\n" + << "HTTP only serves static HTML/CSS/JS; events and pixels stay on WebSocket.\n"; + auto& app = drogon::app(); + const auto redirect_to_gallery = []( + const drogon::HttpRequestPtr&, + std::function&& callback) { + callback(drogon::HttpResponse::newRedirectionResponse("/")); + }; + app.registerHandler("/gallery", redirect_to_gallery, {drogon::Get}); + app.registerHandler("/gallery/", redirect_to_gallery, {drogon::Get}); + app .registerController(controller) + .registerController(gallery_controller) + .setDocumentRoot(gallery_root.string()) + .setHomePage("index.html") + .setStaticFileHeaders({{"Cache-Control", "no-store"}}) .addListener("127.0.0.1", port) .setThreadNum(std::min(8U, hardware_threads)) .setIdleConnectionTimeout(90) diff --git a/web_server/Web_Server.h b/web_server/Web_Server.h index 7cd9901..0077527 100644 --- a/web_server/Web_Server.h +++ b/web_server/Web_Server.h @@ -1,9 +1,10 @@ #pragma once #include +#include namespace renderive::web { -int run_web_server(std::uint16_t port); +int run_web_server(std::uint16_t port, const std::filesystem::path& asset_root); } // namespace renderive::web diff --git a/web_server/main.cpp b/web_server/main.cpp index affec88..9e74db3 100644 --- a/web_server/main.cpp +++ b/web_server/main.cpp @@ -1,12 +1,10 @@ #include "Web_Server.h" - #include #include +#include #include #include - namespace { - std::uint16_t parse_port(int argc, char** argv) { constexpr std::uint16_t default_port = 8848; if (argc != 3 || std::string_view(argv[1]) != "--port") @@ -20,10 +18,12 @@ std::uint16_t parse_port(int argc, char** argv) { } return static_cast(value); } - } // namespace - int main(int argc, char** argv) { const std::uint16_t port = parse_port(argc, argv); - return port == 0 ? 2 : renderive::web::run_web_server(port); + if (port == 0) + return 2; + std::cout << "http://127.0.0.1:8848" << std::endl; + const std::filesystem::path executable = std::filesystem::absolute(argv[0]); + return renderive::web::run_web_server(port, executable.parent_path()); } diff --git a/web_server/tests/Web_Bridge_Tests.cpp b/web_server/tests/Web_Bridge_Tests.cpp index ea57087..51d0caf 100644 --- a/web_server/tests/Web_Bridge_Tests.cpp +++ b/web_server/tests/Web_Bridge_Tests.cpp @@ -1,10 +1,21 @@ +#include "web_server/Gallery_Plot_Session.h" +#include "web_server/Gallery_Protocol.h" #include "web_server/Pixel_Frame.h" #include "web_server/Web_Event_Adapter.h" #include "web_server/Web_Plot_Session.h" #include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include #include namespace renderive::web { @@ -17,6 +28,52 @@ std::uint32_t read_u32_le(const std::string& value, std::size_t offset) { (static_cast(static_cast(value[offset + 3])) << 24U); } +nlohmann::json parse_json(const std::string& value) { + return nlohmann::json::parse(value); +} + +Gallery_Request gallery_request(Gallery_Request_Kind kind, std::string message) { + return {kind, std::move(message)}; +} + +std::string open_message(std::string_view case_id, std::string_view frame_mode) { + return std::string(R"({"category":"event","type":"gallery_open","case":")") + + std::string(case_id) + R"(","frame_mode":")" + std::string(frame_mode) + R"("})"; +} + +nlohmann::json response_json(std::optional response) { + if (!response) { + ADD_FAILURE() << "expected a Web response"; + return nlohmann::json::object(); + } + EXPECT_EQ(response->type, Web_Response_Type::Json); + return parse_json(response->payload); +} + +nlohmann::json observe_telemetry(Gallery_Plot_Session& session) { + return response_json(session.handle(gallery_request( + Gallery_Request_Kind::Observe, + R"({"category":"event","type":"gallery_observe"})"))).value( + "telemetry", nlohmann::json::object()); +} + +nlohmann::json invoke_action(Gallery_Plot_Session& session, std::string_view action_id, + std::optional argument = std::nullopt) { + nlohmann::json request{{"category", "event"}, {"type", "gallery_action"}, + {"action", action_id}}; + if (argument) + request["argument"] = *argument; + return response_json(session.handle(gallery_request( + Gallery_Request_Kind::Action, request.dump()))); +} + +nlohmann::json patch_controls(Gallery_Plot_Session& session, nlohmann::json patch) { + const nlohmann::json request{{"category", "event"}, {"type", "gallery_patch"}, + {"patch", std::move(patch)}}; + return response_json(session.handle(gallery_request( + Gallery_Request_Kind::Patch, request.dump()))); +} + TEST(RenderiveWebBridge, DecodesOnlyEventMessages) { const auto resize = Web_Event_Adapter::decode( R"({"category":"event","type":"resize","width":800,"height":450})"); @@ -33,6 +90,27 @@ TEST(RenderiveWebBridge, DecodesOnlyEventMessages) { EXPECT_FALSE(Web_Event_Adapter::decode(R"({"category":"command","type":"frame"})")); EXPECT_FALSE(Web_Event_Adapter::decode("not-json")); + + const auto catalog = Web_Event_Adapter::decode( + R"({"category":"event","type":"gallery_catalog"})"); + ASSERT_TRUE(catalog.has_value()); + ASSERT_TRUE(std::holds_alternative(*catalog)); + EXPECT_EQ(std::get(*catalog).kind, Gallery_Request_Kind::Catalog); + + const auto open = Web_Event_Adapter::decode( + R"({"category":"event","type":"gallery_open","case":"spectrum","frame_mode":"manual"})"); + ASSERT_TRUE(open.has_value()); + EXPECT_EQ(std::get(*open).kind, Gallery_Request_Kind::Open); + + const auto observe = Web_Event_Adapter::decode( + R"({"category":"event","type":"gallery_observe"})"); + ASSERT_TRUE(observe.has_value()); + EXPECT_EQ(std::get(*observe).kind, Gallery_Request_Kind::Observe); + + const auto bounded = Web_Event_Adapter::decode( + R"({"category":"event","type":"resize","width":1e100,"height":-1e100})"); + ASSERT_TRUE(bounded.has_value()); + EXPECT_EQ(std::get(*bounded).size, (Size{1920, 180})); } TEST(RenderiveWebBridge, EncodesRgbaPixelFrame) { @@ -54,16 +132,1033 @@ TEST(RenderiveWebBridge, EncodesRgbaPixelFrame) { EXPECT_EQ(rgba[5], 255); } -TEST(RenderiveWebBridge, RendersPixelsOnlyForFrameEvent) { +TEST(RenderiveWebBridge, LegacyReceiverStillRendersPixels) { Web_Plot_Session session; EXPECT_FALSE(session.handle(Viewport_Resize{{640, 360}}).has_value()); EXPECT_FALSE(session.handle(Set_Demo_Mode{Demo_Mode::Usb}).has_value()); const auto frame = session.handle(Frame_Request{}); ASSERT_TRUE(frame.has_value()); - ASSERT_GE(frame->size(), pixel_frame_header_size); - EXPECT_EQ(frame->substr(0, 4), "RVP1"); - EXPECT_EQ(read_u32_le(*frame, 4), 640U); - EXPECT_EQ(read_u32_le(*frame, 8), 360U); + EXPECT_EQ(frame->type, Web_Response_Type::Pixels); + ASSERT_GE(frame->payload.size(), pixel_frame_header_size); + EXPECT_EQ(frame->payload.substr(0, 4), "RVP1"); + EXPECT_EQ(read_u32_le(frame->payload, 4), 640U); + EXPECT_EQ(read_u32_le(frame->payload, 8), 360U); +} + +TEST(RenderiveWebGallery, AdminiveCatalogCoversEveryControlCaseAndThreeModes) { + const nlohmann::json catalog = parse_json(Gallery_Protocol::catalog_json()); + EXPECT_EQ(catalog.at("category"), "gallery"); + EXPECT_EQ(catalog.at("type"), "catalog"); + EXPECT_EQ(catalog.at("protocol"), "renderive.control-gallery"); + EXPECT_EQ(catalog.at("case_descriptor").at("protocol"), "adminive.resource"); + EXPECT_EQ(catalog.at("cases").size(), 8U); + EXPECT_EQ(catalog.at("frame_modes").size(), 3U); + EXPECT_EQ(catalog.at("coverage").at("page_count"), 3); + EXPECT_EQ(catalog.at("coverage").at("canvas_count"), 24); + EXPECT_GT(catalog.at("coverage").at("manual_control_count").get(), 180U); + EXPECT_GT(catalog.at("coverage").at("manual_action_count").get(), 50U); + EXPECT_EQ(catalog.at("coverage").at("frequency_modes"), + nlohmann::json::array({"spectrum", "afterglow", "sweep_spectrum"})); + EXPECT_EQ(catalog.at("coverage").at("image_interpolation_modes"), + nlohmann::json::array({"Nearest", "Bilinear", "Bicubic"})); +} + +TEST(RenderiveWebGallery, BackendMenuMapsRetainedControlApis) { + constexpr std::array cases{ + "axis_lab", "spectrum", "afterglow", "sweep_spectrum", + "waterfall", "frequency_trace", "selection_overlay", "constellation" + }; + std::string api_text; + constexpr Gallery_Frame_Mode modes[]{Gallery_Frame_Mode::Manual, + Gallery_Frame_Mode::Low_Latency, + Gallery_Frame_Mode::Playback}; + for (const auto case_id : cases) { + for (const auto mode : modes) { + const auto contract = parse_json(Gallery_Protocol::case_json( + case_id, Gallery_Protocol::default_state(case_id, mode), "{}", {}, mode)); + for (const auto& control : contract.at("controls").at("data")) + api_text += control.at("api").get() + '\n'; + for (const auto& action : contract.at("actions").at("data")) + api_text += action.at("api").get() + '\n'; + } + } + constexpr std::array required{ + "Plot_Core::set_background_color", "Plot_Core::set_max_render_fps", + "Plot_Core::activate_view", "Plot_Core::remove_renderable", + "Renderable::set_visible", "Renderable::set_cache_mode", "Renderable::set_object_name", + "Abs_Axis::set_x", "Abs_Axis::set_y", "Abs_Axis::set_orientation", + "Abs_Axis::set_pixel_length", "Abs_Axis::set_locale", "Abs_Axis::set_unit_text_font", + "Axis::set_coord_range", "Axis::set_coord_start", "Axis::set_coord_length", + "Axis::set_use_wheel", "Axis::set_use_drag", "Time_Axis::set_time_format", + "Time_Axis::append_time", "Spectrum::set_frequency_axis", "Spectrum::set_frequency_point_size", + "Spectrum::set_interpolation_mode", "Spectrum::set_current_pen", "Spectrum::set_max_brush", + "Spectrum::update_samples", "Spectrum::power_at", "Spectrum::add_custom_marker", + "Spectrum::set_marker_frequency", "Waterfall::set_frequency_range", + "Waterfall::set_interpolation_mode", "Waterfall::append_row", "Color_Map::set_colors", + "Afterglow::set_attenuation_rate", "Afterglow::append_spectrum", + "Sweep_Spectrum::set_bins_per_block", "Sweep_Spectrum::append_block", + "Frequency_Trace::Builder::set_pen", "Selection_Rectangle_Overlay::set_selection_brush", + "Selection_Rectangle_Overlay::clear_selected_regions", + "Constellation_Diagram::Builder::set_type", "Constellation_Diagram::fit_square_to_axes" + }; + for (const auto api : required) + EXPECT_NE(api_text.find(api), std::string::npos) << api; + EXPECT_NE(api_text.find("Plot_Core::refresh_manual_frame"), std::string::npos); + EXPECT_NE(api_text.find("Plot_Core::prepare_frame"), std::string::npos); + EXPECT_NE(api_text.find("Plot_Core::render_prepared_frame"), std::string::npos); + EXPECT_NE(api_text.find("Flow_Refresh_Strategy"), std::string::npos); +} + +TEST(RenderiveWebGallery, BackendControlSchemaValidatesEveryPatch) { + const Gallery_State current = Gallery_Protocol::default_state("spectrum"); + const auto accepted = Gallery_Protocol::apply_patch( + "spectrum", current, + R"({"category":"event","type":"gallery_patch","patch":{"current_pen":"#ff8844","frequency_point_size":1024,"line_interpolation":"Cubic_Value","max_render_fps":1000}})"); + ASSERT_TRUE(accepted.candidate.has_value()); + EXPECT_EQ(std::get(accepted.candidate->values.at("current_pen")), "#ff8844"); + EXPECT_DOUBLE_EQ(std::get(accepted.candidate->values.at("frequency_point_size")), 1024); + EXPECT_DOUBLE_EQ(std::get(accepted.candidate->values.at("max_render_fps")), 1000); + + const auto excessive_fps = Gallery_Protocol::apply_patch( + "spectrum", current, + R"({"category":"event","type":"gallery_patch","patch":{"max_render_fps":1000000001}})"); + EXPECT_FALSE(excessive_fps.candidate.has_value()); + EXPECT_TRUE(parse_json(excessive_fps.response_json).at("field_errors").contains("max_render_fps")); + + const auto invalid_color = Gallery_Protocol::apply_patch( + "spectrum", current, + R"({"category":"event","type":"gallery_patch","patch":{"current_pen":"red"}})"); + EXPECT_FALSE(invalid_color.candidate.has_value()); + EXPECT_TRUE(parse_json(invalid_color.response_json).at("field_errors").contains("current_pen")); + + const auto foreign_control = Gallery_Protocol::apply_patch( + "spectrum", current, + R"({"category":"event","type":"gallery_patch","patch":{"image_interpolation":"Bicubic"}})"); + EXPECT_FALSE(foreign_control.candidate.has_value()); + EXPECT_TRUE(parse_json(foreign_control.response_json).at("field_errors").contains("image_interpolation")); +} + +TEST(RenderiveWebGallery, EveryPublishedControlValidatesItsCompleteInputContract) { + constexpr std::array cases{ + "axis_lab", "spectrum", "afterglow", "sweep_spectrum", + "waterfall", "frequency_trace", "selection_overlay", "constellation" + }; + struct Mode { + std::string_view id; + Gallery_Frame_Mode value; + }; + constexpr std::array modes{ + Mode{"manual", Gallery_Frame_Mode::Manual}, + Mode{"low_latency", Gallery_Frame_Mode::Low_Latency}, + Mode{"playback", Gallery_Frame_Mode::Playback} + }; + + std::size_t validated_control_instances{}; + for (const auto case_id : cases) { + for (const auto mode : modes) { + const auto defaults = Gallery_Protocol::default_state(case_id, mode.value); + const auto contract = parse_json(Gallery_Protocol::case_json( + case_id, defaults, "{}", {}, mode.value)); + const auto& controls = contract.at("controls").at("data"); + EXPECT_EQ(controls.size(), defaults.values.size()) << case_id << '/' << mode.id; + std::set ids; + for (const auto& control : controls) { + ++validated_control_instances; + const std::string id = control.at("id"); + SCOPED_TRACE(std::string(case_id) + "/" + std::string(mode.id) + "/" + id); + EXPECT_TRUE(ids.insert(id).second); + EXPECT_FALSE(control.at("api").get().empty()); + EXPECT_FALSE(control.at("group").get().empty()); + + const std::string input = control.at("input"); + const auto apply = [&](const nlohmann::json& value) { + nlohmann::json request{{"category", "event"}, {"type", "gallery_patch"}, + {"patch", {{id, value}}}}; + return Gallery_Protocol::apply_patch(case_id, defaults, request.dump(), + mode.value); + }; + const auto expect_rejected = [&](const nlohmann::json& value) { + const auto result = apply(value); + EXPECT_FALSE(result.candidate.has_value()); + EXPECT_TRUE(parse_json(result.response_json).at("field_errors").contains(id)); + }; + + if (input == "boolean") { + const bool alternative = !control.at("value").get(); + const auto accepted = apply(alternative); + ASSERT_TRUE(accepted.candidate.has_value()); + EXPECT_EQ(std::get(accepted.candidate->values.at(id)), alternative); + expect_rejected(1); + } else if (input == "number") { + const double minimum = control.at("minimum"); + const double maximum = control.at("maximum"); + const double current = control.at("value"); + const double step = std::max(control.at("step").get(), 1e-9); + double alternative = current + step; + if (alternative > maximum) + alternative = current - step; + if (alternative < minimum || alternative == current) + alternative = current == minimum ? maximum : minimum; + if (control.at("integer").get()) + alternative = std::round(alternative); + + const auto accepted = apply(alternative); + ASSERT_TRUE(accepted.candidate.has_value()); + EXPECT_DOUBLE_EQ(std::get(accepted.candidate->values.at(id)), + alternative); + EXPECT_TRUE(apply(minimum).candidate.has_value()); + EXPECT_TRUE(apply(maximum).candidate.has_value()); + expect_rejected(minimum - std::max(1.0, std::abs(minimum) * 0.1 + 1.0)); + expect_rejected(maximum + std::max(1.0, std::abs(maximum) * 0.1 + 1.0)); + expect_rejected("not-a-number"); + if (control.at("integer").get() && maximum - minimum >= 1.0) + expect_rejected(std::clamp(std::floor(current) + 0.5, + minimum + 0.5, maximum - 0.5)); + } else if (input == "select") { + const auto& options = control.at("options"); + ASSERT_FALSE(options.empty()); + const std::string current = control.at("value"); + const auto alternative = std::find_if( + options.begin(), options.end(), [¤t](const auto& option) { + return option.template get() != current; + }); + ASSERT_NE(alternative, options.end()); + const std::string value = alternative->get(); + const auto accepted = apply(value); + ASSERT_TRUE(accepted.candidate.has_value()); + EXPECT_EQ(std::get(accepted.candidate->values.at(id)), value); + expect_rejected("__invalid_gallery_option__"); + expect_rejected(7); + } else if (input == "color") { + const std::string value = control.at("value") == "#123456" ? + "#654321" : "#123456"; + const auto accepted = apply(value); + ASSERT_TRUE(accepted.candidate.has_value()); + EXPECT_EQ(std::get(accepted.candidate->values.at(id)), value); + expect_rejected("red"); + expect_rejected(7); + } else { + ASSERT_EQ(input, "text"); + std::string value = control.at("value").get() + "_qa"; + const auto accepted = apply(value); + ASSERT_TRUE(accepted.candidate.has_value()); + EXPECT_EQ(std::get(accepted.candidate->values.at(id)), value); + expect_rejected(std::string(161, 'x')); + expect_rejected(7); + } + } + } + } + EXPECT_GT(validated_control_instances, 180U); +} + +TEST(RenderiveWebGallery, EveryPublishedActionDispatchesToItsCanvasAndFrameMode) { + constexpr std::array cases{ + "axis_lab", "spectrum", "afterglow", "sweep_spectrum", + "waterfall", "frequency_trace", "selection_overlay", "constellation" + }; + constexpr std::array modes{"manual", "low_latency", "playback"}; + std::size_t dispatched_action_instances{}; + for (const auto case_id : cases) { + for (const auto mode : modes) { + Gallery_Plot_Session catalog_session; + const auto opened = response_json(catalog_session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message(case_id, mode)))); + ASSERT_EQ(opened.at("type"), "case_state"); + const auto actions = opened.at("actions").at("data"); + std::set ids; + for (const auto& action : actions) { + ++dispatched_action_instances; + const std::string id = action.at("id"); + SCOPED_TRACE(std::string(case_id) + "/" + std::string(mode) + "/" + id); + EXPECT_TRUE(ids.insert(id).second); + EXPECT_FALSE(action.at("api").get().empty()); + EXPECT_FALSE(action.at("group").get().empty()); + + Gallery_Plot_Session session; + ASSERT_EQ(response_json(session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message(case_id, mode)))).at("type"), + "case_state"); + std::optional argument; + if (!action.at("argument_input").get().empty()) + argument = action.at("argument_default").get(); + const auto result = invoke_action(session, id, argument); + EXPECT_EQ(result.at("type"), "case_state"); + EXPECT_EQ(result.at("case").at("id").get(), case_id); + EXPECT_EQ(result.at("frame_mode").at("id").get(), mode); + EXPECT_TRUE(result.at("telemetry").contains("kernel_observer")); + } + } + } + EXPECT_GT(dispatched_action_instances, 150U); +} + +TEST(RenderiveWebGallery, EveryPublishedControlAppliesToTheLiveCore2Scene) { + constexpr std::array cases{ + "axis_lab", "spectrum", "afterglow", "sweep_spectrum", + "waterfall", "frequency_trace", "selection_overlay", "constellation" + }; + constexpr std::array modes{"manual", "low_latency", "playback"}; + std::size_t applied_control_instances{}; + for (const auto case_id : cases) { + for (const auto mode : modes) { + Gallery_Plot_Session session; + auto state = response_json(session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message(case_id, mode)))); + ASSERT_EQ(state.at("type"), "case_state"); + const auto published_controls = state.at("controls").at("data"); + for (const auto& published : published_controls) { + ++applied_control_instances; + const std::string id = published.at("id"); + SCOPED_TRACE(std::string(case_id) + "/" + std::string(mode) + "/" + id); + const std::string input = published.at("input"); + nlohmann::json alternative; + if (input == "boolean") { + alternative = !published.at("value").get(); + } else if (input == "number") { + const double minimum = published.at("minimum"); + const double maximum = published.at("maximum"); + const double current = published.at("value"); + const double step = std::max(published.at("step").get(), 1e-9); + double value = current + step; + if (value > maximum) + value = current - step; + if (value < minimum || value == current) + value = current == minimum ? maximum : minimum; + if (published.at("integer").get()) + value = std::round(value); + alternative = value; + } else if (input == "select") { + const auto& options = published.at("options"); + const std::string current = published.at("value"); + const auto selected = std::find_if( + options.begin(), options.end(), [¤t](const auto& option) { + return option.template get() != current; + }); + ASSERT_NE(selected, options.end()); + alternative = *selected; + } else if (input == "color") { + alternative = published.at("value") == "#123456" ? "#654321" : "#123456"; + } else { + ASSERT_EQ(input, "text"); + alternative = published.at("value").get() + "_live"; + } + + nlohmann::json request{{"category", "event"}, {"type", "gallery_patch"}, + {"patch", {{id, alternative}}}}; + state = response_json(session.handle(gallery_request( + Gallery_Request_Kind::Patch, request.dump()))); + ASSERT_EQ(state.at("type"), "case_state"); + EXPECT_EQ(state.at("frame_mode").at("id").get(), mode); + const auto& returned = state.at("controls").at("data"); + const auto found = std::find_if(returned.begin(), returned.end(), + [&id](const auto& item) { + return item.at("id") == id; + }); + ASSERT_NE(found, returned.end()); + EXPECT_EQ(found->at("value"), alternative); + EXPECT_TRUE(state.at("telemetry").contains("kernel_observer")); + } + } + } + EXPECT_GT(applied_control_instances, 180U); +} + +TEST(RenderiveWebGallery, CanvasSpecificActionsProduceObservableStateChanges) { + { + Gallery_Plot_Session session; + const auto opened = response_json(session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message("axis_lab", "manual")))); + const auto before = opened.at("telemetry").at("axis_lab").at("time_axis_point_count") + .get(); + const auto after = invoke_action(session, "append_time").at("telemetry"); + EXPECT_EQ(after.at("axis_lab").at("time_axis_point_count").get(), + before + 1); + EXPECT_NE(after.at("last_action_result").get().find("tick="), + std::string::npos); + } + { + Gallery_Plot_Session session; + ASSERT_EQ(response_json(session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message("spectrum", "manual")))).at("type"), + "case_state"); + auto telemetry = invoke_action(session, "add_line_marker", 96e6).at("telemetry"); + EXPECT_EQ(telemetry.at("spectrum").at("selectable_line_markers"), 1); + telemetry = invoke_action(session, "select_marker", 0).at("telemetry"); + EXPECT_EQ(telemetry.at("spectrum").at("selected_marker_index"), 0); + telemetry = invoke_action(session, "set_marker_frequency", 99e6).at("telemetry"); + EXPECT_DOUBLE_EQ(telemetry.at("spectrum").at("selected_marker_frequency"), 99e6); + telemetry = invoke_action(session, "clear_markers").at("telemetry"); + EXPECT_EQ(telemetry.at("spectrum").at("selectable_line_markers"), 0); + EXPECT_EQ(telemetry.at("spectrum").at("selected_marker_index"), -1); + } + { + Gallery_Plot_Session session; + const auto opened = response_json(session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message("waterfall", "manual")))); + const auto before_rows = opened.at("telemetry").at("waterfall").at("row_count") + .get(); + auto telemetry = invoke_action(session, "append_row").at("telemetry"); + EXPECT_EQ(telemetry.at("waterfall").at("row_count").get(), + before_rows + 1); + telemetry = invoke_action(session, "append_tick_row").at("telemetry"); + EXPECT_EQ(telemetry.at("waterfall").at("row_count").get(), + before_rows + 2); + EXPECT_NE(invoke_action(session, "rebind_axes").at("telemetry") + .at("last_action_result").get().find("valid"), + std::string::npos); + } + { + Gallery_Plot_Session session; + const auto opened = response_json(session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message("afterglow", "manual")))); + const auto before = opened.at("telemetry").at("afterglow") + .at("history_frame_count").get(); + const auto telemetry = invoke_action(session, "append_spectrum").at("telemetry"); + EXPECT_EQ(telemetry.at("afterglow").at("history_frame_count").get(), + before + 1); + } + { + Gallery_Plot_Session session; + const auto opened = response_json(session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message("sweep_spectrum", "manual")))); + const auto before = opened.at("telemetry").at("sweep_spectrum") + .at("stored_block_count").get(); + const auto telemetry = invoke_action(session, "append_block").at("telemetry"); + EXPECT_EQ(telemetry.at("sweep_spectrum").at("stored_block_count").get(), + before + 1); + } + { + Gallery_Plot_Session session; + const auto opened = response_json(session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message("frequency_trace", "manual")))); + const auto before = opened.at("telemetry").at("frequency_trace") + .at("sample_count").get(); + auto telemetry = invoke_action(session, "append_sample").at("telemetry"); + EXPECT_EQ(telemetry.at("frequency_trace").at("sample_count").get(), + before + 1); + telemetry = invoke_action(session, "append_tick_sample").at("telemetry"); + EXPECT_EQ(telemetry.at("frequency_trace").at("sample_count").get(), + before + 2); + } + { + Gallery_Plot_Session session; + const auto opened = response_json(session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message("constellation", "manual")))); + const auto before = opened.at("telemetry").at("constellation") + .at("point_count").get(); + const auto telemetry = invoke_action(session, "append_points").at("telemetry"); + EXPECT_EQ(telemetry.at("constellation").at("point_count").get(), + before + 24); + EXPECT_NE(invoke_action(session, "read_axes").at("telemetry") + .at("last_action_result").get().find("valid"), + std::string::npos); + } +} + +TEST(RenderiveWebGallery, CommonControlsAndViewLifecycleProduceObservableEffects) { + Gallery_Plot_Session session; + ASSERT_EQ(response_json(session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message("spectrum", "manual")))).at("type"), + "case_state"); + + const auto background_state = patch_controls(session, {{"background_color", "#123456"}}); + EXPECT_EQ(background_state.at("controls").at("data").at(0).at("value"), "#123456"); + ASSERT_EQ(invoke_action(session, "mode_cycle").at("type"), "case_state"); + const auto background_frame = session.handle(Frame_Request{}); + ASSERT_TRUE(background_frame.has_value()); + ASSERT_EQ(background_frame->type, Web_Response_Type::Pixels); + ASSERT_GE(background_frame->payload.size(), pixel_frame_header_size + 4); + const auto* corner = reinterpret_cast( + background_frame->payload.data() + pixel_frame_header_size); + EXPECT_EQ(corner[0], 0x12); + EXPECT_EQ(corner[1], 0x34); + EXPECT_EQ(corner[2], 0x56); + EXPECT_EQ(corner[3], 0xff); + + auto state = patch_controls(session, { + {"performance_overlay", true}, + {"renderable_visible", false}, + {"cache_mode", "Direct"}, + {"object_name", "Observable_Spectrum"} + }); + auto telemetry = state.at("telemetry"); + EXPECT_TRUE(telemetry.at("performance_overlay_enabled").get()); + EXPECT_FALSE(telemetry.at("renderable").at("visible").get()); + EXPECT_EQ(telemetry.at("renderable").at("cache_mode"), "Direct"); + EXPECT_EQ(telemetry.at("renderable").at("object_name"), "Observable_Spectrum"); + + state = invoke_action(session, "toggle_view"); + EXPECT_FALSE(state.at("telemetry").at("view_active").get()); + EXPECT_FALSE(session.handle(Frame_Request{}).has_value()); + state = invoke_action(session, "toggle_view"); + EXPECT_TRUE(state.at("telemetry").at("view_active").get()); + ASSERT_EQ(invoke_action(session, "mode_cycle").at("type"), "case_state"); + EXPECT_TRUE(session.handle(Frame_Request{}).has_value()); +} + +TEST(RenderiveWebGallery, SelectionOverlayPointerLifecycleCreatesAndClearsRegion) { + Gallery_Plot_Session session; + ASSERT_EQ(response_json(session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message("selection_overlay", "manual")))).at("type"), + "case_state"); + + const auto resize = Web_Event_Adapter::decode( + R"({"category":"event","type":"resize","width":560,"height":320})"); + ASSERT_TRUE(resize.has_value()); + EXPECT_FALSE(session.handle(*resize).has_value()); + constexpr std::array drag{ + R"({"category":"event","type":"pointer_press","x":120,"y":90,"button":"left","buttons":1,"modifiers":0})", + R"({"category":"event","type":"pointer_move","x":300,"y":210,"button":"none","buttons":1,"modifiers":0})", + R"({"category":"event","type":"pointer_release","x":300,"y":210,"button":"left","buttons":0,"modifiers":0})" + }; + for (const auto encoded : drag) { + const auto event = Web_Event_Adapter::decode(encoded); + ASSERT_TRUE(event.has_value()); + EXPECT_FALSE(session.handle(*event).has_value()); + } + + EXPECT_EQ(observe_telemetry(session).at("selection_regions"), 1); + EXPECT_EQ(invoke_action(session, "clear_selection").at("telemetry") + .at("selection_regions"), 0); + const auto rebound = invoke_action(session, "rebind_selection_axes").at("telemetry"); + EXPECT_NE(rebound.at("last_action_result").get().find("Selection Overlay"), + std::string::npos); +} + +TEST(RenderiveWebGallery, EveryCanvasAcceptsTheCompleteWebEventSetAndStillRendersPixels) { + constexpr std::array cases{ + "axis_lab", "spectrum", "afterglow", "sweep_spectrum", + "waterfall", "frequency_trace", "selection_overlay", "constellation" + }; + constexpr std::array modes{"manual", "low_latency", "playback"}; + constexpr std::array interactive_events{ + R"({"category":"event","type":"show"})", + R"({"category":"event","type":"pointer_move","x":100,"y":100,"button":"none","buttons":0,"modifiers":0})", + R"({"category":"event","type":"pointer_press","x":100,"y":100,"button":"left","buttons":1,"modifiers":1})", + R"({"category":"event","type":"pointer_move","x":180,"y":160,"button":"none","buttons":1,"modifiers":1})", + R"({"category":"event","type":"pointer_release","x":180,"y":160,"button":"left","buttons":0,"modifiers":1})", + R"({"category":"event","type":"wheel","x":160,"y":120,"pixelDeltaX":0,"pixelDeltaY":-24,"angleDeltaX":0,"angleDeltaY":192,"buttons":0,"modifiers":2})", + R"({"category":"event","type":"key_press","key":"Escape","nativeKey":27,"repeat":false,"modifiers":0})", + R"({"category":"event","type":"key_release","key":"Escape","nativeKey":27,"repeat":false,"modifiers":0})", + R"({"category":"event","type":"leave"})", + R"({"category":"event","type":"hide"})" + }; + + for (const auto case_id : cases) { + for (const auto mode : modes) { + SCOPED_TRACE(std::string(case_id) + "/" + std::string(mode)); + Gallery_Plot_Session session; + ASSERT_EQ(response_json(session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message(case_id, mode)))).at("type"), + "case_state"); + const auto resize = Web_Event_Adapter::decode( + R"({"category":"event","type":"resize","width":420,"height":260})"); + ASSERT_TRUE(resize.has_value()); + EXPECT_FALSE(session.handle(*resize).has_value()); + + for (const auto encoded : interactive_events) { + const auto event = Web_Event_Adapter::decode(encoded); + ASSERT_TRUE(event.has_value()) << encoded; + EXPECT_FALSE(session.handle(*event).has_value()) << encoded; + } + EXPECT_FALSE(session.handle(Frame_Request{}).has_value()); + + const auto show = Web_Event_Adapter::decode( + R"({"category":"event","type":"show"})"); + ASSERT_TRUE(show.has_value()); + EXPECT_FALSE(session.handle(*show).has_value()); + const auto frame = session.handle(Frame_Request{}); + ASSERT_TRUE(frame.has_value()); + ASSERT_EQ(frame->type, Web_Response_Type::Pixels); + EXPECT_EQ(frame->payload.substr(0, 4), "RVP1"); + EXPECT_EQ(read_u32_le(frame->payload, 4), 420U); + EXPECT_EQ(read_u32_le(frame->payload, 8), 260U); + const auto telemetry = observe_telemetry(session); + EXPECT_TRUE(telemetry.at("view_active").get()); + EXPECT_EQ(telemetry.at("viewport").at("width"), 420); + EXPECT_EQ(telemetry.at("viewport").at("height"), 260); + } + } +} + +TEST(RenderiveWebGallery, InvalidInteractiveEventsAreRejectedBeforeReachingCore2) { + constexpr std::array invalid{ + R"({"category":"command","type":"pointer_move","x":1,"y":2})", + R"({"category":"event","type":"pointer_move","x":1})", + R"({"category":"event","type":"pointer_press","x":"bad","y":2})", + R"({"category":"event","type":"wheel","x":1,"y":2})", + R"({"category":"event","type":"resize","width":"bad","height":200})", + R"({"category":"event","type":"control","control":"mode","value":"INVALID"})", + R"({"category":"event","type":"unknown"})", + "not-json" + }; + for (const auto encoded : invalid) + EXPECT_FALSE(Web_Event_Adapter::decode(encoded).has_value()) << encoded; +} + +TEST(RenderiveWebGallery, EveryIndependentCore2CanvasBuildsAndRenders) { + constexpr std::array cases{ + "axis_lab", "spectrum", "afterglow", "sweep_spectrum", + "waterfall", "frequency_trace", "selection_overlay", "constellation" + }; + constexpr std::array modes{"manual", "low_latency", "playback"}; + for (const std::string_view mode : modes) { + for (const std::string_view case_id : cases) { + Gallery_Plot_Session session; + const auto opened = session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message(case_id, mode))); + ASSERT_TRUE(opened.has_value()) << case_id; + ASSERT_EQ(opened->type, Web_Response_Type::Json) << case_id; + const auto state = parse_json(opened->payload); + EXPECT_EQ(state.at("type"), "case_state") << case_id; + EXPECT_EQ(state.at("case").at("id").get(), std::string(case_id)) << case_id; + EXPECT_EQ(state.at("frame_mode").at("id"), std::string(mode)) << case_id; + EXPECT_EQ(state.at("controls").at("descriptor").at("protocol"), + "adminive.resource") << case_id; + EXPECT_EQ(state.at("controls").at("view").at("protocol"), + "adminive.view") << case_id; + + EXPECT_FALSE(session.handle(Viewport_Resize{{480, 280}}).has_value()) << case_id; + const auto frame = session.handle(Frame_Request{}); + ASSERT_TRUE(frame.has_value()) << case_id; + ASSERT_EQ(frame->type, Web_Response_Type::Pixels) << case_id; + EXPECT_EQ(frame->payload.substr(0, 4), "RVP1") << case_id; + EXPECT_EQ(read_u32_le(frame->payload, 4), 480U) << case_id; + EXPECT_EQ(read_u32_le(frame->payload, 8), 280U) << case_id; + const auto observed = session.handle(gallery_request( + Gallery_Request_Kind::Observe, + R"({"category":"event","type":"gallery_observe"})")); + ASSERT_TRUE(observed.has_value()) << case_id; + const auto telemetry = parse_json(observed->payload); + EXPECT_EQ(telemetry.at("type"), "observer_state") << case_id; + EXPECT_EQ(telemetry.at("frame_mode").at("id"), std::string(mode)) << case_id; + EXPECT_TRUE(telemetry.at("telemetry").contains("kernel_observer")) << case_id; + EXPECT_TRUE(telemetry.at("telemetry").contains("performance")) << case_id; + EXPECT_TRUE(telemetry.at("telemetry").contains("data_shape")) << case_id; + EXPECT_GT(telemetry.at("telemetry").at("data_shape").at("rendered_elements") + .get(), 0U) << case_id; + } + } +} + +TEST(RenderiveWebGallery, ActionsMutateLiveCore2ObjectsAndReturnTelemetry) { + Gallery_Plot_Session session; + ASSERT_TRUE(session.handle(gallery_request( + Gallery_Request_Kind::Open, + open_message("spectrum", "low_latency")))); + + const auto add = session.handle(gallery_request( + Gallery_Request_Kind::Action, + R"({"category":"event","type":"gallery_action","action":"add_line_marker","argument":97000000})")); + ASSERT_TRUE(add.has_value()); + const auto add_state = parse_json(add->payload); + EXPECT_EQ(add_state.at("type"), "case_state"); + EXPECT_EQ(add_state.at("telemetry").at("spectrum").at("selectable_line_markers"), 1); + + const auto patch = session.handle(gallery_request( + Gallery_Request_Kind::Patch, + R"({"category":"event","type":"gallery_patch","patch":{"line_interpolation":"Step_Left","max_hold_visible":true}})")); + ASSERT_TRUE(patch.has_value()); + EXPECT_EQ(parse_json(patch->payload).at("type"), "case_state"); + + const auto rebuild = session.handle(gallery_request( + Gallery_Request_Kind::Action, + R"({"category":"event","type":"gallery_action","action":"rebuild_renderable"})")); + ASSERT_TRUE(rebuild.has_value()); + EXPECT_EQ(parse_json(rebuild->payload).at("type"), "case_state"); +} + +TEST(RenderiveWebGallery, ThreeFrameStrategiesExposeTheirRealActionsAndObservers) { + const auto action = [](Gallery_Plot_Session& session, std::string_view id, + std::optional argument = std::nullopt) { + nlohmann::json request{{"category", "event"}, {"type", "gallery_action"}, + {"action", id}}; + if (argument) + request["argument"] = *argument; + return session.handle(gallery_request(Gallery_Request_Kind::Action, request.dump())); + }; + const auto observe = [](Gallery_Plot_Session& session) { + const auto response = session.handle(gallery_request( + Gallery_Request_Kind::Observe, + R"({"category":"event","type":"gallery_observe"})")); + EXPECT_TRUE(response.has_value()); + return parse_json(response->payload).at("telemetry"); + }; + + Gallery_Plot_Session manual; + ASSERT_TRUE(manual.handle(gallery_request( + Gallery_Request_Kind::Open, open_message("spectrum", "manual")))); + ASSERT_TRUE(action(manual, "mode_prepare")); + auto manual_observer = observe(manual).at("kernel_observer"); + EXPECT_EQ(manual_observer.at("last_event"), "prepared"); + EXPECT_EQ(manual_observer.at("pending_frame_count"), 1); + ASSERT_TRUE(action(manual, "mode_refresh")); + ASSERT_TRUE(action(manual, "mode_render")); + manual_observer = observe(manual).at("kernel_observer"); + EXPECT_EQ(manual_observer.at("last_event"), "rendered"); + EXPECT_GE(manual_observer.at("consumed_frame_count").get(), 2U); + + Gallery_Plot_Session low_latency; + ASSERT_TRUE(low_latency.handle(gallery_request( + Gallery_Request_Kind::Open, open_message("spectrum", "low_latency")))); + const auto high_frequency = low_latency.handle(gallery_request( + Gallery_Request_Kind::Patch, + R"({"category":"event","type":"gallery_patch","patch":{"max_render_fps":1000000000}})")); + ASSERT_TRUE(high_frequency.has_value()); + ASSERT_EQ(parse_json(high_frequency->payload).at("type"), "case_state"); + ASSERT_TRUE(low_latency.handle(Frame_Request{})); + const auto low_telemetry = observe(low_latency); + EXPECT_EQ(low_telemetry.at("frame_mode"), "low_latency"); + EXPECT_GT(low_telemetry.at("kernel_observer").at("observation_count").get(), 0U); + EXPECT_TRUE(low_telemetry.contains("render_fps")); + ASSERT_TRUE(low_telemetry.contains("low_latency_limit")); + const auto& limit = low_telemetry.at("low_latency_limit"); + EXPECT_TRUE(limit.at("paint_limited").get() || + limit.at("render_limited").get()); + EXPECT_FALSE(limit.at("frequency_limited").get()); + EXPECT_EQ(limit.at("target_interval_ns"), 1); + EXPECT_GT(limit.at("bottleneck_duration_ns").get(), 1U); + EXPECT_TRUE(low_telemetry.at("kernel_observer").contains("paint_lease_wait_ns")); + EXPECT_TRUE(low_telemetry.at("kernel_observer").contains("render_finish_state_wait_ns")); + constexpr std::array observer_fields{ + "mode", "last_event", "limit_state", "configured_frequency_hz", "observation_count", + "produced_frame_count", "consumed_frame_count", "dropped_frame_count", + "failed_operation_count", "pending_frame_count", "latest_sequence", + "paint_duration_ns", "render_duration_ns", "target_interval_ns", + "bottleneck_duration_ns", "next_refresh_interval_ns", "paint_lease_wait_ns", + "paint_state_wait_ns", "publish_state_wait_ns", "ready_wait_ns", + "frame_age_at_render_ns", "render_lease_wait_ns", "render_state_wait_ns", + "render_finish_state_wait_ns", "queue_wait_ns", "end_to_end_ns" + }; + for (const auto field : observer_fields) + EXPECT_TRUE(low_telemetry.at("kernel_observer").contains(field)) << field; + + Gallery_Plot_Session playback; + ASSERT_TRUE(playback.handle(gallery_request( + Gallery_Request_Kind::Open, open_message("spectrum", "playback")))); + ASSERT_TRUE(action(playback, "mode_enqueue_burst", 5)); + auto playback_observer = observe(playback).at("kernel_observer"); + EXPECT_EQ(playback_observer.at("last_event"), "enqueued"); + EXPECT_EQ(playback_observer.at("pending_frame_count"), 5); + ASSERT_TRUE(action(playback, "mode_dequeue")); + playback_observer = observe(playback).at("kernel_observer"); + EXPECT_EQ(playback_observer.at("last_event"), "rendered"); + EXPECT_EQ(playback_observer.at("pending_frame_count"), 4); + EXPECT_GT(playback_observer.at("queue_wait_ns").get(), 0U); +} + +TEST(RenderiveWebGallery, ManualStrategyRunsOnlyExplicitPrepareRefreshRenderCycles) { + Gallery_Plot_Session session(true); + const auto opened = response_json(session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message("spectrum", "manual")))); + ASSERT_EQ(opened.at("type"), "case_state"); + const auto baseline = opened.at("telemetry").at("kernel_observer"); + EXPECT_EQ(baseline.at("mode"), "manual"); + EXPECT_EQ(baseline.at("limit_state"), "not_applicable"); + + std::this_thread::sleep_for(std::chrono::milliseconds(80)); + auto observer = observe_telemetry(session).at("kernel_observer"); + EXPECT_EQ(observer.at("latest_sequence"), baseline.at("latest_sequence")); + EXPECT_EQ(observer.at("produced_frame_count"), baseline.at("produced_frame_count")); + EXPECT_EQ(observer.at("consumed_frame_count"), baseline.at("consumed_frame_count")); + + auto state = invoke_action(session, "mode_prepare"); + observer = state.at("telemetry").at("kernel_observer"); + EXPECT_EQ(observer.at("last_event"), "prepared"); + EXPECT_EQ(observer.at("pending_frame_count"), 1); + EXPECT_EQ(observer.at("produced_frame_count").get(), + baseline.at("produced_frame_count").get() + 1); + + state = invoke_action(session, "mode_refresh"); + observer = state.at("telemetry").at("kernel_observer"); + EXPECT_EQ(observer.at("last_event"), "refresh_succeeded"); + EXPECT_EQ(observer.at("pending_frame_count"), 0); + EXPECT_EQ(observer.at("consumed_frame_count"), baseline.at("consumed_frame_count")); + + state = invoke_action(session, "mode_render"); + observer = state.at("telemetry").at("kernel_observer"); + EXPECT_EQ(observer.at("last_event"), "rendered"); + EXPECT_EQ(observer.at("pending_frame_count"), 0); + EXPECT_EQ(observer.at("consumed_frame_count").get(), + baseline.at("consumed_frame_count").get() + 1); + + ASSERT_EQ(invoke_action(session, "mode_prepare").at("type"), "case_state"); + state = invoke_action(session, "mode_discard"); + observer = state.at("telemetry").at("kernel_observer"); + EXPECT_EQ(observer.at("last_event"), "manually_discarded"); + EXPECT_EQ(observer.at("pending_frame_count"), 0); + EXPECT_EQ(observer.at("dropped_frame_count").get(), + baseline.at("dropped_frame_count").get() + 1); + + const auto failed_before = observer.at("failed_operation_count").get(); + state = invoke_action(session, "mode_refresh"); + observer = state.at("telemetry").at("kernel_observer"); + EXPECT_EQ(observer.at("last_event"), "refresh_failed"); + EXPECT_EQ(observer.at("failed_operation_count").get(), failed_before + 1); + + const auto sequence_after_actions = observer.at("latest_sequence"); + std::this_thread::sleep_for(std::chrono::milliseconds(80)); + EXPECT_EQ(observe_telemetry(session).at("kernel_observer").at("latest_sequence"), + sequence_after_actions); +} + +TEST(RenderiveWebGallery, LowLatencyStrategyKeepsForegroundResponsiveAt1000Hz) { + Gallery_Plot_Session session(true); + ASSERT_EQ(response_json(session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message("spectrum", "low_latency")))).at("type"), + "case_state"); + ASSERT_EQ(response_json(session.handle(gallery_request( + Gallery_Request_Kind::Patch, + R"({"category":"event","type":"gallery_patch","patch":{"max_render_fps":1000,"pixel_stream_fps":60}})"))).at("type"), + "case_state"); + + std::this_thread::sleep_for(std::chrono::milliseconds(80)); + std::string first_pixels; + std::string last_pixels; + for (int iteration = 0; iteration < 12; ++iteration) { + const auto started = std::chrono::steady_clock::now(); + const auto frame = session.handle(Frame_Request{}); + ASSERT_TRUE(frame.has_value()) << iteration; + ASSERT_EQ(frame->type, Web_Response_Type::Pixels) << iteration; + const auto telemetry = observe_telemetry(session); + EXPECT_LT(std::chrono::steady_clock::now() - started, + std::chrono::milliseconds(250)) << iteration; + EXPECT_EQ(telemetry.at("kernel_observer").at("configured_frequency_hz"), 1000.0); + if (iteration == 0) + first_pixels = frame->payload; + last_pixels = frame->payload; + std::this_thread::sleep_for(std::chrono::milliseconds(3)); + } + EXPECT_NE(first_pixels, last_pixels); + + const auto telemetry = observe_telemetry(session); + const auto& observer = telemetry.at("kernel_observer"); + EXPECT_EQ(observer.at("target_interval_ns"), 1'000'000); + EXPECT_TRUE(observer.at("limit_state") == "paint_limited" || + observer.at("limit_state") == "render_limited"); + EXPECT_GT(observer.at("latest_sequence").get(), 12U); + EXPECT_GT(telemetry.at("performance").at("measured_fps").get(), 100.0); + EXPECT_TRUE(telemetry.at("low_latency_limit").at("paint_limited").get() || + telemetry.at("low_latency_limit").at("render_limited").get()); + EXPECT_FALSE(telemetry.at("low_latency_limit").at("frequency_limited").get()); +} + +TEST(RenderiveWebGallery, PlaybackStrategyQueuesWithoutAutomaticConsumptionAndReportsEmptyQueue) { + Gallery_Plot_Session session(true); + const auto opened = response_json(session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message("spectrum", "playback")))); + ASSERT_EQ(opened.at("type"), "case_state"); + const auto baseline = opened.at("telemetry").at("kernel_observer"); + EXPECT_EQ(baseline.at("mode"), "playback"); + EXPECT_EQ(baseline.at("limit_state"), "not_applicable"); + + std::this_thread::sleep_for(std::chrono::milliseconds(80)); + auto observer = observe_telemetry(session).at("kernel_observer"); + EXPECT_EQ(observer.at("latest_sequence"), baseline.at("latest_sequence")); + EXPECT_EQ(observer.at("consumed_frame_count"), baseline.at("consumed_frame_count")); + + auto state = invoke_action(session, "mode_enqueue_burst", 5); + observer = state.at("telemetry").at("kernel_observer"); + EXPECT_EQ(observer.at("last_event"), "enqueued"); + EXPECT_EQ(observer.at("pending_frame_count"), 5); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + EXPECT_EQ(observe_telemetry(session).at("kernel_observer").at("pending_frame_count"), 5); + + state = invoke_action(session, "mode_dequeue"); + observer = state.at("telemetry").at("kernel_observer"); + EXPECT_EQ(observer.at("last_event"), "rendered"); + EXPECT_EQ(observer.at("pending_frame_count"), 4); + EXPECT_GT(observer.at("queue_wait_ns").get(), 0U); + + state = invoke_action(session, "mode_cycle"); + observer = state.at("telemetry").at("kernel_observer"); + EXPECT_EQ(observer.at("last_event"), "rendered"); + EXPECT_EQ(observer.at("pending_frame_count"), 4); + + for (int remaining = 3; remaining >= 0; --remaining) { + state = invoke_action(session, "mode_dequeue"); + observer = state.at("telemetry").at("kernel_observer"); + EXPECT_EQ(observer.at("pending_frame_count"), remaining); + } + const auto failed_before = observer.at("failed_operation_count").get(); + state = invoke_action(session, "mode_dequeue"); + observer = state.at("telemetry").at("kernel_observer"); + EXPECT_EQ(observer.at("last_event"), "queue_empty"); + EXPECT_EQ(observer.at("failed_operation_count").get(), failed_before + 1); + EXPECT_EQ(observer.at("pending_frame_count"), 0); + + const auto sequence_after_actions = observer.at("latest_sequence"); + std::this_thread::sleep_for(std::chrono::milliseconds(80)); + EXPECT_EQ(observe_telemetry(session).at("kernel_observer").at("latest_sequence"), + sequence_after_actions); +} + +TEST(RenderiveWebGallery, BackendRejectsActionsHiddenByCurrentCaseOrFrameMode) { + struct Rejected_Action { + std::string_view mode; + std::string_view action; + }; + constexpr std::array rejected{ + Rejected_Action{"manual", "mode_enqueue"}, + Rejected_Action{"low_latency", "mode_refresh"}, + Rejected_Action{"playback", "mode_discard"}, + Rejected_Action{"manual", "append_row"} + }; + for (const auto& item : rejected) { + Gallery_Plot_Session session; + ASSERT_EQ(response_json(session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message("spectrum", item.mode)))).at("type"), + "case_state") << item.mode; + const auto response = invoke_action(session, item.action); + EXPECT_EQ(response.at("type"), "error") << item.mode << '/' << item.action; + EXPECT_TRUE(response.at("field_errors").contains("action")) + << item.mode << '/' << item.action; + } +} + +TEST(RenderiveWebGallery, ProductionLowLatencySessionRendersWithoutPixelPulls) { + Gallery_Plot_Session session(true); + ASSERT_TRUE(session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message("spectrum", "low_latency")))); + ASSERT_TRUE(session.handle(gallery_request( + Gallery_Request_Kind::Patch, + R"({"category":"event","type":"gallery_patch","patch":{"max_render_fps":1000}})"))); + + std::this_thread::sleep_for(std::chrono::milliseconds(350)); + const auto observed = session.handle(gallery_request( + Gallery_Request_Kind::Observe, + R"({"category":"event","type":"gallery_observe","client_metrics":{"transport_fps":120,"presentation_fps":60,"websocket_buffered_bytes":0,"changed_pixel_frames":17,"duplicate_pixel_frames":3,"last_pixel_change_age_ms":12.5}})")); + ASSERT_TRUE(observed.has_value()); + const auto telemetry = parse_json(observed->payload).at("telemetry"); + SCOPED_TRACE(telemetry.dump(2)); + const auto& performance = telemetry.at("performance"); + RecordProperty("configured_fps", telemetry.at("kernel_observer") + .at("configured_frequency_hz").get()); + RecordProperty("measured_fps", performance.at("measured_fps").get()); + RecordProperty("successful_render_count", + performance.at("successful_render_count").get()); + RecordProperty("limit_state", + telemetry.at("kernel_observer").at("limit_state").get()); + EXPECT_TRUE(performance.at("automatic_low_latency_scheduler").get()); + EXPECT_GT(performance.at("successful_render_count").get(), 100U); + EXPECT_GT(performance.at("measured_fps").get(), 300.0); + EXPECT_DOUBLE_EQ(telemetry.at("kernel_observer").at("configured_frequency_hz"), 1000.0); + EXPECT_EQ(telemetry.at("kernel_observer").at("target_interval_ns"), 1'000'000); + EXPECT_TRUE(telemetry.at("low_latency_limit").at("paint_limited").get() || + telemetry.at("low_latency_limit").at("render_limited").get()); + EXPECT_FALSE(telemetry.at("low_latency_limit").at("frequency_limited").get()); + EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("transport_fps"), 120.0); + EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("presentation_fps"), 60.0); + EXPECT_EQ(telemetry.at("client_performance").at("changed_pixel_frames"), 17); + EXPECT_EQ(telemetry.at("client_performance").at("duplicate_pixel_frames"), 3); + EXPECT_DOUBLE_EQ(telemetry.at("client_performance").at("last_pixel_change_age_ms"), 12.5); +} + +void expect_low_latency_canvas_to_change(std::string_view case_id) { + SCOPED_TRACE(std::string(case_id)); + Gallery_Plot_Session session(true); + ASSERT_TRUE(session.handle(gallery_request( + Gallery_Request_Kind::Open, open_message(case_id, "low_latency")))); + ASSERT_FALSE(session.handle(Viewport_Resize{{240, 180}}).has_value()); + ASSERT_TRUE(session.handle(gallery_request( + Gallery_Request_Kind::Patch, + R"({"category":"event","type":"gallery_patch","patch":{"max_render_fps":120,"pixel_stream_fps":30}})"))); + + std::this_thread::sleep_for(std::chrono::milliseconds(90)); + const auto first = session.handle(Frame_Request{}); + ASSERT_TRUE(first.has_value()); + ASSERT_EQ(first->type, Web_Response_Type::Pixels); + std::this_thread::sleep_for(std::chrono::milliseconds(90)); + const auto second = session.handle(Frame_Request{}); + ASSERT_TRUE(second.has_value()); + ASSERT_EQ(second->type, Web_Response_Type::Pixels); + EXPECT_NE(first->payload, second->payload); + + const auto observed = session.handle(gallery_request( + Gallery_Request_Kind::Observe, + R"({"category":"event","type":"gallery_observe"})")); + ASSERT_TRUE(observed.has_value()); + const auto telemetry = parse_json(observed->payload).at("telemetry"); + EXPECT_GT(telemetry.at("frame_index").get(), 2U); + EXPECT_GT(telemetry.at("kernel_observer").at("latest_sequence").get(), 1U); +} + +TEST(RenderiveWebGallery, AxisLabLowLatencyPixelsChange) { + expect_low_latency_canvas_to_change("axis_lab"); +} + +TEST(RenderiveWebGallery, SpectrumLowLatencyPixelsChange) { + expect_low_latency_canvas_to_change("spectrum"); +} + +TEST(RenderiveWebGallery, AfterglowLowLatencyPixelsChange) { + expect_low_latency_canvas_to_change("afterglow"); +} + +TEST(RenderiveWebGallery, SweepSpectrumLowLatencyPixelsChange) { + expect_low_latency_canvas_to_change("sweep_spectrum"); +} + +TEST(RenderiveWebGallery, WaterfallLowLatencyPixelsChange) { + expect_low_latency_canvas_to_change("waterfall"); +} + +TEST(RenderiveWebGallery, FrequencyTraceLowLatencyPixelsChange) { + expect_low_latency_canvas_to_change("frequency_trace"); +} + +TEST(RenderiveWebGallery, SelectionOverlayLowLatencyPixelsChange) { + expect_low_latency_canvas_to_change("selection_overlay"); +} + +TEST(RenderiveWebGallery, ConstellationLowLatencyPixelsChange) { + expect_low_latency_canvas_to_change("constellation"); +} + +TEST(RenderiveWebGallery, BuilderOnlyControlsRebuildAndAllImageModesRender) { + Gallery_Plot_Session constellation; + ASSERT_TRUE(constellation.handle(gallery_request( + Gallery_Request_Kind::Open, + open_message("constellation", "low_latency")))); + const auto rebuilt = constellation.handle(gallery_request( + Gallery_Request_Kind::Patch, + R"({"category":"event","type":"gallery_patch","patch":{"constellation_type":"PSK16","phase_offset":0.75}})")); + ASSERT_TRUE(rebuilt.has_value()); + const auto rebuilt_json = parse_json(rebuilt->payload); + const auto& rebuilt_controls = rebuilt_json.at("controls").at("data"); + const auto type = std::find_if(rebuilt_controls.begin(), rebuilt_controls.end(), [](const auto& item) { + return item.at("id") == "constellation_type"; + }); + ASSERT_NE(type, rebuilt_controls.end()); + EXPECT_EQ(type->at("value"), "PSK16"); + ASSERT_TRUE(constellation.handle(Frame_Request{}).has_value()); + + Gallery_Plot_Session waterfall; + ASSERT_TRUE(waterfall.handle(gallery_request( + Gallery_Request_Kind::Open, + open_message("waterfall", "low_latency")))); + for (const std::string_view mode : {"Nearest", "Bilinear", "Bicubic"}) { + const std::string patch = + std::string(R"({"category":"event","type":"gallery_patch","patch":{"image_interpolation":")") + + std::string(mode) + R"("}})"; + ASSERT_TRUE(waterfall.handle(gallery_request(Gallery_Request_Kind::Patch, patch)).has_value()) + << mode; + ASSERT_TRUE(waterfall.handle(Frame_Request{}).has_value()) << mode; + } + const auto palette = waterfall.handle(gallery_request( + Gallery_Request_Kind::Patch, + R"({"category":"event","type":"gallery_patch","patch":{"color_map":"Ember"}})")); + ASSERT_TRUE(palette.has_value()); + ASSERT_TRUE(waterfall.handle(Frame_Request{}).has_value()); + const auto waterfall_observer = waterfall.handle(gallery_request( + Gallery_Request_Kind::Observe, + R"({"category":"event","type":"gallery_observe"})")); + ASSERT_TRUE(waterfall_observer.has_value()); + const auto waterfall_telemetry = parse_json(waterfall_observer->payload).at("telemetry"); + EXPECT_GT(waterfall_telemetry.at("waterfall").at("row_count").get(), 0U); + EXPECT_GT(waterfall_telemetry.at("waterfall").at("stored_point_count").get(), 0U); + EXPECT_GT(waterfall_telemetry.at("waterfall").at("rendered_cell_count").get(), 0U); } } // namespace diff --git a/webapp_gallery/app.js b/webapp_gallery/app.js new file mode 100644 index 0000000..48fdf91 --- /dev/null +++ b/webapp_gallery/app.js @@ -0,0 +1,554 @@ +"use strict"; + +const $ = id => document.getElementById(id); +const elements = { + pages: $("pages"), pageTemplate: $("page-template"), cardTemplate: $("card-template"), + connection: $("connection-status"), filters: $("category-filter"), modeTabs: $("mode-tabs"), + streamToggle: $("toggle-streams"), pageCount: $("page-count"), caseCount: $("case-count"), + canvasCount: $("canvas-count"), apiCount: $("api-count"), modeDescription: $("mode-description"), + menu: $("context-menu"), menuTitle: $("menu-title"), menuComponent: $("menu-component"), + menuDescription: $("menu-description"), menuBody: $("menu-body"), menuStatus: $("menu-status"), + menuClose: $("menu-close"), menuTabs: [...document.querySelectorAll(".menu-tabs button")], toast: $("toast") +}; + +const query = new URLSearchParams(location.search); +const hosted = location.protocol !== "file:" && ["/", "/index.html", "/gallery", "/gallery/"].includes(location.pathname); +const jetBrainsPreview = location.port === "63342"; +const socketPort = query.get("port") || (hosted && !jetBrainsPreview ? location.port : "8848") || "8848"; +const socketHost = query.get("host") || (hosted ? location.hostname : "127.0.0.1") || "127.0.0.1"; +const socketUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${socketHost}:${socketPort}/renderive/gallery`; + +const pages = new Map(); +let definitions = []; +let modes = []; +let activeMode = "low_latency"; +let activeCategory = "全部"; +let activeCard = null; +let activeTab = "controls"; +let streamsPaused = false; +let toastTimer = 0; + +const message = (type, payload = {}) => JSON.stringify({category: "event", type, ...payload}); +const setConnection = (state, text) => { + elements.connection.dataset.state = state; + elements.connection.querySelector("span").textContent = text; +}; +function toast(text, error = false) { + clearTimeout(toastTimer); + elements.toast.textContent = text; + elements.toast.dataset.error = String(error); + elements.toast.hidden = false; + toastTimer = setTimeout(() => { elements.toast.hidden = true; }, 2600); +} +function grouped(items) { + const groups = new Map(); + for (const item of items || []) { + const group = item.group || "其他"; + if (!groups.has(group)) groups.set(group, []); + groups.get(group).push(item); + } + return groups; +} +function flatten(value, prefix = "", result = []) { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + for (const [key, child] of Object.entries(value)) flatten(child, prefix ? `${prefix}.${key}` : key, result); + } else result.push([prefix, typeof value === "object" ? JSON.stringify(value) : String(value)]); + return result; +} +function formatNanoseconds(value) { + const nanoseconds = Math.max(0, Number(value) || 0); + if (nanoseconds < 1_000) return `${Math.round(nanoseconds)} ns`; + if (nanoseconds < 1_000_000) return `${(nanoseconds / 1_000).toFixed(2)} µs`; + return `${(nanoseconds / 1_000_000).toFixed(3)} ms`; +} + +class GalleryCard { + constructor(definition, mode) { + this.definition = definition; + this.mode = mode; + this.controls = []; + this.actions = []; + this.telemetry = {}; + this.frameCount = 0; + this.framePending = false; + this.frameTimer = null; + this.frameTimeout = null; + this.latestPixelBuffer = null; + this.transportTimes = []; + this.presentationTimes = []; + this.transportFps = 0; + this.presentationFps = 0; + this.lastPixelSignature = null; + this.changedPixelFrames = 0; + this.duplicatePixelFrames = 0; + this.lastPixelReceivedAt = 0; + this.lastPixelChangeAt = 0; + this.motionState = "waiting"; + this.reconnectTimer = null; + this.disposed = false; + this.ready = false; + this.visible = true; + this.lastFrameRequest = 0; + this.lastObserveRequest = 0; + this.node = elements.cardTemplate.content.firstElementChild.cloneNode(true); + this.node.dataset.category = definition.category; + this.node.dataset.mode = mode.id; + this.canvas = this.node.querySelector("canvas"); + this.shell = this.node.querySelector(".canvas-shell"); + this.context = this.canvas.getContext("2d", {alpha: false}); + this.socketState = this.node.querySelector(".card-socket"); + this.motionStatus = this.node.querySelector(".motion-status"); + this.observerFields = new Map([...this.node.querySelectorAll("[data-observer-field]")] + .map(field => [field.dataset.observerField, field])); + this.frameLabel = this.node.querySelector(".card-frames"); + this.node.querySelector(".card-category").textContent = definition.category; + this.node.querySelector(".card-title").textContent = definition.title; + this.node.querySelector(".card-description").textContent = definition.description; + this.node.querySelector(".card-component").textContent = definition.component; + this.node.querySelector(".card-controls").textContent = definition.control_count_by_mode?.[mode.id] ?? "—"; + this.node.querySelector(".card-actions").textContent = definition.action_count_by_mode?.[mode.id] ?? "—"; + const frameButton = this.node.querySelector(".frame-button"); + frameButton.textContent = mode.id === "manual" ? "手动刷新一帧" : mode.id === "playback" ? "消费下一帧" : "立即刷新"; + frameButton.addEventListener("click", () => this.requestFrame(performance.now(), true)); + this.node.querySelector(".open-menu").addEventListener("click", event => { + const rect = event.currentTarget.getBoundingClientRect(); + openMenu(this, rect.right, rect.bottom); + }); + this.node.addEventListener("contextmenu", event => { + event.preventDefault(); + openMenu(this, event.clientX, event.clientY); + }); + this.installCanvasEvents(); + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.resizeObserver.observe(this.shell); + this.intersectionObserver = new IntersectionObserver(entries => { + const wasVisible = this.visible; + this.visible = entries[0]?.isIntersecting !== false; + if (this.ready && wasVisible !== this.visible) this.send(this.visible ? "show" : "hide"); + if (this.visible) this.scheduleFramePump(); + }, {rootMargin: "160px"}); + this.intersectionObserver.observe(this.node); + this.connect(); + } + setSocketState(state, text) { + this.socketState.dataset.state = state; + this.socketState.querySelector("span").textContent = text; + } + connect() { + if (this.disposed || [WebSocket.OPEN, WebSocket.CONNECTING].includes(this.socket?.readyState)) return; + clearTimeout(this.reconnectTimer); + const socket = new WebSocket(socketUrl); + this.socket = socket; + socket.binaryType = "arraybuffer"; + socket.addEventListener("open", () => { + if (this.socket !== socket) return; + this.setSocketState("ready", "WS 已连接"); + this.send("gallery_open", {case: this.definition.id, frame_mode: this.mode.id}); + this.resize(); + }); + socket.addEventListener("message", event => { + if (this.socket === socket) + typeof event.data === "string" ? this.receiveJson(event.data) : this.receivePixels(event.data); + }); + socket.addEventListener("close", () => { + if (this.socket !== socket) return; + this.ready = false; + this.framePending = false; + clearTimeout(this.frameTimer); this.frameTimer = null; + clearTimeout(this.frameTimeout); this.frameTimeout = null; + this.setSocketState("error", "连接关闭 · 自动重连"); + this.updateMotionStatus(); + if (!this.disposed) this.reconnectTimer = setTimeout(() => this.connect(), 1000); + }); + socket.addEventListener("error", () => { + if (this.socket === socket) this.setSocketState("error", "连接错误 · 自动重连"); + }); + } + send(type, payload = {}) { + if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(message(type, payload)); + } + receiveJson(raw) { + let data; + try { data = JSON.parse(raw); } catch { toast(`${this.definition.title} 返回无效 JSON`, true); return; } + if (data.type === "error") { + const detail = Object.values(data.field_errors || {})[0] || data.message; + toast(detail || "后端拒绝操作", true); + if (activeCard === this) elements.menuStatus.textContent = detail; + return; + } + if (data.type === "observer_state") { + this.telemetry = data.telemetry || {}; + this.updatePerformance(); + if (activeCard === this && ["observer", "performance"].includes(activeTab)) renderMenuBody(); + return; + } + if (data.type !== "case_state") return; + this.controls = data.controls?.data || []; + this.actions = data.actions?.data || []; + this.telemetry = data.telemetry || {}; + this.ready = true; + this.node.dataset.ready = "true"; + this.send(this.visible ? "show" : "hide"); + this.node.querySelector(".card-controls").textContent = this.controls.length; + this.node.querySelector(".card-actions").textContent = this.actions.length; + this.setSocketState("ready", `${data.frame_mode?.strategy || "Core2"} 在线`); + this.updatePerformance(); + this.scheduleFramePump(true); + if (data.notice && !data.notice.includes("已创建")) toast(`${this.definition.title}:${data.notice}`); + if (activeCard === this) { elements.menuStatus.textContent = data.notice || "后端状态已回读"; renderMenuBody(); } + } + updatePerformance() { + const performanceData = this.telemetry.performance || {}; + const observer = this.telemetry.kernel_observer || {}; + const dataShape = this.telemetry.data_shape || {}; + const limit = this.telemetry.low_latency_limit || {}; + const limitNames = {frequency_limited: "用户设置频率受限", paint_limited: "PaintEvent 受限", render_limited: "后台渲染受限", not_applicable: "N/A"}; + this.node.querySelector(".perf-fps").textContent = Number(performanceData.measured_fps || 0).toFixed(1); + this.node.querySelector(".perf-transport-fps").textContent = Number(performanceData.pixel_response_fps || 0).toFixed(1); + this.node.querySelector(".perf-present-fps").textContent = Number(this.presentationFps || 0).toFixed(1); + this.node.querySelector(".perf-render").textContent = Number(performanceData.last_render_ms || 0).toFixed(2); + this.node.querySelector(".perf-encode").textContent = Number(performanceData.last_pixel_encode_ms || 0).toFixed(2); + this.node.querySelector(".perf-bandwidth").textContent = Number(performanceData.pixel_payload_megabytes_per_second || 0).toFixed(1); + this.node.querySelector(".perf-pending").textContent = observer.pending_frame_count ?? 0; + this.node.querySelector(".perf-dropped").textContent = observer.dropped_frame_count ?? 0; + this.node.querySelector(".perf-points").textContent = `${Number(dataShape.input_points || 0).toLocaleString()}→${Number(dataShape.rendered_elements || 0).toLocaleString()}`; + this.node.querySelector(".perf-limit").textContent = limitNames[observer.limit_state] || observer.limit_state || "N/A"; + this.node.querySelector(".perf-event").textContent = observer.last_event || "none"; + this.updateObserverDashboard(observer); + this.updateMotionStatus(); + const setLimitFlag = (selector, active, duration) => { + const flag = this.node.querySelector(selector); + flag.dataset.active = String(Boolean(active)); + flag.querySelector("b").textContent = `${active ? "受限" : "未受限"} · ${formatNanoseconds(duration)}`; + }; + setLimitFlag(".limit-frequency", limit.frequency_limited, observer.target_interval_ns); + setLimitFlag(".limit-paint", limit.paint_limited, observer.paint_duration_ns); + setLimitFlag(".limit-render", limit.render_limited, observer.render_duration_ns); + } + updateObserverDashboard(observer) { + const nanosecondFields = new Set([ + "paint_duration_ns", "render_duration_ns", "target_interval_ns", + "bottleneck_duration_ns", "next_refresh_interval_ns", "paint_lease_wait_ns", + "paint_state_wait_ns", "publish_state_wait_ns", "ready_wait_ns", + "frame_age_at_render_ns", "render_lease_wait_ns", "render_state_wait_ns", + "render_finish_state_wait_ns", "queue_wait_ns", "end_to_end_ns" + ]); + for (const [name, field] of this.observerFields) { + const raw = observer[name] ?? (name === "limit_state" ? "not_applicable" : name === "last_event" ? "none" : 0); + field.textContent = nanosecondFields.has(name) ? formatNanoseconds(raw) : + name === "configured_frequency_hz" ? `${Number(raw || 0).toLocaleString()} Hz` : + typeof raw === "number" ? raw.toLocaleString() : String(raw); + field.title = nanosecondFields.has(name) ? `${Number(raw || 0).toLocaleString()} ns` : String(raw); + } + } + pixelSignature(buffer, width, height, stride) { + const bytes = new Uint8Array(buffer, 16, stride * height); + let hash = (2166136261 ^ width ^ (height << 16)) >>> 0; + for (let offset = 0; offset < bytes.length; offset += 64) + hash = Math.imul(hash ^ bytes[offset], 16777619) >>> 0; + return hash; + } + updateMotionStatus(now = performance.now()) { + let state = "waiting", label = "等待动态帧"; + if (!this.ready || this.socket?.readyState !== WebSocket.OPEN) { + state = "stalled"; label = "像素流断开"; + } else if (this.lastPixelChangeAt && now - this.lastPixelChangeAt < 1500) { + state = "moving"; label = `画面变化 ${this.changedPixelFrames.toLocaleString()}`; + } else if (this.lastPixelReceivedAt && now - this.lastPixelReceivedAt < 1500) { + state = "duplicate"; label = `重复像素帧 ${this.duplicatePixelFrames.toLocaleString()}`; + } else if (this.lastPixelReceivedAt) { + state = "stalled"; label = "像素帧已停滞"; + } + if (state !== this.motionState || this.motionStatus.querySelector("span").textContent !== label) { + this.motionState = state; + this.motionStatus.dataset.state = state; + this.motionStatus.querySelector("span").textContent = label; + } + } + receivePixels(buffer) { + this.framePending = false; + if (this.frameTimeout !== null) { clearTimeout(this.frameTimeout); this.frameTimeout = null; } + if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 16) return; + const header = new DataView(buffer, 0, 16); + const magic = String.fromCharCode(...new Uint8Array(buffer, 0, 4)); + const width = header.getUint32(4, true), height = header.getUint32(8, true), stride = header.getUint32(12, true); + if (magic !== "RVP1" || !width || !height || stride < width * 4 || buffer.byteLength < 16 + stride * height) return; + const now = performance.now(); + const signature = this.pixelSignature(buffer, width, height, stride); + if (this.lastPixelSignature === null || signature !== this.lastPixelSignature) { + this.changedPixelFrames++; + this.lastPixelChangeAt = now; + } else this.duplicatePixelFrames++; + this.lastPixelSignature = signature; + this.lastPixelReceivedAt = now; + this.latestPixelBuffer = buffer; + this.transportFps = this.recordRate(this.transportTimes, now); + this.frameCount++; + this.frameLabel.textContent = this.frameCount.toLocaleString(); + this.updateMotionStatus(now); + this.scheduleFramePump(); + } + presentLatest(time) { + const buffer = this.latestPixelBuffer; + if (!buffer) return; + this.latestPixelBuffer = null; + const header = new DataView(buffer, 0, 16); + const width = header.getUint32(4, true), height = header.getUint32(8, true), stride = header.getUint32(12, true); + this.canvas.width = width; this.canvas.height = height; + if (stride === width * 4) { + this.context.putImageData(new ImageData(new Uint8ClampedArray(buffer, 16, width * height * 4), width, height), 0, 0); + } else { + const packed = new Uint8ClampedArray(width * height * 4), source = new Uint8Array(buffer, 16); + for (let row = 0; row < height; row++) packed.set(source.subarray(row * stride, row * stride + width * 4), row * width * 4); + this.context.putImageData(new ImageData(packed, width, height), 0, 0); + } + this.presentationFps = this.recordRate(this.presentationTimes, time); + } + recordRate(history, now) { + history.push(now); + while (history.length > 2 && history[0] < now - 1000) history.shift(); + if (history.length < 2) return 0; + return (history.length - 1) * 1000 / Math.max(1, history[history.length - 1] - history[0]); + } + resize() { + if (this.socket?.readyState !== WebSocket.OPEN) return; + const rect = this.shell.getBoundingClientRect(); + this.send("resize", {width: Math.max(240, Math.round(rect.width)), height: Math.max(180, Math.round(rect.height))}); + } + requestFrame(time, explicit = false) { + if (streamsPaused && !explicit || !this.visible || !this.ready || this.framePending || this.socket?.readyState !== WebSocket.OPEN) return; + if (!explicit && this.mode.id === "manual") return; + const streamControl = this.controls.find(item => item.id === "pixel_stream_fps"); + const interval = this.mode.id === "low_latency" ? 1000 / Math.max(1, Math.min(60, Number(streamControl?.value || 30))) : 66; + if (!explicit && time - this.lastFrameRequest < interval) return; + this.lastFrameRequest = time; + this.framePending = true; + this.send("frame"); + this.frameTimeout = setTimeout(() => { + this.framePending = false; + this.frameTimeout = null; + this.scheduleFramePump(); + }, 1500); + } + scheduleFramePump(reset = false) { + if (this.mode.id !== "low_latency") return; + if (reset && this.frameTimer !== null) { clearTimeout(this.frameTimer); this.frameTimer = null; } + if (this.frameTimer !== null || this.framePending || streamsPaused || !this.visible || + !this.ready || this.socket?.readyState !== WebSocket.OPEN) return; + const streamControl = this.controls.find(item => item.id === "pixel_stream_fps"); + const interval = 1000 / Math.max(1, Math.min(60, Number(streamControl?.value || 30))); + const delay = Math.max(0, this.lastFrameRequest + interval - performance.now()); + this.frameTimer = setTimeout(() => { + this.frameTimer = null; + this.requestFrame(performance.now()); + }, delay); + } + observe(time) { + if (!this.ready || !this.visible || this.socket?.readyState !== WebSocket.OPEN || time - this.lastObserveRequest < 650) return; + this.lastObserveRequest = time; + this.send("gallery_observe", {client_metrics: { + transport_fps: this.transportFps, + presentation_fps: this.presentationFps, + websocket_buffered_bytes: this.socket.bufferedAmount || 0, + changed_pixel_frames: this.changedPixelFrames, + duplicate_pixel_frames: this.duplicatePixelFrames, + last_pixel_change_age_ms: this.lastPixelChangeAt ? Math.max(0, time - this.lastPixelChangeAt) : 0 + }}); + } + position(event) { + const rect = this.canvas.getBoundingClientRect(); + return {x: (event.clientX - rect.left) * this.canvas.width / Math.max(1, rect.width), y: (event.clientY - rect.top) * this.canvas.height / Math.max(1, rect.height)}; + } + modifiers(event) { return (event.shiftKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.altKey ? 4 : 0) | (event.metaKey ? 8 : 0); } + installCanvasEvents() { + const pointer = (type, event) => this.send(type, {...this.position(event), button: ["left", "middle", "right"][event.button] || "none", buttons: event.buttons, modifiers: this.modifiers(event)}); + this.canvas.addEventListener("pointermove", event => pointer("pointer_move", event)); + this.canvas.addEventListener("pointerdown", event => { if (event.button !== 2) { this.shell.focus(); this.canvas.setPointerCapture(event.pointerId); pointer("pointer_press", event); } }); + this.canvas.addEventListener("pointerup", event => { if (event.button !== 2) pointer("pointer_release", event); }); + this.canvas.addEventListener("pointerleave", () => this.send("leave")); + this.canvas.addEventListener("wheel", event => { event.preventDefault(); this.send("wheel", {...this.position(event), pixelDeltaX: event.deltaX, pixelDeltaY: event.deltaY, angleDeltaX: -event.deltaX * 8, angleDeltaY: -event.deltaY * 8, buttons: event.buttons, modifiers: this.modifiers(event)}); }, {passive: false}); + this.shell.addEventListener("keydown", event => this.send("key_press", {key: event.key, nativeKey: event.keyCode, repeat: event.repeat, modifiers: this.modifiers(event)})); + this.shell.addEventListener("keyup", event => this.send("key_release", {key: event.key, nativeKey: event.keyCode, repeat: false, modifiers: this.modifiers(event)})); + } +} + +function createPage(mode) { + const page = elements.pageTemplate.content.firstElementChild.cloneNode(true); + page.dataset.mode = mode.id; + page.querySelector(".page-strategy").textContent = mode.strategy; + page.querySelector(".page-title").textContent = `${mode.title} · 全控件页`; + page.querySelector(".page-description").textContent = mode.description; + const gallery = page.querySelector(".gallery"); + const cards = definitions.map(definition => new GalleryCard(definition, mode)); + cards.forEach(card => gallery.append(card.node)); + elements.pages.append(page); + pages.set(mode.id, {mode, page, cards}); + return pages.get(mode.id); +} +function selectMode(id) { + activeMode = id; + let selected = pages.get(id); + if (!selected) selected = createPage(modes.find(mode => mode.id === id)); + for (const [modeId, page] of pages) page.page.hidden = modeId !== id; + [...elements.modeTabs.children].forEach(button => button.classList.toggle("active", button.dataset.mode === id)); + elements.modeDescription.textContent = selected.mode.description; + applyCategory(); + closeMenu(); +} +function applyCategory() { + const page = pages.get(activeMode); + if (!page) return; + for (const card of page.cards) card.node.hidden = activeCategory !== "全部" && card.definition.category !== activeCategory; +} +function installNavigation() { + elements.modeTabs.replaceChildren(...modes.map(mode => { + const button = document.createElement("button"); + button.type = "button"; button.dataset.mode = mode.id; + button.innerHTML = `${mode.title}${mode.strategy}`; + button.addEventListener("click", () => selectMode(mode.id)); + return button; + })); + const categories = ["全部", ...new Set(definitions.map(item => item.category))]; + elements.filters.replaceChildren(...categories.map(category => { + const button = document.createElement("button"); + button.type = "button"; button.textContent = category; + button.classList.toggle("active", category === activeCategory); + button.addEventListener("click", () => { + activeCategory = category; + [...elements.filters.children].forEach(child => child.classList.toggle("active", child === button)); + applyCategory(); + }); + return button; + })); +} + +function openMenu(card, x, y) { + if (!card.ready) { toast("该控件仍在等待后端描述", true); return; } + activeCard = card; activeTab = "controls"; + elements.menuComponent.textContent = `${card.mode.strategy} / ${card.definition.component}`; + elements.menuTitle.textContent = card.definition.title; + elements.menuDescription.textContent = card.definition.description; + elements.menuStatus.textContent = "菜单仅包含当前控件和当前帧策略可调用的 API"; + elements.menuTabs.forEach(button => button.classList.toggle("active", button.dataset.tab === activeTab)); + renderMenuBody(); + elements.menu.hidden = false; + const rect = elements.menu.getBoundingClientRect(), gap = 10; + elements.menu.style.left = `${Math.max(gap, Math.min(x, innerWidth - rect.width - gap))}px`; + elements.menu.style.top = `${Math.max(gap, Math.min(y, innerHeight - rect.height - gap))}px`; +} +function closeMenu() { elements.menu.hidden = true; activeCard = null; } +function renderControl(item) { + const row = document.createElement("div"); row.className = "control-row"; + const copy = document.createElement("div"); copy.className = "control-copy"; + const label = document.createElement("label"); label.textContent = item.label; + const api = document.createElement("code"); api.textContent = item.api; api.title = item.api; copy.append(label, api); + let input; + if (item.input === "select") { + input = document.createElement("select"); + for (const value of item.options || []) { const option = document.createElement("option"); option.value = value; option.textContent = value; input.append(option); } + input.value = String(item.value); + } else { + input = document.createElement("input"); input.type = item.input === "boolean" ? "checkbox" : item.input; + if (item.input === "boolean") input.checked = Boolean(item.value); else input.value = item.value; + if (item.input === "number") { input.min = item.minimum; input.max = item.maximum; input.step = item.step || "any"; } + } + input.className = "control-input"; input.title = item.description || item.api; + input.addEventListener("change", () => { + const value = item.input === "boolean" ? input.checked : item.input === "number" ? Number(input.value) : input.value; + activeCard.send("gallery_patch", {patch: {[item.id]: value}}); + elements.menuStatus.textContent = `调用 ${item.api}`; + }); + row.append(copy, input); return row; +} +function renderAction(item) { + const row = document.createElement("div"); row.className = "action-row"; + const copy = document.createElement("div"); copy.className = "action-copy"; + const label = document.createElement("strong"); label.textContent = item.label; + const api = document.createElement("code"); api.textContent = item.api; api.title = item.api; copy.append(label, api); + const controls = document.createElement("div"); controls.className = "action-controls"; + let argument = null; + if (item.argument_input) { argument = document.createElement("input"); argument.className = "control-input"; argument.type = item.argument_input; argument.value = item.argument_default; controls.append(argument); } + const button = document.createElement("button"); button.className = "action-button"; button.type = "button"; button.textContent = "执行"; + button.addEventListener("click", () => { + const card = activeCard; + const payload = {action: item.id}; + if (argument) payload.argument = item.argument_input === "number" ? Number(argument.value) : argument.value; + card.send("gallery_action", payload); + elements.menuStatus.textContent = `执行 ${item.api}`; + if (["mode_render", "mode_dequeue", "mode_cycle"].includes(item.id)) setTimeout(() => card.requestFrame(performance.now(), true), 40); + }); + controls.append(button); row.append(copy, controls); return row; +} +function renderGroups(items, renderer) { + const fragment = document.createDocumentFragment(); + for (const [name, children] of grouped(items)) { + const section = document.createElement("section"); section.className = "control-group"; + const title = document.createElement("h3"); title.textContent = name; + section.append(title, ...children.map(renderer)); fragment.append(section); + } + elements.menuBody.replaceChildren(fragment); +} +function renderData(value) { + const list = document.createElement("dl"); list.className = "telemetry-grid"; + for (const [key, content] of flatten(value)) { const dt = document.createElement("dt"), dd = document.createElement("dd"); dt.textContent = key; dd.textContent = content; list.append(dt, dd); } + elements.menuBody.replaceChildren(list); +} +function renderMenuBody() { + if (!activeCard) return; + if (activeTab === "actions") renderGroups(activeCard.actions, renderAction); + else if (activeTab === "observer") renderData(activeCard.telemetry.kernel_observer || {}); + else if (activeTab === "performance") renderData({performance: activeCard.telemetry.performance || {}, client_performance: activeCard.telemetry.client_performance || {}, low_latency_limit: activeCard.telemetry.low_latency_limit || {current: "not_applicable"}, data_shape: activeCard.telemetry.data_shape || {}, overlay: {enabled: activeCard.telemetry.performance_overlay_enabled, lines: activeCard.telemetry.performance_overlay_lines}, frame_mode: activeCard.telemetry.frame_mode}); + else renderGroups(activeCard.controls, renderControl); +} + +function buildCatalog(data) { + definitions = [...(data.cases || [])].sort((a, b) => a.order - b.order); + modes = [...(data.frame_modes || [])].sort((a, b) => a.order - b.order); + if (!modes.length) throw new Error("后端没有返回帧策略目录"); + elements.pages.replaceChildren(); + elements.pageCount.textContent = data.coverage?.page_count ?? modes.length; + elements.caseCount.textContent = data.coverage?.case_count ?? definitions.length; + elements.canvasCount.textContent = data.coverage?.canvas_count ?? definitions.length * modes.length; + elements.apiCount.textContent = (data.coverage?.manual_control_count || 0) + (data.coverage?.manual_action_count || 0); + installNavigation(); + if (!modes.some(mode => mode.id === activeMode)) activeMode = modes[0].id; + selectMode(activeMode); + setConnection("ready", "三种 Kernel 策略目录已加载"); +} +function connectCatalog() { + const socket = new WebSocket(socketUrl); + socket.addEventListener("open", () => socket.send(message("gallery_catalog"))); + socket.addEventListener("message", event => { + if (typeof event.data !== "string") return; + try { const data = JSON.parse(event.data); if (data.type === "catalog") { buildCatalog(data); socket.close(); } else if (data.type === "error") throw new Error(data.message); } + catch (error) { setConnection("error", "目录解析失败"); toast(error.message, true); } + }); + socket.addEventListener("error", () => { setConnection("error", `无法连接 ${socketUrl} · 自动重连`); }); + socket.addEventListener("close", () => { + if (!definitions.length) setTimeout(connectCatalog, 1000); + }); +} +function loop(time) { + const page = pages.get(activeMode); + for (const card of page?.cards || []) { + card.presentLatest(time); + card.updateMotionStatus(time); + if (card.mode.id !== "low_latency") card.requestFrame(time); + card.observe(time); + } + requestAnimationFrame(loop); +} + +elements.streamToggle.addEventListener("click", () => { + streamsPaused = !streamsPaused; + elements.streamToggle.textContent = streamsPaused ? "恢复自动像素流" : "暂停自动像素流"; + if (!streamsPaused) for (const page of pages.values()) for (const card of page.cards) card.scheduleFramePump(true); +}); +elements.menuClose.addEventListener("click", closeMenu); +elements.menuTabs.forEach(button => button.addEventListener("click", () => { activeTab = button.dataset.tab; elements.menuTabs.forEach(item => item.classList.toggle("active", item === button)); renderMenuBody(); })); +document.addEventListener("keydown", event => { if (event.key === "Escape" && !elements.menu.hidden) closeMenu(); }); +document.addEventListener("pointerdown", event => { if (!elements.menu.hidden && !elements.menu.contains(event.target) && !event.target.closest(".open-menu")) closeMenu(); }); +window.addEventListener("beforeunload", () => { for (const page of pages.values()) for (const card of page.cards) { card.disposed = true; clearTimeout(card.reconnectTimer); card.socket?.close(); } }); + +connectCatalog(); +requestAnimationFrame(loop); diff --git a/webapp_gallery/index.html b/webapp_gallery/index.html new file mode 100644 index 0000000..76735c9 --- /dev/null +++ b/webapp_gallery/index.html @@ -0,0 +1,147 @@ + + + + + + Renderive Core2 全控件性能画廊 + + + +
+
+ +

CORE2 · KERNEL · WEBSOCKET

全控件 API 与帧策略性能画廊

+
+
+ 读取后端目录 + +
+
+ +
+
+

THREE REAL KERNEL STRATEGIES

+

三套对称页面,同一批控件,直接比较

+

所有属性、动作、观察者和性能数据均由后端通过 WebSocket 返回。

+
+
+
3
帧策略页
+
每页控件
+
独立场景
+
手测入口
+
+
+ + + + +
+
等待后端返回控件与帧策略目录
+
+ + + + + + + + + + + + diff --git a/webapp_gallery/styles.css b/webapp_gallery/styles.css new file mode 100644 index 0000000..9cd54c6 --- /dev/null +++ b/webapp_gallery/styles.css @@ -0,0 +1,84 @@ +:root { + color-scheme: dark; + font-family: Inter, "Segoe UI Variable", "Microsoft YaHei UI", sans-serif; + --bg: #07090d; --panel: #10141b; --panel2: #151b24; --line: #29313d; + --strong: #3b4655; --text: #f2f5f8; --muted: #929eac; --accent: #45ddbe; + --blue: #6aa9ff; --warning: #ffd166; --danger: #ff6885; +} +* { box-sizing: border-box; } +html { background: var(--bg); } +body { + margin: 0; min-width: 320px; min-height: 100vh; color: var(--text); + background: linear-gradient(rgba(255,255,255,.018) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,.018) 1px, transparent 1px), + radial-gradient(circle at 16% -10%, rgba(69,221,190,.12), transparent 34rem), var(--bg); + background-size: 24px 24px, 24px 24px, auto, auto; +} +button, input, select { font: inherit; } button { color: inherit; } +h1, h2, h3, p { margin-top: 0; } +.eyebrow { margin: 0 0 5px; color: var(--accent); font: 700 10px/1.2 ui-monospace, monospace; letter-spacing: .15em; text-transform: uppercase; } +.topbar { position: sticky; z-index: 20; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 22px; min-height: 76px; padding: 12px clamp(18px,4vw,56px); border-bottom: 1px solid #ffffff14; background: #07090dea; backdrop-filter: blur(20px); } +.brand, .topbar-actions { display: flex; align-items: center; gap: 14px; } +.brand-mark { display: grid; place-items: center; width: 46px; height: 46px; border-radius: 12px; color: #07110f; background: var(--accent); font: 800 16px/1 ui-monospace, monospace; } +h1 { margin: 0; font-size: clamp(17px,2vw,23px); } +.connection-status, .card-socket { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font: 600 11px/1 ui-monospace, monospace; } +.connection-status i, .card-socket i { width: 8px; height: 8px; border-radius: 50%; background: var(--warning); box-shadow: 0 0 12px currentColor; } +[data-state="ready"] i { background: var(--accent); } [data-state="error"] i { background: var(--danger); } +.card-health { display:flex; align-items:flex-end; flex-direction:column; gap:7px; } +.motion-status { display:inline-flex; align-items:center; gap:6px; color:var(--muted); font:600 9px/1 ui-monospace,monospace; } +.motion-status i { width:6px; height:6px; border-radius:50%; background:var(--warning); } +.motion-status[data-state="moving"] { color:var(--accent); }.motion-status[data-state="moving"] i { background:var(--accent); box-shadow:0 0 10px var(--accent); animation:motion-pulse .9s ease-in-out infinite alternate; } +.motion-status[data-state="duplicate"] { color:var(--warning); }.motion-status[data-state="duplicate"] i { background:var(--warning); } +.motion-status[data-state="stalled"] { color:var(--danger); }.motion-status[data-state="stalled"] i { background:var(--danger); } +@keyframes motion-pulse { to { opacity:.35; transform:scale(.72); } } +.quiet-button, .open-menu, .frame-button, .icon-button, .menu-tabs button, .category-filter button, .mode-tabs button, .action-button { border: 1px solid var(--line); border-radius: 8px; background: var(--panel2); cursor: pointer; transition: .16s border-color,.16s background,.16s transform; } +button:hover { border-color: var(--accent); } button:active { transform: translateY(1px); } +.quiet-button { padding: 9px 13px; font-size: 12px; } +.hero { display: grid; grid-template-columns: minmax(300px,1fr) minmax(430px,.9fr); align-items: end; gap: 40px; max-width: 1680px; margin: auto; padding: clamp(32px,5vw,64px) clamp(18px,4vw,56px) 28px; } +.hero h2 { margin-bottom: 10px; font-size: clamp(27px,4vw,47px); letter-spacing: -.045em; } +.hero > div > p:last-child { margin: 0; max-width: 750px; color: var(--muted); line-height: 1.7; } +.metrics { display: grid; grid-template-columns: repeat(4,1fr); gap: 1px; margin: 0; border: 1px solid var(--line); background: var(--line); } +.metrics div { padding: 17px; background: var(--panel); } .metrics dt { color: var(--text); font: 750 clamp(20px,3vw,33px)/1 ui-monospace,monospace; } .metrics dd { margin: 8px 0 0; color: var(--muted); font-size: 10px; } +.mode-tabs { display: grid; grid-template-columns: repeat(3,1fr); gap: 10px; max-width: 1680px; margin: auto; padding: 0 clamp(18px,4vw,56px) 18px; } +.mode-tabs button { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; color: var(--muted); text-align: left; } +.mode-tabs button span { color: var(--text); font-weight: 750; } .mode-tabs button code { font-size: 10px; } +.mode-tabs button.active { border-color: var(--accent); background: #123029; box-shadow: inset 0 0 0 1px #45ddbe35; } +.category-filter { display: flex; gap: 7px; overflow-x: auto; max-width: 1680px; margin: auto; padding: 0 clamp(18px,4vw,56px) 20px; } +.category-filter button { flex: 0 0 auto; padding: 8px 11px; color: var(--muted); font-size: 11px; } +.category-filter button.active { color: #06110e; border-color: var(--accent); background: var(--accent); } +.pages, .mode-page { max-width: 1680px; margin: auto; } +.mode-page[hidden] { display: none; } +.page-header { display: flex; align-items: end; justify-content: space-between; gap: 24px; padding: 3px clamp(18px,4vw,56px) 18px; } +.page-header h2 { margin: 0; font-size: 23px; }.page-strategy { margin-bottom: 5px; color: var(--accent); font: 700 10px/1 ui-monospace,monospace; }.page-description { max-width: 650px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; text-align: right; } +.gallery { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 18px; padding: 0 clamp(18px,4vw,56px) 70px; } +.loading-card { display: grid; place-items: center; gap: 12px; min-height: 360px; margin: 0 clamp(18px,4vw,56px); border: 1px dashed var(--strong); color: var(--muted); } +.loader { width: 18px; height: 18px; border: 2px solid var(--strong); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } +.plot-card { min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 13px; background: linear-gradient(180deg,#ffffff08,transparent 30%),var(--panel); box-shadow: 0 20px 55px #0000002e; } +.plot-card[data-mode="manual"] { border-top-color: #ffd166aa; }.plot-card[data-mode="low_latency"] { border-top-color: #45ddbeaa; }.plot-card[data-mode="playback"] { border-top-color: #6aa9ffaa; }.plot-card[hidden] { display:none; } +.card-header, .card-footer { display:flex; align-items:center; justify-content:space-between; gap:15px; padding:13px 15px; }.card-header { border-bottom:1px solid var(--line); }.card-category { display:block; margin-bottom:4px; color:var(--blue); font:700 9px/1 ui-monospace,monospace; letter-spacing:.1em; }.card-title { margin:0; font-size:17px; } +.canvas-shell { position:relative; height:clamp(245px,27vw,350px); outline:none; background:#060a11; cursor:crosshair; }.canvas-shell:focus-visible { box-shadow:inset 0 0 0 2px var(--accent); }.canvas-shell canvas { display:block; width:100%; height:100%; }.canvas-hint { position:absolute; right:9px; bottom:8px; padding:5px 7px; border:1px solid #ffffff1e; border-radius:5px; color:#ffffffa8; background:#04070bc9; font:9px/1 ui-monospace,monospace; pointer-events:none; }.canvas-loading { position:absolute; inset:0; display:grid; place-content:center; justify-items:center; gap:9px; color:var(--muted); background:#080d15; }.plot-card[data-ready="true"] .canvas-loading { display:none; } +.performance-strip { display:grid; grid-template-columns:repeat(11,1fr); gap:1px; margin:0; border-block:1px solid var(--line); background:var(--line); }.performance-strip div { min-width:0; padding:9px 8px; background:#0c1118; }.performance-strip dt { overflow:hidden; color:var(--accent); font:700 11px/1 ui-monospace,monospace; text-overflow:ellipsis; white-space:nowrap; }.performance-strip .perf-event,.performance-strip .perf-limit { font-size:8px; }.performance-strip dd { margin:5px 0 0; color:var(--muted); font-size:8px; } +.limit-flags { display:flex; flex-wrap:wrap; align-items:center; gap:7px; padding:7px 10px; border-bottom:1px solid var(--line); color:var(--muted); background:#090e15; font:9px/1.2 ui-monospace,monospace; }.limit-flags strong { margin-right:auto; color:var(--text); }.limit-flags span { padding:4px 6px; border:1px solid var(--line); border-radius:4px; }.limit-flags span[data-active="true"] { border-color:#ffb84d88; color:#ffcf82; background:#ff9d1712; }.limit-flags b { color:inherit; }.plot-card:not([data-mode="low_latency"]) .limit-flags { display:none; } +.kernel-observer-panel { border-bottom:1px solid var(--line); background:#080d14; font-family:ui-monospace,monospace; } +.kernel-observer-panel > header { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:9px 11px; border-bottom:1px solid var(--line); color:var(--muted); font-size:8px; } +.kernel-observer-panel > header div:first-child { display:flex; align-items:center; gap:9px; }.kernel-observer-panel > header span { color:var(--accent); letter-spacing:.08em; }.kernel-observer-panel > header strong,.kernel-observer-panel > header b { color:var(--text); font-weight:700; } +.observer-counters,.latency-summary,.latency-details { display:grid; gap:1px; background:var(--line); } +.observer-counters { grid-template-columns:repeat(8,1fr); }.latency-summary { grid-template-columns:repeat(6,1fr); border-top:1px solid var(--line); }.latency-details { grid-template-columns:repeat(3,1fr); border-top:1px solid var(--line); } +.observer-counters div,.latency-summary div,.latency-details div { min-width:0; padding:8px 7px; background:#0b1119; } +.kernel-observer-panel small { display:block; overflow:hidden; margin-bottom:5px; color:var(--muted); font-size:7px; text-overflow:ellipsis; white-space:nowrap; } +.kernel-observer-panel b { display:block; overflow:hidden; color:#b7c7dc; font-size:9px; text-overflow:ellipsis; white-space:nowrap; } +.latency-summary b { color:var(--accent); font-size:10px; }.latency-summary .critical { background:#10201e; }.latency-summary .critical b { color:#72f3d9; } +.plot-card:not([data-mode="low_latency"]) .kernel-observer-panel { display:none; } +.card-meta { display:grid; grid-template-columns:1fr auto; gap:17px; padding:13px 15px; }.card-description { margin:0; color:var(--muted); font-size:11px; line-height:1.55; }.card-meta dl { display:flex; margin:0; }.card-meta dl div { min-width:52px; padding-left:11px; border-left:1px solid var(--line); }.card-meta dt { font:700 14px/1 ui-monospace,monospace; }.card-meta dd { margin:5px 0 0; color:var(--muted); font-size:9px; } +.card-footer { border-top:1px solid var(--line); background:#0000001e; }.card-footer code { color:var(--accent); font-size:10px; }.card-footer > div { display:flex; gap:7px; }.open-menu,.frame-button { padding:7px 9px; color:var(--muted); font-size:10px; }.frame-button { color:var(--text); } +.context-menu { position:fixed; z-index:100; width:min(570px,calc(100vw - 24px)); max-height:min(840px,calc(100vh - 24px)); overflow:hidden; border:1px solid var(--strong); border-radius:12px; background:#0f131afa; box-shadow:0 30px 90px #0000009e; backdrop-filter:blur(22px); }.context-menu[hidden] { display:none; } +.menu-header { display:flex; justify-content:space-between; gap:18px; padding:16px 18px 13px; border-bottom:1px solid var(--line); }.menu-header h2 { margin-bottom:6px; font-size:19px; }.menu-header p:last-child { margin:0; color:var(--muted); font-size:10px; line-height:1.5; }.icon-button { flex:0 0 auto; width:31px; height:31px; font-size:20px; } +.menu-tabs { display:grid; grid-template-columns:repeat(4,1fr); padding:8px; border-bottom:1px solid var(--line); }.menu-tabs button { padding:8px 4px; border-color:transparent; color:var(--muted); background:transparent; font-size:10px; }.menu-tabs button.active { color:var(--text); border-color:var(--strong); background:var(--panel2); } +.menu-body { max-height:calc(min(840px,100vh - 24px) - 190px); overflow:auto; padding:12px; overscroll-behavior:contain; }.control-group + .control-group { margin-top:15px; }.control-group h3 { position:sticky; z-index:1; top:-12px; margin:0 -2px 6px; padding:9px 4px 7px; color:var(--accent); background:#0f131af5; font:700 9px/1 ui-monospace,monospace; letter-spacing:.1em; } +.control-row { display:grid; grid-template-columns:minmax(145px,1fr) minmax(130px,.75fr); gap:13px; align-items:center; min-height:49px; padding:8px 9px; border-top:1px solid #ffffff0e; }.control-copy label,.action-copy strong { display:block; margin-bottom:4px; font-size:11px; }.control-copy code,.action-copy code { display:block; overflow:hidden; color:var(--muted); font-size:9px; text-overflow:ellipsis; white-space:nowrap; }.control-input { width:100%; min-width:0; padding:7px 8px; border:1px solid var(--strong); border-radius:6px; outline:none; color:var(--text); background:#090d13; font-size:10px; }.control-input:focus { border-color:var(--accent); }input[type="color"].control-input { height:34px; padding:3px; }input[type="checkbox"].control-input { justify-self:end; width:38px; height:20px; accent-color:var(--accent); } +.action-row { display:flex; align-items:center; justify-content:space-between; gap:13px; padding:10px 9px; border-top:1px solid #ffffff0e; }.action-copy { min-width:0; }.action-controls { display:flex; gap:7px; align-items:center; }.action-controls input { width:105px; }.action-button { padding:7px 10px; color:#07110f; border-color:var(--accent); background:var(--accent); font-size:10px; font-weight:700; } +.telemetry-grid { display:grid; grid-template-columns:minmax(165px,.7fr) 1fr; margin:0; border:1px solid var(--line); }.telemetry-grid dt,.telemetry-grid dd { margin:0; padding:8px 10px; border-bottom:1px solid var(--line); font:10px/1.4 ui-monospace,monospace; overflow-wrap:anywhere; }.telemetry-grid dt { color:var(--muted); background:#ffffff06; }.telemetry-grid dd { color:var(--accent); } +.menu-footer { display:flex; justify-content:space-between; gap:14px; padding:10px 14px; border-top:1px solid var(--line); color:var(--muted); font-size:9px; }.menu-footer code { color:var(--accent); } +.toast { position:fixed; z-index:140; left:50%; bottom:22px; max-width:min(560px,calc(100vw - 30px)); padding:11px 15px; border:1px solid var(--strong); border-radius:8px; background:var(--panel2); box-shadow:0 18px 50px #00000073; transform:translateX(-50%); font-size:11px; }.toast[data-error="true"] { border-color:var(--danger); } +@media (max-width:980px) { .hero { grid-template-columns:1fr; }.gallery { grid-template-columns:1fr; }.mode-tabs button { flex-direction:column; align-items:flex-start; }.page-header { align-items:flex-start; flex-direction:column; }.page-description { text-align:left; } } +@media (max-width:650px) { .topbar { align-items:flex-start; }.topbar-actions { align-items:flex-end; flex-direction:column; }.connection-status span { display:none; }.metrics { grid-template-columns:repeat(2,1fr); }.mode-tabs { grid-template-columns:1fr; }.mode-tabs button { flex-direction:row; }.card-meta { grid-template-columns:1fr; }.performance-strip { grid-template-columns:repeat(3,1fr); }.observer-counters { grid-template-columns:repeat(4,1fr); }.latency-summary,.latency-details { grid-template-columns:repeat(2,1fr); }.context-menu { inset:auto 6px 6px!important; width:auto; max-height:calc(100vh - 12px); } }