完善建模交互与结果曲线功能

支持元件接口接触吸附、旋转后兼容连接及无连线逻辑边。

完善 AMESim 图标显示、参数双栏、结果曲线待定窗口及相关端到端测试。
This commit is contained in:
ljz committed 2026-08-02 17:31:28 +08:00
1 parent 410ef535e8
commit 503394f3ed
10 files changed
+2542 -389

No files matched your search

+229 -229
View File
@@ -34,6 +34,10 @@ import {
type WorkspaceView,
} from "./WorkspaceViewTabs";
import { AutoFitView } from "./AutoFitView";
import {
CONTACT_AWARE_EDGE_TYPE,
contactAwareEdgeTypes,
} from "./ContactAwareEdge";
type ResultPortDefinition = {
name: string;
@@ -55,6 +59,10 @@ type ResultNodeData = {
mirrored: boolean;
};
type ResultRenderedNodeData = ResultNodeData & {
portConnectionStates: Record<string, "idle" | "connected">;
};
type ResultProjectNode = {
id: string;
type: "simulationComponent";
@@ -340,8 +348,6 @@ export function SimulationResultsView({
const [activePaneResize, setActivePaneResize] =
useState<PaneResizeKind | null>(null);
const [csvDownloadPending, setCsvDownloadPending] = useState(false);
const [multiCreateMenuOpen, setMultiCreateMenuOpen] = useState(false);
const [mixedCreateMenuOpen, setMixedCreateMenuOpen] = useState(false);
const [activeMultiPickerWindowId, setActiveMultiPickerWindowId] =
useState<string | null>(null);
const [multiDropTargetWindowId, setMultiDropTargetWindowId] =
@@ -428,7 +434,21 @@ export function SimulationResultsView({
const selectedNode =
snapshot.project.nodes.find((node) => node.id === selectedNodeId) ?? null;
const readOnlyNodes = useMemo<Node<ResultNodeData, "simulationComponent">[]>(
const connectedPortKeys = useMemo(() => {
const keys = new Set<string>();
snapshot.project.edges.forEach((edge) => {
if (edge.sourceHandle) {
keys.add(`${edge.source}:${edge.sourceHandle}`);
}
if (edge.targetHandle) {
keys.add(`${edge.target}:${edge.targetHandle}`);
}
});
return keys;
}, [snapshot.project.edges]);
const readOnlyNodes = useMemo<
Node<ResultRenderedNodeData, "simulationComponent">[]
>(
() =>
snapshot.project.nodes.map((node) => ({
...node,
@@ -438,8 +458,19 @@ export function SimulationResultsView({
focusable: true,
selectable: true,
selected: node.id === selectedNodeId,
data: {
...node.data,
portConnectionStates: Object.fromEntries(
node.data.ports.map((port) => [
port.name,
connectedPortKeys.has(`${node.id}:${port.name}`)
? "connected"
: "idle",
]),
),
},
})),
[selectedNodeId, snapshot.project.nodes],
[connectedPortKeys, selectedNodeId, snapshot.project.nodes],
);
const readOnlyEdges = useMemo<Edge[]>(
() =>
@@ -447,7 +478,7 @@ export function SimulationResultsView({
...edge,
focusable: false,
selectable: false,
type: "step",
type: CONTACT_AWARE_EDGE_TYPE,
})),
[snapshot.project.edges],
);
@@ -480,23 +511,6 @@ export function SimulationResultsView({
),
[snapshot.result.series, snapshot.result.variables],
);
const multiCurveUnitGroups = useMemo(() => {
const groups = new Map<string, ResultVariableMetadata[]>();
plottableResultVariables.forEach((variable) => {
const group = groups.get(variable.unit) ?? [];
group.push(variable);
groups.set(variable.unit, group);
});
return Array.from(groups, ([unit, variables]) => ({ unit, variables }))
.filter((group) => group.variables.length >= 2)
.sort((first, second) =>
(first.unit || "无量纲").localeCompare(
second.unit || "无量纲",
"zh-CN",
),
);
}, [plottableResultVariables]);
const setPaneSize = (kind: PaneResizeKind, requestedSize: number) => {
const bounds = workbenchRef.current?.getBoundingClientRect();
if (!bounds) {
@@ -659,15 +673,12 @@ export function SimulationResultsView({
]);
};
const addMultiChartWindow = (
variableKey: string,
const addCombinedChartWindow = (
kind: "multi" | "mixed",
requestedPosition?: { x: number; y: number },
) => {
const workspace = workspaceRef.current;
const variable = snapshot.result.variables.find(
(item) => item.key === variableKey,
);
if (!workspace || !variable) {
if (!workspace) {
return;
}
@@ -680,8 +691,8 @@ export function SimulationResultsView({
);
const index = chartWindows.length;
const position = requestedPosition ?? {
x: 28 + (index % 3) * 28,
y: 28 + (index % 4) * 28,
x: (kind === "mixed" ? 38 : 28) + (index % 3) * 28,
y: (kind === "mixed" ? 38 : 28) + (index % 4) * 28,
};
const id = `chart-${nextWindowIdRef.current++}`;
const zIndex = ++topZIndexRef.current;
@@ -689,68 +700,21 @@ export function SimulationResultsView({
...current,
{
id,
kind: "multi",
variableKey,
variableKeys: [variableKey],
kind,
variableKey: "",
variableKeys: [],
hiddenVariableKeys: [],
variableUnits: { [variableKey]: variable.unit },
variableUnits: {},
x: clampWindowCoordinate(position.x, bounds.width, size.width),
y: clampWindowCoordinate(position.y, bounds.height, size.height),
width: size.width,
height: size.height,
unit: variable.unit,
unit: "",
cursorEnabled: false,
zIndex,
},
]);
setActiveMultiPickerWindowId(id);
};
const addMixedChartWindow = (
variableKey: string,
requestedPosition?: { x: number; y: number },
) => {
const workspace = workspaceRef.current;
const variable = snapshot.result.variables.find(
(item) => item.key === variableKey,
);
if (!workspace || !variable) {
return;
}
const bounds = workspace.getBoundingClientRect();
const size = clampChartWindowDimensions(
MULTI_CHART_WINDOW_WIDTH,
MULTI_CHART_WINDOW_HEIGHT,
bounds.width,
bounds.height,
);
const index = chartWindows.length;
const position = requestedPosition ?? {
x: 38 + (index % 3) * 28,
y: 38 + (index % 4) * 28,
};
const id = `chart-${nextWindowIdRef.current++}`;
const zIndex = ++topZIndexRef.current;
setChartWindows((current) => [
...current,
{
id,
kind: "mixed",
variableKey,
variableKeys: [variableKey],
hiddenVariableKeys: [],
variableUnits: { [variableKey]: variable.unit },
x: clampWindowCoordinate(position.x, bounds.width, size.width),
y: clampWindowCoordinate(position.y, bounds.height, size.height),
width: size.width,
height: size.height,
unit: variable.unit,
cursorEnabled: false,
zIndex,
},
]);
setActiveMultiPickerWindowId(id);
setActiveMultiPickerWindowId(null);
};
const toggleChartCurveVisibility = (
@@ -774,41 +738,47 @@ export function SimulationResultsView({
};
const removeChartCurve = (windowId: string, variableKey: string) => {
const removesWindow = chartWindows.some(
const resetsWindow = chartWindows.some(
(window) =>
window.id === windowId &&
window.kind !== "single" &&
window.variableKeys.length === 1,
);
setChartWindows((current) =>
current.flatMap((window) => {
current.map((window) => {
if (window.id !== windowId || window.kind === "single") {
return [window];
return window;
}
if (window.variableKeys.length === 1) {
return [];
return {
...window,
variableKey: "",
variableKeys: [],
hiddenVariableKeys: [],
variableUnits: {},
unit: "",
cursorEnabled: false,
};
}
const variableKeys = window.variableKeys.filter(
(key) => key !== variableKey,
);
return [
{
...window,
variableKey: variableKeys[0],
variableKeys,
hiddenVariableKeys: window.hiddenVariableKeys.filter(
(key) => key !== variableKey,
return {
...window,
variableKey: variableKeys[0],
variableKeys,
hiddenVariableKeys: window.hiddenVariableKeys.filter(
(key) => key !== variableKey,
),
variableUnits: Object.fromEntries(
Object.entries(window.variableUnits).filter(
([key]) => key !== variableKey,
),
variableUnits: Object.fromEntries(
Object.entries(window.variableUnits).filter(
([key]) => key !== variableKey,
),
),
},
];
),
};
}),
);
if (removesWindow) {
if (resetsWindow) {
setActiveMultiPickerWindowId(null);
}
};
@@ -824,18 +794,23 @@ export function SimulationResultsView({
const variable = snapshot.result.variables.find(
(item) => item.key === variableKey,
);
const primaryVariable = snapshot.result.variables.find(
(item) => item.key === chartWindow.variableKey,
);
if (
!variableKey ||
!variable ||
!primaryVariable ||
chartWindow.kind === "single"
) {
const primaryVariable =
snapshot.result.variables.find(
(item) => item.key === chartWindow.variableKey,
) ??
chartWindow.variableKeys
.map((key) =>
snapshot.result.variables.find((item) => item.key === key),
)
.find((item): item is ResultVariableMetadata => item !== undefined);
if (!variableKey || !variable || chartWindow.kind === "single") {
return;
}
if (chartWindow.kind === "multi" && variable.unit !== primaryVariable.unit) {
if (
chartWindow.kind === "multi" &&
primaryVariable &&
variable.unit !== primaryVariable.unit
) {
window.alert(
`无法加入曲线:该窗口只接受 SI 单位为 ${primaryVariable.unit || "无量纲"} 的变量。`,
);
@@ -846,6 +821,16 @@ export function SimulationResultsView({
if (window.id !== chartWindow.id || window.kind === "single") {
return window;
}
if (window.variableKeys.length === 0 || !primaryVariable) {
return {
...window,
variableKey,
variableKeys: [variableKey],
hiddenVariableKeys: [],
variableUnits: { [variableKey]: variable.unit },
unit: variable.unit,
};
}
if (window.variableKeys.includes(variableKey)) {
return {
...window,
@@ -1143,6 +1128,7 @@ export function SimulationResultsView({
connectionMode={ConnectionMode.Loose}
deleteKeyCode={null}
edges={readOnlyEdges}
edgeTypes={contactAwareEdgeTypes}
edgesFocusable={false}
edgesReconnectable={false}
elementsSelectable
@@ -1263,32 +1249,11 @@ export function SimulationResultsView({
)}{" "}
条曲线
</span>
<div
className="results-multi-create"
onBlur={(event) => {
if (
!(event.relatedTarget instanceof HTMLElement) ||
!event.currentTarget.contains(event.relatedTarget)
) {
setMultiCreateMenuOpen(false);
}
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.stopPropagation();
setMultiCreateMenuOpen(false);
}
}}
>
<div className="results-multi-create">
<button
aria-expanded={multiCreateMenuOpen}
aria-label="新建同单位多曲线对比窗口"
className={multiCreateMenuOpen ? "active" : ""}
disabled={multiCurveUnitGroups.length === 0}
onClick={() => {
setMixedCreateMenuOpen(false);
setMultiCreateMenuOpen((current) => !current);
}}
disabled={plottableResultVariables.length === 0}
onClick={() => addCombinedChartWindow("multi")}
title="新建同单位多曲线对比窗口"
type="button"
>
@@ -1298,56 +1263,12 @@ export function SimulationResultsView({
strokeWidth={1.9}
/>
</button>
{multiCreateMenuOpen ? (
<div
aria-label="选择多曲线窗口的 SI 单位"
className="results-multi-create-menu"
role="menu"
>
<div className="results-multi-create-title">选择 SI 单位</div>
{multiCurveUnitGroups.map((group) => (
<button
key={group.unit || "dimensionless"}
onClick={() => {
addMultiChartWindow(group.variables[0].key);
setMultiCreateMenuOpen(false);
}}
role="menuitem"
type="button"
>
<strong>{group.unit || "无量纲"}</strong>
<span>{group.variables.length} 条变量</span>
</button>
))}
</div>
) : null}
</div>
<div
className="results-multi-create"
onBlur={(event) => {
if (
!(event.relatedTarget instanceof HTMLElement) ||
!event.currentTarget.contains(event.relatedTarget)
) {
setMixedCreateMenuOpen(false);
}
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.stopPropagation();
setMixedCreateMenuOpen(false);
}
}}
>
<div className="results-multi-create">
<button
aria-expanded={mixedCreateMenuOpen}
aria-label="新建异单位上下对比窗口"
className={mixedCreateMenuOpen ? "active" : ""}
disabled={plottableResultVariables.length === 0}
onClick={() => {
setMultiCreateMenuOpen(false);
setMixedCreateMenuOpen((current) => !current);
}}
onClick={() => addCombinedChartWindow("mixed")}
title="新建异单位上下对比窗口"
type="button"
>
@@ -1357,43 +1278,6 @@ export function SimulationResultsView({
strokeWidth={1.9}
/>
</button>
{mixedCreateMenuOpen ? (
<div
aria-label="选择异单位上下对比窗口的首条曲线"
className="results-multi-create-menu variables"
role="menu"
>
<div className="results-multi-create-title">选择首条曲线</div>
{plottableResultVariables.map((item) => {
const itemComponent = snapshot.project.nodes.find(
(node) => node.id === item.componentId,
);
return (
<button
key={item.key}
onClick={() => {
addMixedChartWindow(item.key);
setMixedCreateMenuOpen(false);
}}
role="menuitem"
title={resultVariableTitle(
item,
itemComponent?.data.label ?? item.componentId,
)}
type="button"
>
<strong>
{resultVariableTitle(
item,
itemComponent?.data.label ?? item.componentId,
)}
</strong>
<span>{item.unit || "无量纲"}</span>
</button>
);
})}
</div>
) : null}
</div>
</div>
</div>
@@ -1409,6 +1293,8 @@ export function SimulationResultsView({
ref={workspaceRef}
>
{chartWindows.map((chartWindow) => {
const isCombined = chartWindow.kind !== "single";
const isMixed = chartWindow.kind === "mixed";
const windowVariables = chartWindow.variableKeys
.map((key) =>
snapshot.result.variables.find((item) => item.key === key),
@@ -1421,10 +1307,114 @@ export function SimulationResultsView({
(item) => item.key === chartWindow.variableKey,
) ?? windowVariables[0];
if (!variable) {
return null;
if (!isCombined) {
return null;
}
const pendingTitle = isMixed
? "异单位上下对比"
: "同单位多曲线对比";
return (
<article
aria-label={`${pendingTitle}窗口(待定)`}
className={`result-chart-window ${chartWindow.kind} pending ${
multiDropTargetWindowId === chartWindow.id
? "drop-target"
: ""
}`}
data-chart-kind={chartWindow.kind}
data-pending="true"
key={chartWindow.id}
onDragLeave={(event) => {
if (
event.relatedTarget instanceof Element &&
event.currentTarget.contains(event.relatedTarget)
) {
return;
}
if (multiDropTargetWindowId === chartWindow.id) {
setMultiDropTargetWindowId(null);
}
}}
onDragOver={(event) => {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
setMultiDropTargetWindowId(chartWindow.id);
}}
onDrop={(event) =>
addDroppedVariableToMultiChart(event, chartWindow)
}
onPointerDown={() => focusChartWindow(chartWindow.id)}
style={{
height: chartWindow.height,
left: chartWindow.x,
top: chartWindow.y,
width: chartWindow.width,
zIndex: chartWindow.zIndex,
}}
>
<div
className="result-chart-window-header"
onPointerCancel={stopWindowDrag}
onPointerDown={(event) =>
startWindowDrag(event, chartWindow)
}
onPointerMove={moveChartWindow}
onPointerUp={stopWindowDrag}
>
<div className="result-chart-window-title">
<strong>{pendingTitle}</strong>
<span>待定 · 请拖入第一条曲线</span>
</div>
<div className="result-chart-window-actions">
<button
aria-label={`关闭${pendingTitle}窗口`}
className="close"
onClick={(event) => {
event.stopPropagation();
removeChartWindow(chartWindow.id);
}}
onPointerDown={(event) => event.stopPropagation()}
title="关闭曲线窗口"
type="button"
>
<X aria-hidden="true" size={16} strokeWidth={1.9} />
</button>
</div>
</div>
<div className="result-chart-pending">
{isMixed ? (
<ChartSpline aria-hidden="true" size={34} strokeWidth={1.5} />
) : (
<ChartNoAxesCombined
aria-hidden="true"
size={34}
strokeWidth={1.5}
/>
)}
<strong>等待第一条曲线</strong>
<span>
拖入变量后,将以第一条曲线初始化单位和显示设置
</span>
</div>
<button
aria-label={`调整${pendingTitle}窗口大小`}
className="result-chart-resize-handle"
onLostPointerCapture={stopWindowResize}
onPointerCancel={stopWindowResize}
onPointerDown={(event) =>
startWindowResize(event, chartWindow)
}
onPointerMove={resizeChartWindow}
onPointerUp={stopWindowResize}
title="调整窗口大小"
type="button"
>
<Scaling aria-hidden="true" size={13} strokeWidth={1.8} />
</button>
</article>
);
}
const isCombined = chartWindow.kind !== "single";
const isMixed = chartWindow.kind === "mixed";
const label = resultVariableLabel(variable);
const component = snapshot.project.nodes.find(
(node) => node.id === variable.componentId,
@@ -1476,6 +1466,8 @@ export function SimulationResultsView({
? "drop-target"
: ""
}`}
data-chart-kind={chartWindow.kind}
data-pending="false"
key={chartWindow.id}
onDragLeave={(event) => {
if (
@@ -1525,7 +1517,7 @@ export function SimulationResultsView({
{isMixed
? "异单位上下对比"
: isCombined
? "多曲线对比"
? "同单位多曲线对比"
: label}
</strong>
<span>
@@ -4284,16 +4276,24 @@ function normalizeStoredChartWindow(window: StoredChartWindow): ChartWindow {
window.kind === "multi" || window.kind === "mixed"
? window.kind
: "single";
const storedVariableKeys = Array.isArray(window.variableKeys)
? window.variableKeys.filter(
const rawVariableKeys = window.variableKeys;
const hasStoredVariableKeys = Array.isArray(rawVariableKeys);
const storedVariableKeys = hasStoredVariableKeys
? rawVariableKeys.filter(
(key, index, keys) =>
typeof key === "string" && key.length > 0 && keys.indexOf(key) === index,
)
: [];
const variableKeys =
kind !== "single" && storedVariableKeys.length > 0
? storedVariableKeys
: [window.variableKey];
kind === "single"
? window.variableKey
? [window.variableKey]
: []
: hasStoredVariableKeys
? storedVariableKeys
: window.variableKey
? [window.variableKey]
: [];
const hiddenVariableKeys = Array.isArray(window.hiddenVariableKeys)
? window.hiddenVariableKeys.filter(
(key, index, keys) =>
@@ -4314,7 +4314,7 @@ function normalizeStoredChartWindow(window: StoredChartWindow): ChartWindow {
return {
...window,
kind,
variableKey: variableKeys[0],
variableKey: variableKeys[0] ?? "",
variableKeys,
hiddenVariableKeys,
variableUnits,