Files
Aethera/webapp_gallery/src/app.tsx
T
2026-08-21 19:53:00 +08:00

512 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;
}