三维频谱marker

This commit is contained in:
2026-08-22 23:43:44 +08:00
parent 7c3f27474b
commit 9540f257e5
12 changed files with 294 additions and 71 deletions
+94 -36
View File
@@ -20,6 +20,8 @@ type Color_Channel_Scale = "normalized" | "byte";
type Field = {key: string; label: string; description: string; technical_description?: string; editor: Editor; editable: boolean; value: unknown; options?: Option[]; color_channel_scale?: Color_Channel_Scale; minimum?: number; maximum?: number; step?: number};
type Component = {id: string; label: string; kind: string; fields: Field[]; state: Record<string, unknown>};
type Data_Generator = {label: string; description: string; fields: Field[]};
type Plot_Execution_Policy = {visible: boolean; refresh_hidden: boolean; transfer_pixels: boolean};
type Plot_Execution_Policies = Record<string, Plot_Execution_Policy>;
type Frame_Analysis = Omit<Component, "state"> & {data_generator?: Data_Generator};
type Schema = {protocol: "aethera.plot.inspector"; version: 2; components: Component[]; frame_analysis: Frame_Analysis};
type State_Histories = Record<string, number[]>;
@@ -43,6 +45,8 @@ type Frame_Policy_Event = {plot_id: string; key: "pacing_mode" | "fixed_rate_fps
type Stage_Statistic = "average" | "variability" | "p95" | "p99";
type Stage_Unit = "value" | "percentage";
const default_plot_execution_policy = (): Plot_Execution_Policy => ({visible: true, refresh_hidden: false, transfer_pixels: true});
function socket_url(path: string) { return `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${path}`; }
function local_time_milliseconds() {
@@ -655,14 +659,14 @@ function Structured_Control({field, on_change}: {field: Field; on_change: (value
</div></fieldset>;
}
type Marker_Value = {position: {x: number; y: number; z: number}; color: {red: number; green: number; blue: number; alpha: number}; diameter_px: number; angle: number; shape: string};
type Marker_Value = {position: {x: number; y: number; z: number}; color: {red: number; green: number; blue: number; alpha: number}; diameter_px: number; angle: number; shape: string; coordinate_label_visible: boolean};
const marker_shapes = ["disc", "square", "triangle", "diamond", "cross"];
function Marker_List_Control({field, on_change}: {field: Field; on_change: (value: unknown) => void}) {
const markers = Array.isArray(field.value) ? field.value as Marker_Value[] : [];
const surface_attached = field.editor === "surface-marker-list";
const replace = (index: number, marker: Marker_Value) => on_change(markers.map((value, item_index) => item_index === index ? marker : value));
const add = () => on_change([...markers, {position: {x: 0, y: 0, z: 0}, color: {red: 255, green: 218, blue: 112, alpha: 255}, diameter_px: 22, angle: 0, shape: "diamond"}]);
const add = () => on_change([...markers, {position: {x: 0, y: 0, z: 0}, color: {red: 255, green: 218, blue: 112, alpha: 255}, diameter_px: 22, angle: 0, shape: "diamond", coordinate_label_visible: surface_attached}]);
return <fieldset className="control markerListControl" title={field_tooltip(field)}><legend>{field_label(field)} <code>{field.key}</code></legend>
<div className="markerToolbar"><span>{markers.length} Marker</span><button type="button" onClick={add}> Marker</button></div>
<div className="markerRows">{markers.map((marker, index) => <section className="markerRow" key={index}>
@@ -670,6 +674,7 @@ function Marker_List_Control({field, on_change}: {field: Field; on_change: (valu
<div className="markerCoordinates">{(["x", "y", "z"] as const).map(axis => <label key={axis}><span>{axis === "z" && surface_attached ? "Z(谱面自动值)" : axis.toUpperCase()}</span><input type="number" min="-1" max="1" step="0.01" disabled={axis === "z" && surface_attached} value={marker.position?.[axis] ?? 0} onChange={event => replace(index, {...marker, position: {...marker.position, [axis]: Number(event.target.value)}})}/></label>)}</div>
<div className="markerAppearance"><label><span></span><select value={marker.shape ?? "diamond"} onChange={event => replace(index, {...marker, shape: event.target.value})}>{marker_shapes.map(shape => <option key={shape} value={shape}>{enum_label(shape)}</option>)}</select></label>
<label><span> px</span><input type="number" min="1" max="256" step="1" value={marker.diameter_px ?? 22} onChange={event => replace(index, {...marker, diameter_px: Number(event.target.value)})}/></label>
<label title="在 Marker 旁显示随实际位置更新的 X/Y/Z 值"><span> XYZ</span><input type="checkbox" checked={marker.coordinate_label_visible ?? false} onChange={event => replace(index, {...marker, coordinate_label_visible: event.target.checked})}/></label>
<label><span></span><input type="color" value={rgba_hex(marker.color ?? {red: 255, green: 218, blue: 112, alpha: 255})} onChange={event => replace(index, {...marker, color: update_rgb(marker.color, event.target.value) as Marker_Value["color"]})}/></label></div>
</section>)}</div>
</fieldset>;
@@ -985,28 +990,35 @@ function State_Pane({plot, schema, busy, on_refresh, histories}: {plot: Plot; sc
}
function Data_Generator_View({plot, generator, on_generated}: {plot: Plot; generator: Data_Generator; on_generated: () => void}) {
const defaults = () => Object.fromEntries(generator.fields.map(field => [field.key, field.value]));
const [input, set_input] = useState<Record<string, unknown>>(defaults);
const [input, set_input] = useState<Record<string, unknown>>({});
const [busy, set_busy] = useState(false);
const [status, set_status] = useState("");
const generator_signature = JSON.stringify(generator.fields.map(field => [field.key, field.value]));
useEffect(() => { set_input(defaults()); set_status(""); }, [plot.id, generator_signature]);
const generator_signature = `${plot.id}:${generator.fields.map(field => field.key).join("\u0000")}`;
useEffect(() => {
set_input(Object.fromEntries(generator.fields.map(field => [field.key, field.value])));
set_status("");
}, [generator_signature]);
const generate = async () => {
set_busy(true); set_status("");
set_busy(true);
set_status("");
try {
const response = await fetch(`/plot/${encodeURIComponent(plot.id)}/data/generate`, {
method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(input)
});
const result = await response.json() as {success?: boolean; error?: string; generated_count?: number};
const result = await response.json() as {success?: boolean; error?: string; generated_count?: number; triangle_count?: number};
if (!result.success) throw new Error(result.error ?? "生成原始数据失败");
on_generated();
set_status(`已生成 ${(result.generated_count ?? 0).toLocaleString("zh-CN")} 条数据,并从头统计。`);
} catch (error) { set_status(error instanceof Error ? error.message : "生成原始数据失败"); }
finally { set_busy(false); }
const topology = result.triangle_count === undefined ? "" : `${result.triangle_count.toLocaleString("zh-CN")} 个三角形`;
set_status(`已为当前图生成 ${(result.generated_count ?? 0).toLocaleString("zh-CN")} 条数据${topology},并从头统计。`);
} catch (error) {
set_status(error instanceof Error ? error.message : "生成原始数据失败");
} finally {
set_busy(false);
}
};
return <section className="analysisSection dataGenerator"><header><div><strong>{generator.label}</strong><span>{generator.description}</span></div></header>
<div className="generatorPropGrid">{generator.fields.map(field => <Field_Control key={field.key} field={{...field, value: input[field.key]}} on_change={value => set_input(current => ({...current, [field.key]: value}))}/>)}</div>
<div className="generatorActions"><button disabled={busy} onClick={() => void generate()}>{busy ? "生成中…" : "生成并从头统计"}</button></div>
<div className="generatorPropGrid">{generator.fields.map(field => <Field_Control key={field.key} field={{...field, value: input[field.key] ?? field.value}} on_change={value => set_input(current => ({...current, [field.key]: value}))}/>)}</div>
<div className="generatorActions"><button disabled={busy} onClick={() => void generate()}>{busy ? "生成中…" : "生成当前图数据并从头统计"}</button></div>
{status ? <p className="analysisStatus">{status}</p> : null}</section>;
}
@@ -1025,7 +1037,7 @@ function Frame_Policy_Pane({plot, analysis, busy, on_refresh, on_update, on_manu
function Data_Generation_Pane({plot, analysis, busy, on_refresh, on_generated}: {plot: Plot; analysis: Frame_Analysis | null; busy: boolean; on_refresh: () => void; on_generated: () => void}) {
return <section className="workspacePane"><Workspace_Header plot={plot} label="原始数据生成" count={analysis?.data_generator?.fields.length ?? 0} busy={busy} on_refresh={on_refresh}/>
<div className="workspaceBody">{analysis?.data_generator ? <Data_Generator_View plot={plot} generator={analysis.data_generator} on_generated={on_generated}/>
: <section className="analysisSection"><strong></strong><p className="muted"></p></section>}</div></section>;
: <section className="analysisSection"><strong></strong><p className="muted"> Schema </p></section>}</div></section>;
}
function Frame_Statistics_Pane({plot, diagnostics, busy, on_refresh, on_reset}: {plot: Plot; diagnostics: Frame_Diagnostics | null; busy: boolean; on_refresh: () => void; on_reset: () => void}) {
@@ -1033,20 +1045,40 @@ function Frame_Statistics_Pane({plot, diagnostics, busy, on_refresh, on_reset}:
<div className="workspaceBody"><Frame_Diagnostics_View key={plot.id} diagnostics={diagnostics} dimension={plot.dimension} on_reset={on_reset}/></div></section>;
}
const Plot_Card = memo(function Plot_Card({plot, selected, on_select}: {plot: Plot; selected: boolean; on_select: (plot: Plot) => void}) {
const Plot_Card = memo(function Plot_Card({plot, selected, policy, on_policy, on_select}: {
plot: Plot; selected: boolean; policy: Plot_Execution_Policy;
on_policy: (plot_id: string, patch: Partial<Plot_Execution_Policy>) => void; on_select: (plot: Plot) => void;
}) {
const canvas_ref = useRef<HTMLCanvasElement>(null);
const {status, metrics} = use_plot_stream(plot, canvas_ref, true, selected);
const card_ref = useRef<HTMLElement>(null);
const [onscreen, set_onscreen] = useState(false);
useEffect(() => {
const card = card_ref.current;
if (!card) return;
const observer = new IntersectionObserver(entries => set_onscreen(entries[0]?.isIntersecting ?? false), {threshold: 0.05});
observer.observe(card);
return () => observer.disconnect();
}, []);
const sampling = (policy.visible && onscreen) || policy.refresh_hidden;
const {status, metrics} = use_plot_stream(plot, canvas_ref, policy.visible && onscreen && policy.transfer_pixels, sampling);
const generated_time = metrics ? new Date(metrics.generated_time_unix_ms).toLocaleTimeString("zh-CN", {hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, fractionalSecondDigits: 3}) : "尚未生成帧";
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><div className="cardRuntime" title={`帧序号 ${metrics?.sequence ?? 0} · 创建于 ${generated_time}`}><div><span className="status">{{IDLE: "待选中", CONNECTING: "重连中", LIVE: "实时", OFFLINE: "已离线"}[status]}</span><span className="framePolicy">{metrics?.delivery === "diagnostics" ? "后台诊断" : metrics ? enum_label(metrics.pacing_mode) : "等待策略"}</span></div><div className="frameMetrics"><span>{metrics ? metrics.frame_rate_fps.toFixed(1) : "--.-"} FPS</span><span>E2E {metrics ? metrics.request_to_pixels_ms.toFixed(1) : "--.-"} ms</span><span>P95 {metrics ? metrics.p95_request_to_pixels_ms.toFixed(1) : "--.-"} ms</span><span>P99 {metrics ? metrics.p99_request_to_pixels_ms.toFixed(1) : "--.-"} ms</span></div></div></header>
{plot.description ? <p>{plot.description}</p> : null}<div className="plotViewport"><canvas ref={canvas_ref} tabIndex={0}/></div></article>;
return <article ref={card_ref} className={`card${selected ? " selected" : ""}`} data-plot-id={plot.id} aria-current={selected ? "true" : undefined}
onClick={() => on_select(plot)} onPointerUpCapture={event => {
if (event.target instanceof HTMLCanvasElement) on_select(plot);
}}><header className="cardDragHandle"><div><span className="eyebrow"> · {plot.dimension}</span><h2>{plot_labels[plot.id] ?? plot.title}</h2></div><div className="cardRuntime" title={`帧序号 ${metrics?.sequence ?? 0} · 创建于 ${generated_time}`}><div><span className="status">{{IDLE: "已停止", CONNECTING: "重连中", LIVE: "实时", OFFLINE: "已离线"}[status]}</span><span className="framePolicy">{metrics?.delivery === "diagnostics" ? "无像素传输" : metrics ? enum_label(metrics.pacing_mode) : "等待策略"}</span></div><div className="frameMetrics"><span>{metrics ? metrics.frame_rate_fps.toFixed(1) : "--.-"} FPS</span><span>E2E {metrics ? metrics.request_to_pixels_ms.toFixed(1) : "--.-"} ms</span><span>P95 {metrics ? metrics.p95_request_to_pixels_ms.toFixed(1) : "--.-"} ms</span><span>P99 {metrics ? metrics.p99_request_to_pixels_ms.toFixed(1) : "--.-"} ms</span></div></div></header>
<div className="plotExecutionPolicy" onPointerDown={event => event.stopPropagation()}>
<label title="控制这张图的画面是否显示;不改变 Scene 自身的 visible 属性。"><input type="checkbox" checked={policy.visible} onChange={event => on_policy(plot.id, {visible: event.target.checked})}/></label>
<label title="关闭显示画面后,是否仍持续请求真实后端帧并采集诊断。"><input type="checkbox" checked={policy.refresh_hidden} onChange={event => on_policy(plot.id, {refresh_hidden: event.target.checked})}/></label>
<label title="开启时传输完整 RGBA 像素;关闭时只传诊断 JSON,用于隔离 GPU 渲染与 WebSocket 像素传输开销。"><input type="checkbox" checked={policy.transfer_pixels} onChange={event => on_policy(plot.id, {transfer_pixels: event.target.checked})}/>WebSocket </label>
</div>
{plot.description ? <p>{plot.description}</p> : null}<div className={`plotViewport${policy.visible ? "" : " plotViewportHidden"}`}><canvas ref={canvas_ref} tabIndex={0}/>{!policy.visible ? <div className="plotHiddenState"><strong></strong><span>{policy.refresh_hidden ? "后端仍在渲染与采样" : "后端帧请求已停止"}</span></div> : null}</div></article>;
});
type Gallery_Breakpoint = "lg" | "md" | "sm" | "xs";
const gallery_layout_key = "aethera-gallery-grid-v4";
const gallery_breakpoints: Record<Gallery_Breakpoint, number> = {lg: 1280, md: 860, sm: 560, xs: 0};
const gallery_layout_key = "aethera-gallery-grid-v8";
const gallery_breakpoints: Record<Gallery_Breakpoint, number> = {lg: 1200, md: 760, sm: 420, 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};
const gallery_item_width: Record<Gallery_Breakpoint, number> = {lg: 4, md: 6, sm: 6, xs: 6};
function default_gallery_layout(plots: Plot[], breakpoint: Gallery_Breakpoint): LayoutItem[] {
const column_count = gallery_columns[breakpoint];
@@ -1055,8 +1087,7 @@ function default_gallery_layout(plots: Plot[], breakpoint: Gallery_Breakpoint):
let y = 0;
let row_height = 0;
return plots.map(plot => {
const showcase = plot.id === "datoviz_spectrogram";
const width = showcase ? column_count : ordinary_width;
const width = ordinary_width;
const height = 6;
if (x + width > column_count) {
x = 0;
@@ -1069,7 +1100,7 @@ function default_gallery_layout(plots: Plot[], breakpoint: Gallery_Breakpoint):
y,
w: width,
h: height,
minW: breakpoint === "lg" ? 3 : breakpoint === "md" ? 4 : 8,
minW: breakpoint === "lg" ? 3 : breakpoint === "md" ? 4 : 6,
minH: 4,
resizeHandles: ["s", "e", "se", "sw", "w"]
};
@@ -1084,16 +1115,23 @@ function default_gallery_layout(plots: Plot[], breakpoint: Gallery_Breakpoint):
});
}
function load_gallery_layouts(): ResponsiveLayouts<Gallery_Breakpoint> {
type Stored_Gallery_Layouts = Record<string, ResponsiveLayouts<Gallery_Breakpoint>>;
function load_gallery_layouts(scope: string): ResponsiveLayouts<Gallery_Breakpoint> {
const saved = localStorage.getItem(gallery_layout_key);
if (!saved) return {};
try { return JSON.parse(saved) as ResponsiveLayouts<Gallery_Breakpoint>; }
try { return (JSON.parse(saved) as Stored_Gallery_Layouts)[scope] ?? {}; }
catch { localStorage.removeItem(gallery_layout_key); return {}; }
}
function save_gallery_layouts(scope: string, layouts: ResponsiveLayouts<Gallery_Breakpoint>) {
let stored: Stored_Gallery_Layouts = {};
try { stored = JSON.parse(localStorage.getItem(gallery_layout_key) ?? "{}") as Stored_Gallery_Layouts; }
catch { /* 下面由当前权威布局覆盖损坏的持久化快照。 */ }
localStorage.setItem(gallery_layout_key, JSON.stringify({...stored, [scope]: layouts}));
}
function Gallery_Grid({plots, selected, on_select}: {plots: Plot[]; selected: Plot | null; on_select: (plot: Plot) => void}) {
function Gallery_Grid({plots, selected, policies, layout_scope, on_policy, on_select}: {plots: Plot[]; selected: Plot | null; policies: Plot_Execution_Policies; layout_scope: string; on_policy: (plot_id: string, patch: Partial<Plot_Execution_Policy>) => void; 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 [stored_layouts, set_stored_layouts] = useState<ResponsiveLayouts<Gallery_Breakpoint>>(() => load_gallery_layouts(layout_scope));
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] ?? [];
@@ -1110,14 +1148,14 @@ function Gallery_Grid({plots, selected, on_select}: {plots: Plot[]; selected: Pl
})) 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));
save_gallery_layouts(layout_scope, 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>)}
{plots.map(plot => <div className="plotGridItem" key={plot.id}><Plot_Card plot={plot} selected={selected?.id === plot.id} policy={policies[plot.id] ?? default_plot_execution_policy()} on_policy={on_policy} on_select={on_select}/></div>)}
</Responsive> : null}</div>;
}
@@ -1181,6 +1219,7 @@ function load_workspace_model() {
export function App() {
const [plots, set_plots] = useState<Plot[]>([]); const [category, set_category] = useState("全部"); const [selected, set_selected] = useState<Plot | null>(null);
const [execution_policies, set_execution_policies] = useState<Plot_Execution_Policies>({});
const [schema, set_schema] = useState<Schema | null>(null);
const [state_histories, set_state_histories] = useState<State_Histories>({});
const [frame_diagnostics, set_frame_diagnostics] = useState<Frame_Diagnostics | null>(null);
@@ -1189,7 +1228,12 @@ export function App() {
const [gallery_layout_revision, set_gallery_layout_revision] = useState(0);
const schema_request = useRef(0);
const schema_busy_request = useRef(0);
useEffect(() => { void fetch("/plot").then(response => response.json()).then(set_plots); }, []);
useEffect(() => { void fetch("/plot").then(response => response.json()).then((next: Plot[]) => {
set_plots(next);
set_execution_policies(current => Object.fromEntries(next.map(plot => [
plot.id, current[plot.id] ?? default_plot_execution_policy()
])));
}); }, []);
useEffect(() => { if (!selected && plots.length > 0) set_selected(plots[0]); }, [plots, selected]);
useEffect(() => {
set_frame_diagnostics(null);
@@ -1237,15 +1281,24 @@ export function App() {
: {...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)})});
};
const update_execution_policy = useCallback((plot_id: string, patch: Partial<Plot_Execution_Policy>) => {
set_execution_policies(current => ({
...current,
[plot_id]: {...(current[plot_id] ?? default_plot_execution_policy()), ...patch}
}));
}, []);
const reset_frame_diagnostics = () => { if (selected) window.dispatchEvent(new CustomEvent("aethera-reset-frame-diagnostics", {detail: {plot_id: selected.id}})); };
const categories = useMemo(() => ["全部", ...new Set(plots.map(() => "绘图组件"))], [plots]);
const visible = category === "全部" ? plots : plots;
const categories = ["全部", "2D", "3D"];
const visible = useMemo(() => {
if (category === "2D" || category === "3D") return plots.filter(plot => plot.dimension === category);
return plots;
}, [category, 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>;
<Gallery_Grid key={`${gallery_layout_revision}:${category}`} plots={visible} selected={selected} policies={execution_policies} layout_scope={category} on_policy={update_execution_policy} on_select={set_selected}/></section>;
const factory = (node: TabNode) => {
if (node.getComponent() === "gallery") return gallery;
if (!selected) return <div className="emptyPane"></div>;
@@ -1254,7 +1307,12 @@ export function App() {
if (node.getComponent() === "frame-policy") return <aside className="inspector" aria-label="采样与帧策略"><Frame_Policy_Pane plot={selected} analysis={schema?.frame_analysis ?? null} busy={schema_busy} on_refresh={() => void load_schema(true)} on_update={update}
on_manual_frame={() => window.dispatchEvent(new CustomEvent("aethera-manual-frame", {detail: {plot_id: selected.id}}))}
on_reset={reset_frame_diagnostics}/></aside>;
if (node.getComponent() === "data-generation") return <aside className="inspector" aria-label="原始数据生成"><Data_Generation_Pane plot={selected} analysis={schema?.frame_analysis ?? null} busy={schema_busy} on_refresh={() => void load_schema(true)} on_generated={() => { reset_frame_diagnostics(); window.dispatchEvent(new CustomEvent("aethera-manual-frame", {detail: {plot_id: selected.id}})); }}/></aside>;
if (node.getComponent() === "data-generation") return <aside className="inspector" aria-label="原始数据生成"><Data_Generation_Pane
plot={selected} analysis={schema?.frame_analysis ?? null} busy={schema_busy} on_refresh={() => void load_schema(true)}
on_generated={() => {
window.dispatchEvent(new CustomEvent("aethera-reset-frame-diagnostics", {detail: {plot_id: selected.id}}));
window.dispatchEvent(new CustomEvent("aethera-manual-frame", {detail: {plot_id: selected.id}}));
}}/></aside>;
if (node.getComponent() === "frame-statistics") return <aside className="inspector" aria-label="帧流水线统计"><Frame_Statistics_Pane plot={selected} diagnostics={frame_diagnostics} busy={schema_busy} on_refresh={() => void load_schema(true)} on_reset={reset_frame_diagnostics}/></aside>;
return <div className="emptyPane"></div>;
};
+7
View File
@@ -72,6 +72,13 @@ nav { display: flex; flex-wrap: wrap; gap: 8px; padding: 18px 0; }
.frameMetrics { display: grid; grid-template-columns: auto auto; gap: 4px 9px; color: #8296b2; font: 9px/1.15 ui-monospace, monospace; font-variant-numeric: tabular-nums; white-space: nowrap; }
.frameMetrics span:nth-child(2n) { text-align: right; }
.plotViewport { flex: 1; width: 100%; min-height: 160px; overflow: hidden; background: #070d18; }
.plotExecutionPolicy { display:flex; flex-wrap:wrap; gap:6px 12px; padding:8px 12px; border-block:1px solid rgba(126,155,194,.12); background:rgba(6,13,24,.58); font-size:11px; color:#9dafc7; }
.plotExecutionPolicy label { display:flex; align-items:center; gap:5px; cursor:pointer; user-select:none; }
.plotExecutionPolicy input { accent-color:#52dbc1; }
.plotViewportHidden { position:relative; }
.plotViewportHidden canvas { visibility:hidden; }
.plotHiddenState { position:absolute; inset:0; display:grid; place-content:center; gap:5px; text-align:center; color:#91a5bf; background:repeating-linear-gradient(135deg,rgba(15,27,43,.96),rgba(15,27,43,.96) 12px,rgba(18,33,52,.96) 12px,rgba(18,33,52,.96) 24px); }
.plotHiddenState strong { color:#d9e8f6; }
canvas { display: block; width: 100%; height: 100%; background: #070d18; overscroll-behavior: contain; touch-action: none; }
canvas:focus { outline: 1px solid #5ce4c2; outline-offset: -1px; }