功能比较完善的一版

This commit is contained in:
2026-08-21 19:53:00 +08:00
parent ade34e18f2
commit ae8d2f7272
15 changed files with 403 additions and 154 deletions
-1
View File
@@ -22,7 +22,6 @@ concept Axis_Object = Renderable_Object<T> && std::derived_from<T, Abs_Axis> &&
struct Abs_Axis : Def<Abs_Axis, Renderable_2D<>> { struct Abs_Axis : Def<Abs_Axis, Renderable_2D<>> {
struct Prop : Prev_Prop { struct Prop : Prev_Prop {
Point_F position{}; /* 坐标轴起点在画布中的二维像素位置。 */ Point_F position{}; /* 坐标轴起点在画布中的二维像素位置。 */
Size canvas_size{}; /* 颜色缓存与裁剪区域使用的画布像素尺寸。 */
Axis_Pixel_Length pixel_length{}; /* 从坐标起点到终点的轴向像素跨度;为 0 时反算返回坐标起点。 */ Axis_Pixel_Length pixel_length{}; /* 从坐标起点到终点的轴向像素跨度;为 0 时反算返回坐标起点。 */
Axis_Orientation orientation{Axis_Orientation::horizontal}; /* 从二维点取轴向分量时使用的方向。 */ Axis_Orientation orientation{Axis_Orientation::horizontal}; /* 从二维点取轴向分量时使用的方向。 */
Axis_Tick_Length tick_length{10.0}; /* 主刻度线长度,单位为像素。 */ Axis_Tick_Length tick_length{10.0}; /* 主刻度线长度,单位为像素。 */
+2 -2
View File
@@ -184,7 +184,7 @@ inline void Abs_Axis::Private::prepare_data(Attached auto* object) {
const auto& state = static_cast<const Prop&>(*private_data.current); const auto& state = static_cast<const Prop&>(*private_data.current);
auto& output = prepared; auto& output = prepared;
output = {}; output = {};
if (state.pixel_length == 0.0 || state.canvas_size.empty()) return; if (state.pixel_length == 0.0) return;
const Axis_Range coordinates = private_data.coordinate_range(object); const Axis_Range coordinates = private_data.coordinate_range(object);
const double step = private_data.tick_step(object, coordinates); const double step = private_data.tick_step(object, coordinates);
if (!(step > 0.0) || !std::isfinite(step)) return; if (!(step > 0.0) || !std::isfinite(step)) return;
@@ -244,7 +244,7 @@ inline void Abs_Axis::Private::paint(Attached auto* object) {
const auto& state = static_cast<const Prop&>(*private_data.current); const auto& state = static_cast<const Prop&>(*private_data.current);
auto& cache = private_data.paint_surface(); auto& cache = private_data.paint_surface();
if (!prepared.valid) return; if (!prepared.valid) return;
detail::Painter painter(cache, state.canvas_size); detail::Painter painter(cache, cache.size());
for (const auto& line : prepared.lines) painter.line(line.first, line.second, state.axis_pen); for (const auto& line : prepared.lines) painter.line(line.first, line.second, state.axis_pen);
for (const auto& label : prepared.labels) for (const auto& label : prepared.labels)
painter.text(label.position, label.text, state.unit_text_font, state.unit_text_pen, painter.text(label.position, label.text, state.unit_text_font, state.unit_text_pen,
+1 -1
View File
@@ -10,7 +10,7 @@ struct Time_Axis : Def<Time_Axis, Abs_Axis> {
struct Prop : Prev_Prop { struct Prop : Prev_Prop {
Axis_Visible_Count visible_count{100}; /* 当前坐标区间最多覆盖的样本数量;小于 2 时按 2 计算。 */ Axis_Visible_Count visible_count{100}; /* 当前坐标区间最多覆盖的样本数量;小于 2 时按 2 计算。 */
Axis_Pixel_Length tick_label_spacing_px{8.0}; /* 相邻标签之间预留的像素间距;负值按 0 计算。 */ Axis_Pixel_Length tick_label_spacing_px{8.0}; /* 相邻标签之间预留的像素间距;负值按 0 计算。 */
Axis_Pixel_Length estimated_label_width_px{48.0}; /* 当前字体下单个时间标签的估算像素宽度;小于 48 时按 48 计算。 */ Axis_Pixel_Length estimated_label_width_px{76.0}; /* 当前字体下单个时间标签的估算像素宽度;小于 76 时按 76 计算。 */
std::string format{"mm:ss.zzz"}; /* 标签格式;支持 hh、HH、mm、ss 和 zzz。 */ std::string format{"mm:ss.zzz"}; /* 标签格式;支持 hh、HH、mm、ss 和 zzz。 */
bool newest_at_start{}; /* true 时最新样本位于坐标区间起点。 */ bool newest_at_start{}; /* true 时最新样本位于坐标区间起点。 */
bool operator==(const Prop&) const; bool operator==(const Prop&) const;
+2 -2
View File
@@ -44,8 +44,8 @@ inline double Time_Axis::Private::tick_step(const Attached auto* object, Axis_Ra
const auto& private_data = static_cast<const typename Object::Private&>(*this); const auto& private_data = static_cast<const typename Object::Private&>(*this);
const auto& time_state = static_cast<const Prop&>(*private_data.current); const auto& time_state = static_cast<const Prop&>(*private_data.current);
const auto& axis_state = static_cast<const Abs_Axis::Prop&>(*private_data.current); const auto& axis_state = static_cast<const Abs_Axis::Prop&>(*private_data.current);
const double label_width = std::max(48.0, time_state.estimated_label_width_px); const double label_width = std::max(76.0, time_state.estimated_label_width_px);
const double label_count = std::max(1.0, axis_state.pixel_length / (label_width + std::max(0.0, time_state.tick_label_spacing_px))); const double label_count = std::max(1.0, std::abs(axis_state.pixel_length) / (label_width + std::max(0.0, time_state.tick_label_spacing_px)));
return std::max(1.0, std::ceil(coordinate_range.size() / label_count)); return std::max(1.0, std::ceil(coordinate_range.size() / label_count));
} }
inline std::string Time_Axis::Private::tick_label(const Attached auto* object, double tick) const { inline std::string Time_Axis::Private::tick_label(const Attached auto* object, double tick) const {
@@ -84,8 +84,23 @@ void Frequency_Trace::Private::before_advance(Object*, Prop_Type*, State_Access<
template <Attached Object> template <Attached Object>
const Frequency_Trace::Private::Dispatch& Frequency_Trace::Private::dispatch_for() { const Frequency_Trace::Private::Dispatch& Frequency_Trace::Private::dispatch_for() {
static const Dispatch value{ static const Dispatch value{
[](Root* root, Plot_Time_Tick tick, Plot_Value sample_value) { auto* object = static_cast<Object*>(root); object->template update_prop<&Prop::samples>([=](Prop_Access<typename Object::Prop> props) { props.template get<Frequency_Trace::Base_Tag>().samples.push_back({tick, sample_value}); }); }, [](Root* root, Plot_Time_Tick tick, Plot_Value sample_value) {
[](Root* root, Time_Of_Day time, Plot_Value sample_value) { auto* object = static_cast<Object*>(root); auto& data = static_cast<typename Object::Private&>(*object->d); const Plot_Time_Tick tick = data.time_axis->append_time(time); object->template update_prop<&Prop::samples>([=](Prop_Access<typename Object::Prop> props) { props.template get<Frequency_Trace::Base_Tag>().samples.push_back({tick, sample_value}); }); }, auto* object = static_cast<Object*>(root);
const auto& data = static_cast<const typename Object::Private&>(*object->d);
const auto visible_count = static_cast<std::size_t>(std::max<Axis_Visible_Count>(
2, data.time_axis->template read_prop<Time_Axis::Base_Tag>().visible_count));
object->template update_prop<&Prop::samples>([=](Prop_Access<typename Object::Prop> props) {
auto& samples = props.template get<Frequency_Trace::Base_Tag>().samples;
samples.push_back({tick, sample_value});
if (samples.size() > visible_count)
samples.erase(samples.begin(), samples.begin() + static_cast<std::ptrdiff_t>(samples.size() - visible_count));
});
},
[](Root* root, Time_Of_Day time, Plot_Value sample_value) {
auto* object = static_cast<Object*>(root);
auto& data = static_cast<typename Object::Private&>(*object->d);
data.dispatch->append(root, data.time_axis->append_time(time), sample_value);
},
[](const Root* root) { return static_cast<const Object*>(root)->template read_prop<Frequency_Trace::Base_Tag>().samples.size(); }, [](const Root* root) { return static_cast<const Object*>(root)->template read_prop<Frequency_Trace::Base_Tag>().samples.size(); },
[](const Root* root) { const auto& data = static_cast<const typename Object::Private&>(*static_cast<const Object*>(root)->d); std::size_t result{}; for (const auto& curve : data.prepared.partitions) result += curve.points.size(); return result; } [](const Root* root) { const auto& data = static_cast<const typename Object::Private&>(*static_cast<const Object*>(root)->d); std::size_t result{}; for (const auto& curve : data.prepared.partitions) result += curve.points.size(); return result; }
}; return value; }; return value;
+1 -1
View File
@@ -181,7 +181,7 @@ void Spectrum::Private::prepare_frame(Object* object, std::size_t partition_coun
prepared = {}; prepared = {};
prepared.partitions.resize(partition_count); prepared.partitions.resize(partition_count);
if (frequency_layout.orientation == power_layout.orientation) return; if (frequency_layout.orientation == power_layout.orientation) return;
if (scene_state.viewport.empty() || frequency_layout.canvas_size != scene_state.viewport || power_layout.canvas_size != scene_state.viewport) return; if (scene_state.viewport.empty()) return;
prepared.canvas_size = scene_state.viewport; prepared.canvas_size = scene_state.viewport;
if (state.sweep_region_visible) prepared.sweep_region = detail::map_plot_rect(frequency_axis, state.sweep_frequency_range, power_axis, power_state.coordinate_range, frequency_layout.orientation); if (state.sweep_region_visible) prepared.sweep_region = detail::map_plot_rect(frequency_axis, state.sweep_frequency_range, power_axis, power_state.coordinate_range, frequency_layout.orientation);
prepared.markers.push_back({detail::map_plot_point(frequency_axis, state.center_frequency, power_axis, power_state.coordinate_range.origin, frequency_layout.orientation), detail::map_plot_point(frequency_axis, state.center_frequency, power_axis, power_state.coordinate_range.target, frequency_layout.orientation), Marker_Style::middle}); prepared.markers.push_back({detail::map_plot_point(frequency_axis, state.center_frequency, power_axis, power_state.coordinate_range.origin, frequency_layout.orientation), detail::map_plot_point(frequency_axis, state.center_frequency, power_axis, power_state.coordinate_range.target, frequency_layout.orientation), Marker_Style::middle});
+4 -3
View File
@@ -36,6 +36,10 @@ TEST(axis_dispatch, numeric_axis_public_shell_reads_final_private_state) {
EXPECT_DOUBLE_EQ(axis->tick_step({0.0, 20.0}), 5.0); EXPECT_DOUBLE_EQ(axis->tick_step({0.0, 20.0}), 5.0);
EXPECT_EQ(axis->tick_label(2.5), "2.5"); EXPECT_EQ(axis->tick_label(2.5), "2.5");
EXPECT_EQ(axis->sub_tick_count(5.0), 4); EXPECT_EQ(axis->sub_tick_count(5.0), 4);
axis->set<&Abs_Axis::Prop::pixel_length>(-200.0);
axis->advance();
EXPECT_DOUBLE_EQ(axis->coordinate_to_pixel(5.0), -40.0);
EXPECT_DOUBLE_EQ(axis->pixel_to_coordinate(-40.0), 5.0);
} }
TEST(axis_dispatch, point_conversion_uses_current_orientation) { TEST(axis_dispatch, point_conversion_uses_current_orientation) {
using Object = Impl<Numeric_Axis>; using Object = Impl<Numeric_Axis>;
@@ -71,7 +75,6 @@ TEST(axis_render, scene_prepares_and_paints_uncached_axis_into_frame) {
auto axis = build_axis<Object>(); auto axis = build_axis<Object>();
auto scene = build_axis<Scene_Object>(); auto scene = build_axis<Scene_Object>();
axis->set<&Abs_Axis::Prop::position>(Point_F{8.0, 8.0}); axis->set<&Abs_Axis::Prop::position>(Point_F{8.0, 8.0});
axis->set<&Abs_Axis::Prop::canvas_size>(Size{160, 64});
axis->set<&Abs_Axis::Prop::pixel_length>(120.0); axis->set<&Abs_Axis::Prop::pixel_length>(120.0);
scene->set<&Render_Scene_2D::Prop::viewport>(Size{160, 64}); scene->set<&Render_Scene_2D::Prop::viewport>(Size{160, 64});
scene->activate_view(); scene->activate_view();
@@ -122,11 +125,9 @@ TEST(axis_event, scene_routes_pointer_interaction_to_the_nearest_axis_segment) {
auto vertical = build_axis<Axis_Object>(); auto vertical = build_axis<Axis_Object>();
auto scene = build_axis<Scene_Object>(); auto scene = build_axis<Scene_Object>();
horizontal->set<&Abs_Axis::Prop::position>(Point_F{20.0, 180.0}); horizontal->set<&Abs_Axis::Prop::position>(Point_F{20.0, 180.0});
horizontal->set<&Abs_Axis::Prop::canvas_size>(Size{200, 200});
horizontal->set<&Abs_Axis::Prop::pixel_length>(160.0); horizontal->set<&Abs_Axis::Prop::pixel_length>(160.0);
horizontal->set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{0.0, 10.0}); horizontal->set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{0.0, 10.0});
vertical->set<&Abs_Axis::Prop::position>(Point_F{20.0, 180.0}); vertical->set<&Abs_Axis::Prop::position>(Point_F{20.0, 180.0});
vertical->set<&Abs_Axis::Prop::canvas_size>(Size{200, 200});
vertical->set<&Abs_Axis::Prop::pixel_length>(-160.0); vertical->set<&Abs_Axis::Prop::pixel_length>(-160.0);
vertical->set<&Abs_Axis::Prop::orientation>(Axis_Orientation::vertical); vertical->set<&Abs_Axis::Prop::orientation>(Axis_Orientation::vertical);
vertical->set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{0.0, 10.0}); vertical->set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{0.0, 10.0});
@@ -29,7 +29,6 @@ void configure_axis(Axis* axis, Axis_Orientation orientation, Point_F position,
axis->template set<&Abs_Axis::Prop::orientation>(orientation); axis->template set<&Abs_Axis::Prop::orientation>(orientation);
axis->template set<&Abs_Axis::Prop::position>(position); axis->template set<&Abs_Axis::Prop::position>(position);
axis->template set<&Abs_Axis::Prop::pixel_length>(length); axis->template set<&Abs_Axis::Prop::pixel_length>(length);
axis->template set<&Abs_Axis::Prop::canvas_size>(canvas);
} }
} }
-4
View File
@@ -85,11 +85,9 @@ TEST(render_scene_2d, composites_axes_and_spectrum_into_final_frame) {
auto spectrum = build_object<Spectrum_Object>(scene.get(), frequency.get(), power.get()); auto spectrum = build_object<Spectrum_Object>(scene.get(), frequency.get(), power.get());
const Size canvas{160, 120}; const Size canvas{160, 120};
frequency->set<&Abs_Axis::Prop::position>(Point_F{20.0, 100.0}); frequency->set<&Abs_Axis::Prop::position>(Point_F{20.0, 100.0});
frequency->set<&Abs_Axis::Prop::canvas_size>(canvas);
frequency->set<&Abs_Axis::Prop::pixel_length>(120.0); frequency->set<&Abs_Axis::Prop::pixel_length>(120.0);
frequency->set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{0.0, 100.0}); frequency->set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{0.0, 100.0});
power->set<&Abs_Axis::Prop::position>(Point_F{20.0, 100.0}); power->set<&Abs_Axis::Prop::position>(Point_F{20.0, 100.0});
power->set<&Abs_Axis::Prop::canvas_size>(canvas);
power->set<&Abs_Axis::Prop::pixel_length>(-80.0); power->set<&Abs_Axis::Prop::pixel_length>(-80.0);
power->set<&Abs_Axis::Prop::orientation>(Axis_Orientation::vertical); power->set<&Abs_Axis::Prop::orientation>(Axis_Orientation::vertical);
power->set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{-100.0, 0.0}); power->set<&Numeric_Axis::Prop::coordinate_range>(Axis_Range{-100.0, 0.0});
@@ -149,8 +147,6 @@ TEST(render_scene_2d, composites_axes_and_spectrum_into_final_frame) {
EXPECT_TRUE(contains_color(scene->frame_view())); EXPECT_TRUE(contains_color(scene->frame_view()));
const Size resized_canvas{200, 140}; const Size resized_canvas{200, 140};
scene->set<&Render_Scene_2D::Prop::viewport>(resized_canvas); scene->set<&Render_Scene_2D::Prop::viewport>(resized_canvas);
frequency->set<&Abs_Axis::Prop::canvas_size>(resized_canvas);
power->set<&Abs_Axis::Prop::canvas_size>(resized_canvas);
EXPECT_TRUE(frequency->dirty<Prepare_Data_Tag>()); EXPECT_TRUE(frequency->dirty<Prepare_Data_Tag>());
EXPECT_TRUE(power->dirty<Prepare_Data_Tag>()); EXPECT_TRUE(power->dirty<Prepare_Data_Tag>());
scene->render(); scene->render();
+44 -69
View File
@@ -97,7 +97,7 @@ public:
nlohmann::json schema() const override { nlohmann::json schema() const override {
nlohmann::json components = nlohmann::json::array(); nlohmann::json components = nlohmann::json::array();
for (const auto& descriptor : descriptors) components.push_back(descriptor->schema()); for (const auto& descriptor : descriptors) components.push_back(descriptor->schema());
return {{"protocol", "aethera.plot.inspector"}, {"version", 1}, {"components", std::move(components)}}; return {{"protocol", "aethera.plot.inspector"}, {"version", 2}, {"components", std::move(components)}};
} }
nlohmann::json write_prop(std::string_view component, std::string_view key, const nlohmann::json& value) override { nlohmann::json write_prop(std::string_view component, std::string_view key, const nlohmann::json& value) override {
const auto found = std::ranges::find_if(descriptors, [&](const auto& item) { return item->id() == component; }); const auto found = std::ranges::find_if(descriptors, [&](const auto& item) { return item->id() == component; });
@@ -145,6 +145,7 @@ std::unique_ptr<detail::Renderable_Descriptor> make_scene_component(Scene_Object
using Definition = typename Scene_Object::Attached_Object; using Definition = typename Scene_Object::Attached_Object;
using Prop = typename Definition::Prop; using Prop = typename Definition::Prop;
using Adapter = detail::Renderable_Adapter<Scene_Object, using Adapter = detail::Renderable_Adapter<Scene_Object,
detail::Prop_Field<&Prop::viewport, "viewport", "Final scene viewport in physical pixels.">,
detail::Prop_Field<&Prop::background, "background", "Scene clear color.">, detail::Prop_Field<&Prop::background, "background", "Scene clear color.">,
detail::Prop_Field<&Prop::view_active, "view_active", "Whether the scene publishes rendered frames.">, detail::Prop_Field<&Prop::view_active, "view_active", "Whether the scene publishes rendered frames.">,
detail::State_Field<Scene::Base_Tag, &Scene::State::taskflow_rebuilt, "taskflow_rebuilt", "Whether the scene task graph was rebuilt.">, detail::State_Field<Scene::Base_Tag, &Scene::State::taskflow_rebuilt, "taskflow_rebuilt", "Whether the scene task graph was rebuilt.">,
@@ -154,7 +155,7 @@ std::unique_ptr<detail::Renderable_Descriptor> make_scene_component(Scene_Object
detail::State_Field<Scene::Base_Tag, &Scene::State::taskflow_max_predecessors, "taskflow_max_predecessors", "Maximum direct predecessors of a task.">, detail::State_Field<Scene::Base_Tag, &Scene::State::taskflow_max_predecessors, "taskflow_max_predecessors", "Maximum direct predecessors of a task.">,
detail::State_Field<Scene::Base_Tag, &Scene::State::taskflow_max_successors, "taskflow_max_successors", "Maximum direct successors of a task.">, detail::State_Field<Scene::Base_Tag, &Scene::State::taskflow_max_successors, "taskflow_max_successors", "Maximum direct successors of a task.">,
detail::State_Field<Scene::Base_Tag, &Scene::State::taskflow_execution_time_ns, "taskflow_execution_time_ns", "Scene graph execution time in nanoseconds.">>; detail::State_Field<Scene::Base_Tag, &Scene::State::taskflow_execution_time_ns, "taskflow_execution_time_ns", "Scene graph execution time in nanoseconds.">>;
return detail::make_renderable_descriptor("scene", "Scene", "scene", Adapter{scene}); return detail::make_renderable_descriptor("scene", "场景", "scene", Adapter{scene});
} }
template <> template <>
@@ -169,33 +170,14 @@ std::unique_ptr<detail::Renderable_Descriptor> make_scene_component<Scene_3D>(Sc
detail::State_Field<Scene::Base_Tag, &Scene::State::taskflow_max_predecessors, "taskflow_max_predecessors", "Maximum direct predecessors of a task.">, detail::State_Field<Scene::Base_Tag, &Scene::State::taskflow_max_predecessors, "taskflow_max_predecessors", "Maximum direct predecessors of a task.">,
detail::State_Field<Scene::Base_Tag, &Scene::State::taskflow_max_successors, "taskflow_max_successors", "Maximum direct successors of a task.">, detail::State_Field<Scene::Base_Tag, &Scene::State::taskflow_max_successors, "taskflow_max_successors", "Maximum direct successors of a task.">,
detail::State_Field<Scene::Base_Tag, &Scene::State::taskflow_execution_time_ns, "taskflow_execution_time_ns", "Scene graph execution time in nanoseconds.">>; detail::State_Field<Scene::Base_Tag, &Scene::State::taskflow_execution_time_ns, "taskflow_execution_time_ns", "Scene graph execution time in nanoseconds.">>;
return detail::make_renderable_descriptor("scene", "Scene", "scene", Adapter{scene}); return detail::make_renderable_descriptor("scene", "场景", "scene", Adapter{scene});
} }
template <typename Axis_Object> template <typename Axis_Object>
std::unique_ptr<detail::Renderable_Descriptor> make_axis_component( std::unique_ptr<detail::Renderable_Descriptor> make_axis_component(
std::string id, std::string label, Axis_Object& axis) { std::string id, std::string label, Axis_Object& axis) {
using Adapter = detail::Renderable_Adapter<Axis_Object,
Prop_Field<&Abs_Axis::Prop::position, "position", "Axis origin in viewport pixels.">,
Prop_Field<&Abs_Axis::Prop::canvas_size, "canvas_size", "Axis canvas extent.">,
Prop_Field<&Abs_Axis::Prop::pixel_length, "pixel_length", "Signed axis length in pixels.">,
Prop_Field<&Abs_Axis::Prop::orientation, "orientation", "Axis orientation.">,
Prop_Field<&Abs_Axis::Prop::tick_length, "tick_length", "Major tick length.">,
Prop_Field<&Abs_Axis::Prop::sub_tick_length, "sub_tick_length", "Minor tick length.">,
Prop_Field<&Abs_Axis::Prop::axis_pen, "axis_pen", "Axis line and tick style.">,
Prop_Field<&Abs_Axis::Prop::unit_text, "unit_text", "Axis unit label.">,
Prop_Field<&Abs_Axis::Prop::unit_text_font, "unit_text_font", "Axis label font.">,
Prop_Field<&Abs_Axis::Prop::unit_text_pen, "unit_text_pen", "Axis label foreground.">,
Prop_Field<&Abs_Axis::Prop::unit_text_background_brush, "unit_text_background_brush", "Axis label background.">,
Prop_Field<&Abs_Axis::Prop::label_rotation_degrees, "label_rotation_degrees", "Tick label rotation.">,
Prop_Field<&Numeric_Axis::Prop::coordinate_range, "coordinate_range", "Visible coordinate range.">,
Prop_Field<&Numeric_Axis::Prop::precision, "precision", "Maximum decimal precision.">,
Prop_Field<&Numeric_Axis::Prop::locale, "locale", "Numeric label locale.">,
Prop_Field<&Numeric_Axis::Prop::wheel_enabled, "wheel_enabled", "Allows wheel zoom.">,
Prop_Field<&Numeric_Axis::Prop::drag_enabled, "drag_enabled", "Allows pointer drag panning.">>;
return make_renderable_component<Axis_Object, return make_renderable_component<Axis_Object,
Prop_Field<&Abs_Axis::Prop::position, "position", "Axis origin in viewport pixels.">, Prop_Field<&Abs_Axis::Prop::position, "position", "Axis origin in viewport pixels.">,
Prop_Field<&Abs_Axis::Prop::canvas_size, "canvas_size", "Axis canvas extent.">,
Prop_Field<&Abs_Axis::Prop::pixel_length, "pixel_length", "Signed axis length in pixels.">, Prop_Field<&Abs_Axis::Prop::pixel_length, "pixel_length", "Signed axis length in pixels.">,
Prop_Field<&Abs_Axis::Prop::orientation, "orientation", "Axis orientation.">, Prop_Field<&Abs_Axis::Prop::orientation, "orientation", "Axis orientation.">,
Prop_Field<&Abs_Axis::Prop::tick_length, "tick_length", "Major tick length.">, Prop_Field<&Abs_Axis::Prop::tick_length, "tick_length", "Major tick length.">,
@@ -219,7 +201,6 @@ std::unique_ptr<detail::Renderable_Descriptor> make_axis_component<Time_Axis_Obj
std::string id, std::string label, Time_Axis_Object& axis) { std::string id, std::string label, Time_Axis_Object& axis) {
return make_renderable_component<Time_Axis_Object, return make_renderable_component<Time_Axis_Object,
Prop_Field<&Abs_Axis::Prop::position, "position", "Axis origin in viewport pixels.">, Prop_Field<&Abs_Axis::Prop::position, "position", "Axis origin in viewport pixels.">,
Prop_Field<&Abs_Axis::Prop::canvas_size, "canvas_size", "Axis canvas extent.">,
Prop_Field<&Abs_Axis::Prop::pixel_length, "pixel_length", "Signed axis length in pixels.">, Prop_Field<&Abs_Axis::Prop::pixel_length, "pixel_length", "Signed axis length in pixels.">,
Prop_Field<&Abs_Axis::Prop::orientation, "orientation", "Axis orientation.">, Prop_Field<&Abs_Axis::Prop::orientation, "orientation", "Axis orientation.">,
Prop_Field<&Abs_Axis::Prop::tick_length, "tick_length", "Major tick length.">, Prop_Field<&Abs_Axis::Prop::tick_length, "tick_length", "Major tick length.">,
@@ -251,7 +232,7 @@ std::unique_ptr<Plot::Scene_View> make_scene_view(
using State = typename Definition::State; using State = typename Definition::State;
std::vector<std::unique_ptr<detail::Renderable_Descriptor>> components; std::vector<std::unique_ptr<detail::Renderable_Descriptor>> components;
components.push_back(make_scene_component(scene)); components.push_back(make_scene_component(scene));
components.push_back(make_renderable_component<Object, Fields...>("plot", "Plot Renderable", "renderable", object)); components.push_back(make_renderable_component<Object, Fields...>("plot", "主绘图组件", "renderable", object));
std::size_t axis_index{}; std::size_t axis_index{};
const auto append_owned = [&](const auto& owned) { const auto append_owned = [&](const auto& owned) {
using Owned = std::remove_cvref_t<decltype(owned)>; using Owned = std::remove_cvref_t<decltype(owned)>;
@@ -259,7 +240,7 @@ std::unique_ptr<Plot::Scene_View> make_scene_view(
|| std::same_as<typename Owned::element_type, Numeric_Axis_Object> || std::same_as<typename Owned::element_type, Numeric_Axis_Object>
|| std::same_as<typename Owned::element_type, Time_Axis_Object>) { || std::same_as<typename Owned::element_type, Time_Axis_Object>) {
const auto id = axis_index++ == 0 ? "axis-x" : "axis-y"; const auto id = axis_index++ == 0 ? "axis-x" : "axis-y";
components.push_back(make_axis_component(id, id == std::string_view{"axis-x"} ? "Horizontal Axis" : "Vertical Axis", *owned)); components.push_back(make_axis_component(id, id == std::string_view{"axis-x"} ? "横向坐标轴" : "纵向坐标轴", *owned));
} }
}; };
(append_owned(owned_objects), ...); (append_owned(owned_objects), ...);
@@ -269,51 +250,46 @@ std::unique_ptr<Plot::Scene_View> make_scene_view(
std::forward<Owned_Objects>(owned_objects)...); std::forward<Owned_Objects>(owned_objects)...);
} }
Frequency_Axis_Object::Builder frequency_axis_builder(Size canvas) { Frequency_Axis_Object::Builder frequency_axis_builder() {
Frequency_Axis_Object::Builder builder; Frequency_Axis_Object::Builder builder;
builder builder
.set(&Abs_Axis::Prop::orientation, Axis_Orientation::horizontal) .set(&Abs_Axis::Prop::orientation, Axis_Orientation::horizontal)
.set(&Abs_Axis::Prop::position, Point_F{64.0, 370.0}) .set(&Abs_Axis::Prop::position, Point_F{64.0, 370.0})
.set(&Abs_Axis::Prop::pixel_length, 620.0) .set(&Abs_Axis::Prop::pixel_length, 620.0)
.set(&Abs_Axis::Prop::canvas_size, canvas)
.set(&Numeric_Axis::Prop::coordinate_range, Axis_Range{0.0, 100.0}); .set(&Numeric_Axis::Prop::coordinate_range, Axis_Range{0.0, 100.0});
return builder; return builder;
} }
Numeric_Axis_Object::Builder numeric_axis_builder( Numeric_Axis_Object::Builder numeric_axis_builder(
Axis_Orientation orientation, Point_F position, Axis_Pixel_Length length, Axis_Orientation orientation, Point_F position, Axis_Pixel_Length length,
Axis_Range range, Size canvas) { Axis_Range range) {
Numeric_Axis_Object::Builder builder; Numeric_Axis_Object::Builder builder;
builder builder
.set(&Abs_Axis::Prop::orientation, orientation) .set(&Abs_Axis::Prop::orientation, orientation)
.set(&Abs_Axis::Prop::position, position) .set(&Abs_Axis::Prop::position, position)
.set(&Abs_Axis::Prop::pixel_length, length) .set(&Abs_Axis::Prop::pixel_length, length)
.set(&Abs_Axis::Prop::canvas_size, canvas)
.set(&Numeric_Axis::Prop::coordinate_range, range); .set(&Numeric_Axis::Prop::coordinate_range, range);
return builder; return builder;
} }
Time_Axis_Object::Builder time_axis_builder( Time_Axis_Object::Builder time_axis_builder(
Axis_Orientation orientation, Point_F position, Axis_Pixel_Length length, Size canvas) { Axis_Orientation orientation, Point_F position, Axis_Pixel_Length length) {
Time_Axis_Object::Builder builder; Time_Axis_Object::Builder builder;
builder builder
.set(&Abs_Axis::Prop::orientation, orientation) .set(&Abs_Axis::Prop::orientation, orientation)
.set(&Abs_Axis::Prop::position, position) .set(&Abs_Axis::Prop::position, position)
.set(&Abs_Axis::Prop::pixel_length, length) .set(&Abs_Axis::Prop::pixel_length, length);
.set(&Abs_Axis::Prop::canvas_size, canvas);
return builder; return builder;
} }
template <typename... Axes> template <typename... Axes>
void resize_axes(Size viewport, Axes*... axes) { void resize_axes(Scene_2D* scene, Size viewport, Axes*... axes) {
const Size previous_viewport = scene->template read_prop<Render_Scene_2D::Base_Tag>().viewport;
if (previous_viewport == viewport || previous_viewport.empty()) return;
const auto resize_axis = [&](auto* axis) { const auto resize_axis = [&](auto* axis) {
const auto layout = axis->template read_prop<Abs_Axis::Base_Tag>(); const auto layout = axis->template read_prop<Abs_Axis::Base_Tag>();
if (layout.canvas_size == viewport) return; const double horizontal_scale = static_cast<double>(viewport.width) / previous_viewport.width;
const double horizontal_scale = layout.canvas_size.width > 0 const double vertical_scale = static_cast<double>(viewport.height) / previous_viewport.height;
? static_cast<double>(viewport.width) / layout.canvas_size.width : 1.0;
const double vertical_scale = layout.canvas_size.height > 0
? static_cast<double>(viewport.height) / layout.canvas_size.height : 1.0;
axis->template set<&Abs_Axis::Prop::canvas_size>(viewport);
axis->template set<&Abs_Axis::Prop::position>(Point_F{ axis->template set<&Abs_Axis::Prop::position>(Point_F{
layout.position.x * horizontal_scale, layout.position.x * horizontal_scale,
layout.position.y * vertical_scale}); layout.position.y * vertical_scale});
@@ -373,9 +349,9 @@ void dispatch_plot_input(Scene_Object& scene, const Plot_Input_Event& input) {
std::shared_ptr<Plot> make_spectrum_plot(asio::any_io_executor executor) { std::shared_ptr<Plot> make_spectrum_plot(asio::any_io_executor executor) {
constexpr Size canvas{720, 420}; constexpr Size canvas{720, 420};
auto frequency = *frequency_axis_builder(canvas).build(); auto frequency = *frequency_axis_builder().build();
auto vertical = *numeric_axis_builder( auto vertical = *numeric_axis_builder(
Axis_Orientation::vertical, {64.0, 370.0}, -320.0, {-110.0, 0.0}, canvas).build(); Axis_Orientation::vertical, {64.0, 370.0}, -320.0, {-110.0, 0.0}).build();
Impl<Spectrum>::Builder spectrum_builder(frequency.get(), vertical.get()); Impl<Spectrum>::Builder spectrum_builder(frequency.get(), vertical.get());
spectrum_builder.set(&Spectrum::Prop::frequency_range, Axis_Range{0.0, 100.0}) spectrum_builder.set(&Spectrum::Prop::frequency_range, Axis_Range{0.0, 100.0})
.set(&Spectrum::Prop::max_hold_visible, true); .set(&Spectrum::Prop::max_hold_visible, true);
@@ -386,8 +362,8 @@ std::shared_ptr<Plot> make_spectrum_plot(asio::any_io_executor executor) {
.set(&Render_Scene_2D::Prop::view_active, true); .set(&Render_Scene_2D::Prop::view_active, true);
scene_builder.add_renderable(spectrum.get()); scene_builder.add_renderable(spectrum.get());
auto scene = *scene_builder.build(); auto scene = *scene_builder.build();
auto update = [raw = spectrum.get(), frequency = frequency.get(), vertical = vertical.get()](const Plot_Event& event) { auto update = [scene = scene.get(), raw = spectrum.get(), frequency = frequency.get(), vertical = vertical.get()](const Plot_Event& event) {
resize_axes({static_cast<int>(event.width), static_cast<int>(event.height)}, frequency, vertical); resize_axes(scene, {static_cast<int>(event.width), static_cast<int>(event.height)}, frequency, vertical);
if (event.input) return; if (event.input) return;
std::array<double, 256> samples{}; std::array<double, 256> samples{};
for (std::size_t i = 0; i < samples.size(); ++i) { for (std::size_t i = 0; i < samples.size(); ++i) {
@@ -433,9 +409,9 @@ std::shared_ptr<Plot> make_spectrum_plot(asio::any_io_executor executor) {
std::shared_ptr<Plot> make_frequency_trace_plot(asio::any_io_executor executor) { std::shared_ptr<Plot> make_frequency_trace_plot(asio::any_io_executor executor) {
constexpr Size canvas{720, 420}; constexpr Size canvas{720, 420};
auto time = *time_axis_builder( auto time = *time_axis_builder(
Axis_Orientation::horizontal, {64.0, 370.0}, 620.0, canvas).build(); Axis_Orientation::horizontal, {64.0, 370.0}, 620.0).build();
auto vertical = *numeric_axis_builder( auto vertical = *numeric_axis_builder(
Axis_Orientation::vertical, {64.0, 370.0}, -320.0, {-1.2, 1.2}, canvas).build(); Axis_Orientation::vertical, {64.0, 370.0}, -320.0, {-1.2, 1.2}).build();
auto trace = *Impl<Frequency_Trace>::Builder(time.get(), vertical.get()).build(); auto trace = *Impl<Frequency_Trace>::Builder(time.get(), vertical.get()).build();
Scene_2D::Builder scene_builder; Scene_2D::Builder scene_builder;
scene_builder.set(&Render_Scene_2D::Prop::viewport, canvas) scene_builder.set(&Render_Scene_2D::Prop::viewport, canvas)
@@ -443,8 +419,8 @@ std::shared_ptr<Plot> make_frequency_trace_plot(asio::any_io_executor executor)
.set(&Render_Scene_2D::Prop::view_active, true); .set(&Render_Scene_2D::Prop::view_active, true);
scene_builder.add_renderable(trace.get()); scene_builder.add_renderable(trace.get());
auto scene = *scene_builder.build(); auto scene = *scene_builder.build();
auto update = [raw = trace.get(), time = time.get(), vertical = vertical.get()](const Plot_Event& event) { auto update = [scene = scene.get(), raw = trace.get(), time = time.get(), vertical = vertical.get()](const Plot_Event& event) {
resize_axes({static_cast<int>(event.width), static_cast<int>(event.height)}, time, vertical); resize_axes(scene, {static_cast<int>(event.width), static_cast<int>(event.height)}, time, vertical);
if (event.input) return; if (event.input) return;
constexpr double day_milliseconds = 86'400'000.0; constexpr double day_milliseconds = 86'400'000.0;
raw->append_sample(Time_Of_Day{static_cast<std::int64_t>( raw->append_sample(Time_Of_Day{static_cast<std::int64_t>(
@@ -465,9 +441,9 @@ std::shared_ptr<Plot> make_frequency_trace_plot(asio::any_io_executor executor)
std::shared_ptr<Plot> make_sweep_spectrum_plot(asio::any_io_executor executor) { std::shared_ptr<Plot> make_sweep_spectrum_plot(asio::any_io_executor executor) {
constexpr Size canvas{720, 420}; constexpr Size canvas{720, 420};
auto frequency = *frequency_axis_builder(canvas).build(); auto frequency = *frequency_axis_builder().build();
auto vertical = *numeric_axis_builder( auto vertical = *numeric_axis_builder(
Axis_Orientation::vertical, {64.0, 370.0}, -320.0, {-110.0, 0.0}, canvas).build(); Axis_Orientation::vertical, {64.0, 370.0}, -320.0, {-110.0, 0.0}).build();
Impl<Sweep_Spectrum>::Builder sweep_builder(frequency.get(), vertical.get()); Impl<Sweep_Spectrum>::Builder sweep_builder(frequency.get(), vertical.get());
sweep_builder.set(&Sweep_Spectrum::Prop::frequency_range, Axis_Range{0.0, 100.0}) sweep_builder.set(&Sweep_Spectrum::Prop::frequency_range, Axis_Range{0.0, 100.0})
.set(&Sweep_Spectrum::Prop::bins_per_block, std::size_t{8}) .set(&Sweep_Spectrum::Prop::bins_per_block, std::size_t{8})
@@ -479,8 +455,8 @@ std::shared_ptr<Plot> make_sweep_spectrum_plot(asio::any_io_executor executor) {
.set(&Render_Scene_2D::Prop::view_active, true); .set(&Render_Scene_2D::Prop::view_active, true);
scene_builder.add_renderable(sweep.get()); scene_builder.add_renderable(sweep.get());
auto scene = *scene_builder.build(); auto scene = *scene_builder.build();
auto update = [raw = sweep.get(), frequency = frequency.get(), vertical = vertical.get(), block_index = std::size_t{}](const Plot_Event& event) mutable { auto update = [scene = scene.get(), raw = sweep.get(), frequency = frequency.get(), vertical = vertical.get(), block_index = std::size_t{}](const Plot_Event& event) mutable {
resize_axes({static_cast<int>(event.width), static_cast<int>(event.height)}, frequency, vertical); resize_axes(scene, {static_cast<int>(event.width), static_cast<int>(event.height)}, frequency, vertical);
if (event.input) return; if (event.input) return;
const auto& state = raw->template read_prop<Sweep_Spectrum::Base_Tag>(); const auto& state = raw->template read_prop<Sweep_Spectrum::Base_Tag>();
const std::size_t block_count = std::max<std::size_t>(1, state.block_count); const std::size_t block_count = std::max<std::size_t>(1, state.block_count);
@@ -517,9 +493,9 @@ std::shared_ptr<Plot> make_sweep_spectrum_plot(asio::any_io_executor executor) {
std::shared_ptr<Plot> make_afterglow_plot(asio::any_io_executor executor) { std::shared_ptr<Plot> make_afterglow_plot(asio::any_io_executor executor) {
constexpr Size canvas{720, 420}; constexpr Size canvas{720, 420};
auto frequency = *frequency_axis_builder(canvas).build(); auto frequency = *frequency_axis_builder().build();
auto vertical = *numeric_axis_builder( auto vertical = *numeric_axis_builder(
Axis_Orientation::vertical, {64.0, 370.0}, -320.0, {-110.0, 0.0}, canvas).build(); Axis_Orientation::vertical, {64.0, 370.0}, -320.0, {-110.0, 0.0}).build();
Impl<Afterglow>::Builder afterglow_builder(frequency.get(), vertical.get()); Impl<Afterglow>::Builder afterglow_builder(frequency.get(), vertical.get());
afterglow_builder.set(&Afterglow::Prop::frequency_range, Axis_Range{0.0, 100.0}) afterglow_builder.set(&Afterglow::Prop::frequency_range, Axis_Range{0.0, 100.0})
.set(&Afterglow::Prop::power_range, Axis_Range{-110.0, 0.0}) .set(&Afterglow::Prop::power_range, Axis_Range{-110.0, 0.0})
@@ -531,8 +507,8 @@ std::shared_ptr<Plot> make_afterglow_plot(asio::any_io_executor executor) {
.set(&Render_Scene_2D::Prop::view_active, true); .set(&Render_Scene_2D::Prop::view_active, true);
scene_builder.add_renderable(afterglow.get()); scene_builder.add_renderable(afterglow.get());
auto scene = *scene_builder.build(); auto scene = *scene_builder.build();
auto update = [raw = afterglow.get(), frequency = frequency.get(), vertical = vertical.get()](const Plot_Event& event) { auto update = [scene = scene.get(), raw = afterglow.get(), frequency = frequency.get(), vertical = vertical.get()](const Plot_Event& event) {
resize_axes({static_cast<int>(event.width), static_cast<int>(event.height)}, frequency, vertical); resize_axes(scene, {static_cast<int>(event.width), static_cast<int>(event.height)}, frequency, vertical);
if (event.input) return; if (event.input) return;
std::array<double, 192> values{}; std::array<double, 192> values{};
for (std::size_t i = 0; i < values.size(); ++i) for (std::size_t i = 0; i < values.size(); ++i)
@@ -561,9 +537,9 @@ std::shared_ptr<Plot> make_afterglow_plot(asio::any_io_executor executor) {
std::shared_ptr<Plot> make_waterfall_plot(asio::any_io_executor executor) { std::shared_ptr<Plot> make_waterfall_plot(asio::any_io_executor executor) {
constexpr Size canvas{720, 420}; constexpr Size canvas{720, 420};
auto frequency = *frequency_axis_builder(canvas).build(); auto frequency = *frequency_axis_builder().build();
auto time = *time_axis_builder( auto time = *time_axis_builder(
Axis_Orientation::vertical, {64.0, 370.0}, -320.0, canvas).build(); Axis_Orientation::vertical, {64.0, 370.0}, -320.0).build();
Impl<Waterfall>::Builder waterfall_builder(frequency.get(), time.get()); Impl<Waterfall>::Builder waterfall_builder(frequency.get(), time.get());
waterfall_builder.set(&Waterfall::Prop::frequency_range, Axis_Range{0.0, 100.0}) waterfall_builder.set(&Waterfall::Prop::frequency_range, Axis_Range{0.0, 100.0})
.set(&Waterfall::Prop::power_range, Axis_Range{-110.0, 0.0}); .set(&Waterfall::Prop::power_range, Axis_Range{-110.0, 0.0});
@@ -574,8 +550,8 @@ std::shared_ptr<Plot> make_waterfall_plot(asio::any_io_executor executor) {
.set(&Render_Scene_2D::Prop::view_active, true); .set(&Render_Scene_2D::Prop::view_active, true);
scene_builder.add_renderable(waterfall.get()); scene_builder.add_renderable(waterfall.get());
auto scene = *scene_builder.build(); auto scene = *scene_builder.build();
auto update = [raw = waterfall.get(), frequency = frequency.get(), time = time.get()](const Plot_Event& event) { auto update = [scene = scene.get(), raw = waterfall.get(), frequency = frequency.get(), time = time.get()](const Plot_Event& event) {
resize_axes({static_cast<int>(event.width), static_cast<int>(event.height)}, frequency, time); resize_axes(scene, {static_cast<int>(event.width), static_cast<int>(event.height)}, frequency, time);
if (event.input) return; if (event.input) return;
std::array<double, 192> values{}; std::array<double, 192> values{};
for (std::size_t i = 0; i < values.size(); ++i) for (std::size_t i = 0; i < values.size(); ++i)
@@ -610,9 +586,9 @@ std::shared_ptr<Plot> make_waterfall_plot(asio::any_io_executor executor) {
std::shared_ptr<Plot> make_constellation_plot(asio::any_io_executor executor) { std::shared_ptr<Plot> make_constellation_plot(asio::any_io_executor executor) {
constexpr Size canvas{720, 420}; constexpr Size canvas{720, 420};
auto horizontal = *numeric_axis_builder( auto horizontal = *numeric_axis_builder(
Axis_Orientation::horizontal, {64.0, 370.0}, 620.0, {-1.2, 1.2}, canvas).build(); Axis_Orientation::horizontal, {64.0, 370.0}, 620.0, {-1.2, 1.2}).build();
auto vertical = *numeric_axis_builder( auto vertical = *numeric_axis_builder(
Axis_Orientation::vertical, {64.0, 370.0}, -320.0, {-1.2, 1.2}, canvas).build(); Axis_Orientation::vertical, {64.0, 370.0}, -320.0, {-1.2, 1.2}).build();
Impl<Constellation_Diagram>::Builder constellation_builder(horizontal.get(), vertical.get()); Impl<Constellation_Diagram>::Builder constellation_builder(horizontal.get(), vertical.get());
constellation_builder.set(&Constellation_Diagram::Prop::i_range, Axis_Range{-1.2, 1.2}) constellation_builder.set(&Constellation_Diagram::Prop::i_range, Axis_Range{-1.2, 1.2})
.set(&Constellation_Diagram::Prop::q_range, Axis_Range{-1.2, 1.2}); .set(&Constellation_Diagram::Prop::q_range, Axis_Range{-1.2, 1.2});
@@ -623,8 +599,8 @@ std::shared_ptr<Plot> make_constellation_plot(asio::any_io_executor executor) {
.set(&Render_Scene_2D::Prop::view_active, true); .set(&Render_Scene_2D::Prop::view_active, true);
scene_builder.add_renderable(constellation.get()); scene_builder.add_renderable(constellation.get());
auto scene = *scene_builder.build(); auto scene = *scene_builder.build();
auto update = [raw = constellation.get(), horizontal = horizontal.get(), vertical = vertical.get()](const Plot_Event& event) { auto update = [scene = scene.get(), raw = constellation.get(), horizontal = horizontal.get(), vertical = vertical.get()](const Plot_Event& event) {
resize_axes({static_cast<int>(event.width), static_cast<int>(event.height)}, horizontal, vertical); resize_axes(scene, {static_cast<int>(event.width), static_cast<int>(event.height)}, horizontal, vertical);
if (event.input) return; if (event.input) return;
const auto& state = raw->template read_prop<Constellation_Diagram::Base_Tag>(); const auto& state = raw->template read_prop<Constellation_Diagram::Base_Tag>();
const int anchor_count = static_cast<int>(state.type); const int anchor_count = static_cast<int>(state.type);
@@ -658,9 +634,9 @@ std::shared_ptr<Plot> make_constellation_plot(asio::any_io_executor executor) {
std::shared_ptr<Plot> make_selection_overlay_plot(asio::any_io_executor executor) { std::shared_ptr<Plot> make_selection_overlay_plot(asio::any_io_executor executor) {
constexpr Size canvas{720, 420}; constexpr Size canvas{720, 420};
auto horizontal = *numeric_axis_builder( auto horizontal = *numeric_axis_builder(
Axis_Orientation::horizontal, {64.0, 370.0}, 620.0, {0.0, 100.0}, canvas).build(); Axis_Orientation::horizontal, {64.0, 370.0}, 620.0, {0.0, 100.0}).build();
auto vertical = *numeric_axis_builder( auto vertical = *numeric_axis_builder(
Axis_Orientation::vertical, {64.0, 370.0}, -320.0, {0.0, 100.0}, canvas).build(); Axis_Orientation::vertical, {64.0, 370.0}, -320.0, {0.0, 100.0}).build();
auto selection = *Impl<Selection_Rectangle_Overlay>::Builder(horizontal.get(), vertical.get()).build(); auto selection = *Impl<Selection_Rectangle_Overlay>::Builder(horizontal.get(), vertical.get()).build();
Scene_2D::Builder scene_builder; Scene_2D::Builder scene_builder;
scene_builder.set(&Render_Scene_2D::Prop::viewport, canvas) scene_builder.set(&Render_Scene_2D::Prop::viewport, canvas)
@@ -668,8 +644,8 @@ std::shared_ptr<Plot> make_selection_overlay_plot(asio::any_io_executor executor
.set(&Render_Scene_2D::Prop::view_active, true); .set(&Render_Scene_2D::Prop::view_active, true);
scene_builder.add_renderable(selection.get()); scene_builder.add_renderable(selection.get());
auto scene = *scene_builder.build(); auto scene = *scene_builder.build();
auto update = [horizontal = horizontal.get(), vertical = vertical.get()](const Plot_Event& event) { auto update = [scene = scene.get(), horizontal = horizontal.get(), vertical = vertical.get()](const Plot_Event& event) {
resize_axes({static_cast<int>(event.width), static_cast<int>(event.height)}, horizontal, vertical); resize_axes(scene, {static_cast<int>(event.width), static_cast<int>(event.height)}, horizontal, vertical);
}; };
auto view = make_scene_view< auto view = make_scene_view<
Prop_Field<&Selection_Rectangle_Overlay::Prop::label_font, "label_font", "Font used for labels attached to selected regions.">, Prop_Field<&Selection_Rectangle_Overlay::Prop::label_font, "label_font", "Font used for labels attached to selected regions.">,
@@ -787,7 +763,6 @@ void Plot::attach(const void* owner, Frame_Handler handler) {
std::lock_guard lock(d->handlers_mutex); std::lock_guard lock(d->handlers_mutex);
d->handlers.insert_or_assign(owner, std::move(handler)); d->handlers.insert_or_assign(owner, std::move(handler));
} }
submit({});
} }
void Plot::detach(const void* owner) { void Plot::detach(const void* owner) {
@@ -835,7 +810,7 @@ std::shared_ptr<Plot> make_datoviz_point_plot(asio::any_io_executor executor) {
detail::State_Field<Point_Visual::Base_Tag, &Point_Visual::State::prepared_revision, "prepared_revision", "Property revision represented by the currently prepared GPU data.">>; detail::State_Field<Point_Visual::Base_Tag, &Point_Visual::State::prepared_revision, "prepared_revision", "Property revision represented by the currently prepared GPU data.">>;
std::vector<std::unique_ptr<detail::Renderable_Descriptor>> components; std::vector<std::unique_ptr<detail::Renderable_Descriptor>> components;
components.push_back(make_scene_component(*scene)); components.push_back(make_scene_component(*scene));
components.push_back(detail::make_renderable_descriptor("plot", "Point Visual", "visual", Adapter{*visual})); components.push_back(detail::make_renderable_descriptor("plot", "主绘图组件", "visual", Adapter{*visual}));
auto view = std::make_unique<Scene_View_Model<decltype(visual)>>( auto view = std::make_unique<Scene_View_Model<decltype(visual)>>(
std::move(components), std::move(components),
[](const Plot_Event&) {}, std::move(visual)); [](const Plot_Event&) {}, std::move(visual));
+71 -7
View File
@@ -224,18 +224,82 @@ constexpr std::string_view protocol_editor() {
return "json"; return "json";
} }
inline std::string_view protocol_field_label(std::string_view key) {
static constexpr std::pair<std::string_view, std::string_view> labels[] = {
{"position", "位置"}, {"viewport", "视口尺寸"}, {"background", "背景颜色"},
{"clear_color", "清屏颜色"}, {"view_active", "启用视图"},
{"pixel_length", "轴线长度"}, {"orientation", "轴线方向"}, {"tick_length", "主刻度长度"},
{"sub_tick_length", "次刻度长度"}, {"axis_pen", "轴线画笔"}, {"unit_text", "单位文字"},
{"unit_text_font", "单位字体"}, {"unit_text_pen", "单位文字画笔"},
{"unit_text_background_brush", "单位文字背景"}, {"label_rotation_degrees", "标签旋转角度"},
{"coordinate_range", "坐标范围"}, {"precision", "小数精度"}, {"locale", "数字区域格式"},
{"wheel_enabled", "允许滚轮缩放"}, {"drag_enabled", "允许拖动平移"},
{"visible_count", "可见数量"}, {"tick_label_spacing_px", "刻度文字间距"},
{"estimated_label_width_px", "预计标签宽度"}, {"format", "时间格式"},
{"newest_at_start", "最新数据置于起点"}, {"center_frequency", "中心频率"},
{"partition_count", "分区数量"}, {"partition_mode", "分区模式"},
{"interpolation_mode", "插值模式"}, {"visible_range_only", "仅处理可见范围"},
{"frequency_range", "频率范围"}, {"power_range", "功率范围"}, {"i_range", "同相范围"},
{"q_range", "正交范围"}, {"phase_offset_radians", "相位偏移"}, {"pen", "画笔"},
{"samples", "样本数据"}, {"rows", "瀑布图行数据"}, {"color_map", "颜色映射"},
{"tooltip_enabled", "启用提示框"}, {"tooltip_font", "提示框字体"},
{"tooltip_text_pen", "提示文字画笔"}, {"tooltip_background_brush", "提示框背景"},
{"frequency_bin_count", "频率箱数量"}, {"frequency_point_size", "频率网格数量"},
{"power_point_size", "功率网格数量"}, {"attenuation_rate", "衰减速率"},
{"interpolate", "启用插值"}, {"spectra", "频谱历史"}, {"blocks", "扫频块数据"},
{"bins_per_block", "每块频点数"}, {"block_count", "扫频块数量"},
{"max_hold_visible", "显示最大保持"}, {"min_hold_visible", "显示最小保持"},
{"max_marker_visible", "显示最大标记"}, {"min_marker_visible", "显示最小标记"},
{"sweep_region_visible", "显示扫频区域"}, {"sweep_frequency_range", "扫频范围"},
{"max_brush", "最大保持画刷"}, {"current_brush", "当前曲线画刷"},
{"min_brush", "最小保持画刷"}, {"max_pen", "最大保持画笔"},
{"current_pen", "当前曲线画笔"}, {"min_pen", "最小保持画笔"},
{"selected_marker_pen", "选中标记画笔"}, {"marker_pen", "标记画笔"},
{"middle_frequency_pen", "中心频率画笔"}, {"sweep_region_brush", "扫频区域画刷"},
{"current_frequency_pen", "当前频率指示线"}, {"custom_markers", "自定义标记"},
{"selected_marker", "当前选中标记"}, {"point_lifetime_ms", "点保留时间"},
{"type", "星座类型"}, {"point_color", "数据点颜色"}, {"anchor_color", "参考点颜色"},
{"points", "星座点数据"}, {"label_font", "标签字体"}, {"label_pen", "标签画笔"},
{"selection_brush", "选区画刷"}, {"selection_border_pen", "选区边框画笔"},
{"selected_regions", "已选区域"}, {"transform", "空间变换"}, {"visible", "是否可见"},
{"depth_test", "深度测试"}, {"items", "项目数据"},
{"prepare_dirty", "准备阶段待更新"}, {"paint_dirty", "绘制阶段待更新"},
{"prepare_executed", "准备阶段已执行"}, {"paint_executed", "绘制阶段已执行"},
{"prepare_graph_rebuilt", "准备任务图已重建"}, {"paint_graph_rebuilt", "绘制任务图已重建"},
{"prepare_task_count", "准备任务数量"}, {"paint_task_count", "绘制任务数量"},
{"prepare_execution_time_ns", "准备耗时"}, {"paint_execution_time_ns", "绘制耗时"},
{"taskflow_rebuilt", "场景任务图已重建"}, {"renderable_count", "可渲染对象数量"},
{"taskflow_task_count", "场景任务数量"}, {"taskflow_dependency_count", "任务依赖数量"},
{"taskflow_max_predecessors", "最大前驱数量"}, {"taskflow_max_successors", "最大后继数量"},
{"taskflow_execution_time_ns", "场景执行耗时"}, {"next_tick", "下一时间刻度"},
{"sample_count", "样本数量"}, {"rendered_point_count", "已绘制点数量"},
{"selectable_marker_count", "可选标记数量"}, {"stored_block_count", "已保存数据块数量"},
{"stored_point_count", "已保存数据点数量"}, {"history_count", "历史帧数量"},
{"latest_spectrum_point_count", "最新频谱点数量"}, {"rendered_cell_count", "已绘制单元数量"},
{"row_count", "瀑布图行数量"}, {"point_count", "数据点数量"},
{"selected_region_count", "已选区域数量"}, {"item_count", "项目数量"},
{"prepared_item_count", "已准备项目数量"}, {"prepared_revision", "已准备修订号"}
};
const auto found = std::ranges::find_if(labels, [key](const auto& entry) { return entry.first == key; });
return found == std::end(labels) ? key : found->second;
}
template <typename Adapter> template <typename Adapter>
nlohmann::json adapter_schema(const Adapter& adapter, std::string_view id, std::string_view label, std::string_view kind) { nlohmann::json adapter_schema(const Adapter& adapter, std::string_view id, std::string_view label, std::string_view kind) {
nlohmann::json properties = nlohmann::json::array(); nlohmann::json fields = nlohmann::json::array();
nlohmann::json state = nlohmann::json::object(); nlohmann::json state = nlohmann::json::object();
structive::type_descriptor<Adapter>().for_each_property([&](auto, const auto& field) { structive::type_descriptor<Adapter>().for_each_property([&](auto, const auto& field) {
using Field = std::remove_cvref_t<decltype(field)>; using Field = std::remove_cvref_t<decltype(field)>;
using Value = typename Field::value_type; using Value = typename Field::value_type;
const auto value = field.accessor.read(adapter); const auto value = field.accessor.read(adapter);
if (field.template attribute<Renderable_Field_Role_Category>().value == Renderable_Field_Role::prop) { const bool editable = field.template attribute<Renderable_Field_Role_Category>().value == Renderable_Field_Role::prop;
nlohmann::json item{{"key", field.key()}, {"editor", protocol_editor<Value>()}, const auto field_label = protocol_field_label(field.key());
{"description", std::string(Field::accessor_type::description.view())}, nlohmann::json item{{"key", field.key()}, {"label", field_label}, {"editor", protocol_editor<Value>()},
{"value", encode_protocol_value(value)}}; {"editable", editable},
{"description", std::string(field_label) + "。协议字段:" + std::string(field.key()) + "。"},
{"technical_description", std::string(Field::accessor_type::description.view())},
{"value", encode_protocol_value(value)}};
if (editable) {
if constexpr (std::is_enum_v<Value>) { if constexpr (std::is_enum_v<Value>) {
item["options"] = nlohmann::json::array(); item["options"] = nlohmann::json::array();
for (const auto enum_value : magic_enum::enum_values<Value>()) { for (const auto enum_value : magic_enum::enum_values<Value>()) {
@@ -243,13 +307,13 @@ nlohmann::json adapter_schema(const Adapter& adapter, std::string_view id, std::
item["options"].push_back({{"value", name}, {"label", name}}); item["options"].push_back({{"value", name}, {"label", name}});
} }
} }
properties.push_back(std::move(item));
} else { } else {
state[field.key()] = encode_protocol_value(value); state[field.key()] = encode_protocol_value(value);
} }
fields.push_back(std::move(item));
}); });
return {{"id", id}, {"label", label}, {"kind", kind}, return {{"id", id}, {"label", label}, {"kind", kind},
{"properties", std::move(properties)}, {"state", std::move(state)}}; {"fields", std::move(fields)}, {"state", std::move(state)}};
} }
template <typename Adapter> template <typename Adapter>
+59
View File
@@ -18,6 +18,7 @@
"flexlayout-react": "^0.10.5", "flexlayout-react": "^0.10.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-grid-layout": "^2.2.4",
"reconnecting-websocket": "^4.4.0" "reconnecting-websocket": "^4.4.0"
}, },
"devDependencies": { "devDependencies": {
@@ -2775,6 +2776,12 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/fast-equals": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
"license": "MIT"
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -3406,6 +3413,38 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-draggable": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.1.tgz",
"integrity": "sha512-wa3tzfFnYt3yaZLuyU58fl1TNunfWfBekDgWhZA1+gb2jnp42wZ0ymuopR6M5kqDYmm4hKmzGlcKWjZf3Zb6RQ==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-grid-layout": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.4.tgz",
"integrity": "sha512-Eb57FsgOMYOfsUGrMI1ku/FFR+dPNPrE8qo+3hwZubpqVSy4GO9v52DeX50Tl3JDYAlCypP4rmw7Vrqk/zOIvA==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"fast-equals": "^4.0.3",
"prop-types": "^15.8.1",
"react-draggable": "^4.4.6",
"react-resizable": "^3.1.3",
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "19.2.8", "version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz",
@@ -3422,6 +3461,20 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-resizable": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz",
"integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==",
"license": "MIT",
"dependencies": {
"prop-types": "15.x",
"react-draggable": "^4.5.0"
},
"peerDependencies": {
"react": ">= 16.3",
"react-dom": ">= 16.3"
}
},
"node_modules/react-transition-group": { "node_modules/react-transition-group": {
"version": "4.4.5", "version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@@ -3458,6 +3511,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.12", "version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+1
View File
@@ -21,6 +21,7 @@
"flexlayout-react": "^0.10.5", "flexlayout-react": "^0.10.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-grid-layout": "^2.2.4",
"reconnecting-websocket": "^4.4.0" "reconnecting-websocket": "^4.4.0"
}, },
"devDependencies": { "devDependencies": {
+170 -58
View File
@@ -1,13 +1,18 @@
import {memo, useCallback, useEffect, useMemo, useRef, useState} from "react"; import {memo, useCallback, useEffect, useMemo, useRef, useState} from "react";
import {I18nLabel, Layout, Model, type IJsonModel, type TabNode} from "flexlayout-react"; import {I18nLabel, Layout, Model, type IJsonModel, type TabNode} from "flexlayout-react";
import {Responsive, useContainerWidth, type LayoutItem, type ResponsiveLayouts} from "react-grid-layout";
import ReconnectingWebSocket from "reconnecting-websocket";
import "flexlayout-react/style/alpha_dark.css"; import "flexlayout-react/style/alpha_dark.css";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
type Plot = {id: string; title: string; category: string; description: string; dimension: "2D" | "3D"; websocket: string; schema: string}; type Plot = {id: string; title: string; category: string; description: string; dimension: "2D" | "3D"; websocket: string; schema: string};
type Option = {value: string; label: string}; type Option = {value: string; label: string};
type Editor = "boolean" | "integer" | "number" | "text" | "select" | "color" | "point2" | "size" | "rect" | "range" | "pen" | "brush" | "font" | "color-map" | "vector3" | "matrix4" | "json"; type Editor = "boolean" | "integer" | "number" | "text" | "select" | "color" | "point2" | "size" | "rect" | "range" | "pen" | "brush" | "font" | "color-map" | "vector3" | "matrix4" | "json";
type Field = {key: string; description: string; editor: Editor; value: unknown; options?: Option[]}; type Field = {key: string; label: string; description: string; technical_description?: string; editor: Editor; editable: boolean; value: unknown; options?: Option[]};
type Component = {id: string; label: string; kind: string; properties: Field[]; state: Record<string, unknown>}; type Component = {id: string; label: string; kind: string; fields: Field[]; state: Record<string, unknown>};
type Schema = {protocol: "aethera.plot.inspector"; version: 1; components: Component[]}; type Schema = {protocol: "aethera.plot.inspector"; version: 2; components: Component[]};
type State_Histories = Record<string, number[]>;
type Stream_Status = "CONNECTING" | "LIVE" | "OFFLINE"; type Stream_Status = "CONNECTING" | "LIVE" | "OFFLINE";
const protocol_header_size = 24; const protocol_header_size = 24;
@@ -15,6 +20,11 @@ const frame_interval_ms = 1000 / 30;
function socket_url(path: string) { return `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${path}`; } function socket_url(path: string) { return `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${path}`; }
function local_time_milliseconds() {
const now = new Date();
return ((now.getHours() * 60 + now.getMinutes()) * 60 + now.getSeconds()) * 1000 + now.getMilliseconds();
}
function draw_pixels(canvas: HTMLCanvasElement, bytes: ArrayBuffer) { function draw_pixels(canvas: HTMLCanvasElement, bytes: ArrayBuffer) {
if (bytes.byteLength < protocol_header_size) return; if (bytes.byteLength < protocol_header_size) return;
const view = new DataView(bytes); const view = new DataView(bytes);
@@ -29,13 +39,13 @@ function draw_pixels(canvas: HTMLCanvasElement, bytes: ArrayBuffer) {
function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasElement | null>) { function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasElement | null>) {
const [status, set_status] = useState<Stream_Status>("CONNECTING"); const [status, set_status] = useState<Stream_Status>("CONNECTING");
const socket_ref = useRef<WebSocket | null>(null); const socket_ref = useRef<ReconnectingWebSocket | null>(null);
const envelope = useCallback((kind: "frame" | "input", event?: Record<string, unknown>) => { const envelope = useCallback((kind: "frame" | "input", event?: Record<string, unknown>) => {
const canvas = canvas_ref.current; const canvas = canvas_ref.current;
if (!canvas) return null; if (!canvas) return null;
const bounds = canvas.getBoundingClientRect(); const bounds = canvas.getBoundingClientRect();
return {kind, time: performance.now(), viewport: { return {kind, time: local_time_milliseconds(), viewport: {
width: Math.round(bounds.width * devicePixelRatio), height: Math.round(bounds.height * devicePixelRatio) width: Math.round(bounds.width * devicePixelRatio), height: Math.round(bounds.height * devicePixelRatio)
}, ...(event ? {event} : {})}; }, ...(event ? {event} : {})};
}, [canvas_ref]); }, [canvas_ref]);
@@ -50,11 +60,17 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
let stopped = false; let stopped = false;
let animation = 0; let animation = 0;
let previous_frame_time = 0; let previous_frame_time = 0;
const socket = new WebSocket(socket_url(plot.websocket)); const socket = new ReconnectingWebSocket(socket_url(plot.websocket), [], {
minReconnectionDelay: 300,
maxReconnectionDelay: 5000,
reconnectionDelayGrowFactor: 1.6,
maxRetries: Number.POSITIVE_INFINITY
});
socket_ref.current = socket; socket_ref.current = socket;
socket.binaryType = "arraybuffer"; socket.binaryType = "arraybuffer";
set_status("CONNECTING");
socket.onopen = () => set_status("LIVE"); socket.onopen = () => set_status("LIVE");
socket.onclose = () => set_status("OFFLINE"); socket.onclose = () => { if (!stopped) set_status("CONNECTING"); };
socket.onmessage = event => { socket.onmessage = event => {
if (event.data instanceof ArrayBuffer && canvas_ref.current) draw_pixels(canvas_ref.current, event.data); if (event.data instanceof ArrayBuffer && canvas_ref.current) draw_pixels(canvas_ref.current, event.data);
}; };
@@ -152,37 +168,16 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
return status; return status;
} }
const property_labels: Record<string, string> = {
position: "位置", viewport: "视口尺寸", background: "背景颜色", clear_color: "清屏颜色", view_active: "启用视图",
canvas_size: "画布尺寸", pixel_length: "轴线长度", orientation: "轴线方向", tick_length: "主刻度长度", sub_tick_length: "次刻度长度",
axis_pen: "轴线画笔", unit_text: "单位文字", unit_text_font: "单位字体", unit_text_pen: "单位文字画笔", unit_text_background_brush: "单位文字背景",
label_rotation_degrees: "标签旋转角度", coordinate_range: "坐标范围", precision: "小数精度", locale: "数字区域格式", wheel_enabled: "允许滚轮缩放", drag_enabled: "允许拖动平移",
visible_count: "可见数量", tick_label_spacing_px: "刻度文字间距", estimated_label_width_px: "预计标签宽度", format: "时间格式", newest_at_start: "最新数据置于起点",
center_frequency: "中心频率", partition_count: "分区数量", partition_mode: "分区模式", interpolation_mode: "插值模式", visible_range_only: "仅处理可见范围",
frequency_range: "频率范围", power_range: "功率范围", i_range: "同相范围", q_range: "正交范围", phase_offset_radians: "相位偏移",
max_hold_visible: "显示最大保持", min_hold_visible: "显示最小保持", max_marker_visible: "显示最大标记", min_marker_visible: "显示最小标记",
sweep_region_visible: "显示扫频区域", sweep_frequency_range: "扫频范围", max_brush: "最大保持画刷", current_brush: "当前曲线画刷", min_brush: "最小保持画刷",
max_pen: "最大保持画笔", current_pen: "当前曲线画笔", min_pen: "最小保持画笔", selected_marker_pen: "选中标记画笔", marker_pen: "标记画笔",
middle_frequency_pen: "中心频率画笔", sweep_region_brush: "扫频区域画刷", custom_markers: "自定义标记", selected_marker: "当前选中标记",
pen: "画笔", samples: "样本数据", bins_per_block: "每块频点数", block_count: "扫频块数量", current_frequency_pen: "当前频率指示线", blocks: "扫频块数据",
frequency_point_size: "频率网格数量", power_point_size: "功率网格数量", attenuation_rate: "衰减速率", interpolate: "启用插值", color_map: "颜色映射", spectra: "频谱历史",
tooltip_enabled: "启用提示框", tooltip_font: "提示框字体", tooltip_text_pen: "提示文字画笔", tooltip_background_brush: "提示框背景",
frequency_bin_count: "频率箱数量", rows: "瀑布图行数据", point_lifetime_ms: "点保留时间", type: "星座类型", point_color: "数据点颜色", anchor_color: "参考点颜色", points: "星座点数据",
label_font: "标签字体", label_pen: "标签画笔", selection_brush: "选区画刷", selection_border_pen: "选区边框画笔", selected_regions: "已选区域",
transform: "空间变换", visible: "是否可见", depth_test: "深度测试", items: "项目数据"
};
const member_labels: Record<string, string> = {x: "横坐标", y: "纵坐标", z: "深度", width: "宽度", height: "高度", origin: "起点", target: "终点", const member_labels: Record<string, string> = {x: "横坐标", y: "纵坐标", z: "深度", width: "宽度", height: "高度", origin: "起点", target: "终点",
red: "红", green: "绿", blue: "蓝", alpha: "透明度", color: "颜色", style: "样式", cap: "端点", join: "连接", size: "大小", weight: "字重", italic: "斜体"}; red: "红", green: "绿", blue: "蓝", alpha: "透明度", color: "颜色", style: "样式", cap: "端点", join: "连接", size: "大小", weight: "字重", italic: "斜体"};
const enum_labels: Record<string, string> = {automatic: "自动", fixed: "固定", horizontal: "水平", vertical: "垂直", none: "无", solid: "实线", dash: "虚线", dot: "点线", const enum_labels: Record<string, string> = {automatic: "自动", fixed: "固定", horizontal: "水平", vertical: "垂直", none: "无", solid: "实线", dash: "虚线", dot: "点线",
butt: "平直", square: "方形", round: "圆形", miter: "尖角", bevel: "斜角", nearest: "最近邻", bilinear: "双线性", bicubic: "双三次", butt: "平直", square: "方形", round: "圆形", miter: "尖角", bevel: "斜角", nearest: "最近邻", bilinear: "双线性", bicubic: "双三次",
nearest_sample: "最近样本", linear_value: "线性数值", linear_power_domain: "线性功率域", step_left: "左阶梯", step_right: "右阶梯", cubic_value: "三次插值"}; nearest_sample: "最近样本", linear_value: "线性数值", linear_power_domain: "线性功率域", step_left: "左阶梯", step_right: "右阶梯", cubic_value: "三次插值"};
const component_labels: Record<string, string> = {scene: "场景", plot: "主绘图组件", "axis-x": "横向坐标轴", "axis-y": "纵向坐标轴"};
const plot_labels: Record<string, string> = {spectrum: "频谱图", frequency_trace: "频率轨迹", sweep_spectrum: "扫频图", afterglow: "余辉图", waterfall: "瀑布图", const plot_labels: Record<string, string> = {spectrum: "频谱图", frequency_trace: "频率轨迹", sweep_spectrum: "扫频图", afterglow: "余辉图", waterfall: "瀑布图",
constellation: "星座图", selection_overlay: "矩形选区", datoviz_point: "三维点图"}; constellation: "星座图", selection_overlay: "矩形选区", datoviz_point: "三维点图"};
const field_label = (key: string) => property_labels[key] ?? `属性${key}`; const field_label = (field: Field) => field.label || `字段${field.key}`;
const field_tooltip = (field: Field) => `${field_label(field.key)}。协议字段:${field.key}`; const field_tooltip = (field: Field) => field.description;
const enum_label = (value: string) => enum_labels[value] ?? value; const enum_label = (value: string) => enum_labels[value] ?? value;
const component_label = (component: Component) => component_labels[component.id] ?? component.label;
function Json_Control({field, on_change}: {field: Field; on_change: (value: unknown) => void}) { function Json_Control({field, on_change}: {field: Field; on_change: (value: unknown) => void}) {
const [draft, set_draft] = useState(() => JSON.stringify(field.value, null, 2)); const [draft, set_draft] = useState(() => JSON.stringify(field.value, null, 2));
@@ -192,7 +187,7 @@ function Json_Control({field, on_change}: {field: Field; on_change: (value: unkn
set_draft(text); set_draft(text);
try { on_change(JSON.parse(text)); set_invalid(false); } catch { set_invalid(true); } try { on_change(JSON.parse(text)); set_invalid(false); } catch { set_invalid(true); }
}; };
return <label className="control controlJson" title={field_tooltip(field)}><span>{field_label(field.key)} <code>{field.key}</code></span> return <label className="control controlJson" title={field_tooltip(field)}><span>{field_label(field)} <code>{field.key}</code></span>
<textarea value={draft} onChange={event => change(event.target.value)}/> <textarea value={draft} onChange={event => change(event.target.value)}/>
<small className={invalid ? "error" : ""}>{invalid ? "JSON 格式不正确" : "编辑完成后点击确定提交"}</small></label>; <small className={invalid ? "error" : ""}>{invalid ? "JSON 格式不正确" : "编辑完成后点击确定提交"}</small></label>;
} }
@@ -205,11 +200,11 @@ function rgba_hex(value: Record<string, unknown>) {
function Structured_Control({field, on_change}: {field: Field; on_change: (value: unknown) => void}) { function Structured_Control({field, on_change}: {field: Field; on_change: (value: unknown) => void}) {
const value = (field.value && typeof field.value === "object" ? field.value : {}) as Record<string, unknown>; const value = (field.value && typeof field.value === "object" ? field.value : {}) as Record<string, unknown>;
const update = (key: string, next: unknown) => on_change({...value, [key]: next}); const update = (key: string, next: unknown) => on_change({...value, [key]: next});
if (field.editor === "color") return <label className="control structuredControl" title={field_tooltip(field)}><span>{field_label(field.key)} <code>{field.key}</code></span><div className="inlineFields"> if (field.editor === "color") return <label className="control structuredControl" title={field_tooltip(field)}><span>{field_label(field)} <code>{field.key}</code></span><div className="inlineFields">
<input type="color" value={rgba_hex(value)} onChange={event => { <input type="color" value={rgba_hex(value)} onChange={event => {
const hex = event.target.value; on_change({...value, red: parseInt(hex.slice(1, 3), 16), green: parseInt(hex.slice(3, 5), 16), blue: parseInt(hex.slice(5, 7), 16)}); const hex = event.target.value; on_change({...value, red: parseInt(hex.slice(1, 3), 16), green: parseInt(hex.slice(3, 5), 16), blue: parseInt(hex.slice(5, 7), 16)});
}}/><input aria-label="透明度" type="number" min="0" max="255" value={String(value.alpha ?? 255)} onChange={event => update("alpha", Number(event.target.value))}/></div></label>; }}/><input aria-label="透明度" type="number" min="0" max="255" value={String(value.alpha ?? 255)} onChange={event => update("alpha", Number(event.target.value))}/></div></label>;
return <fieldset className="control structuredControl" title={field_tooltip(field)}><legend>{field_label(field.key)} <code>{field.key}</code></legend><div className="inlineFields"> return <fieldset className="control structuredControl" title={field_tooltip(field)}><legend>{field_label(field)} <code>{field.key}</code></legend><div className="inlineFields">
{Object.entries(value).map(([key, item]) => item && typeof item === "object" && "red" in item {Object.entries(value).map(([key, item]) => item && typeof item === "object" && "red" in item
? <label key={key}><span>{member_labels[key] ?? key}</span><div className="nestedColor"><input type="color" value={rgba_hex(item as Record<string, unknown>)} onChange={event => { ? <label key={key}><span>{member_labels[key] ?? key}</span><div className="nestedColor"><input type="color" value={rgba_hex(item as Record<string, unknown>)} onChange={event => {
const color = item as Record<string, unknown>; const hex = event.target.value; const color = item as Record<string, unknown>; const hex = event.target.value;
@@ -223,7 +218,7 @@ function Structured_Control({field, on_change}: {field: Field; on_change: (value
? <label key={key}><span>{member_labels[key] ?? key}</span><input type="checkbox" checked={item} onChange={event => update(key, event.target.checked)}/></label> ? <label key={key}><span>{member_labels[key] ?? key}</span><input type="checkbox" checked={item} onChange={event => update(key, event.target.checked)}/></label>
: typeof item === "number" ? <label key={key}><span>{member_labels[key] ?? key}</span><input type="number" value={String(item)} onChange={event => update(key, Number(event.target.value))}/></label> : typeof item === "number" ? <label key={key}><span>{member_labels[key] ?? key}</span><input type="number" value={String(item)} onChange={event => update(key, Number(event.target.value))}/></label>
: typeof item === "string" ? <label key={key}><span>{member_labels[key] ?? key}</span><input value={item} onChange={event => update(key, event.target.value)}/></label> : typeof item === "string" ? <label key={key}><span>{member_labels[key] ?? key}</span><input value={item} onChange={event => update(key, event.target.value)}/></label>
: <Json_Control key={key} field={{key, description: field.description, editor: "json", value: item}} on_change={next => update(key, next)}/>) } : <Json_Control key={key} field={{key, label: member_labels[key] ?? key, description: field.description, editor: "json", editable: true, value: item}} on_change={next => update(key, next)}/>) }
</div></fieldset>; </div></fieldset>;
} }
@@ -231,19 +226,20 @@ function Field_Control({field, on_change}: {field: Field; on_change: (value: unk
if (["color", "point2", "size", "rect", "range", "pen", "brush", "font", "vector3"].includes(field.editor)) if (["color", "point2", "size", "rect", "range", "pen", "brush", "font", "vector3"].includes(field.editor))
return <Structured_Control field={field} on_change={on_change}/>; return <Structured_Control field={field} on_change={on_change}/>;
if (["json", "color-map", "matrix4"].includes(field.editor)) return <Json_Control field={field} on_change={on_change}/>; if (["json", "color-map", "matrix4"].includes(field.editor)) return <Json_Control field={field} on_change={on_change}/>;
if (field.editor === "boolean") return <label className="control controlBoolean" title={field_tooltip(field)}><span>{field_label(field.key)} <code>{field.key}</code></span> if (field.editor === "boolean") return <label className="control controlBoolean" title={field_tooltip(field)}><span>{field_label(field)} <code>{field.key}</code></span>
<input type="checkbox" checked={Boolean(field.value)} onChange={event => on_change(event.target.checked)}/></label>; <input type="checkbox" checked={Boolean(field.value)} onChange={event => on_change(event.target.checked)}/></label>;
if (field.editor === "select") return <label className="control" title={field_tooltip(field)}><span>{field_label(field.key)} <code>{field.key}</code></span><select value={String(field.value ?? "")} onChange={event => on_change(event.target.value)}> if (field.editor === "select") return <label className="control" title={field_tooltip(field)}><span>{field_label(field)} <code>{field.key}</code></span><select value={String(field.value ?? "")} onChange={event => on_change(event.target.value)}>
{field.options?.map(option => <option key={option.value} value={option.value}>{enum_label(option.value)}</option>)}</select></label>; {field.options?.map(option => <option key={option.value} value={option.value}>{enum_label(option.value)}</option>)}</select></label>;
if (field.editor === "text") return <label className="control" title={field_tooltip(field)}><span>{field_label(field.key)} <code>{field.key}</code></span><input value={String(field.value ?? "")} onChange={event => on_change(event.target.value)}/></label>; if (field.editor === "text") return <label className="control" title={field_tooltip(field)}><span>{field_label(field)} <code>{field.key}</code></span><input value={String(field.value ?? "")} onChange={event => on_change(event.target.value)}/></label>;
return <label className="control" title={field_tooltip(field)}><span>{field_label(field.key)} <code>{field.key}</code></span><input type="number" value={String(field.value ?? "")} onChange={event => on_change(Number(event.target.value))}/></label>; return <label className="control" title={field_tooltip(field)}><span>{field_label(field)} <code>{field.key}</code></span><input type="number" value={String(field.value ?? "")} onChange={event => on_change(Number(event.target.value))}/></label>;
} }
function Property_Control({field, on_commit}: {field: Field; on_commit: (value: unknown) => Promise<void>}) { function Property_Control({field, on_commit}: {field: Field; on_commit: (value: unknown) => Promise<void>}) {
const [draft, set_draft] = useState(field.value); const [draft, set_draft] = useState(field.value);
const [dirty, set_dirty] = useState(false); const [dirty, set_dirty] = useState(false);
const [saving, set_saving] = useState(false); const [saving, set_saving] = useState(false);
useEffect(() => { set_draft(field.value); set_dirty(false); }, [field.value]); const encoded_value = JSON.stringify(field.value);
useEffect(() => { set_draft(field.value); set_dirty(false); }, [encoded_value]);
const change = (value: unknown) => { const change = (value: unknown) => {
set_draft(value); set_draft(value);
set_dirty(true); set_dirty(true);
@@ -270,36 +266,138 @@ function Workspace_Header({plot, label, count, busy, on_refresh}: {plot: Plot; l
<span className="fieldCount">{count}</span><button className="refreshButton" disabled={busy} onClick={on_refresh} title="从后台重新读取全部属性和状态">{busy ? "读取中" : "刷新"}</button></header>; <span className="fieldCount">{count}</span><button className="refreshButton" disabled={busy} onClick={on_refresh} title="从后台重新读取全部属性和状态">{busy ? "读取中" : "刷新"}</button></header>;
} }
function State_Component({component}: {component: Component}) { function Component_Tabs({components, selected, on_select}: {components: Component[]; selected: string; on_select: (id: string) => void}) {
return <nav className="componentTabs" aria-label="组件标签">
{components.map(component => <button key={component.id} className={selected === component.id ? "active" : ""}
onClick={() => on_select(component.id)} title={`${component.label}${component.id}`}>{component.label}</button>)}
</nav>;
}
function use_active_component(schema: Schema | null) {
const [active, set_active] = useState("");
const components = schema?.components ?? [];
const selected = components.some(component => component.id === active) ? active : components[0]?.id ?? "";
return {components, selected, set_active};
}
function percentile(values: number[], ratio: number) {
if (values.length === 0) return 0;
const sorted = [...values].sort((left, right) => left - right);
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
}
function format_metric(value: number, field: Field) {
if (field.key.endsWith("_time_ns")) return `${(value / 1_000_000).toFixed(3)} ms`;
return Number.isInteger(value) ? value.toLocaleString("zh-CN") : value.toLocaleString("zh-CN", {maximumFractionDigits: 3});
}
function State_Field_View({component, field, histories}: {component: Component; field: Field; histories: State_Histories}) {
const history = histories[`${component.id}.${field.key}`] ?? [];
const numeric = typeof field.value === "number";
const average = numeric && history.length > 0 ? history.reduce((sum, value) => sum + value, 0) / history.length : 0;
return <article className="stateField" title={field.description}>
<header><span>{field_label(field)}</span><code>{field.key}</code><em></em></header>
{numeric ? <><strong>{format_metric(field.value as number, field)}</strong><dl className="metricSummary">
<div><dt></dt><dd>{format_metric(average, field)}</dd></div>
<div><dt>P95</dt><dd>{format_metric(percentile(history, .95), field)}</dd></div>
<div><dt>P99</dt><dd>{format_metric(percentile(history, .99), field)}</dd></div>
<div><dt></dt><dd>{history.length}</dd></div>
</dl></> : typeof field.value === "boolean" ? <strong className={field.value ? "stateTrue" : "stateFalse"}>{field.value ? "是" : "否"}</strong>
: <pre>{JSON.stringify(field.value, null, 2)}</pre>}
</article>;
}
function State_Component({component, histories}: {component: Component; histories: State_Histories}) {
const [copied, set_copied] = useState(false); const [copied, set_copied] = useState(false);
const copy = async () => { await navigator.clipboard.writeText(JSON.stringify(component.state, null, 2)); set_copied(true); window.setTimeout(() => set_copied(false), 1200); }; const copy = async () => { await navigator.clipboard.writeText(JSON.stringify(component.state, null, 2)); set_copied(true); window.setTimeout(() => set_copied(false), 1200); };
return <details className="componentCard" open><summary><span>{component_label(component)}</span><em></em></summary> const fields = component.fields.filter(field => !field.editable);
<div className="jsonStateHeader"><span>{Object.keys(component.state).length} </span><button onClick={() => void copy()}>{copied ? "已复制" : "复制 JSON"}</button></div> return <section className="componentContent"><div className="stateToolbar"><span>{fields.length} </span>
<pre className="stateJson">{JSON.stringify(component.state, null, 2)}</pre></details>; <button onClick={() => void copy()}>{copied ? "已复制" : "复制状态 JSON"}</button></div>
<div className="stateGrid">{fields.map(field => <State_Field_View key={field.key} component={component} field={field} histories={histories}/>)}</div>
<details className="rawState"><summary> JSON</summary><pre className="stateJson">{JSON.stringify(component.state, null, 2)}</pre></details></section>;
} }
function Property_Pane({plot, schema, busy, on_refresh, on_update}: {plot: Plot; schema: Schema | null; busy: boolean; on_refresh: () => void; on_update: (component: Component, field: Field, value: unknown) => Promise<void>}) { function Property_Pane({plot, schema, busy, on_refresh, on_update}: {plot: Plot; schema: Schema | null; busy: boolean; on_refresh: () => void; on_update: (component: Component, field: Field, value: unknown) => Promise<void>}) {
const count = schema?.components.reduce((sum, component) => sum + component.properties.length, 0) ?? 0; const {components, selected, set_active} = use_active_component(schema);
return <section className="workspacePane"><Workspace_Header plot={plot} label="属性编辑" count={count} busy={busy} on_refresh={on_refresh}/><div className="workspaceBody"> const component = components.find(item => item.id === selected);
{busy && !schema ? <p className="muted"></p> : schema?.components.map(component => <details className="componentCard" open key={component.id}> const fields = component?.fields.filter(field => field.editable) ?? [];
<summary><span>{component_label(component)}</span><em>{component.properties.length} </em></summary><div className="propGrid"> const count = components.reduce((sum, item) => sum + item.fields.filter(field => field.editable).length, 0);
{component.properties.map(field => <Property_Control key={field.key} field={field} on_commit={value => on_update(component, field, value)}/>)}</div></details>)}</div></section>; return <section className="workspacePane"><Workspace_Header plot={plot} label="属性编辑" count={count} busy={busy} on_refresh={on_refresh}/>
<Component_Tabs components={components} selected={selected} on_select={set_active}/><div className="workspaceBody">
{busy && !schema ? <p className="muted"></p> : component ? <section className="componentContent"><div className="sectionIntro">
<strong>{component.label}</strong><span>{fields.length} </span></div><div className="propGrid">
{fields.map(field => <Property_Control key={field.key} field={field} on_commit={value => on_update(component, field, value)}/>)}</div></section> : null}</div></section>;
} }
function State_Pane({plot, schema, busy, on_refresh}: {plot: Plot; schema: Schema | null; busy: boolean; on_refresh: () => void}) { function State_Pane({plot, schema, busy, on_refresh, histories}: {plot: Plot; schema: Schema | null; busy: boolean; on_refresh: () => void; histories: State_Histories}) {
const count = schema?.components.reduce((sum, component) => sum + Object.keys(component.state).length, 0) ?? 0; const {components, selected, set_active} = use_active_component(schema);
const component = components.find(item => item.id === selected);
const count = components.reduce((sum, item) => sum + item.fields.filter(field => !field.editable).length, 0);
return <section className="workspacePane"><Workspace_Header plot={plot} label="运行状态 JSON" count={count} busy={busy} on_refresh={on_refresh}/> return <section className="workspacePane"><Workspace_Header plot={plot} label="运行状态 JSON" count={count} busy={busy} on_refresh={on_refresh}/>
<div className="workspaceBody">{schema?.components.map(component => <State_Component key={component.id} component={component}/>)}</div></section>; <Component_Tabs components={components} selected={selected} on_select={set_active}/>
<div className="workspaceBody">{component ? <State_Component component={component} histories={histories}/> : null}</div></section>;
} }
const Plot_Card = memo(function Plot_Card({plot, selected, on_select}: {plot: Plot; selected: boolean; on_select: (plot: Plot) => void}) { const Plot_Card = memo(function Plot_Card({plot, selected, on_select}: {plot: Plot; selected: boolean; on_select: (plot: Plot) => void}) {
const canvas_ref = useRef<HTMLCanvasElement>(null); const canvas_ref = useRef<HTMLCanvasElement>(null);
const status = use_plot_stream(plot, canvas_ref); const status = use_plot_stream(plot, canvas_ref);
return <article className={`card${selected ? " selected" : ""}`} data-plot-id={plot.id} aria-current={selected ? "true" : undefined} return <article className={`card${selected ? " selected" : ""}`} data-plot-id={plot.id} aria-current={selected ? "true" : undefined}
onPointerDownCapture={() => on_select(plot)} onFocusCapture={() => on_select(plot)}><header><div><span className="eyebrow"> · {plot.dimension}</span><h2>{plot_labels[plot.id] ?? plot.title}</h2></div><span className="status">{{CONNECTING: "连中", LIVE: "实时", OFFLINE: "已离线"}[status]}</span></header> onPointerDownCapture={() => on_select(plot)} onFocusCapture={() => on_select(plot)}><header className="cardDragHandle"><div><span className="eyebrow"> · {plot.dimension}</span><h2>{plot_labels[plot.id] ?? plot.title}</h2></div><span className="status">{{CONNECTING: "连中", LIVE: "实时", OFFLINE: "已离线"}[status]}</span></header>
{plot.description ? <p>{plot.description}</p> : null}<div className="plotViewport"><canvas ref={canvas_ref} tabIndex={0}/></div></article>; {plot.description ? <p>{plot.description}</p> : null}<div className="plotViewport"><canvas ref={canvas_ref} tabIndex={0}/></div></article>;
}); });
type Gallery_Breakpoint = "lg" | "md" | "sm" | "xs";
const gallery_layout_key = "aethera-gallery-grid-v1";
const gallery_breakpoints: Record<Gallery_Breakpoint, number> = {lg: 1280, md: 860, sm: 560, xs: 0};
const gallery_columns: Record<Gallery_Breakpoint, number> = {lg: 12, md: 12, sm: 12, xs: 12};
const gallery_item_width: Record<Gallery_Breakpoint, number> = {lg: 4, md: 6, sm: 12, xs: 12};
function default_gallery_layout(plots: Plot[], breakpoint: Gallery_Breakpoint): LayoutItem[] {
const width = gallery_item_width[breakpoint];
const columns = Math.max(1, Math.floor(gallery_columns[breakpoint] / width));
return plots.map((plot, index) => ({
i: plot.id,
x: index % columns * width,
y: Math.floor(index / columns) * 6,
w: width,
h: 6,
minW: breakpoint === "lg" ? 3 : breakpoint === "md" ? 4 : 8,
minH: 4,
resizeHandles: ["s", "e", "se", "sw", "w"]
}));
}
function load_gallery_layouts(): ResponsiveLayouts<Gallery_Breakpoint> {
const saved = localStorage.getItem(gallery_layout_key);
if (!saved) return {};
try { return JSON.parse(saved) as ResponsiveLayouts<Gallery_Breakpoint>; }
catch { localStorage.removeItem(gallery_layout_key); return {}; }
}
function Gallery_Grid({plots, selected, on_select}: {plots: Plot[]; selected: Plot | null; on_select: (plot: Plot) => void}) {
const {width, containerRef, mounted} = useContainerWidth({measureBeforeMount: true});
const [stored_layouts, set_stored_layouts] = useState<ResponsiveLayouts<Gallery_Breakpoint>>(load_gallery_layouts);
const layouts = useMemo(() => Object.fromEntries((Object.keys(gallery_breakpoints) as Gallery_Breakpoint[]).map(breakpoint => {
const defaults = default_gallery_layout(plots, breakpoint);
const saved = stored_layouts[breakpoint] ?? [];
const current_ids = new Set(plots.map(plot => plot.id));
const retained = saved.filter(item => current_ids.has(item.i));
const retained_ids = new Set(retained.map(item => item.i));
return [breakpoint, [...retained, ...defaults.filter(item => !retained_ids.has(item.i))]];
})) as ResponsiveLayouts<Gallery_Breakpoint>, [plots, stored_layouts]);
const save_layouts = (_layout: readonly LayoutItem[], next: ResponsiveLayouts<Gallery_Breakpoint>) => {
set_stored_layouts(next);
localStorage.setItem(gallery_layout_key, JSON.stringify(next));
};
return <div className="plotGridHost" ref={node => { (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node; }}>{mounted ? <Responsive<Gallery_Breakpoint>
width={width} breakpoints={gallery_breakpoints} cols={gallery_columns} layouts={layouts} rowHeight={64}
margin={[16, 16]} containerPadding={[0, 0]} onLayoutChange={save_layouts}
dragConfig={{handle: ".cardDragHandle", cancel: "canvas,button,input,select,textarea,a", threshold: 4}}
resizeConfig={{handles: ["s", "e", "se", "sw", "w"]}}>
{plots.map(plot => <div className="plotGridItem" key={plot.id}><Plot_Card plot={plot} selected={selected?.id === plot.id} on_select={on_select}/></div>)}
</Responsive> : null}</div>;
}
const workspace_layout_key = "aethera-flexlayout-v1"; const workspace_layout_key = "aethera-flexlayout-v1";
const layout_labels: Record<I18nLabel, string> = { const layout_labels: Record<I18nLabel, string> = {
[I18nLabel.Close_Tab]: "关闭标签", [I18nLabel.Close_Tab]: "关闭标签",
@@ -358,8 +456,10 @@ function load_workspace_model() {
export function App() { export function App() {
const [plots, set_plots] = useState<Plot[]>([]); const [category, set_category] = useState("全部"); const [selected, set_selected] = useState<Plot | null>(null); const [plots, set_plots] = useState<Plot[]>([]); const [category, set_category] = useState("全部"); const [selected, set_selected] = useState<Plot | null>(null);
const [schema, set_schema] = useState<Schema | null>(null); const [schema, set_schema] = useState<Schema | null>(null);
const [state_histories, set_state_histories] = useState<State_Histories>({});
const [schema_busy, set_schema_busy] = useState(false); const [schema_busy, set_schema_busy] = useState(false);
const [layout_model, set_layout_model] = useState(load_workspace_model); const [layout_model, set_layout_model] = useState(load_workspace_model);
const [gallery_layout_revision, set_gallery_layout_revision] = useState(0);
const schema_request = useRef(0); const schema_request = useRef(0);
useEffect(() => { void fetch("/plot").then(response => response.json()).then(set_plots); }, []); useEffect(() => { void fetch("/plot").then(response => response.json()).then(set_plots); }, []);
useEffect(() => { if (!selected && plots.length > 0) set_selected(plots[0]); }, [plots, selected]); useEffect(() => { if (!selected && plots.length > 0) set_selected(plots[0]); }, [plots, selected]);
@@ -367,10 +467,21 @@ export function App() {
if (!selected) return; if (!selected) return;
const request = ++schema_request.current; const request = ++schema_request.current;
set_schema_busy(true); set_schema_busy(true);
try { const response = await fetch(selected.schema); const result = await response.json(); if (request === schema_request.current) set_schema(result); } try { const response = await fetch(selected.schema); const result = await response.json() as Schema; if (request === schema_request.current) {
set_schema(result);
set_state_histories(current => {
const next = {...current};
for (const component of result.components) for (const field of component.fields) if (!field.editable && typeof field.value === "number") {
const key = `${component.id}.${field.key}`;
next[key] = [...(next[key] ?? []), field.value].slice(-120);
}
return next;
});
} }
finally { if (request === schema_request.current) set_schema_busy(false); } finally { if (request === schema_request.current) set_schema_busy(false); }
}, [selected]); }, [selected]);
useEffect(() => { ++schema_request.current; set_schema(null); if (selected) void load_schema(); }, [selected, load_schema]); useEffect(() => { ++schema_request.current; set_schema(null); set_state_histories({}); if (selected) void load_schema(); }, [selected, load_schema]);
useEffect(() => { if (!selected) return; const timer = window.setInterval(() => void load_schema(), 1000); return () => window.clearInterval(timer); }, [selected, load_schema]);
const update = async (component: Component, field: Field, value: unknown) => { const update = async (component: Component, field: Field, value: unknown) => {
if (!selected) return; if (!selected) return;
const response = await fetch(`/plot/${encodeURIComponent(selected.id)}/component/${encodeURIComponent(component.id)}/prop/${encodeURIComponent(field.key)}`, { const response = await fetch(`/plot/${encodeURIComponent(selected.id)}/component/${encodeURIComponent(component.id)}/prop/${encodeURIComponent(field.key)}`, {
@@ -378,20 +489,21 @@ export function App() {
const result = await response.json(); const result = await response.json();
if (!result.success) throw new Error(result.error ?? "属性提交失败"); if (!result.success) throw new Error(result.error ?? "属性提交失败");
set_schema(current => current ? {...current, components: current.components.map(item => item.id !== component.id ? item : {...item, set_schema(current => current ? {...current, components: current.components.map(item => item.id !== component.id ? item : {...item,
properties: item.properties.map(property => property.key === field.key ? {...property, value: result.value} : property)})} : current); fields: item.fields.map(current_field => current_field.key === field.key ? {...current_field, value: result.value} : current_field)})} : current);
}; };
const categories = useMemo(() => ["全部", ...new Set(plots.map(() => "绘图组件"))], [plots]); const categories = useMemo(() => ["全部", ...new Set(plots.map(() => "绘图组件"))], [plots]);
const visible = category === "全部" ? plots : plots; const visible = category === "全部" ? plots : plots;
const gallery = <section className="galleryPanel"><header className="topbar"><div><span className="eyebrow">AETHERA </span><h1></h1></div><div className="topbarActions"> const gallery = <section className="galleryPanel"><header className="topbar"><div><span className="eyebrow">AETHERA </span><h1></h1></div><div className="topbarActions">
{selected ? <span className="selectionName"> <strong>{plot_labels[selected.id] ?? selected.title}</strong></span> : <span className="muted"></span>} {selected ? <span className="selectionName"> <strong>{plot_labels[selected.id] ?? selected.title}</strong></span> : <span className="muted"></span>}
<button onClick={() => { localStorage.removeItem(workspace_layout_key); set_layout_model(Model.fromJson(default_workspace_layout)); }}></button></div></header> <button onClick={() => { localStorage.removeItem(workspace_layout_key); localStorage.removeItem(gallery_layout_key);
set_layout_model(Model.fromJson(default_workspace_layout)); set_gallery_layout_revision(value => value + 1); }}></button></div></header>
<nav>{categories.map(value => <button key={value} className={category === value ? "active" : ""} onClick={() => set_category(value)}>{value}</button>)}</nav> <nav>{categories.map(value => <button key={value} className={category === value ? "active" : ""} onClick={() => set_category(value)}>{value}</button>)}</nav>
<section className="grid">{visible.map(plot => <Plot_Card key={plot.id} plot={plot} selected={selected?.id === plot.id} on_select={set_selected}/>)}</section></section>; <Gallery_Grid key={gallery_layout_revision} plots={visible} selected={selected} on_select={set_selected}/></section>;
const factory = (node: TabNode) => { const factory = (node: TabNode) => {
if (node.getComponent() === "gallery") return gallery; if (node.getComponent() === "gallery") return gallery;
if (!selected) return <div className="emptyPane"></div>; if (!selected) return <div className="emptyPane"></div>;
if (node.getComponent() === "properties") return <aside className="inspector" aria-label="属性编辑面板"><Property_Pane plot={selected} schema={schema} busy={schema_busy} on_refresh={() => void load_schema()} on_update={update}/></aside>; if (node.getComponent() === "properties") return <aside className="inspector" aria-label="属性编辑面板"><Property_Pane plot={selected} schema={schema} busy={schema_busy} on_refresh={() => void load_schema()} on_update={update}/></aside>;
if (node.getComponent() === "state") return <aside className="inspector" aria-label="状态查看面板"><State_Pane plot={selected} schema={schema} busy={schema_busy} on_refresh={() => void load_schema()}/></aside>; if (node.getComponent() === "state") return <aside className="inspector" aria-label="状态查看面板"><State_Pane plot={selected} schema={schema} busy={schema_busy} on_refresh={() => void load_schema()} histories={state_histories}/></aside>;
return <div className="emptyPane"></div>; return <div className="emptyPane"></div>;
}; };
return <div className="appShell flexlayout__theme_alpha"><Layout model={layout_model} factory={factory} realtimeResize i18nMapper={label => layout_labels[label]} return <div className="appShell flexlayout__theme_alpha"><Layout model={layout_model} factory={factory} realtimeResize i18nMapper={label => layout_labels[label]}
+31 -3
View File
@@ -53,10 +53,17 @@ h2 { margin: 4px 0 0; font-size: 23px; letter-spacing: -.03em; }
.selectionName strong { color: #e8f0ff; } .selectionName strong { color: #e8f0ff; }
nav { display: flex; flex-wrap: wrap; gap: 8px; padding: 18px 0; } nav { display: flex; flex-wrap: wrap; gap: 8px; padding: 18px 0; }
.grid { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; } .plotGridHost { width: 100%; min-height: 360px; }
.card { display: flex; flex: 0 1 auto; flex-direction: column; width: min(520px, 100%); height: 430px; min-width: min(320px, 100%); min-height: 300px; max-width: 100%; overflow: hidden; resize: both; border: 1px solid #1f2f48; border-radius: 18px; background: linear-gradient(145deg, #0d1727, #080e19); box-shadow: 0 15px 45px #0006; } .react-grid-layout { position: relative; transition: height .2s ease; }
.plotGridItem { overflow: visible; }
.react-grid-item.react-grid-placeholder { border-radius: 18px; background: #5ce4c244; opacity: 1; }
.react-grid-item > .react-resizable-handle { z-index: 5; width: 18px; height: 18px; opacity: .7; }
.react-grid-item > .react-resizable-handle::after { border-color: #5ce4c2; }
.card { display: flex; flex-direction: column; width: 100%; height: 100%; min-width: 0; min-height: 0; overflow: hidden; border: 1px solid #1f2f48; border-radius: 18px; background: linear-gradient(145deg, #0d1727, #080e19); box-shadow: 0 15px 45px #0006; }
.card.selected { border-color: #5ce4c2; box-shadow: 0 0 0 1px #5ce4c255, 0 18px 55px #0008; } .card.selected { border-color: #5ce4c2; box-shadow: 0 0 0 1px #5ce4c255, 0 18px 55px #0008; }
.card > header { display: flex; justify-content: space-between; gap: 16px; padding: 18px 20px 14px; } .card > header { display: flex; justify-content: space-between; gap: 16px; padding: 18px 20px 14px; }
.cardDragHandle { cursor: grab; user-select: none; touch-action: none; }
.cardDragHandle:active { cursor: grabbing; }
.card > p { min-height: 42px; margin: 0; padding: 0 20px 14px; color: #8fa2bd; line-height: 1.5; } .card > p { min-height: 42px; margin: 0; padding: 0 20px 14px; color: #8fa2bd; line-height: 1.5; }
.status { align-self: flex-start; padding: 5px 8px; color: #5ce4c2; border: 1px solid #27594f; border-radius: 7px; font: 700 10px/1 ui-monospace, monospace; letter-spacing: .08em; } .status { align-self: flex-start; padding: 5px 8px; color: #5ce4c2; border: 1px solid #27594f; border-radius: 7px; font: 700 10px/1 ui-monospace, monospace; letter-spacing: .08em; }
.plotViewport { flex: 1; width: 100%; min-height: 160px; overflow: hidden; background: #070d18; } .plotViewport { flex: 1; width: 100%; min-height: 160px; overflow: hidden; background: #070d18; }
@@ -73,6 +80,14 @@ canvas:focus { outline: 1px solid #5ce4c2; outline-offset: -1px; }
.refreshButton:hover:not(:disabled) { color: #06110f; border-color: #5ce4c2; background: #5ce4c2; } .refreshButton:hover:not(:disabled) { color: #06110f; border-color: #5ce4c2; background: #5ce4c2; }
.refreshButton:disabled { opacity: .55; cursor: wait; } .refreshButton:disabled { opacity: .55; cursor: wait; }
.workspaceBody { flex: 1; min-height: 0; overflow: auto; padding: 18px; } .workspaceBody { flex: 1; min-height: 0; overflow: auto; padding: 18px; }
.componentTabs { display: flex; flex: 0 0 auto; flex-wrap: nowrap; gap: 6px; overflow-x: auto; padding: 9px 12px; border-bottom: 1px solid #20314b; background: #091321; }
.componentTabs button { flex: 0 0 auto; padding: 8px 11px; color: #8fa2bd; border: 1px solid #273b59; border-radius: 8px; background: #0d192a; cursor: pointer; }
.componentTabs button:hover, .componentTabs button.active { color: #07110f; border-color: #5ce4c2; background: #5ce4c2; }
.componentContent { min-width: 0; }
.sectionIntro, .stateToolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; color: #7f93ae; }
.sectionIntro strong { color: #dce8f8; font-size: 16px; }
.stateToolbar button { padding: 6px 9px; color: #b9c9dd; border: 1px solid #304664; border-radius: 7px; background: #101c2d; cursor: pointer; }
.stateToolbar button:hover { color: #07110f; border-color: #5ce4c2; background: #5ce4c2; }
.propGrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px; align-items: start; } .propGrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px; align-items: start; }
.control { display: grid; gap: 7px; color: #c8d5e8; } .control { display: grid; gap: 7px; color: #c8d5e8; }
@@ -105,6 +120,20 @@ canvas:focus { outline: 1px solid #5ce4c2; outline-offset: -1px; }
.jsonStateHeader button { padding: 5px 8px; color: #b9c9dd; border: 1px solid #304664; border-radius: 6px; background: #101c2d; cursor: pointer; } .jsonStateHeader button { padding: 5px 8px; color: #b9c9dd; border: 1px solid #304664; border-radius: 6px; background: #101c2d; cursor: pointer; }
.jsonStateHeader button:hover { color: #07110f; border-color: #5ce4c2; background: #5ce4c2; } .jsonStateHeader button:hover { color: #07110f; border-color: #5ce4c2; background: #5ce4c2; }
.stateJson { max-height: 320px; overflow: auto; margin: 0; padding: 12px; color: #cddbef; border-top: 1px solid #1d304a; background: #070f1b; font: 12px/1.5 ui-monospace, monospace; white-space: pre-wrap; overflow-wrap: anywhere; } .stateJson { max-height: 320px; overflow: auto; margin: 0; padding: 12px; color: #cddbef; border-top: 1px solid #1d304a; background: #070f1b; font: 12px/1.5 ui-monospace, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
.stateGrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 11px; }
.stateField { min-width: 0; padding: 12px; border: 1px solid #213653; border-radius: 10px; background: #0a1422; }
.stateField > header { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 7px; color: #cbd8ea; }
.stateField > header code { color: #71839e; font: 10px/1 ui-monospace, monospace; }
.stateField > header em { padding: 3px 5px; color: #6f89aa; border-radius: 5px; background: #142239; font-size: 9px; font-style: normal; }
.stateField > strong { display: block; margin: 13px 0 11px; color: #5ce4c2; font: 700 21px/1.2 ui-monospace, monospace; }
.stateField > strong.stateFalse { color: #8fa2bd; }
.stateField > pre { max-height: 190px; overflow: auto; margin: 11px 0 0; color: #dce8f8; font: 12px/1.45 ui-monospace, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
.metricSummary { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin: 0; }
.metricSummary > div { min-width: 0; padding: 7px; border-radius: 7px; background: #0e1b2d; }
.metricSummary dt { color: #71839e; font-size: 10px; }
.metricSummary dd { overflow: hidden; margin: 3px 0 0; color: #cbd8ea; font: 11px/1.2 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
.rawState { margin-top: 13px; overflow: hidden; border: 1px solid #213653; border-radius: 9px; background: #0a1422; }
.rawState > summary { padding: 9px 11px; color: #8195b1; cursor: pointer; }
.stateList { display: grid; gap: 9px; margin: 0; } .stateList { display: grid; gap: 9px; margin: 0; }
.stateList > div { display: grid; grid-template-columns: minmax(125px, .8fr) minmax(0, 1.2fr); gap: 14px; padding: 11px 12px; border: 1px solid #1d304a; border-radius: 9px; background: #0b1524; } .stateList > div { display: grid; grid-template-columns: minmax(125px, .8fr) minmax(0, 1.2fr); gap: 14px; padding: 11px 12px; border: 1px solid #1d304a; border-radius: 9px; background: #0b1524; }
@@ -128,7 +157,6 @@ canvas:focus { outline: 1px solid #5ce4c2; outline-offset: -1px; }
.topbar { align-items: flex-start; flex-direction: column; } .topbar { align-items: flex-start; flex-direction: column; }
.topbarActions { width: 100%; justify-content: flex-start; flex-wrap: wrap; } .topbarActions { width: 100%; justify-content: flex-start; flex-wrap: wrap; }
.selectionName { width: 100%; } .selectionName { width: 100%; }
.card { width: 100%; resize: vertical; }
.stateList > div { grid-template-columns: 1fr; gap: 6px; } .stateList > div { grid-template-columns: 1fr; gap: 6px; }
.stateList dd { text-align: left; } .stateList dd { text-align: left; }
} }