From b280e2f5ee521cfe0d566119288cec49135c0cd3 Mon Sep 17 00:00:00 2001 From: wyc <1104749580@qq.com> Date: Fri, 21 Aug 2026 15:12:22 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B9bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web_server/src/Graph_WebSocket.cpp | 25 ++- web_server/src/Plot.cpp | 46 ++++- web_server/src/Plot.hpp | 7 + webapp_gallery/src/app.tsx | 281 +++++++++++++++++++++++------ webapp_gallery/src/styles.css | 108 ++++++++++- 5 files changed, 403 insertions(+), 64 deletions(-) diff --git a/web_server/src/Graph_WebSocket.cpp b/web_server/src/Graph_WebSocket.cpp index 7bfd0ea..0b199df 100644 --- a/web_server/src/Graph_WebSocket.cpp +++ b/web_server/src/Graph_WebSocket.cpp @@ -18,7 +18,30 @@ struct Graph_WebSocket::Private { Graph_WebSocket::Graph_WebSocket(drogon::WebSocketConnectionPtr connection, std::shared_ptr plot) : d(std::make_unique()) { d->connection = connection; d->plot = std::move(plot); d->owner = connection.get(); } Graph_WebSocket::~Graph_WebSocket() { close(); } void Graph_WebSocket::start() { if (d->attached) return; const auto weak = weak_from_this(); d->plot->attach(d->owner, [weak](std::string pixels) { const auto socket = weak.lock(); if (!socket) return; const auto connection = socket->d->connection.lock(); if (connection && connection->connected()) connection->send(pixels.data(), pixels.size(), drogon::WebSocketMessageType::Binary); }); d->attached = true; } -void Graph_WebSocket::receive(std::string_view message) { const auto json = nlohmann::json::parse(message, nullptr, false); if (json.is_discarded() || !json.is_object()) return; Plot_Event event; if (const auto value = json.find("time"); value != json.end() && value->is_number()) event.time_milliseconds = value->get(); if (const auto value = json.find("width"); value != json.end() && value->is_number_unsigned()) event.width = std::clamp(value->get(), 160U, 1920U); if (const auto value = json.find("height"); value != json.end() && value->is_number_unsigned()) event.height = std::clamp(value->get(), 120U, 1080U); d->plot->submit(event); } +void Graph_WebSocket::receive(std::string_view message) { + const auto json = nlohmann::json::parse(message, nullptr, false); + if (json.is_discarded() || !json.is_object()) return; + Plot_Event event; + if (const auto value = json.find("time"); value != json.end() && value->is_number()) + event.time_milliseconds = value->get(); + if (const auto value = json.find("width"); value != json.end() && value->is_number_unsigned()) + event.width = std::clamp(value->get(), 160U, 1920U); + if (const auto value = json.find("height"); value != json.end() && value->is_number_unsigned()) + event.height = std::clamp(value->get(), 120U, 1080U); + if (const auto value = json.find("wheel"); value != json.end() && value->is_object()) { + const auto x = value->find("x"); + const auto y = value->find("y"); + const auto delta_y = value->find("delta_y"); + if (x != value->end() && x->is_number() && y != value->end() && y->is_number() + && delta_y != value->end() && delta_y->is_number()) { + event.wheel = Plot_Wheel_Event{ + std::clamp(x->get(), 0.0, static_cast(event.width)), + std::clamp(y->get(), 0.0, static_cast(event.height)), + std::clamp(delta_y->get(), -120.0, 120.0)}; + } + } + d->plot->submit(event); +} void Graph_WebSocket::close() { if (!d->attached) return; d->plot->detach(d->owner); d->attached = false; } Graph_WebSocket_Controller::Graph_WebSocket_Controller(Plot_Resolver resolver) : resolve_plot(std::move(resolver)) {} diff --git a/web_server/src/Plot.cpp b/web_server/src/Plot.cpp index 288af49..676180c 100644 --- a/web_server/src/Plot.cpp +++ b/web_server/src/Plot.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -172,7 +173,21 @@ Time_Axis_Object::Builder time_axis_builder( template void resize_axes(Size viewport, Axes*... axes) { - (axes->template set<&Abs_Axis::Prop::canvas_size>(viewport), ...); + const double left = std::clamp(static_cast(viewport.width) * 0.10, 42.0, 68.0); + const double right = std::clamp(static_cast(viewport.width) * 0.05, 18.0, 34.0); + const double top = std::clamp(static_cast(viewport.height) * 0.07, 16.0, 30.0); + const double bottom = std::clamp(static_cast(viewport.height) * 0.13, 36.0, 52.0); + const Point_F origin{left, static_cast(viewport.height) - bottom}; + const Axis_Pixel_Length horizontal_length = std::max(1.0, static_cast(viewport.width) - left - right); + const Axis_Pixel_Length vertical_length = -std::max(1.0, static_cast(viewport.height) - top - bottom); + const auto resize_axis = [&](auto* axis) { + const auto orientation = axis->template read_prop().orientation; + axis->template set<&Abs_Axis::Prop::canvas_size>(viewport); + axis->template set<&Abs_Axis::Prop::position>(origin); + axis->template set<&Abs_Axis::Prop::pixel_length>( + orientation == Axis_Orientation::horizontal ? horizontal_length : vertical_length); + }; + (resize_axis(axes), ...); } } @@ -361,14 +376,16 @@ std::shared_ptr make_waterfall_plot(asio::any_io_executor executor) { .set(&Render_Scene_2D::Prop::view_active, true); scene_builder.add_renderable(waterfall.get()); auto scene = *scene_builder.build(); - auto update = [raw = waterfall.get(), frequency = frequency.get(), time = time.get(), tick = std::uint64_t{}](const Plot_Event& event) mutable { + auto update = [raw = waterfall.get(), frequency = frequency.get(), time = time.get()](const Plot_Event& event) { resize_axes({static_cast(event.width), static_cast(event.height)}, frequency, time); std::array values{}; for (std::size_t i = 0; i < values.size(); ++i) values[i] = -100.0 + 70.0 * std::exp(-240.0 * std::pow( static_cast(i) / values.size() - 0.5 - 0.22 * std::sin(event.time_milliseconds * 0.0006), 2.0)); - raw->append_row(tick++, values); + constexpr double day_milliseconds = 86'400'000.0; + raw->append_row(Time_Of_Day{static_cast( + std::fmod(std::max(0.0, event.time_milliseconds), day_milliseconds))}, values); }; auto view = make_scene_view< Prop_Field<&Waterfall::Prop::tooltip_enabled, "tooltip_enabled", "Enables value inspection tooltips over waterfall cells.">, @@ -409,9 +426,20 @@ std::shared_ptr make_constellation_plot(asio::any_io_executor executor) { auto scene = *scene_builder.build(); auto update = [raw = constellation.get(), horizontal = horizontal.get(), vertical = vertical.get()](const Plot_Event& event) { resize_axes({static_cast(event.width), static_cast(event.height)}, horizontal, vertical); - const double phase = event.time_milliseconds * 0.003; - raw->append_point({std::cos(phase) * 0.82 + 0.04 * std::sin(phase * 7.0), - std::sin(phase) * 0.82 + 0.04 * std::cos(phase * 5.0)}); + const auto& state = raw->template read_prop(); + const int anchor_count = static_cast(state.type); + const double radius = std::min(state.i_range.size(), state.q_range.size()) * 0.4; + const double phase = event.time_milliseconds * 0.001; + for (int index = 0; index < anchor_count; ++index) { + const double angle = state.phase_offset_radians + + 2.0 * std::numbers::pi * static_cast(index) / anchor_count; + const double noise_i = 0.025 * std::sin(phase * 11.0 + index * 1.73) + + 0.012 * std::cos(phase * 23.0 + index * 0.61); + const double noise_q = 0.025 * std::cos(phase * 13.0 + index * 1.37) + + 0.012 * std::sin(phase * 19.0 + index * 0.47); + raw->append_point({state.i_range.center() + std::cos(angle) * radius + noise_i, + state.q_range.center() + std::sin(angle) * radius + noise_q}); + } }; auto view = make_scene_view< Prop_Field<&Constellation_Diagram::Prop::point_lifetime_ms, "point_lifetime_ms", "Time in milliseconds that an appended constellation point remains visible.">, @@ -538,6 +566,12 @@ void Plot::ensure_started() { if (auto* scene = std::get_if>(&self->d->scene)) { (*scene)->set<&Render_Scene_2D::Prop::viewport>( Size{static_cast(event.width), static_cast(event.height)}); + if (event.wheel) { + Wheel_Event wheel; + wheel.position = {event.wheel->position_x, event.wheel->position_y}; + wheel.angle_delta_y = event.wheel->delta_y; + (*scene)->dispatch_event(wheel); + } (*scene)->render(); } else { auto& scene_3d = std::get>(self->d->scene); diff --git a/web_server/src/Plot.hpp b/web_server/src/Plot.hpp index a02cfe4..a1cdef0 100644 --- a/web_server/src/Plot.hpp +++ b/web_server/src/Plot.hpp @@ -6,13 +6,20 @@ #include #include #include +#include #include #include namespace aethera::web { +struct Plot_Wheel_Event { + double position_x{}; /* Pointer position in the rendered viewport, in physical pixels. */ + double position_y{}; /* Pointer position in the rendered viewport, in physical pixels. */ + double delta_y{}; /* Normalized vertical wheel angle delta. */ +}; struct Plot_Event { double time_milliseconds{}; std::uint32_t width{720}; std::uint32_t height{420}; + std::optional wheel{}; /* Canvas interaction attached to this render request. */ }; class Plot final : public std::enable_shared_from_this { public: diff --git a/webapp_gallery/src/app.tsx b/webapp_gallery/src/app.tsx index d82f9e9..d8733bb 100644 --- a/webapp_gallery/src/app.tsx +++ b/webapp_gallery/src/app.tsx @@ -1,4 +1,5 @@ -import {memo, useEffect, useMemo, useRef, useState} from "react"; +import {memo, useCallback, useEffect, useMemo, useRef, useState} from "react"; +import {Group, Panel, Separator} from "react-resizable-panels"; type Plot = { id: string; @@ -12,20 +13,22 @@ type Plot = { type Option = {value: string; label: string}; type Field = {key: string; description: string; type: "boolean" | "integer" | "number" | "select" | "array" | "object"; value: unknown; options?: Option[]}; type Schema = {prop: Field[]; state: Field[]}; +type Stream_Status = "CONNECTING" | "LIVE" | "OFFLINE"; -const protocolHeaderSize = 24; +const protocol_header_size = 24; +const frame_interval_ms = 1000 / 30; -function socketUrl(path: string) { +function socket_url(path: string) { return `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${path}`; } -function drawPixels(canvas: HTMLCanvasElement, bytes: ArrayBuffer) { - if (bytes.byteLength < protocolHeaderSize) return; +function draw_pixels(canvas: HTMLCanvasElement, bytes: ArrayBuffer) { + if (bytes.byteLength < protocol_header_size) return; const view = new DataView(bytes); if (view.getUint32(0, true) !== 0x41544852 || view.getUint16(4, true) !== 1) return; const width = view.getUint32(8, true); const height = view.getUint32(12, true); - const pixels = new Uint8ClampedArray(bytes, protocolHeaderSize); + const pixels = new Uint8ClampedArray(bytes, protocol_header_size); if (pixels.byteLength !== width * height * 4) return; if (canvas.width !== width || canvas.height !== height) { canvas.width = width; @@ -34,87 +37,253 @@ function drawPixels(canvas: HTMLCanvasElement, bytes: ArrayBuffer) { canvas.getContext("2d", {alpha: false})?.putImageData(new ImageData(pixels, width, height), 0, 0); } -function usePlotStream(plot: Plot, canvasRef: React.RefObject) { - const statusRef = useRef(null); +function use_plot_stream(plot: Plot, canvas_ref: React.RefObject) { + const [status, set_status] = useState("CONNECTING"); + const socket_ref = useRef(null); + useEffect(() => { let stopped = false; let animation = 0; - const socket = new WebSocket(socketUrl(plot.websocket)); + let previous_frame_time = 0; + const socket = new WebSocket(socket_url(plot.websocket)); + socket_ref.current = socket; socket.binaryType = "arraybuffer"; - socket.onopen = () => { if (statusRef.current) statusRef.current.textContent = "LIVE"; }; - socket.onclose = () => { if (statusRef.current) statusRef.current.textContent = "OFFLINE"; }; - socket.onmessage = event => { if (event.data instanceof ArrayBuffer && canvasRef.current) drawPixels(canvasRef.current, event.data); }; + socket.onopen = () => set_status("LIVE"); + socket.onclose = () => set_status("OFFLINE"); + socket.onmessage = event => { + if (event.data instanceof ArrayBuffer && canvas_ref.current) draw_pixels(canvas_ref.current, event.data); + }; const tick = (time: number) => { - const canvas = canvasRef.current; - if (!stopped && socket.readyState === WebSocket.OPEN && canvas) { - socket.send(JSON.stringify({time, width: Math.max(320, canvas.clientWidth * devicePixelRatio), height: Math.max(220, canvas.clientHeight * devicePixelRatio)})); + const canvas = canvas_ref.current; + if (!stopped && socket.readyState === WebSocket.OPEN && canvas && time - previous_frame_time >= frame_interval_ms) { + const bounds = canvas.getBoundingClientRect(); + const visible = bounds.bottom > 0 && bounds.top < innerHeight && bounds.right > 0 && bounds.left < innerWidth; + if (visible && bounds.width > 0 && bounds.height > 0) { + const scale = devicePixelRatio; + socket.send(JSON.stringify({ + time, + width: Math.round(bounds.width * scale), + height: Math.round(bounds.height * scale) + })); + previous_frame_time = time; + } } if (!stopped) animation = requestAnimationFrame(tick); }; animation = requestAnimationFrame(tick); - return () => { stopped = true; cancelAnimationFrame(animation); socket.close(); }; - }, [plot.websocket, canvasRef]); - return statusRef; + return () => { + stopped = true; + cancelAnimationFrame(animation); + socket_ref.current = null; + socket.close(); + }; + }, [plot.websocket, canvas_ref]); + + const send_wheel = useCallback((event: WheelEvent) => { + event.preventDefault(); + event.stopPropagation(); + const socket = socket_ref.current; + if (!socket || socket.readyState !== WebSocket.OPEN) return; + const canvas = canvas_ref.current; + if (!canvas) return; + const bounds = canvas.getBoundingClientRect(); + const delta_scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 16 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? bounds.height : 1; + socket.send(JSON.stringify({ + time: performance.now(), + width: Math.round(bounds.width * devicePixelRatio), + height: Math.round(bounds.height * devicePixelRatio), + wheel: { + x: (event.clientX - bounds.left) * devicePixelRatio, + y: (event.clientY - bounds.top) * devicePixelRatio, + delta_y: Math.max(-120, Math.min(120, -event.deltaY * delta_scale)) + } + })); + }, [canvas_ref]); + + useEffect(() => { + const canvas = canvas_ref.current; + if (!canvas) return; + canvas.addEventListener("wheel", send_wheel, {passive: false}); + return () => canvas.removeEventListener("wheel", send_wheel); + }, [canvas_ref, send_wheel]); + + return status; } -function JsonControl({field, onChange}: {field: Field; onChange: (value: unknown) => void}) { - const [draft, setDraft] = useState(() => JSON.stringify(field.value, null, 2)); - const [invalid, setInvalid] = useState(false); - useEffect(() => setDraft(JSON.stringify(field.value, null, 2)), [field.value]); +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 [invalid, set_invalid] = useState(false); + useEffect(() => set_draft(JSON.stringify(field.value, null, 2)), [field.value]); const apply = () => { - try { onChange(JSON.parse(draft)); setInvalid(false); } - catch { setInvalid(true); } + try { + on_change(JSON.parse(draft)); + set_invalid(false); + } catch { + set_invalid(true); + } }; - return