改bug
This commit is contained in:
@@ -18,7 +18,30 @@ struct Graph_WebSocket::Private {
|
||||
Graph_WebSocket::Graph_WebSocket(drogon::WebSocketConnectionPtr connection, std::shared_ptr<Plot> plot) : d(std::make_unique<Private>()) { 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<double>(); if (const auto value = json.find("width"); value != json.end() && value->is_number_unsigned()) event.width = std::clamp(value->get<std::uint32_t>(), 160U, 1920U); if (const auto value = json.find("height"); value != json.end() && value->is_number_unsigned()) event.height = std::clamp(value->get<std::uint32_t>(), 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<double>();
|
||||
if (const auto value = json.find("width"); value != json.end() && value->is_number_unsigned())
|
||||
event.width = std::clamp(value->get<std::uint32_t>(), 160U, 1920U);
|
||||
if (const auto value = json.find("height"); value != json.end() && value->is_number_unsigned())
|
||||
event.height = std::clamp(value->get<std::uint32_t>(), 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<double>(), 0.0, static_cast<double>(event.width)),
|
||||
std::clamp(y->get<double>(), 0.0, static_cast<double>(event.height)),
|
||||
std::clamp(delta_y->get<double>(), -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)) {}
|
||||
|
||||
+40
-6
@@ -14,6 +14,7 @@
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <numbers>
|
||||
#include <stdexcept>
|
||||
#include <tuple>
|
||||
#include <unordered_map>
|
||||
@@ -172,7 +173,21 @@ Time_Axis_Object::Builder time_axis_builder(
|
||||
|
||||
template <typename... Axes>
|
||||
void resize_axes(Size viewport, Axes*... axes) {
|
||||
(axes->template set<&Abs_Axis::Prop::canvas_size>(viewport), ...);
|
||||
const double left = std::clamp(static_cast<double>(viewport.width) * 0.10, 42.0, 68.0);
|
||||
const double right = std::clamp(static_cast<double>(viewport.width) * 0.05, 18.0, 34.0);
|
||||
const double top = std::clamp(static_cast<double>(viewport.height) * 0.07, 16.0, 30.0);
|
||||
const double bottom = std::clamp(static_cast<double>(viewport.height) * 0.13, 36.0, 52.0);
|
||||
const Point_F origin{left, static_cast<double>(viewport.height) - bottom};
|
||||
const Axis_Pixel_Length horizontal_length = std::max(1.0, static_cast<double>(viewport.width) - left - right);
|
||||
const Axis_Pixel_Length vertical_length = -std::max(1.0, static_cast<double>(viewport.height) - top - bottom);
|
||||
const auto resize_axis = [&](auto* axis) {
|
||||
const auto orientation = axis->template read_prop<Abs_Axis::Base_Tag>().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<Plot> 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<int>(event.width), static_cast<int>(event.height)}, frequency, time);
|
||||
std::array<double, 192> 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<double>(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::int64_t>(
|
||||
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<Plot> 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<int>(event.width), static_cast<int>(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<Constellation_Diagram::Base_Tag>();
|
||||
const int anchor_count = static_cast<int>(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<double>(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<std::unique_ptr<Scene_2D>>(&self->d->scene)) {
|
||||
(*scene)->set<&Render_Scene_2D::Prop::viewport>(
|
||||
Size{static_cast<int>(event.width), static_cast<int>(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<std::unique_ptr<Scene_3D>>(self->d->scene);
|
||||
|
||||
@@ -6,13 +6,20 @@
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
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<Plot_Wheel_Event> wheel{}; /* Canvas interaction attached to this render request. */
|
||||
};
|
||||
class Plot final : public std::enable_shared_from_this<Plot> {
|
||||
public:
|
||||
|
||||
+225
-56
@@ -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<HTMLCanvasElement | null>) {
|
||||
const statusRef = useRef<HTMLSpanElement>(null);
|
||||
function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasElement | null>) {
|
||||
const [status, set_status] = useState<Stream_Status>("CONNECTING");
|
||||
const socket_ref = useRef<WebSocket | null>(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 <label className="control controlJson" title={field.description}><span>{field.key}</span><textarea value={draft} onChange={event => setDraft(event.target.value)} onBlur={apply}/><small className={invalid ? "error" : ""}>{invalid ? "Invalid JSON" : "Changes apply when focus leaves the field."}</small></label>;
|
||||
return <label className="control controlJson" title={field.description}>
|
||||
<span>{field.key}</span>
|
||||
<textarea value={draft} onChange={event => set_draft(event.target.value)} onBlur={apply}/>
|
||||
<small className={invalid ? "error" : ""}>{invalid ? "Invalid JSON" : "Changes apply when focus leaves the field."}</small>
|
||||
</label>;
|
||||
}
|
||||
|
||||
function FieldControl({field, onChange}: {field: Field; onChange: (value: unknown) => void}) {
|
||||
if (field.type === "object" || field.type === "array") return <JsonControl field={field} onChange={onChange}/>;
|
||||
if (field.type === "boolean") return <label className="control controlBoolean" title={field.description}><span>{field.key}</span><input type="checkbox" checked={Boolean(field.value)} onChange={event => onChange(event.target.checked)}/></label>;
|
||||
if (field.type === "select") return <label className="control" title={field.description}><span>{field.key}</span><select value={String(field.value ?? "")} onChange={event => onChange(event.target.value)}>{field.options?.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>;
|
||||
return <label className="control" title={field.description}><span>{field.key}</span><input type="number" value={String(field.value ?? "")} onChange={event => onChange(Number(event.target.value))}/></label>;
|
||||
function Field_Control({field, on_change}: {field: Field; on_change: (value: unknown) => void}) {
|
||||
if (field.type === "object" || field.type === "array") return <Json_Control field={field} on_change={on_change}/>;
|
||||
if (field.type === "boolean") return <label className="control controlBoolean" title={field.description}>
|
||||
<span>{field.key}</span><input type="checkbox" checked={Boolean(field.value)} onChange={event => on_change(event.target.checked)}/>
|
||||
</label>;
|
||||
if (field.type === "select") return <label className="control" title={field.description}>
|
||||
<span>{field.key}</span>
|
||||
<select value={String(field.value ?? "")} onChange={event => on_change(event.target.value)}>
|
||||
{field.options?.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>;
|
||||
return <label className="control" title={field.description}>
|
||||
<span>{field.key}</span><input type="number" value={String(field.value ?? "")} onChange={event => on_change(Number(event.target.value))}/>
|
||||
</label>;
|
||||
}
|
||||
|
||||
function StateValue({value}: {value: unknown}) {
|
||||
function State_Value({value}: {value: unknown}) {
|
||||
if (value !== null && typeof value === "object") return <pre>{JSON.stringify(value, null, 2)}</pre>;
|
||||
return <code>{String(value ?? "—")}</code>;
|
||||
}
|
||||
|
||||
function InspectorSidebar({plot, onClose}: {plot: Plot; onClose: () => void}) {
|
||||
const [schema, setSchema] = useState<Schema | null>(null);
|
||||
const [tab, setTab] = useState<"prop" | "state">("prop");
|
||||
const [busy, setBusy] = useState(true);
|
||||
const loadSchema = async () => {
|
||||
setBusy(true);
|
||||
try { setSchema(await fetch(plot.schema).then(response => response.json())); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
useEffect(() => { void loadSchema(); }, [plot.schema]);
|
||||
useEffect(() => { if (tab === "state") void loadSchema(); }, [tab]);
|
||||
function Workspace_Header({plot, label, count, on_hide}: {plot: Plot; label: string; count: number; on_hide: () => void}) {
|
||||
return <header className="workspaceHeader">
|
||||
<div><span className="eyebrow">{plot.category} · {plot.dimension}</span><h2>{label}</h2><p>{plot.title}</p></div>
|
||||
<span className="fieldCount">{count}</span>
|
||||
<button className="iconButton" onClick={on_hide} aria-label={`Hide ${label}`}>×</button>
|
||||
</header>;
|
||||
}
|
||||
|
||||
function Inspector_Workspace({plot, prop_visible, state_visible, on_hide_prop, on_hide_state}: {
|
||||
plot: Plot;
|
||||
prop_visible: boolean;
|
||||
state_visible: boolean;
|
||||
on_hide_prop: () => void;
|
||||
on_hide_state: () => void;
|
||||
}) {
|
||||
const [schema, set_schema] = useState<Schema | null>(null);
|
||||
const [busy, set_busy] = useState(true);
|
||||
const load_schema = useCallback(async () => {
|
||||
set_busy(true);
|
||||
try {
|
||||
const response = await fetch(plot.schema);
|
||||
set_schema(await response.json());
|
||||
} finally {
|
||||
set_busy(false);
|
||||
}
|
||||
}, [plot.schema]);
|
||||
|
||||
useEffect(() => { void load_schema(); }, [load_schema]);
|
||||
useEffect(() => {
|
||||
if (!state_visible) return;
|
||||
const interval = window.setInterval(() => void load_schema(), 1000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [state_visible, load_schema]);
|
||||
|
||||
const update = async (field: Field, value: unknown) => {
|
||||
const result = await fetch(`/plot/${encodeURIComponent(plot.id)}/prop/${encodeURIComponent(field.key)}`, {
|
||||
const response = await fetch(`/plot/${encodeURIComponent(plot.id)}/prop/${encodeURIComponent(field.key)}`, {
|
||||
method: "PUT",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(value)
|
||||
}).then(response => response.json());
|
||||
if (result.success) setSchema(current => current ? {...current, prop: current.prop.map(item => item.key === field.key ? {...item, value: result.value} : item)} : current);
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
set_schema(current => current ? {
|
||||
...current,
|
||||
prop: current.prop.map(item => item.key === field.key ? {...item, value: result.value} : item)
|
||||
} : current);
|
||||
}
|
||||
};
|
||||
return <><button className="backdrop" aria-label="Close inspector" onClick={onClose}/><aside className="sidebar" aria-label={`${plot.title} inspector`}><header><div><span className="eyebrow">{plot.category} · {plot.dimension}</span><h2>{plot.title}</h2></div><button className="close" onClick={onClose} aria-label="Close">×</button></header><div className="tabs"><button className={tab === "prop" ? "active" : ""} onClick={() => setTab("prop")}>Editable Prop <span>{schema?.prop.length ?? 0}</span></button><button className={tab === "state" ? "active" : ""} onClick={() => setTab("state")}>Published State <span>{schema?.state.length ?? 0}</span></button></div><div className="sidebarBody">{busy && !schema ? <p className="muted">Loading schema…</p> : tab === "prop" ? <section className="propGrid">{schema?.prop.map(field => <FieldControl key={field.key} field={field} onChange={value => void update(field, value)}/>)}</section> : <section><div className="stateHeading"><p>Current published State</p><button onClick={() => void loadSchema()}>Refresh</button></div><dl className="stateList">{schema?.state.map(field => <div key={field.key} title={field.description}><dt>{field.key}</dt><dd><StateValue value={field.value}/></dd></div>)}</dl></section>}</div></aside></>;
|
||||
|
||||
if (prop_visible && state_visible) return <Group orientation="vertical" id="inspector-sections" className="inspectorGroup">
|
||||
<Panel id="prop" defaultSize="58" minSize={220}>
|
||||
<section className="workspacePane" aria-label={`${plot.title} editable properties`}>
|
||||
<Workspace_Header plot={plot} label="Editable Prop" count={schema?.prop.length ?? 0} on_hide={on_hide_prop}/>
|
||||
<div className="workspaceBody">{busy && !schema ? <p className="muted">Loading schema…</p> : <div className="propGrid">
|
||||
{schema?.prop.map(field => <Field_Control key={field.key} field={field} on_change={value => void update(field, value)}/>)}
|
||||
</div>}</div>
|
||||
</section>
|
||||
</Panel>
|
||||
<Separator className="resizeHandle horizontal"/>
|
||||
<Panel id="state" defaultSize="42" minSize={180}>
|
||||
<section className="workspacePane" aria-label={`${plot.title} published state`}>
|
||||
<Workspace_Header plot={plot} label="Published State" count={schema?.state.length ?? 0} on_hide={on_hide_state}/>
|
||||
<div className="workspaceBody"><dl className="stateList">{schema?.state.map(field => <div key={field.key} title={field.description}>
|
||||
<dt>{field.key}</dt><dd><State_Value value={field.value}/></dd>
|
||||
</div>)}</dl></div>
|
||||
</section>
|
||||
</Panel>
|
||||
</Group>;
|
||||
|
||||
const show_prop = prop_visible;
|
||||
return <section className="workspacePane" aria-label={`${plot.title} ${show_prop ? "editable properties" : "published state"}`}>
|
||||
<Workspace_Header plot={plot} label={show_prop ? "Editable Prop" : "Published State"} count={show_prop ? schema?.prop.length ?? 0 : schema?.state.length ?? 0} on_hide={show_prop ? on_hide_prop : on_hide_state}/>
|
||||
<div className="workspaceBody">{show_prop ? <div className="propGrid">
|
||||
{schema?.prop.map(field => <Field_Control key={field.key} field={field} on_change={value => void update(field, value)}/>)}
|
||||
</div> : <dl className="stateList">{schema?.state.map(field => <div key={field.key} title={field.description}>
|
||||
<dt>{field.key}</dt><dd><State_Value value={field.value}/></dd>
|
||||
</div>)}</dl>}</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
const PlotCard = memo(function PlotCard({plot, onInspect}: {plot: Plot; onInspect: (plot: Plot) => void}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const statusRef = usePlotStream(plot, canvasRef);
|
||||
return <article className="card"><header><div><span className="eyebrow">{plot.category} · {plot.dimension}</span><h2>{plot.title}</h2></div><span ref={statusRef} className="status">CONNECTING</span></header><p>{plot.description}</p><canvas ref={canvasRef}/><button className="inspect" onClick={() => onInspect(plot)}>Inspect Prop & State</button></article>;
|
||||
const Plot_Card = memo(function Plot_Card({plot, selected, on_inspect}: {plot: Plot; selected: boolean; on_inspect: (plot: Plot) => void}) {
|
||||
const canvas_ref = useRef<HTMLCanvasElement>(null);
|
||||
const status = use_plot_stream(plot, canvas_ref);
|
||||
return <article className={`card${selected ? " selected" : ""}`} data-plot-id={plot.id}>
|
||||
<header><div><span className="eyebrow">{plot.category} · {plot.dimension}</span><h2>{plot.title}</h2></div><span className="status">{status}</span></header>
|
||||
{plot.description ? <p>{plot.description}</p> : null}
|
||||
<div className="plotViewport"><canvas ref={canvas_ref}/></div>
|
||||
<button className="inspect" onClick={() => on_inspect(plot)}>{selected ? "Editing Prop & State" : "Inspect Prop & State"}</button>
|
||||
</article>;
|
||||
});
|
||||
|
||||
export function App() {
|
||||
const [plots, setPlots] = useState<Plot[]>([]);
|
||||
const [category, setCategory] = useState("All");
|
||||
const [selected, setSelected] = useState<Plot | null>(null);
|
||||
useEffect(() => { void fetch("/plot").then(response => response.json()).then(setPlots); }, []);
|
||||
useEffect(() => { document.body.classList.toggle("sidebarOpen", selected !== null); return () => document.body.classList.remove("sidebarOpen"); }, [selected]);
|
||||
const [plots, set_plots] = useState<Plot[]>([]);
|
||||
const [category, set_category] = useState("All");
|
||||
const [selected, set_selected] = useState<Plot | null>(null);
|
||||
const [prop_visible, set_prop_visible] = useState(true);
|
||||
const [state_visible, set_state_visible] = useState(true);
|
||||
useEffect(() => { void fetch("/plot").then(response => response.json()).then(set_plots); }, []);
|
||||
const categories = useMemo(() => ["All", ...new Set(plots.map(plot => plot.category))], [plots]);
|
||||
const visible = category === "All" ? plots : plots.filter(plot => plot.category === category);
|
||||
return <><main><section className="hero"><div><span className="eyebrow">AETHERA RENDER LAB</span><h1>Live chart gallery</h1><p>Each Plot binds a real engine Scene to its WebSocket stream and Structive-generated property schema.</p></div><div className="heroMetric"><strong>{plots.length}</strong><span>live components</span></div></section><nav>{categories.map(value => <button key={value} className={category === value ? "active" : ""} onClick={() => setCategory(value)}>{value}</button>)}</nav><section className="grid">{visible.map(plot => <PlotCard key={plot.id} plot={plot} onInspect={setSelected}/>)}</section></main>{selected ? <InspectorSidebar plot={selected} onClose={() => setSelected(null)}/> : null}</>;
|
||||
const displayed = selected && visible.some(plot => plot.id === selected.id)
|
||||
? [selected, ...visible.filter(plot => plot.id !== selected.id)]
|
||||
: visible;
|
||||
const workspace_visible = selected !== null && (prop_visible || state_visible);
|
||||
const inspect = (plot: Plot) => {
|
||||
set_selected(plot);
|
||||
set_prop_visible(true);
|
||||
set_state_visible(true);
|
||||
};
|
||||
|
||||
const gallery = <section className="galleryPanel">
|
||||
<header className="topbar">
|
||||
<div><span className="eyebrow">AETHERA RENDER LAB</span><h1>Live chart gallery</h1></div>
|
||||
<div className="topbarActions">
|
||||
{selected ? <>
|
||||
<span className="selectionName">Editing <strong>{selected.title}</strong></span>
|
||||
<button className={prop_visible ? "active" : ""} onClick={() => set_prop_visible(value => !value)}>Prop</button>
|
||||
<button className={state_visible ? "active" : ""} onClick={() => set_state_visible(value => !value)}>State</button>
|
||||
<button onClick={() => set_selected(null)}>Close</button>
|
||||
</> : <span className="muted">Choose a plot to inspect it beside the live canvas.</span>}
|
||||
</div>
|
||||
</header>
|
||||
<nav>{categories.map(value => <button key={value} className={category === value ? "active" : ""} onClick={() => set_category(value)}>{value}</button>)}</nav>
|
||||
<section className="grid">{displayed.map(plot => <Plot_Card key={plot.id} plot={plot} selected={selected?.id === plot.id} on_inspect={inspect}/>)}</section>
|
||||
</section>;
|
||||
|
||||
if (!workspace_visible) return <div className="appShell">{gallery}</div>;
|
||||
return <div className="appShell"><Group orientation="horizontal" id="gallery-workspace" className="rootGroup">
|
||||
<Panel id="gallery" defaultSize="62" minSize={420}>{gallery}</Panel>
|
||||
<Separator className="resizeHandle vertical"/>
|
||||
<Panel id="inspector" defaultSize="38" minSize={340} maxSize="65" groupResizeBehavior="preserve-pixel-size">
|
||||
<aside className="inspector" aria-label={`${selected.title} inspector`}>
|
||||
<Inspector_Workspace plot={selected} prop_visible={prop_visible} state_visible={state_visible} on_hide_prop={() => set_prop_visible(false)} on_hide_state={() => set_state_visible(false)}/>
|
||||
</aside>
|
||||
</Panel>
|
||||
</Group></div>;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user