三维频谱marker
This commit is contained in:
+94
-36
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user