512 lines
37 KiB
TypeScript
512 lines
37 KiB
TypeScript
import {memo, useCallback, useEffect, useMemo, useRef, useState} from "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 "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 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 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; fields: Field[]; state: Record<string, unknown>};
|
||
type Schema = {protocol: "aethera.plot.inspector"; version: 2; components: Component[]};
|
||
type State_Histories = Record<string, number[]>;
|
||
type Stream_Status = "CONNECTING" | "LIVE" | "OFFLINE";
|
||
|
||
const protocol_header_size = 24;
|
||
const frame_interval_ms = 1000 / 30;
|
||
|
||
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) {
|
||
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, protocol_header_size);
|
||
if (pixels.byteLength !== width * height * 4) return;
|
||
if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; }
|
||
canvas.getContext("2d", {alpha: false})?.putImageData(new ImageData(pixels, width, height), 0, 0);
|
||
}
|
||
|
||
function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasElement | null>) {
|
||
const [status, set_status] = useState<Stream_Status>("CONNECTING");
|
||
const socket_ref = useRef<ReconnectingWebSocket | null>(null);
|
||
|
||
const envelope = useCallback((kind: "frame" | "input", event?: Record<string, unknown>) => {
|
||
const canvas = canvas_ref.current;
|
||
if (!canvas) return null;
|
||
const bounds = canvas.getBoundingClientRect();
|
||
return {kind, time: local_time_milliseconds(), viewport: {
|
||
width: Math.round(bounds.width * devicePixelRatio), height: Math.round(bounds.height * devicePixelRatio)
|
||
}, ...(event ? {event} : {})};
|
||
}, [canvas_ref]);
|
||
|
||
const transmit = useCallback((kind: "frame" | "input", event?: Record<string, unknown>) => {
|
||
const socket = socket_ref.current;
|
||
const message = envelope(kind, event);
|
||
if (socket?.readyState === WebSocket.OPEN && message) socket.send(JSON.stringify(message));
|
||
}, [envelope]);
|
||
|
||
useEffect(() => {
|
||
let stopped = false;
|
||
let animation = 0;
|
||
let previous_frame_time = 0;
|
||
const socket = new ReconnectingWebSocket(socket_url(plot.websocket), [], {
|
||
minReconnectionDelay: 300,
|
||
maxReconnectionDelay: 5000,
|
||
reconnectionDelayGrowFactor: 1.6,
|
||
maxRetries: Number.POSITIVE_INFINITY
|
||
});
|
||
socket_ref.current = socket;
|
||
socket.binaryType = "arraybuffer";
|
||
set_status("CONNECTING");
|
||
socket.onopen = () => set_status("LIVE");
|
||
socket.onclose = () => { if (!stopped) set_status("CONNECTING"); };
|
||
socket.onmessage = event => {
|
||
if (event.data instanceof ArrayBuffer && canvas_ref.current) draw_pixels(canvas_ref.current, event.data);
|
||
};
|
||
const tick = (time: number) => {
|
||
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 message = envelope("frame");
|
||
if (message) socket.send(JSON.stringify(message));
|
||
previous_frame_time = time;
|
||
}
|
||
}
|
||
if (!stopped) animation = requestAnimationFrame(tick);
|
||
};
|
||
animation = requestAnimationFrame(tick);
|
||
return () => { stopped = true; cancelAnimationFrame(animation); socket_ref.current = null; socket.close(); };
|
||
}, [plot.websocket, canvas_ref, envelope]);
|
||
|
||
useEffect(() => {
|
||
const canvas = canvas_ref.current;
|
||
if (!canvas) return;
|
||
const point = (event: PointerEvent | WheelEvent) => {
|
||
const bounds = canvas.getBoundingClientRect();
|
||
return {x: (event.clientX - bounds.left) * devicePixelRatio, y: (event.clientY - bounds.top) * devicePixelRatio};
|
||
};
|
||
const global_point = (event: PointerEvent | WheelEvent) => ({x: event.screenX * devicePixelRatio, y: event.screenY * devicePixelRatio});
|
||
const modifiers = (event: MouseEvent | KeyboardEvent) =>
|
||
(event.ctrlKey ? 1 : 0) | (event.shiftKey ? 2 : 0) | (event.altKey ? 4 : 0) | (event.metaKey ? 8 : 0);
|
||
const button = (value: number) => value === 0 ? "left" : value === 1 ? "middle" : value === 2 ? "right" : "none";
|
||
const pointer_payload = (type: string, event: PointerEvent) => ({type, position: point(event), global_position: global_point(event),
|
||
button: button(event.button), buttons: event.buttons, modifiers: modifiers(event)});
|
||
let pending_pointer_move: Record<string, unknown> | null = null;
|
||
let pointer_animation = 0;
|
||
const on_pointer_move = (event: PointerEvent) => {
|
||
event.stopPropagation();
|
||
pending_pointer_move = pointer_payload("pointer_move", event);
|
||
if (pointer_animation) return;
|
||
pointer_animation = requestAnimationFrame(() => {
|
||
pointer_animation = 0;
|
||
if (pending_pointer_move) transmit("input", pending_pointer_move);
|
||
pending_pointer_move = null;
|
||
});
|
||
};
|
||
const on_pointer_down = (event: PointerEvent) => {
|
||
event.preventDefault(); event.stopPropagation(); canvas.focus({preventScroll: true}); canvas.setPointerCapture(event.pointerId);
|
||
transmit("input", pointer_payload("pointer_press", event));
|
||
};
|
||
const on_pointer_up = (event: PointerEvent) => {
|
||
event.preventDefault(); event.stopPropagation();
|
||
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
|
||
transmit("input", pointer_payload("pointer_release", event));
|
||
};
|
||
const on_pointer_cancel = (event: PointerEvent) => transmit("input", pointer_payload("pointer_release", event));
|
||
const on_pointer_leave = (event: PointerEvent) => { event.stopPropagation(); transmit("input", {type: "leave"}); };
|
||
const on_wheel = (event: WheelEvent) => {
|
||
event.preventDefault(); event.stopPropagation();
|
||
const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 16 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? canvas.clientHeight : 1;
|
||
transmit("input", {type: "wheel", position: point(event), global_position: global_point(event), button: "none", buttons: 0,
|
||
modifiers: modifiers(event), pixel_delta_x: -event.deltaX * scale, pixel_delta_y: -event.deltaY * scale,
|
||
angle_delta_x: Math.max(-120, Math.min(120, -event.deltaX * scale)), angle_delta_y: Math.max(-120, Math.min(120, -event.deltaY * scale))});
|
||
};
|
||
const key_name = (key: string) => ({Escape: "escape", Enter: "enter", " ": "space", Delete: "delete_key", Backspace: "backspace",
|
||
ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", ArrowDown: "down"}[key] ?? "unknown");
|
||
const on_key = (type: "key_press" | "key_release") => (event: KeyboardEvent) => {
|
||
event.stopPropagation();
|
||
transmit("input", {type, key: key_name(event.key), native_key: event.keyCode, modifiers: modifiers(event), auto_repeat: event.repeat});
|
||
};
|
||
const on_key_down = on_key("key_press");
|
||
const on_key_up = on_key("key_release");
|
||
const on_focus = () => transmit("input", {type: "show"});
|
||
const on_blur = () => transmit("input", {type: "hide"});
|
||
const on_context = (event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); };
|
||
canvas.addEventListener("pointermove", on_pointer_move);
|
||
canvas.addEventListener("pointerdown", on_pointer_down);
|
||
canvas.addEventListener("pointerup", on_pointer_up);
|
||
canvas.addEventListener("pointercancel", on_pointer_cancel);
|
||
canvas.addEventListener("pointerleave", on_pointer_leave);
|
||
canvas.addEventListener("wheel", on_wheel, {passive: false});
|
||
canvas.addEventListener("keydown", on_key_down);
|
||
canvas.addEventListener("keyup", on_key_up);
|
||
canvas.addEventListener("focus", on_focus);
|
||
canvas.addEventListener("blur", on_blur);
|
||
canvas.addEventListener("contextmenu", on_context);
|
||
return () => {
|
||
if (pointer_animation) cancelAnimationFrame(pointer_animation);
|
||
canvas.removeEventListener("pointermove", on_pointer_move); canvas.removeEventListener("pointerdown", on_pointer_down);
|
||
canvas.removeEventListener("pointerup", on_pointer_up); canvas.removeEventListener("pointercancel", on_pointer_cancel);
|
||
canvas.removeEventListener("pointerleave", on_pointer_leave); canvas.removeEventListener("wheel", on_wheel);
|
||
canvas.removeEventListener("keydown", on_key_down); canvas.removeEventListener("keyup", on_key_up);
|
||
canvas.removeEventListener("focus", on_focus); canvas.removeEventListener("blur", on_blur); canvas.removeEventListener("contextmenu", on_context);
|
||
};
|
||
}, [canvas_ref, transmit]);
|
||
return status;
|
||
}
|
||
|
||
const member_labels: Record<string, string> = {x: "横坐标", y: "纵坐标", z: "深度", width: "宽度", height: "高度", origin: "起点", target: "终点",
|
||
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: "点线",
|
||
butt: "平直", square: "方形", round: "圆形", miter: "尖角", bevel: "斜角", nearest: "最近邻", bilinear: "双线性", bicubic: "双三次",
|
||
nearest_sample: "最近样本", linear_value: "线性数值", linear_power_domain: "线性功率域", step_left: "左阶梯", step_right: "右阶梯", cubic_value: "三次插值"};
|
||
const plot_labels: Record<string, string> = {spectrum: "频谱图", frequency_trace: "频率轨迹", sweep_spectrum: "扫频图", afterglow: "余辉图", waterfall: "瀑布图",
|
||
constellation: "星座图", selection_overlay: "矩形选区", datoviz_point: "三维点图"};
|
||
const field_label = (field: Field) => field.label || `字段(${field.key})`;
|
||
const field_tooltip = (field: Field) => field.description;
|
||
const enum_label = (value: string) => enum_labels[value] ?? 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 change = (text: string) => {
|
||
set_draft(text);
|
||
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)} <code>{field.key}</code></span>
|
||
<textarea value={draft} onChange={event => change(event.target.value)}/>
|
||
<small className={invalid ? "error" : ""}>{invalid ? "JSON 格式不正确" : "编辑完成后点击确定提交"}</small></label>;
|
||
}
|
||
|
||
function rgba_hex(value: Record<string, unknown>) {
|
||
const channel = (name: string) => Math.max(0, Math.min(255, Number(value[name] ?? 0))).toString(16).padStart(2, "0");
|
||
return `#${channel("red")}${channel("green")}${channel("blue")}`;
|
||
}
|
||
|
||
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 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)} <code>{field.key}</code></span><div className="inlineFields">
|
||
<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)});
|
||
}}/><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)} <code>{field.key}</code></legend><div className="inlineFields">
|
||
{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 => {
|
||
const color = item as Record<string, unknown>; const hex = event.target.value;
|
||
update(key, {...color, red: parseInt(hex.slice(1, 3), 16), green: parseInt(hex.slice(3, 5), 16), blue: parseInt(hex.slice(5, 7), 16)});
|
||
}}/><input type="number" min="0" max="255" aria-label={`${member_labels[key] ?? key}透明度`} value={String((item as Record<string, unknown>).alpha ?? 255)}
|
||
onChange={event => update(key, {...item as Record<string, unknown>, alpha: Number(event.target.value)})}/></div></label>
|
||
: ["style", "cap", "join"].includes(key) && typeof item === "string"
|
||
? <label key={key}><span>{member_labels[key] ?? key}</span><select value={item} onChange={event => update(key, event.target.value)}>
|
||
{(key === "style" ? ["none", "solid", "dash", "dot"] : key === "cap" ? ["butt", "square", "round"] : ["miter", "bevel", "round"]).map(option => <option key={option} value={option}>{enum_label(option)}</option>)}</select></label>
|
||
: typeof item === "boolean"
|
||
? <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 === "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, label: member_labels[key] ?? key, description: field.description, editor: "json", editable: true, value: item}} on_change={next => update(key, next)}/>) }
|
||
</div></fieldset>;
|
||
}
|
||
|
||
function Field_Control({field, on_change}: {field: Field; on_change: (value: unknown) => void}) {
|
||
if (["color", "point2", "size", "rect", "range", "pen", "brush", "font", "vector3"].includes(field.editor))
|
||
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 (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>;
|
||
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>;
|
||
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)} <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>}) {
|
||
const [draft, set_draft] = useState(field.value);
|
||
const [dirty, set_dirty] = useState(false);
|
||
const [saving, set_saving] = useState(false);
|
||
const encoded_value = JSON.stringify(field.value);
|
||
useEffect(() => { set_draft(field.value); set_dirty(false); }, [encoded_value]);
|
||
const change = (value: unknown) => {
|
||
set_draft(value);
|
||
set_dirty(true);
|
||
if (field.editor === "boolean") {
|
||
set_saving(true);
|
||
void on_commit(value).finally(() => set_saving(false));
|
||
}
|
||
};
|
||
const commit = async () => {
|
||
set_saving(true);
|
||
try { await on_commit(draft); set_dirty(false); } finally { set_saving(false); }
|
||
};
|
||
return <div className={`propertyEditor${dirty ? " dirty" : ""}`}>
|
||
<Field_Control field={{...field, value: draft}} on_change={change}/>
|
||
{field.editor !== "boolean" ? <div className="propertyActions">
|
||
<button disabled={!dirty || saving} onClick={() => void commit()}>{saving ? "提交中…" : "确定"}</button>
|
||
<button disabled={!dirty || saving} onClick={() => { set_draft(field.value); set_dirty(false); }}>撤销</button>
|
||
</div> : saving ? <small className="savingHint">正在提交…</small> : null}
|
||
</div>;
|
||
}
|
||
|
||
function Workspace_Header({plot, label, count, busy, on_refresh}: {plot: Plot; label: string; count: number; busy: boolean; on_refresh: () => void}) {
|
||
return <header className="workspaceHeader"><div><span className="eyebrow">{plot.dimension} 绘图组件</span><h2>{label}</h2><p>{plot_labels[plot.id] ?? plot.title}</p></div>
|
||
<span className="fieldCount">{count}</span><button className="refreshButton" disabled={busy} onClick={on_refresh} title="从后台重新读取全部属性和状态">{busy ? "读取中" : "刷新"}</button></header>;
|
||
}
|
||
|
||
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 copy = async () => { await navigator.clipboard.writeText(JSON.stringify(component.state, null, 2)); set_copied(true); window.setTimeout(() => set_copied(false), 1200); };
|
||
const fields = component.fields.filter(field => !field.editable);
|
||
return <section className="componentContent"><div className="stateToolbar"><span>{fields.length} 个只读字段</span>
|
||
<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>}) {
|
||
const {components, selected, set_active} = use_active_component(schema);
|
||
const component = components.find(item => item.id === selected);
|
||
const fields = component?.fields.filter(field => field.editable) ?? [];
|
||
const count = components.reduce((sum, item) => sum + item.fields.filter(field => field.editable).length, 0);
|
||
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, histories}: {plot: Plot; schema: Schema | null; busy: boolean; on_refresh: () => void; histories: State_Histories}) {
|
||
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}/>
|
||
<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 canvas_ref = useRef<HTMLCanvasElement>(null);
|
||
const status = use_plot_stream(plot, canvas_ref);
|
||
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 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>;
|
||
});
|
||
|
||
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 layout_labels: Record<I18nLabel, string> = {
|
||
[I18nLabel.Close_Tab]: "关闭标签",
|
||
[I18nLabel.Pinned_Tab]: "已固定",
|
||
[I18nLabel.Rename_Tab]: "重命名标签",
|
||
[I18nLabel.Close_Tabset]: "关闭标签组",
|
||
[I18nLabel.Active_Tabset]: "当前标签组",
|
||
[I18nLabel.Move_Tabset]: "移动标签组",
|
||
[I18nLabel.Move_Tabs]: "移动 ? 个标签",
|
||
[I18nLabel.Maximize]: "最大化标签组",
|
||
[I18nLabel.Restore]: "还原标签组",
|
||
[I18nLabel.Popout_Tab]: "在新窗口打开所选标签",
|
||
[I18nLabel.Popout_Tab_Float]: "浮动所选标签",
|
||
[I18nLabel.Popout_Float_To_Window]: "在新窗口打开面板",
|
||
[I18nLabel.Dock_Float_To_Layout]: "拖入其他布局",
|
||
[I18nLabel.Dock_Float_Tabs]: "停靠 ? 个标签",
|
||
[I18nLabel.Overflow_Menu_Tooltip]: "隐藏的标签",
|
||
[I18nLabel.Splitter]: "调整面板尺寸",
|
||
[I18nLabel.Error_rendering_component]: "面板渲染失败",
|
||
[I18nLabel.Error_rendering_component_retry]: "重试",
|
||
[I18nLabel.Menu_Rename]: "重命名",
|
||
[I18nLabel.Menu_Pin]: "固定",
|
||
[I18nLabel.Menu_Unpin]: "取消固定",
|
||
[I18nLabel.Menu_Popout]: "在新窗口打开",
|
||
[I18nLabel.Menu_Float]: "浮动",
|
||
[I18nLabel.Menu_Popout_Tabset]: "在新窗口打开标签组",
|
||
[I18nLabel.Menu_Float_Tabset]: "浮动标签组",
|
||
[I18nLabel.Menu_Maximize]: "最大化",
|
||
[I18nLabel.Menu_Restore]: "还原",
|
||
[I18nLabel.Menu_Overlay]: "覆盖显示",
|
||
[I18nLabel.Menu_Split]: "分栏显示",
|
||
[I18nLabel.Menu_Close_All]: "全部关闭",
|
||
[I18nLabel.Menu_Close_Right]: "关闭右侧标签",
|
||
[I18nLabel.Menu_Close_Others]: "关闭其他标签"
|
||
};
|
||
const default_workspace_layout: IJsonModel = {
|
||
global: {tabEnableRename: false, tabSetEnableMaximize: true, tabEnablePopout: false},
|
||
borders: [],
|
||
layout: {type: "row", children: [
|
||
{type: "tabset", id: "gallery-set", weight: 54, minWidth: 360, children: [
|
||
{type: "tab", id: "gallery-tab", name: "图形组件", component: "gallery", enableClose: false, enableScrollbars: false, minWidth: 320, minHeight: 240}]},
|
||
{type: "tabset", id: "properties-set", weight: 28, minWidth: 280, children: [
|
||
{type: "tab", id: "properties-tab", name: "属性编辑", component: "properties", enableClose: false, enableScrollbars: false, minWidth: 260, minHeight: 220}]},
|
||
{type: "tabset", id: "state-set", weight: 18, minWidth: 260, children: [
|
||
{type: "tab", id: "state-tab", name: "运行状态", component: "state", enableClose: false, enableScrollbars: false, minWidth: 240, minHeight: 220}]}
|
||
]}
|
||
};
|
||
|
||
function load_workspace_model() {
|
||
const saved = localStorage.getItem(workspace_layout_key);
|
||
if (!saved) return Model.fromJson(default_workspace_layout);
|
||
try { return Model.fromJson(JSON.parse(saved) as IJsonModel); }
|
||
catch { localStorage.removeItem(workspace_layout_key); return Model.fromJson(default_workspace_layout); }
|
||
}
|
||
|
||
export function App() {
|
||
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 [state_histories, set_state_histories] = useState<State_Histories>({});
|
||
const [schema_busy, set_schema_busy] = useState(false);
|
||
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);
|
||
useEffect(() => { void fetch("/plot").then(response => response.json()).then(set_plots); }, []);
|
||
useEffect(() => { if (!selected && plots.length > 0) set_selected(plots[0]); }, [plots, selected]);
|
||
const load_schema = useCallback(async () => {
|
||
if (!selected) return;
|
||
const request = ++schema_request.current;
|
||
set_schema_busy(true);
|
||
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); }
|
||
}, [selected]);
|
||
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) => {
|
||
if (!selected) return;
|
||
const response = await fetch(`/plot/${encodeURIComponent(selected.id)}/component/${encodeURIComponent(component.id)}/prop/${encodeURIComponent(field.key)}`, {
|
||
method: "PUT", headers: {"Content-Type": "application/json"}, body: JSON.stringify(value)});
|
||
const result = await response.json();
|
||
if (!result.success) throw new Error(result.error ?? "属性提交失败");
|
||
set_schema(current => current ? {...current, components: current.components.map(item => item.id !== component.id ? item : {...item,
|
||
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 visible = category === "全部" ? plots : plots;
|
||
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>}
|
||
<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>
|
||
<Gallery_Grid key={gallery_layout_revision} plots={visible} selected={selected} on_select={set_selected}/></section>;
|
||
const factory = (node: TabNode) => {
|
||
if (node.getComponent() === "gallery") return gallery;
|
||
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() === "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="appShell flexlayout__theme_alpha"><Layout model={layout_model} factory={factory} realtimeResize i18nMapper={label => layout_labels[label]}
|
||
onModelChange={model => localStorage.setItem(workspace_layout_key, JSON.stringify(model.toJson()))}/></div>;
|
||
}
|