三维频谱marker

This commit is contained in:
2026-08-22 21:36:21 +08:00
parent cd028e8d4f
commit 7c3f27474b
15 changed files with 594 additions and 207 deletions
+48 -11
View File
@@ -15,7 +15,7 @@ echarts.use([LineChart, DataZoomComponent, GridComponent, LegendComponent, Toolt
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 Editor = "boolean" | "integer" | "number" | "text" | "select" | "color" | "point2" | "size" | "rect" | "range" | "pen" | "brush" | "font" | "color-map" | "vector3" | "matrix4" | "marker-list" | "surface-marker-list" | "json";
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>};
@@ -443,11 +443,9 @@ function use_plot_stream(plot: Plot, canvas_ref: React.RefObject<HTMLCanvasEleme
const detail = (event as CustomEvent<{plot_id: string}>).detail;
const canvas = canvas_ref.current;
if (detail?.plot_id !== plot.id || !canvas) return;
const bounds = canvas.getBoundingClientRect();
const position = {x: bounds.width * devicePixelRatio * 0.5, y: bounds.height * devicePixelRatio * 0.5};
const global_position = {x: (bounds.left + bounds.width * 0.5) * devicePixelRatio, y: (bounds.top + bounds.height * 0.5) * devicePixelRatio};
const pointer = (type: "pointer_press" | "pointer_release", buttons: number) => transmit("input", {type, position, global_position, button: "left", buttons, modifiers: 0});
pointer("pointer_press", 1); pointer("pointer_release", 0); pointer("pointer_press", 1); pointer("pointer_release", 0);
canvas.focus({preventScroll: true});
transmit("input", {type: "key_press", key: "home", native_key: 36, modifiers: 0, auto_repeat: false});
transmit("input", {type: "key_release", key: "home", native_key: 36, modifiers: 0, auto_repeat: false});
};
const on_diagnostics_reset = (event: Event) => {
const detail = (event as CustomEvent<{plot_id: string}>).detail;
@@ -657,9 +655,30 @@ 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};
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"}]);
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}>
<header><strong>Marker {index + 1}</strong><button type="button" onClick={() => on_change(markers.filter((_, item_index) => item_index !== index))}></button></header>
<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><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>;
}
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 (field.editor === "marker-list" || field.editor === "surface-marker-list") return <Marker_List_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>;
@@ -925,6 +944,22 @@ function State_Component({component, histories}: {component: Component; historie
<details className="rawState"><summary> JSON</summary><pre className="stateJson">{JSON.stringify(component.state, null, 2)}</pre></details></section>;
}
const camera_mode_descriptions: Record<string, {label: string; usage: string}> = {
turntable: {label: "Turntable 环绕", usage: "左键环绕目标,右键或中键平移,滚轮改变观察距离。"},
arcball: {label: "Arcball 自由旋转", usage: "拖动虚拟轨迹球进行无固定水平面的自由旋转,滚轮缩放。"},
fly: {label: "Fly 第一人称", usage: "拖动改变观察方向,W/A/S/D 和方向键移动,Shift 加速,滚轮前后推进。"},
panzoom: {label: "Panzoom 平面导航", usage: "拖动二维平面,滚轮缩放;适合固定观察方向下检查截面。"}
};
function Camera_Mode_Guide({fields}: {fields: Field[]}) {
const active = String(fields.find(field => field.key === "controller")?.value ?? "turntable");
return <div className="cameraModeGuide" aria-label="Datoviz 原生相机模式">
{Object.entries(camera_mode_descriptions).map(([key, mode]) => <div key={key} className={key === active ? "active" : ""}>
<strong>{mode.label}</strong><span>{mode.usage}</span>
</div>)}
</div>;
}
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);
@@ -933,7 +968,9 @@ function Property_Pane({plot, schema, busy, on_refresh, on_update}: {plot: Plot;
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>
<strong>{component.label}</strong><span>{fields.length} </span>
{component.kind === "camera" ? <button className="componentAction" onClick={() => window.dispatchEvent(new CustomEvent("aethera-reset-camera", {detail: {plot_id: plot.id}}))}></button> : null}</div>
{component.kind === "camera" ? <Camera_Mode_Guide fields={fields}/> : null}
<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>;
}
@@ -973,14 +1010,14 @@ function Data_Generator_View({plot, generator, on_generated}: {plot: Plot; gener
{status ? <p className="analysisStatus">{status}</p> : null}</section>;
}
function Frame_Policy_Pane({plot, analysis, busy, on_refresh, on_update, on_manual_frame, on_camera_reset, on_reset}: {
function Frame_Policy_Pane({plot, analysis, busy, on_refresh, on_update, on_manual_frame, on_reset}: {
plot: Plot; analysis: Frame_Analysis | null; busy: boolean; on_refresh: () => void; on_update: (component: Frame_Analysis, field: Field, value: unknown) => Promise<void>;
on_manual_frame: () => void; on_camera_reset: () => void; on_reset: () => void;
on_manual_frame: () => void; on_reset: () => void;
}) {
const fields = analysis?.fields.filter(field => field.editable) ?? [];
return <section className="workspacePane"><Workspace_Header plot={plot} label="采样与帧策略" count={fields.length} busy={busy} on_refresh={on_refresh}/>
<div className="workspaceBody"><section className="analysisSection"><header><div><strong>{plot.dimension} </strong><span>2D 3D 使</span></div>
<div className="framePolicyActions"><button onClick={on_manual_frame}></button>{plot.dimension === "3D" ? <button onClick={on_camera_reset}></button> : null}<button onClick={on_reset}></button></div></header>
<div className="framePolicyActions"><button onClick={on_manual_frame}></button><button onClick={on_reset}></button></div></header>
{analysis ? <div className="propGrid">{fields.map(field => <Property_Control key={field.key} field={field} on_commit={value => on_update(analysis, field, value)}/>)}</div> : <p className="muted"></p>}
</section></div></section>;
}
@@ -1216,7 +1253,7 @@ export function App() {
if (node.getComponent() === "state") return <aside className="inspector" aria-label="状态查看面板"><State_Pane plot={selected} schema={schema} busy={schema_busy} on_refresh={() => void load_schema(true)} histories={state_histories}/></aside>;
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_camera_reset={() => window.dispatchEvent(new CustomEvent("aethera-reset-camera", {detail: {plot_id: selected.id}}))} on_reset={reset_frame_diagnostics}/></aside>;
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() === "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>;
+15
View File
@@ -109,6 +109,21 @@ canvas:focus { outline: 1px solid #5ce4c2; outline-offset: -1px; }
.propertyActions button { padding: 6px 10px; color: #b9c9dd; border: 1px solid #304664; border-radius: 6px; background: #101c2d; cursor: pointer; }
.propertyActions button:first-child:not(:disabled) { color: #062019; border-color: #5ce4c2; background: #5ce4c2; }
.propertyActions button:disabled { opacity: .4; cursor: default; }
.componentAction { margin-left: auto; padding: 6px 10px; color: #062019; border: 1px solid #5ce4c2; border-radius: 6px; background: #5ce4c2; cursor: pointer; }
.cameraModeGuide { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin: 0 0 14px; }
.cameraModeGuide > div { display: grid; gap: 4px; padding: 9px 10px; border: 1px solid #243850; border-radius: 7px; background: #0a1422; color: #7890ae; }
.cameraModeGuide > div.active { border-color: #52dfbd; background: #0c211f; box-shadow: inset 3px 0 #52dfbd; }
.cameraModeGuide strong { color: #dce8f8; font-size: 12px; }
.cameraModeGuide span { font-size: 11px; line-height: 1.45; }
.markerListControl { grid-column: 1 / -1; min-width: 0; }
.markerToolbar, .markerRow header, .markerAppearance, .markerCoordinates { display: flex; align-items: center; gap: 8px; }
.markerToolbar, .markerRow header { justify-content: space-between; }
.markerToolbar button, .markerRow button { padding: 5px 9px; color: #b9c9dd; border: 1px solid #304664; border-radius: 6px; background: #101c2d; cursor: pointer; }
.markerRows { display: grid; gap: 9px; margin-top: 10px; }
.markerRow { padding: 10px; border: 1px solid #263d59; border-radius: 8px; background: #0b1625; }
.markerCoordinates, .markerAppearance { flex-wrap: wrap; margin-top: 8px; }
.markerCoordinates label, .markerAppearance label { display: grid; grid-template-columns: auto minmax(64px, 1fr); align-items: center; gap: 6px; flex: 1 1 110px; }
.markerCoordinates input, .markerAppearance input, .markerAppearance select { min-width: 0; width: 100%; }
.savingHint { display: block; margin-top: 5px; color: #5ce4c2; text-align: right; }
.framePolicyActions { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; padding: 11px; border: 1px solid #27594f; border-radius: 9px; background: #0b1b1a; }
.framePolicyActions button { flex: 0 0 auto; padding: 8px 12px; color: #062019; border: 1px solid #5ce4c2; border-radius: 7px; background: #5ce4c2; cursor: pointer; }