完善建模交互与结果曲线功能
支持元件接口接触吸附、旋转后兼容连接及无连线逻辑边。 完善 AMESim 图标显示、参数双栏、结果曲线待定窗口及相关端到端测试。
This commit is contained in:
1 parent
410ef535e8
commit
503394f3ed
10 files changed
+2542
-389
No files matched your search
+1020
-140
File diff suppressed because it is too large.
Load diff
@@ -25,6 +25,7 @@ import { SymbolSvg } from "./componentSymbols/primitives";
|
|||||||
import { AmesimStep0Symbol, AmesimUd00Symbol } from "./componentSymbols/signals";
|
import { AmesimStep0Symbol, AmesimUd00Symbol } from "./componentSymbols/signals";
|
||||||
import { AmesimPnch012Symbol, AmesimPnch023Symbol } from "./componentSymbols/storage";
|
import { AmesimPnch012Symbol, AmesimPnch023Symbol } from "./componentSymbols/storage";
|
||||||
import type {
|
import type {
|
||||||
|
ComponentSymbolPortAnchors,
|
||||||
ComponentSymbolPresentation,
|
ComponentSymbolPresentation,
|
||||||
SymbolDefinition,
|
SymbolDefinition,
|
||||||
} from "./componentSymbols/types";
|
} from "./componentSymbols/types";
|
||||||
@@ -60,7 +61,7 @@ function CylinderSymbol() {
|
|||||||
function TankSymbol() {
|
function TankSymbol() {
|
||||||
return (
|
return (
|
||||||
<SymbolSvg>
|
<SymbolSvg>
|
||||||
<path d="M32 2v5M32 41v5" stroke="currentColor" strokeWidth="2" />
|
<path d="M2 24h14M32 2v5M32 41v5" stroke="currentColor" strokeWidth="2" />
|
||||||
<path
|
<path
|
||||||
d="M16 13c0-4 7.2-7 16-7s16 3 16 7v22c0 4-7.2 7-16 7s-16-3-16-7V13Z"
|
d="M16 13c0-4 7.2-7 16-7s16 3 16 7v22c0 4-7.2 7-16 7s-16-3-16-7V13Z"
|
||||||
fill="#dcfce7"
|
fill="#dcfce7"
|
||||||
@@ -75,7 +76,7 @@ function TankSymbol() {
|
|||||||
function PipeSymbol() {
|
function PipeSymbol() {
|
||||||
return (
|
return (
|
||||||
<SymbolSvg>
|
<SymbolSvg>
|
||||||
<path d="M2 19h60M2 29h60" stroke="currentColor" strokeWidth="2" />
|
<path d="M2 24h7M55 24h7M9 19h46M9 29h46" stroke="currentColor" strokeWidth="2" />
|
||||||
<path d="M9 17v14M55 17v14" stroke="currentColor" strokeWidth="2" />
|
<path d="M9 17v14M55 17v14" stroke="currentColor" strokeWidth="2" />
|
||||||
</SymbolSvg>
|
</SymbolSvg>
|
||||||
);
|
);
|
||||||
@@ -99,8 +100,18 @@ function OrificeSymbol() {
|
|||||||
function TeeSymbol() {
|
function TeeSymbol() {
|
||||||
return (
|
return (
|
||||||
<SymbolSvg>
|
<SymbolSvg>
|
||||||
<path d="M2 27h60M32 27V3" stroke="currentColor" strokeWidth="8" />
|
<path
|
||||||
<path d="M2 27h60M32 27V3" stroke="#f8fafc" strokeWidth="4" />
|
d="M2 24h28M30 24C42 24 44 12 62 12M30 24c12 0 14 12 32 12"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth="7"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M2 24h28M30 24C42 24 44 12 62 12M30 24c12 0 14 12 32 12"
|
||||||
|
stroke="#f8fafc"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth="3"
|
||||||
|
/>
|
||||||
</SymbolSvg>
|
</SymbolSvg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -195,6 +206,123 @@ const symbolRegistry = {
|
|||||||
|
|
||||||
type RegisteredComponentSymbol = keyof typeof symbolRegistry;
|
type RegisteredComponentSymbol = keyof typeof symbolRegistry;
|
||||||
|
|
||||||
|
const symbolPortAnchors: Partial<
|
||||||
|
Record<RegisteredComponentSymbol, ComponentSymbolPortAnchors>
|
||||||
|
> = {
|
||||||
|
cylinder: {
|
||||||
|
left: [{ x: 3, y: 24 }],
|
||||||
|
right: [{ x: 61, y: 24 }],
|
||||||
|
},
|
||||||
|
tank: { left: [{ x: 2, y: 24 }] },
|
||||||
|
pipe: {
|
||||||
|
left: [{ x: 2, y: 24 }],
|
||||||
|
right: [{ x: 62, y: 24 }],
|
||||||
|
},
|
||||||
|
orifice: {
|
||||||
|
left: [{ x: 2, y: 24 }],
|
||||||
|
right: [{ x: 62, y: 24 }],
|
||||||
|
},
|
||||||
|
tee: {
|
||||||
|
left: [{ x: 2, y: 24 }],
|
||||||
|
right: [
|
||||||
|
{ x: 62, y: 12 },
|
||||||
|
{ x: 62, y: 36 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
generic: {
|
||||||
|
left: [{ x: 2, y: 24 }],
|
||||||
|
right: [{ x: 62, y: 24 }],
|
||||||
|
},
|
||||||
|
signal: {
|
||||||
|
left: [{ x: 2, y: 24 }],
|
||||||
|
right: [{ x: 62, y: 24 }],
|
||||||
|
},
|
||||||
|
junction: {
|
||||||
|
left: [{ x: 2, y: 24 }],
|
||||||
|
right: [{ x: 62, y: 24 }],
|
||||||
|
},
|
||||||
|
amesim_pnpl01: { left: [{ x: 2, y: 24 }] },
|
||||||
|
amesim_pnch023: {
|
||||||
|
left: [{ x: 2, y: 24 }],
|
||||||
|
right: [{ x: 62, y: 24 }],
|
||||||
|
},
|
||||||
|
amesim_pnch012: {
|
||||||
|
left: [
|
||||||
|
{ x: 2, y: 16 },
|
||||||
|
{ x: 2, y: 32 },
|
||||||
|
],
|
||||||
|
right: [
|
||||||
|
{ x: 62, y: 16 },
|
||||||
|
{ x: 62, y: 32 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
amesim_pnor001: {
|
||||||
|
left: [{ x: 2, y: 24 }],
|
||||||
|
right: [{ x: 62, y: 24 }],
|
||||||
|
},
|
||||||
|
amesim_pnvo001_fixed: {
|
||||||
|
left: [{ x: 2, y: 28 }],
|
||||||
|
right: [{ x: 62, y: 28 }],
|
||||||
|
},
|
||||||
|
amesim_pnvo001: {
|
||||||
|
left: [
|
||||||
|
{ x: 2, y: 16 },
|
||||||
|
{ x: 2, y: 32 },
|
||||||
|
],
|
||||||
|
right: [{ x: 62, y: 25 }],
|
||||||
|
},
|
||||||
|
amesim_pnl00r: {
|
||||||
|
left: [{ x: 2, y: 24 }],
|
||||||
|
right: [{ x: 62, y: 24 }],
|
||||||
|
},
|
||||||
|
amesim_pnl0001: {
|
||||||
|
left: [{ x: 2, y: 24 }],
|
||||||
|
right: [{ x: 62, y: 24 }],
|
||||||
|
},
|
||||||
|
amesim_pnl0002: {
|
||||||
|
left: [{ x: 2, y: 24 }],
|
||||||
|
right: [{ x: 62, y: 24 }],
|
||||||
|
},
|
||||||
|
amesim_pnl0003: {
|
||||||
|
left: [{ x: 2, y: 24 }],
|
||||||
|
right: [{ x: 62, y: 24 }],
|
||||||
|
},
|
||||||
|
amesim_pn3node2: {
|
||||||
|
left: [{ x: 3, y: 24 }],
|
||||||
|
right: [
|
||||||
|
{ x: 61, y: 12 },
|
||||||
|
{ x: 61, y: 36 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
amesim_p4node2: {
|
||||||
|
left: [{ x: 3, y: 24 }],
|
||||||
|
right: [
|
||||||
|
{ x: 61, y: 8 },
|
||||||
|
{ x: 61, y: 24 },
|
||||||
|
{ x: 61, y: 40 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
amesim_step0: { right: [{ x: 60, y: 24 }] },
|
||||||
|
amesim_ud00: { right: [{ x: 60, y: 24 }] },
|
||||||
|
amesim_f000: { right: [{ x: 62, y: 24 }] },
|
||||||
|
amesim_forc: {
|
||||||
|
left: [{ x: 2, y: 24 }],
|
||||||
|
right: [{ x: 62, y: 24 }],
|
||||||
|
},
|
||||||
|
amesim_mecmas21: {
|
||||||
|
left: [{ x: 2, y: 24 }],
|
||||||
|
right: [{ x: 62, y: 24 }],
|
||||||
|
},
|
||||||
|
amesim_lstp00a: {
|
||||||
|
left: [{ x: 2, y: 24 }],
|
||||||
|
right: [{ x: 62, y: 24 }],
|
||||||
|
},
|
||||||
|
amesim_lmechn1: {
|
||||||
|
left: [7, 12, 17, 22, 27, 32, 37, 42].map((y) => ({ x: 5, y })),
|
||||||
|
right: [{ x: 59, y: 24 }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function isRegisteredComponentSymbol(symbol: string): symbol is RegisteredComponentSymbol {
|
function isRegisteredComponentSymbol(symbol: string): symbol is RegisteredComponentSymbol {
|
||||||
return Object.prototype.hasOwnProperty.call(symbolRegistry, symbol);
|
return Object.prototype.hasOwnProperty.call(symbolRegistry, symbol);
|
||||||
}
|
}
|
||||||
@@ -211,6 +339,14 @@ export function componentSymbolPresentation(
|
|||||||
: "framed";
|
: "framed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function componentSymbolPortAnchors(
|
||||||
|
symbol: string,
|
||||||
|
): ComponentSymbolPortAnchors | undefined {
|
||||||
|
return isRegisteredComponentSymbol(symbol)
|
||||||
|
? symbolPortAnchors[symbol]
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function ComponentSymbol({
|
export function ComponentSymbol({
|
||||||
symbol,
|
symbol,
|
||||||
surface,
|
surface,
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
BaseEdge,
|
||||||
|
getSmoothStepPath,
|
||||||
|
type EdgeProps,
|
||||||
|
type EdgeTypes,
|
||||||
|
} from "@xyflow/react";
|
||||||
|
|
||||||
|
export const CONTACT_AWARE_EDGE_TYPE = "contact-aware";
|
||||||
|
// React Flow 在 Loose 模式下会把重叠 Handle 的边端点分别放在圆点两侧,
|
||||||
|
// 因此两个端口中心完全重合时,edge props 仍会相差约一个 Handle 直径。
|
||||||
|
export const CONTACT_EDGE_SCREEN_TOLERANCE = 12;
|
||||||
|
|
||||||
|
export function ContactAwareEdge({
|
||||||
|
id,
|
||||||
|
interactionWidth,
|
||||||
|
markerEnd,
|
||||||
|
markerStart,
|
||||||
|
sourcePosition,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
style,
|
||||||
|
targetPosition,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
}: EdgeProps) {
|
||||||
|
const distance = Math.hypot(targetX - sourceX, targetY - sourceY);
|
||||||
|
if (distance <= CONTACT_EDGE_SCREEN_TOLERANCE) {
|
||||||
|
const centerX = (sourceX + targetX) / 2;
|
||||||
|
const centerY = (sourceY + targetY) / 2;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<circle
|
||||||
|
aria-label="接触连接"
|
||||||
|
className="contact-edge-hit"
|
||||||
|
cx={centerX}
|
||||||
|
cy={centerY}
|
||||||
|
data-contact-edge-id={id}
|
||||||
|
r={12}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
aria-hidden="true"
|
||||||
|
className="contact-edge-marker"
|
||||||
|
cx={centerX}
|
||||||
|
cy={centerY}
|
||||||
|
r={3.5}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [edgePath] = getSmoothStepPath({
|
||||||
|
sourcePosition,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetPosition,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
borderRadius: 0,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<BaseEdge
|
||||||
|
id={id}
|
||||||
|
interactionWidth={interactionWidth}
|
||||||
|
markerEnd={markerEnd}
|
||||||
|
markerStart={markerStart}
|
||||||
|
path={edgePath}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const contactAwareEdgeTypes: EdgeTypes = {
|
||||||
|
[CONTACT_AWARE_EDGE_TYPE]: ContactAwareEdge,
|
||||||
|
};
|
||||||
@@ -34,6 +34,10 @@ import {
|
|||||||
type WorkspaceView,
|
type WorkspaceView,
|
||||||
} from "./WorkspaceViewTabs";
|
} from "./WorkspaceViewTabs";
|
||||||
import { AutoFitView } from "./AutoFitView";
|
import { AutoFitView } from "./AutoFitView";
|
||||||
|
import {
|
||||||
|
CONTACT_AWARE_EDGE_TYPE,
|
||||||
|
contactAwareEdgeTypes,
|
||||||
|
} from "./ContactAwareEdge";
|
||||||
|
|
||||||
type ResultPortDefinition = {
|
type ResultPortDefinition = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -55,6 +59,10 @@ type ResultNodeData = {
|
|||||||
mirrored: boolean;
|
mirrored: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ResultRenderedNodeData = ResultNodeData & {
|
||||||
|
portConnectionStates: Record<string, "idle" | "connected">;
|
||||||
|
};
|
||||||
|
|
||||||
type ResultProjectNode = {
|
type ResultProjectNode = {
|
||||||
id: string;
|
id: string;
|
||||||
type: "simulationComponent";
|
type: "simulationComponent";
|
||||||
@@ -340,8 +348,6 @@ export function SimulationResultsView({
|
|||||||
const [activePaneResize, setActivePaneResize] =
|
const [activePaneResize, setActivePaneResize] =
|
||||||
useState<PaneResizeKind | null>(null);
|
useState<PaneResizeKind | null>(null);
|
||||||
const [csvDownloadPending, setCsvDownloadPending] = useState(false);
|
const [csvDownloadPending, setCsvDownloadPending] = useState(false);
|
||||||
const [multiCreateMenuOpen, setMultiCreateMenuOpen] = useState(false);
|
|
||||||
const [mixedCreateMenuOpen, setMixedCreateMenuOpen] = useState(false);
|
|
||||||
const [activeMultiPickerWindowId, setActiveMultiPickerWindowId] =
|
const [activeMultiPickerWindowId, setActiveMultiPickerWindowId] =
|
||||||
useState<string | null>(null);
|
useState<string | null>(null);
|
||||||
const [multiDropTargetWindowId, setMultiDropTargetWindowId] =
|
const [multiDropTargetWindowId, setMultiDropTargetWindowId] =
|
||||||
@@ -428,7 +434,21 @@ export function SimulationResultsView({
|
|||||||
|
|
||||||
const selectedNode =
|
const selectedNode =
|
||||||
snapshot.project.nodes.find((node) => node.id === selectedNodeId) ?? null;
|
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) => ({
|
snapshot.project.nodes.map((node) => ({
|
||||||
...node,
|
...node,
|
||||||
@@ -438,8 +458,19 @@ export function SimulationResultsView({
|
|||||||
focusable: true,
|
focusable: true,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
selected: node.id === selectedNodeId,
|
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[]>(
|
const readOnlyEdges = useMemo<Edge[]>(
|
||||||
() =>
|
() =>
|
||||||
@@ -447,7 +478,7 @@ export function SimulationResultsView({
|
|||||||
...edge,
|
...edge,
|
||||||
focusable: false,
|
focusable: false,
|
||||||
selectable: false,
|
selectable: false,
|
||||||
type: "step",
|
type: CONTACT_AWARE_EDGE_TYPE,
|
||||||
})),
|
})),
|
||||||
[snapshot.project.edges],
|
[snapshot.project.edges],
|
||||||
);
|
);
|
||||||
@@ -480,23 +511,6 @@ export function SimulationResultsView({
|
|||||||
),
|
),
|
||||||
[snapshot.result.series, snapshot.result.variables],
|
[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 setPaneSize = (kind: PaneResizeKind, requestedSize: number) => {
|
||||||
const bounds = workbenchRef.current?.getBoundingClientRect();
|
const bounds = workbenchRef.current?.getBoundingClientRect();
|
||||||
if (!bounds) {
|
if (!bounds) {
|
||||||
@@ -659,15 +673,12 @@ export function SimulationResultsView({
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addMultiChartWindow = (
|
const addCombinedChartWindow = (
|
||||||
variableKey: string,
|
kind: "multi" | "mixed",
|
||||||
requestedPosition?: { x: number; y: number },
|
requestedPosition?: { x: number; y: number },
|
||||||
) => {
|
) => {
|
||||||
const workspace = workspaceRef.current;
|
const workspace = workspaceRef.current;
|
||||||
const variable = snapshot.result.variables.find(
|
if (!workspace) {
|
||||||
(item) => item.key === variableKey,
|
|
||||||
);
|
|
||||||
if (!workspace || !variable) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,8 +691,8 @@ export function SimulationResultsView({
|
|||||||
);
|
);
|
||||||
const index = chartWindows.length;
|
const index = chartWindows.length;
|
||||||
const position = requestedPosition ?? {
|
const position = requestedPosition ?? {
|
||||||
x: 28 + (index % 3) * 28,
|
x: (kind === "mixed" ? 38 : 28) + (index % 3) * 28,
|
||||||
y: 28 + (index % 4) * 28,
|
y: (kind === "mixed" ? 38 : 28) + (index % 4) * 28,
|
||||||
};
|
};
|
||||||
const id = `chart-${nextWindowIdRef.current++}`;
|
const id = `chart-${nextWindowIdRef.current++}`;
|
||||||
const zIndex = ++topZIndexRef.current;
|
const zIndex = ++topZIndexRef.current;
|
||||||
@@ -689,68 +700,21 @@ export function SimulationResultsView({
|
|||||||
...current,
|
...current,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
kind: "multi",
|
kind,
|
||||||
variableKey,
|
variableKey: "",
|
||||||
variableKeys: [variableKey],
|
variableKeys: [],
|
||||||
hiddenVariableKeys: [],
|
hiddenVariableKeys: [],
|
||||||
variableUnits: { [variableKey]: variable.unit },
|
variableUnits: {},
|
||||||
x: clampWindowCoordinate(position.x, bounds.width, size.width),
|
x: clampWindowCoordinate(position.x, bounds.width, size.width),
|
||||||
y: clampWindowCoordinate(position.y, bounds.height, size.height),
|
y: clampWindowCoordinate(position.y, bounds.height, size.height),
|
||||||
width: size.width,
|
width: size.width,
|
||||||
height: size.height,
|
height: size.height,
|
||||||
unit: variable.unit,
|
unit: "",
|
||||||
cursorEnabled: false,
|
cursorEnabled: false,
|
||||||
zIndex,
|
zIndex,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
setActiveMultiPickerWindowId(id);
|
setActiveMultiPickerWindowId(null);
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleChartCurveVisibility = (
|
const toggleChartCurveVisibility = (
|
||||||
@@ -774,41 +738,47 @@ export function SimulationResultsView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeChartCurve = (windowId: string, variableKey: string) => {
|
const removeChartCurve = (windowId: string, variableKey: string) => {
|
||||||
const removesWindow = chartWindows.some(
|
const resetsWindow = chartWindows.some(
|
||||||
(window) =>
|
(window) =>
|
||||||
window.id === windowId &&
|
window.id === windowId &&
|
||||||
window.kind !== "single" &&
|
window.kind !== "single" &&
|
||||||
window.variableKeys.length === 1,
|
window.variableKeys.length === 1,
|
||||||
);
|
);
|
||||||
setChartWindows((current) =>
|
setChartWindows((current) =>
|
||||||
current.flatMap((window) => {
|
current.map((window) => {
|
||||||
if (window.id !== windowId || window.kind === "single") {
|
if (window.id !== windowId || window.kind === "single") {
|
||||||
return [window];
|
return window;
|
||||||
}
|
}
|
||||||
if (window.variableKeys.length === 1) {
|
if (window.variableKeys.length === 1) {
|
||||||
return [];
|
return {
|
||||||
|
...window,
|
||||||
|
variableKey: "",
|
||||||
|
variableKeys: [],
|
||||||
|
hiddenVariableKeys: [],
|
||||||
|
variableUnits: {},
|
||||||
|
unit: "",
|
||||||
|
cursorEnabled: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const variableKeys = window.variableKeys.filter(
|
const variableKeys = window.variableKeys.filter(
|
||||||
(key) => key !== variableKey,
|
(key) => key !== variableKey,
|
||||||
);
|
);
|
||||||
return [
|
return {
|
||||||
{
|
...window,
|
||||||
...window,
|
variableKey: variableKeys[0],
|
||||||
variableKey: variableKeys[0],
|
variableKeys,
|
||||||
variableKeys,
|
hiddenVariableKeys: window.hiddenVariableKeys.filter(
|
||||||
hiddenVariableKeys: window.hiddenVariableKeys.filter(
|
(key) => key !== variableKey,
|
||||||
(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);
|
setActiveMultiPickerWindowId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -824,18 +794,23 @@ export function SimulationResultsView({
|
|||||||
const variable = snapshot.result.variables.find(
|
const variable = snapshot.result.variables.find(
|
||||||
(item) => item.key === variableKey,
|
(item) => item.key === variableKey,
|
||||||
);
|
);
|
||||||
const primaryVariable = snapshot.result.variables.find(
|
const primaryVariable =
|
||||||
(item) => item.key === chartWindow.variableKey,
|
snapshot.result.variables.find(
|
||||||
);
|
(item) => item.key === chartWindow.variableKey,
|
||||||
if (
|
) ??
|
||||||
!variableKey ||
|
chartWindow.variableKeys
|
||||||
!variable ||
|
.map((key) =>
|
||||||
!primaryVariable ||
|
snapshot.result.variables.find((item) => item.key === key),
|
||||||
chartWindow.kind === "single"
|
)
|
||||||
) {
|
.find((item): item is ResultVariableMetadata => item !== undefined);
|
||||||
|
if (!variableKey || !variable || chartWindow.kind === "single") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (chartWindow.kind === "multi" && variable.unit !== primaryVariable.unit) {
|
if (
|
||||||
|
chartWindow.kind === "multi" &&
|
||||||
|
primaryVariable &&
|
||||||
|
variable.unit !== primaryVariable.unit
|
||||||
|
) {
|
||||||
window.alert(
|
window.alert(
|
||||||
`无法加入曲线:该窗口只接受 SI 单位为 ${primaryVariable.unit || "无量纲"} 的变量。`,
|
`无法加入曲线:该窗口只接受 SI 单位为 ${primaryVariable.unit || "无量纲"} 的变量。`,
|
||||||
);
|
);
|
||||||
@@ -846,6 +821,16 @@ export function SimulationResultsView({
|
|||||||
if (window.id !== chartWindow.id || window.kind === "single") {
|
if (window.id !== chartWindow.id || window.kind === "single") {
|
||||||
return window;
|
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)) {
|
if (window.variableKeys.includes(variableKey)) {
|
||||||
return {
|
return {
|
||||||
...window,
|
...window,
|
||||||
@@ -1143,6 +1128,7 @@ export function SimulationResultsView({
|
|||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
deleteKeyCode={null}
|
deleteKeyCode={null}
|
||||||
edges={readOnlyEdges}
|
edges={readOnlyEdges}
|
||||||
|
edgeTypes={contactAwareEdgeTypes}
|
||||||
edgesFocusable={false}
|
edgesFocusable={false}
|
||||||
edgesReconnectable={false}
|
edgesReconnectable={false}
|
||||||
elementsSelectable
|
elementsSelectable
|
||||||
@@ -1263,32 +1249,11 @@ export function SimulationResultsView({
|
|||||||
)}{" "}
|
)}{" "}
|
||||||
条曲线
|
条曲线
|
||||||
</span>
|
</span>
|
||||||
<div
|
<div className="results-multi-create">
|
||||||
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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
aria-expanded={multiCreateMenuOpen}
|
|
||||||
aria-label="新建同单位多曲线对比窗口"
|
aria-label="新建同单位多曲线对比窗口"
|
||||||
className={multiCreateMenuOpen ? "active" : ""}
|
disabled={plottableResultVariables.length === 0}
|
||||||
disabled={multiCurveUnitGroups.length === 0}
|
onClick={() => addCombinedChartWindow("multi")}
|
||||||
onClick={() => {
|
|
||||||
setMixedCreateMenuOpen(false);
|
|
||||||
setMultiCreateMenuOpen((current) => !current);
|
|
||||||
}}
|
|
||||||
title="新建同单位多曲线对比窗口"
|
title="新建同单位多曲线对比窗口"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -1298,56 +1263,12 @@ export function SimulationResultsView({
|
|||||||
strokeWidth={1.9}
|
strokeWidth={1.9}
|
||||||
/>
|
/>
|
||||||
</button>
|
</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>
|
||||||
<div
|
<div className="results-multi-create">
|
||||||
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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
aria-expanded={mixedCreateMenuOpen}
|
|
||||||
aria-label="新建异单位上下对比窗口"
|
aria-label="新建异单位上下对比窗口"
|
||||||
className={mixedCreateMenuOpen ? "active" : ""}
|
|
||||||
disabled={plottableResultVariables.length === 0}
|
disabled={plottableResultVariables.length === 0}
|
||||||
onClick={() => {
|
onClick={() => addCombinedChartWindow("mixed")}
|
||||||
setMultiCreateMenuOpen(false);
|
|
||||||
setMixedCreateMenuOpen((current) => !current);
|
|
||||||
}}
|
|
||||||
title="新建异单位上下对比窗口"
|
title="新建异单位上下对比窗口"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@@ -1357,43 +1278,6 @@ export function SimulationResultsView({
|
|||||||
strokeWidth={1.9}
|
strokeWidth={1.9}
|
||||||
/>
|
/>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1409,6 +1293,8 @@ export function SimulationResultsView({
|
|||||||
ref={workspaceRef}
|
ref={workspaceRef}
|
||||||
>
|
>
|
||||||
{chartWindows.map((chartWindow) => {
|
{chartWindows.map((chartWindow) => {
|
||||||
|
const isCombined = chartWindow.kind !== "single";
|
||||||
|
const isMixed = chartWindow.kind === "mixed";
|
||||||
const windowVariables = chartWindow.variableKeys
|
const windowVariables = chartWindow.variableKeys
|
||||||
.map((key) =>
|
.map((key) =>
|
||||||
snapshot.result.variables.find((item) => item.key === key),
|
snapshot.result.variables.find((item) => item.key === key),
|
||||||
@@ -1421,10 +1307,114 @@ export function SimulationResultsView({
|
|||||||
(item) => item.key === chartWindow.variableKey,
|
(item) => item.key === chartWindow.variableKey,
|
||||||
) ?? windowVariables[0];
|
) ?? windowVariables[0];
|
||||||
if (!variable) {
|
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 label = resultVariableLabel(variable);
|
||||||
const component = snapshot.project.nodes.find(
|
const component = snapshot.project.nodes.find(
|
||||||
(node) => node.id === variable.componentId,
|
(node) => node.id === variable.componentId,
|
||||||
@@ -1476,6 +1466,8 @@ export function SimulationResultsView({
|
|||||||
? "drop-target"
|
? "drop-target"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
|
data-chart-kind={chartWindow.kind}
|
||||||
|
data-pending="false"
|
||||||
key={chartWindow.id}
|
key={chartWindow.id}
|
||||||
onDragLeave={(event) => {
|
onDragLeave={(event) => {
|
||||||
if (
|
if (
|
||||||
@@ -1525,7 +1517,7 @@ export function SimulationResultsView({
|
|||||||
{isMixed
|
{isMixed
|
||||||
? "异单位上下对比"
|
? "异单位上下对比"
|
||||||
: isCombined
|
: isCombined
|
||||||
? "多曲线对比"
|
? "同单位多曲线对比"
|
||||||
: label}
|
: label}
|
||||||
</strong>
|
</strong>
|
||||||
<span>
|
<span>
|
||||||
@@ -4284,16 +4276,24 @@ function normalizeStoredChartWindow(window: StoredChartWindow): ChartWindow {
|
|||||||
window.kind === "multi" || window.kind === "mixed"
|
window.kind === "multi" || window.kind === "mixed"
|
||||||
? window.kind
|
? window.kind
|
||||||
: "single";
|
: "single";
|
||||||
const storedVariableKeys = Array.isArray(window.variableKeys)
|
const rawVariableKeys = window.variableKeys;
|
||||||
? window.variableKeys.filter(
|
const hasStoredVariableKeys = Array.isArray(rawVariableKeys);
|
||||||
|
const storedVariableKeys = hasStoredVariableKeys
|
||||||
|
? rawVariableKeys.filter(
|
||||||
(key, index, keys) =>
|
(key, index, keys) =>
|
||||||
typeof key === "string" && key.length > 0 && keys.indexOf(key) === index,
|
typeof key === "string" && key.length > 0 && keys.indexOf(key) === index,
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
const variableKeys =
|
const variableKeys =
|
||||||
kind !== "single" && storedVariableKeys.length > 0
|
kind === "single"
|
||||||
? storedVariableKeys
|
? window.variableKey
|
||||||
: [window.variableKey];
|
? [window.variableKey]
|
||||||
|
: []
|
||||||
|
: hasStoredVariableKeys
|
||||||
|
? storedVariableKeys
|
||||||
|
: window.variableKey
|
||||||
|
? [window.variableKey]
|
||||||
|
: [];
|
||||||
const hiddenVariableKeys = Array.isArray(window.hiddenVariableKeys)
|
const hiddenVariableKeys = Array.isArray(window.hiddenVariableKeys)
|
||||||
? window.hiddenVariableKeys.filter(
|
? window.hiddenVariableKeys.filter(
|
||||||
(key, index, keys) =>
|
(key, index, keys) =>
|
||||||
@@ -4314,7 +4314,7 @@ function normalizeStoredChartWindow(window: StoredChartWindow): ChartWindow {
|
|||||||
return {
|
return {
|
||||||
...window,
|
...window,
|
||||||
kind,
|
kind,
|
||||||
variableKey: variableKeys[0],
|
variableKey: variableKeys[0] ?? "",
|
||||||
variableKeys,
|
variableKeys,
|
||||||
hiddenVariableKeys,
|
hiddenVariableKeys,
|
||||||
variableUnits,
|
variableUnits,
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import type { ReactNode } from "react";
|
|||||||
|
|
||||||
export type ComponentSymbolPresentation = "framed" | "bare";
|
export type ComponentSymbolPresentation = "framed" | "bare";
|
||||||
|
|
||||||
|
export type ComponentSymbolPortAnchor = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComponentSymbolPortAnchors = Partial<
|
||||||
|
Record<"left" | "right", readonly ComponentSymbolPortAnchor[]>
|
||||||
|
>;
|
||||||
|
|
||||||
export type SymbolDefinition = {
|
export type SymbolDefinition = {
|
||||||
presentation: ComponentSymbolPresentation;
|
presentation: ComponentSymbolPresentation;
|
||||||
render: () => ReactNode;
|
render: () => ReactNode;
|
||||||
|
|||||||
+201
-12
@@ -293,6 +293,7 @@ textarea {
|
|||||||
.workbench {
|
.workbench {
|
||||||
--palette-width: 230px;
|
--palette-width: 230px;
|
||||||
--properties-width: 330px;
|
--properties-width: 330px;
|
||||||
|
--component-parameters-height: 340px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns:
|
grid-template-columns:
|
||||||
var(--palette-width) 7px minmax(0, 1fr) 7px
|
var(--palette-width) 7px minmax(0, 1fr) 7px
|
||||||
@@ -306,6 +307,11 @@ textarea {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workbench.resizing-component-parameters-height {
|
||||||
|
cursor: row-resize;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.modeling-pane-splitter {
|
.modeling-pane-splitter {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 8;
|
z-index: 8;
|
||||||
@@ -367,6 +373,71 @@ textarea {
|
|||||||
|
|
||||||
.properties {
|
.properties {
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows:
|
||||||
|
minmax(0, var(--component-parameters-height)) 7px
|
||||||
|
minmax(120px, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-subpane {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-subpane-content {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-pane-splitter {
|
||||||
|
position: relative;
|
||||||
|
z-index: 9;
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
height: 7px;
|
||||||
|
min-height: 7px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
place-items: center;
|
||||||
|
background: #eef2f6;
|
||||||
|
cursor: row-resize;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-pane-splitter::before {
|
||||||
|
position: absolute;
|
||||||
|
inset: 3px 0 auto;
|
||||||
|
height: 1px;
|
||||||
|
background: #c7d1dc;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-pane-splitter span {
|
||||||
|
position: relative;
|
||||||
|
width: 42px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #9eacbb;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 120ms ease, background 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-pane-splitter:hover span,
|
||||||
|
.properties-pane-splitter:focus-visible span,
|
||||||
|
.workbench.resizing-component-parameters-height .properties-pane-splitter span {
|
||||||
|
background: #1d6fb8;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-pane-splitter:focus-visible {
|
||||||
|
outline: 2px solid #1d6fb8;
|
||||||
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-title {
|
.panel-title {
|
||||||
@@ -1229,15 +1300,15 @@ textarea {
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
width: 48px;
|
width: 112px;
|
||||||
height: 48px;
|
height: 84px;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-symbol {
|
.node-symbol {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 48px;
|
width: 112px;
|
||||||
height: 48px;
|
height: 84px;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
@@ -1248,28 +1319,34 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sim-node.symbol-presentation-bare .sim-node-frame,
|
.sim-node.symbol-presentation-bare .sim-node-frame,
|
||||||
.sim-node.symbol-presentation-bare.selected .sim-node-frame {
|
.sim-node.symbol-presentation-bare.selected .sim-node-frame,
|
||||||
|
.sim-node.component-icon-only .sim-node-frame,
|
||||||
|
.sim-node.component-icon-only.selected .sim-node-frame {
|
||||||
|
border-width: 0;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sim-node.symbol-presentation-bare .node-label {
|
.sim-node.symbol-presentation-bare .node-label,
|
||||||
|
.sim-node.component-icon-only .node-label {
|
||||||
|
display: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sim-node.symbol-presentation-bare.selected .component-symbol-svg {
|
.sim-node.symbol-presentation-bare.selected .component-symbol-svg,
|
||||||
|
.sim-node.component-icon-only.selected .component-symbol-svg {
|
||||||
filter: drop-shadow(0 0 3px rgba(29, 111, 184, 0.62));
|
filter: drop-shadow(0 0 3px rgba(29, 111, 184, 0.62));
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-flow__node.validation-error
|
.react-flow__node.validation-error
|
||||||
.sim-node.symbol-presentation-bare
|
.sim-node.component-icon-only
|
||||||
.component-symbol-svg {
|
.component-symbol-svg {
|
||||||
filter: drop-shadow(0 0 3px rgba(198, 59, 50, 0.68));
|
filter: drop-shadow(0 0 3px rgba(198, 59, 50, 0.68));
|
||||||
}
|
}
|
||||||
|
|
||||||
.react-flow__node.validation-warning
|
.react-flow__node.validation-warning
|
||||||
.sim-node.symbol-presentation-bare
|
.sim-node.component-icon-only
|
||||||
.component-symbol-svg {
|
.component-symbol-svg {
|
||||||
filter: drop-shadow(0 0 3px rgba(192, 138, 22, 0.68));
|
filter: drop-shadow(0 0 3px rgba(192, 138, 22, 0.68));
|
||||||
}
|
}
|
||||||
@@ -1310,10 +1387,49 @@ textarea {
|
|||||||
|
|
||||||
.port-handle {
|
.port-handle {
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
width: 10px;
|
width: 9px;
|
||||||
height: 10px;
|
height: 9px;
|
||||||
border: 2px solid #374151;
|
box-sizing: border-box;
|
||||||
|
border: 1.5px solid var(--port-handle-color, #475569);
|
||||||
|
border-radius: 50%;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
|
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.82);
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 100ms ease, border-color 100ms ease,
|
||||||
|
background 100ms ease, box-shadow 100ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-handle-physical {
|
||||||
|
--port-handle-color: #a12b70;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-handle-signal {
|
||||||
|
--port-handle-color: #dc2626;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-handle.port-connection-connected {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-handle.port-connection-origin {
|
||||||
|
border-color: #d97706;
|
||||||
|
background: #fff7d6;
|
||||||
|
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.24),
|
||||||
|
0 0 8px rgba(217, 119, 6, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-handle.port-connection-compatible {
|
||||||
|
border-color: #15803d;
|
||||||
|
background: #dcfce7;
|
||||||
|
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.24),
|
||||||
|
0 0 9px rgba(21, 128, 61, 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-handle.port-connection-incompatible {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.18;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flow-canvas .react-flow__edge-path,
|
.flow-canvas .react-flow__edge-path,
|
||||||
@@ -1342,6 +1458,38 @@ textarea {
|
|||||||
filter: drop-shadow(0 0 2px rgba(29, 111, 184, 0.42));
|
filter: drop-shadow(0 0 2px rgba(29, 111, 184, 0.42));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-edge-hit {
|
||||||
|
cursor: pointer;
|
||||||
|
fill: transparent;
|
||||||
|
pointer-events: all;
|
||||||
|
stroke: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-edge-marker {
|
||||||
|
fill: #ffffff;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
stroke: #64748b;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
transition: opacity 120ms ease-out, fill 120ms ease-out, stroke 120ms ease-out;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__edge.editor-edge-contact:hover .contact-edge-marker,
|
||||||
|
.react-flow__edge.editor-edge-contact.selected .contact-edge-marker,
|
||||||
|
.react-flow__edge.editor-edge-contact.editor-edge-selected .contact-edge-marker,
|
||||||
|
.contact-edge-hit:focus-visible + .contact-edge-marker {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__edge.editor-edge-contact.selected .contact-edge-marker,
|
||||||
|
.react-flow__edge.editor-edge-contact.editor-edge-selected .contact-edge-marker,
|
||||||
|
.contact-edge-hit:focus-visible + .contact-edge-marker {
|
||||||
|
fill: #dbeafe;
|
||||||
|
stroke: #1d6fb8;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -1791,6 +1939,15 @@ textarea {
|
|||||||
stroke-width: 1.5;
|
stroke-width: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.results-system-canvas .contact-edge-hit {
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-system-canvas .contact-edge-marker {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.results-chart-workspace {
|
.results-chart-workspace {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
@@ -1834,6 +1991,38 @@ textarea {
|
|||||||
0 7px 20px rgba(30, 41, 59, 0.18);
|
0 7px 20px rgba(30, 41, 59, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.result-chart-pending {
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px dashed #9aaaba;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #718096;
|
||||||
|
text-align: center;
|
||||||
|
background: #f8fafc;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-pending strong {
|
||||||
|
color: #43556a;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-pending span {
|
||||||
|
max-width: 300px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-chart-window.pending.drop-target .result-chart-pending {
|
||||||
|
border-color: #1d6fb8;
|
||||||
|
color: #1d6fb8;
|
||||||
|
background: #edf6fd;
|
||||||
|
}
|
||||||
|
|
||||||
.result-chart-window-header {
|
.result-chart-window-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||||
|
|
||||||
type SymbolCase = {
|
type SymbolCase = {
|
||||||
category: string;
|
category: string;
|
||||||
@@ -30,6 +30,176 @@ const symbolCases: SymbolCase[] = [
|
|||||||
{ category: "mechanical", domain: "mechanical", modelType: "amesim_lmechn1" },
|
{ category: "mechanical", domain: "mechanical", modelType: "amesim_lmechn1" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function physicalPort(
|
||||||
|
name: string,
|
||||||
|
side: "left" | "right",
|
||||||
|
order: number,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
kind: "physical",
|
||||||
|
domain: "pneumatic",
|
||||||
|
nominalRole: "bidirectional",
|
||||||
|
positiveFlowDirection: "intoComponent",
|
||||||
|
side,
|
||||||
|
order,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function signalPort(
|
||||||
|
name: string,
|
||||||
|
side: "left" | "right",
|
||||||
|
nominalRole: "input" | "output",
|
||||||
|
order: number,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
kind: "signal",
|
||||||
|
domain: "signal",
|
||||||
|
nominalRole,
|
||||||
|
side,
|
||||||
|
order,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function portsForSymbol(modelType: string) {
|
||||||
|
if (modelType === "amesim_pnor001") {
|
||||||
|
return [
|
||||||
|
physicalPort("port_1", "left", 10),
|
||||||
|
physicalPort("port_2", "right", 20),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (modelType === "amesim_pnvo001") {
|
||||||
|
return [
|
||||||
|
signalPort("res", "left", "input", 5),
|
||||||
|
physicalPort("port_2", "left", 10),
|
||||||
|
physicalPort("port_3", "right", 20),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (modelType === "amesim_step0") {
|
||||||
|
return [signalPort("out", "right", "output", 10)];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dragSymbolToCanvas(
|
||||||
|
page: Page,
|
||||||
|
modelType: string,
|
||||||
|
targetPosition: { x: number; y: number },
|
||||||
|
) {
|
||||||
|
const pane = page.locator(".flow-canvas .react-flow__pane");
|
||||||
|
const paletteItem = page
|
||||||
|
.locator(".palette-item")
|
||||||
|
.filter({
|
||||||
|
has: page.locator(`[data-component-symbol="${modelType}"]`),
|
||||||
|
})
|
||||||
|
.first();
|
||||||
|
await paletteItem.dragTo(pane, { targetPosition });
|
||||||
|
const node = page
|
||||||
|
.locator(
|
||||||
|
`.flow-canvas .react-flow__node[data-id^="${modelType}_"]`,
|
||||||
|
)
|
||||||
|
.last();
|
||||||
|
await expect(node).toBeVisible();
|
||||||
|
const nodeId = await node.getAttribute("data-id");
|
||||||
|
expect(nodeId).not.toBeNull();
|
||||||
|
return page.locator(
|
||||||
|
`.flow-canvas .react-flow__node[data-id="${nodeId}"]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSymbolAnchors(
|
||||||
|
node: Locator,
|
||||||
|
anchors: Array<{ portName: string; x: number; y: number }>,
|
||||||
|
) {
|
||||||
|
for (const anchor of anchors) {
|
||||||
|
const error = await node.evaluate((element, expected) => {
|
||||||
|
const svg = element.querySelector<SVGSVGElement>(".component-symbol-svg");
|
||||||
|
const handle = element.querySelector<HTMLElement>(
|
||||||
|
`.port-handle[data-port-name="${expected.portName}"]`,
|
||||||
|
);
|
||||||
|
const matrix = svg?.getScreenCTM();
|
||||||
|
if (!svg || !handle || !matrix) {
|
||||||
|
return Number.POSITIVE_INFINITY;
|
||||||
|
}
|
||||||
|
const point = svg.createSVGPoint();
|
||||||
|
point.x = expected.x;
|
||||||
|
point.y = expected.y;
|
||||||
|
const screenPoint = point.matrixTransform(matrix);
|
||||||
|
const handleBounds = handle.getBoundingClientRect();
|
||||||
|
return Math.hypot(
|
||||||
|
screenPoint.x - (handleBounds.left + handleBounds.width / 2),
|
||||||
|
screenPoint.y - (handleBounds.top + handleBounds.height / 2),
|
||||||
|
);
|
||||||
|
}, anchor);
|
||||||
|
expect(error).toBeLessThan(2.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function locatorCenter(locator: Locator) {
|
||||||
|
const bounds = await locator.boundingBox();
|
||||||
|
expect(bounds).not.toBeNull();
|
||||||
|
return {
|
||||||
|
x: bounds!.x + bounds!.width / 2,
|
||||||
|
y: bounds!.y + bounds!.height / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function distanceBetween(first: Locator, second: Locator) {
|
||||||
|
const [firstCenter, secondCenter] = await Promise.all([
|
||||||
|
locatorCenter(first),
|
||||||
|
locatorCenter(second),
|
||||||
|
]);
|
||||||
|
return Math.hypot(
|
||||||
|
secondCenter.x - firstCenter.x,
|
||||||
|
secondCenter.y - firstCenter.y,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dragNodePortNearTarget(
|
||||||
|
page: Page,
|
||||||
|
movingNode: Locator,
|
||||||
|
movingPort: Locator,
|
||||||
|
targetPort: Locator,
|
||||||
|
remainingGap = 8,
|
||||||
|
) {
|
||||||
|
const [dragStart, movingPortCenter, targetPortCenter] = await Promise.all([
|
||||||
|
locatorCenter(movingNode.locator(".node-symbol")),
|
||||||
|
locatorCenter(movingPort),
|
||||||
|
locatorCenter(targetPort),
|
||||||
|
]);
|
||||||
|
const deltaX = targetPortCenter.x - movingPortCenter.x;
|
||||||
|
const deltaY = targetPortCenter.y - movingPortCenter.y;
|
||||||
|
const distance = Math.hypot(deltaX, deltaY);
|
||||||
|
expect(distance).toBeGreaterThan(remainingGap);
|
||||||
|
const movementRatio = (distance - remainingGap) / distance;
|
||||||
|
|
||||||
|
await page.mouse.move(dragStart.x, dragStart.y);
|
||||||
|
await page.mouse.down();
|
||||||
|
let pointerX = dragStart.x + deltaX * movementRatio;
|
||||||
|
let pointerY = dragStart.y + deltaY * movementRatio;
|
||||||
|
await page.mouse.move(pointerX, pointerY, { steps: 12 });
|
||||||
|
|
||||||
|
// 旋转会改变节点外框尺寸,React Flow 可能同时刷新 viewport;根据实际
|
||||||
|
// Handle 中心再校正两次,确保测试模拟的是用户真正把端口拖到附近。
|
||||||
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||||
|
const [actualMovingCenter, actualTargetCenter] = await Promise.all([
|
||||||
|
locatorCenter(movingPort),
|
||||||
|
locatorCenter(targetPort),
|
||||||
|
]);
|
||||||
|
const correctionX = actualTargetCenter.x - actualMovingCenter.x;
|
||||||
|
const correctionY = actualTargetCenter.y - actualMovingCenter.y;
|
||||||
|
const actualDistance = Math.hypot(correctionX, correctionY);
|
||||||
|
if (actualDistance <= remainingGap + 1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const correctionRatio = (actualDistance - remainingGap) / actualDistance;
|
||||||
|
pointerX += correctionX * correctionRatio;
|
||||||
|
pointerY += correctionY * correctionRatio;
|
||||||
|
await page.mouse.move(pointerX, pointerY, { steps: 4 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const categoryLabels: Record<string, string> = {
|
const categoryLabels: Record<string, string> = {
|
||||||
boundary: "边界元件",
|
boundary: "边界元件",
|
||||||
flow: "流动元件",
|
flow: "流动元件",
|
||||||
@@ -76,7 +246,7 @@ test.beforeEach(async ({ page }) => {
|
|||||||
label: categoryLabels[item.category],
|
label: categoryLabels[item.category],
|
||||||
order: Object.keys(categoryLabels).indexOf(item.category) + 1,
|
order: Object.keys(categoryLabels).indexOf(item.category) + 1,
|
||||||
},
|
},
|
||||||
ports: [],
|
ports: portsForSymbol(item.modelType),
|
||||||
parameters: [],
|
parameters: [],
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@@ -106,6 +276,429 @@ test("全部已移植 AMESim 模型使用专属图标及正确领域配色", asy
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("AMESim canvas nodes use icon anchors and highlight only compatible free ports", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
const firstOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
|
||||||
|
x: 250,
|
||||||
|
y: 210,
|
||||||
|
});
|
||||||
|
const secondOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
|
||||||
|
x: 610,
|
||||||
|
y: 210,
|
||||||
|
});
|
||||||
|
const stepSource = await dragSymbolToCanvas(page, "amesim_step0", {
|
||||||
|
x: 250,
|
||||||
|
y: 450,
|
||||||
|
});
|
||||||
|
const signalOrifice = await dragSymbolToCanvas(page, "amesim_pnvo001", {
|
||||||
|
x: 610,
|
||||||
|
y: 450,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(firstOrifice.locator(".sim-node")).toHaveClass(
|
||||||
|
/component-icon-only/,
|
||||||
|
);
|
||||||
|
await expect(firstOrifice.locator(".node-label")).toBeHidden();
|
||||||
|
const frameStyle = await firstOrifice.locator(".sim-node-frame").evaluate(
|
||||||
|
(element) => ({
|
||||||
|
borderWidth: getComputedStyle(element).borderTopWidth,
|
||||||
|
background: getComputedStyle(element).backgroundColor,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(frameStyle).toEqual({
|
||||||
|
borderWidth: "0px",
|
||||||
|
background: "rgba(0, 0, 0, 0)",
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstOrificeAnchors = [
|
||||||
|
{ portName: "port_1", x: 2, y: 24 },
|
||||||
|
{ portName: "port_2", x: 62, y: 24 },
|
||||||
|
];
|
||||||
|
await expectSymbolAnchors(firstOrifice, firstOrificeAnchors);
|
||||||
|
await expectSymbolAnchors(signalOrifice, [
|
||||||
|
{ portName: "res", x: 2, y: 16 },
|
||||||
|
{ portName: "port_2", x: 2, y: 32 },
|
||||||
|
{ portName: "port_3", x: 62, y: 25 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await firstOrifice.locator(".node-symbol").click({ position: { x: 56, y: 42 } });
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true })
|
||||||
|
.click();
|
||||||
|
await expectSymbolAnchors(firstOrifice, firstOrificeAnchors);
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "水平镜像 (Shift+H)", exact: true })
|
||||||
|
.click();
|
||||||
|
await expectSymbolAnchors(firstOrifice, firstOrificeAnchors);
|
||||||
|
|
||||||
|
const firstPhysicalOutput = firstOrifice.locator(
|
||||||
|
'.port-handle[data-port-name="port_2"]',
|
||||||
|
);
|
||||||
|
const sameNodePort = firstOrifice.locator(
|
||||||
|
'.port-handle[data-port-name="port_1"]',
|
||||||
|
);
|
||||||
|
const compatiblePhysicalPort = secondOrifice.locator(
|
||||||
|
'.port-handle[data-port-name="port_1"]',
|
||||||
|
);
|
||||||
|
const signalOutput = stepSource.locator(
|
||||||
|
'.port-handle[data-port-name="out"]',
|
||||||
|
);
|
||||||
|
const signalInput = signalOrifice.locator(
|
||||||
|
'.port-handle[data-port-name="res"]',
|
||||||
|
);
|
||||||
|
const pneumaticInput = signalOrifice.locator(
|
||||||
|
'.port-handle[data-port-name="port_2"]',
|
||||||
|
);
|
||||||
|
|
||||||
|
await firstPhysicalOutput.click();
|
||||||
|
await expect(firstPhysicalOutput).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"origin",
|
||||||
|
);
|
||||||
|
await expect(compatiblePhysicalPort).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"compatible",
|
||||||
|
);
|
||||||
|
await expect(pneumaticInput).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"compatible",
|
||||||
|
);
|
||||||
|
await expect(sameNodePort).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"incompatible",
|
||||||
|
);
|
||||||
|
await expect(signalInput).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"incompatible",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.locator(".flow-canvas .react-flow__pane").click({
|
||||||
|
position: { x: 20, y: 20 },
|
||||||
|
});
|
||||||
|
await expect(firstPhysicalOutput).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"idle",
|
||||||
|
);
|
||||||
|
await compatiblePhysicalPort.click();
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
|
||||||
|
await expect(compatiblePhysicalPort).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"origin",
|
||||||
|
);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(compatiblePhysicalPort).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"idle",
|
||||||
|
);
|
||||||
|
|
||||||
|
await firstPhysicalOutput.click();
|
||||||
|
await compatiblePhysicalPort.click();
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
||||||
|
await expect(firstPhysicalOutput).toHaveAttribute("data-connected", "true");
|
||||||
|
await expect(compatiblePhysicalPort).toHaveAttribute("data-connected", "true");
|
||||||
|
await expect(firstPhysicalOutput).toHaveCSS("opacity", "0");
|
||||||
|
await expect(compatiblePhysicalPort).toHaveCSS("opacity", "0");
|
||||||
|
|
||||||
|
const physicalEdge = page.locator(".flow-canvas .react-flow__edge").first();
|
||||||
|
await physicalEdge.locator(".react-flow__edge-interaction").click({ force: true });
|
||||||
|
await page.getByRole("button", { name: "删除 (Delete)", exact: true }).click();
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
|
||||||
|
await expect(firstPhysicalOutput).toHaveAttribute("data-connected", "false");
|
||||||
|
await expect(compatiblePhysicalPort).toHaveAttribute("data-connected", "false");
|
||||||
|
await expect(firstPhysicalOutput).toHaveCSS("opacity", "1");
|
||||||
|
await expect(compatiblePhysicalPort).toHaveCSS("opacity", "1");
|
||||||
|
await expect(firstPhysicalOutput).not.toHaveCSS("pointer-events", "none");
|
||||||
|
await expect(compatiblePhysicalPort).not.toHaveCSS("pointer-events", "none");
|
||||||
|
|
||||||
|
await firstPhysicalOutput.click();
|
||||||
|
await compatiblePhysicalPort.click();
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
||||||
|
|
||||||
|
const signalOutputBounds = await signalOutput.boundingBox();
|
||||||
|
const signalInputBounds = await signalInput.boundingBox();
|
||||||
|
expect(signalOutputBounds).not.toBeNull();
|
||||||
|
expect(signalInputBounds).not.toBeNull();
|
||||||
|
await page.mouse.move(
|
||||||
|
signalOutputBounds!.x + signalOutputBounds!.width / 2,
|
||||||
|
signalOutputBounds!.y + signalOutputBounds!.height / 2,
|
||||||
|
);
|
||||||
|
await page.mouse.down();
|
||||||
|
await expect(signalOutput).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"origin",
|
||||||
|
);
|
||||||
|
await expect(signalInput).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"compatible",
|
||||||
|
);
|
||||||
|
const paneBounds = await page
|
||||||
|
.locator(".flow-canvas .react-flow__pane")
|
||||||
|
.boundingBox();
|
||||||
|
expect(paneBounds).not.toBeNull();
|
||||||
|
await page.mouse.move(paneBounds!.x + 20, paneBounds!.y + 20, { steps: 4 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__connection")).toHaveCount(0);
|
||||||
|
await expect(signalOutput).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"idle",
|
||||||
|
);
|
||||||
|
await expect(signalInput).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"idle",
|
||||||
|
);
|
||||||
|
await expect(pneumaticInput).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"idle",
|
||||||
|
);
|
||||||
|
|
||||||
|
const retriedSignalOutputBounds = await signalOutput.boundingBox();
|
||||||
|
const retriedSignalInputBounds = await signalInput.boundingBox();
|
||||||
|
expect(retriedSignalOutputBounds).not.toBeNull();
|
||||||
|
expect(retriedSignalInputBounds).not.toBeNull();
|
||||||
|
await page.mouse.move(
|
||||||
|
retriedSignalOutputBounds!.x + retriedSignalOutputBounds!.width / 2,
|
||||||
|
retriedSignalOutputBounds!.y + retriedSignalOutputBounds!.height / 2,
|
||||||
|
);
|
||||||
|
await page.mouse.down();
|
||||||
|
await expect(signalOutput).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"origin",
|
||||||
|
);
|
||||||
|
await page.mouse.move(
|
||||||
|
retriedSignalOutputBounds!.x + retriedSignalOutputBounds!.width / 2 + 12,
|
||||||
|
retriedSignalOutputBounds!.y + retriedSignalOutputBounds!.height / 2,
|
||||||
|
);
|
||||||
|
await expect(signalInput).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"compatible",
|
||||||
|
);
|
||||||
|
await expect(pneumaticInput).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"incompatible",
|
||||||
|
);
|
||||||
|
await page.mouse.move(
|
||||||
|
retriedSignalInputBounds!.x + retriedSignalInputBounds!.width / 2,
|
||||||
|
retriedSignalInputBounds!.y + retriedSignalInputBounds!.height / 2,
|
||||||
|
{ steps: 5 },
|
||||||
|
);
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
|
||||||
|
await expect(signalOutput).toHaveAttribute("data-connected", "true");
|
||||||
|
await expect(signalInput).toHaveAttribute("data-connected", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("兼容接口接近时吸附为无连线接触连接,拖开后自动断开", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
|
||||||
|
|
||||||
|
const firstOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
|
||||||
|
x: 280,
|
||||||
|
y: 300,
|
||||||
|
});
|
||||||
|
const secondOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
|
||||||
|
x: 650,
|
||||||
|
y: 300,
|
||||||
|
});
|
||||||
|
const firstPort = firstOrifice.locator(
|
||||||
|
'.port-handle[data-port-name="port_2"]',
|
||||||
|
);
|
||||||
|
const secondPort = secondOrifice.locator(
|
||||||
|
'.port-handle[data-port-name="port_1"]',
|
||||||
|
);
|
||||||
|
|
||||||
|
await dragNodePortNearTarget(
|
||||||
|
page,
|
||||||
|
secondOrifice,
|
||||||
|
secondPort,
|
||||||
|
firstPort,
|
||||||
|
);
|
||||||
|
await expect(firstPort).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"compatible",
|
||||||
|
);
|
||||||
|
await expect(secondPort).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"origin",
|
||||||
|
);
|
||||||
|
expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(4);
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => distanceBetween(firstPort, secondPort))
|
||||||
|
.toBeLessThan(2);
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
|
||||||
|
const contactHit = page.locator(".flow-canvas .contact-edge-hit");
|
||||||
|
await expect(contactHit).toHaveCount(1);
|
||||||
|
await expect(firstPort).toHaveAttribute("data-connected", "true");
|
||||||
|
await expect(secondPort).toHaveAttribute("data-connected", "true");
|
||||||
|
await expect(firstPort).toHaveCSS("opacity", "0");
|
||||||
|
await expect(secondPort).toHaveCSS("opacity", "0");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
|
||||||
|
const xmlBlock = page.getByLabel("生成的系统 XML");
|
||||||
|
await expect(xmlBlock).toContainText('<Connection id="edge-contact-');
|
||||||
|
await expect(xmlBlock).toContainText('component="amesim_pnor001_1" port="port_2"');
|
||||||
|
await expect(xmlBlock).toContainText('component="amesim_pnor001_2" port="port_1"');
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "保存工程", exact: true }).click();
|
||||||
|
const savedEdge = await page.evaluate(() => {
|
||||||
|
const raw = window.localStorage.getItem(
|
||||||
|
"system-simulation-flow:project:demo-system",
|
||||||
|
);
|
||||||
|
const project = raw ? JSON.parse(raw) : null;
|
||||||
|
return project?.edges?.[0] ?? null;
|
||||||
|
});
|
||||||
|
expect(savedEdge).toMatchObject({
|
||||||
|
source: "amesim_pnor001_2",
|
||||||
|
sourceHandle: "port_1",
|
||||||
|
target: "amesim_pnor001_1",
|
||||||
|
targetHandle: "port_2",
|
||||||
|
});
|
||||||
|
|
||||||
|
await contactHit.click();
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveClass(
|
||||||
|
/editor-edge-selected/,
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "删除 (Delete)", exact: true }).click();
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
|
||||||
|
await expect(firstPort).toHaveAttribute("data-connected", "false");
|
||||||
|
await expect(secondPort).toHaveAttribute("data-connected", "false");
|
||||||
|
expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(8);
|
||||||
|
|
||||||
|
await dragNodePortNearTarget(
|
||||||
|
page,
|
||||||
|
secondOrifice,
|
||||||
|
secondPort,
|
||||||
|
firstPort,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
await expect(firstPort).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"compatible",
|
||||||
|
);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
|
||||||
|
await page.getByRole("button", { name: "撤销 (Ctrl+Z)", exact: true }).click();
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
|
||||||
|
|
||||||
|
const secondSymbolCenter = await locatorCenter(
|
||||||
|
secondOrifice.locator(".node-symbol"),
|
||||||
|
);
|
||||||
|
await page.mouse.move(secondSymbolCenter.x, secondSymbolCenter.y);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(secondSymbolCenter.x + 90, secondSymbolCenter.y + 35, {
|
||||||
|
steps: 10,
|
||||||
|
});
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
|
||||||
|
await expect(firstPort).toHaveAttribute("data-connected", "false");
|
||||||
|
await expect(secondPort).toHaveAttribute("data-connected", "false");
|
||||||
|
await expect(firstPort).toHaveCSS("opacity", "1");
|
||||||
|
await expect(secondPort).toHaveCSS("opacity", "1");
|
||||||
|
expect(await distanceBetween(firstPort, secondPort)).toBeGreaterThan(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("不同类型接口接近时不会吸附或建立逻辑连接", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
|
||||||
|
|
||||||
|
const orifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
|
||||||
|
x: 280,
|
||||||
|
y: 320,
|
||||||
|
});
|
||||||
|
const stepSource = await dragSymbolToCanvas(page, "amesim_step0", {
|
||||||
|
x: 650,
|
||||||
|
y: 320,
|
||||||
|
});
|
||||||
|
const physicalPort = orifice.locator(
|
||||||
|
'.port-handle[data-port-name="port_2"]',
|
||||||
|
);
|
||||||
|
const signalPort = stepSource.locator(
|
||||||
|
'.port-handle[data-port-name="out"]',
|
||||||
|
);
|
||||||
|
|
||||||
|
await dragNodePortNearTarget(
|
||||||
|
page,
|
||||||
|
stepSource,
|
||||||
|
signalPort,
|
||||||
|
physicalPort,
|
||||||
|
);
|
||||||
|
await expect(physicalPort).not.toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"compatible",
|
||||||
|
);
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
|
||||||
|
expect(await distanceBetween(physicalPort, signalPort)).toBeGreaterThan(4);
|
||||||
|
await expect(physicalPort).toHaveAttribute("data-connected", "false");
|
||||||
|
await expect(signalPort).toHaveAttribute("data-connected", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("旋转后的接口可以与方向不同的兼容接口接触连接", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
|
||||||
|
|
||||||
|
const horizontalOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
|
||||||
|
x: 300,
|
||||||
|
y: 300,
|
||||||
|
});
|
||||||
|
const rotatedOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
|
||||||
|
x: 650,
|
||||||
|
y: 500,
|
||||||
|
});
|
||||||
|
await rotatedOrifice.locator(".node-symbol").click();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "顺时针旋转 90° (Ctrl+R)", exact: true })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
const horizontalPort = horizontalOrifice.locator(
|
||||||
|
'.port-handle[data-port-name="port_2"]',
|
||||||
|
);
|
||||||
|
const rotatedPort = rotatedOrifice.locator(
|
||||||
|
'.port-handle[data-port-name="port_1"]',
|
||||||
|
);
|
||||||
|
await dragNodePortNearTarget(
|
||||||
|
page,
|
||||||
|
rotatedOrifice,
|
||||||
|
rotatedPort,
|
||||||
|
horizontalPort,
|
||||||
|
);
|
||||||
|
await expect(horizontalPort).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"compatible",
|
||||||
|
);
|
||||||
|
await expect(rotatedPort).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"origin",
|
||||||
|
);
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => distanceBetween(horizontalPort, rotatedPort))
|
||||||
|
.toBeLessThan(2);
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
|
||||||
|
await expect(page.locator(".flow-canvas .react-flow__edge-path")).toHaveCount(0);
|
||||||
|
await expect(horizontalPort).toHaveAttribute("data-connected", "true");
|
||||||
|
await expect(rotatedPort).toHaveAttribute("data-connected", "true");
|
||||||
|
});
|
||||||
|
|
||||||
test("旧工程保存的通用图标会按当前目录定义升级", async ({ page }) => {
|
test("旧工程保存的通用图标会按当前目录定义升级", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
|
|||||||
@@ -52,14 +52,27 @@ test("恢复自动保存工程后自动适配建模画布", async ({ page }) =>
|
|||||||
|
|
||||||
test("切换到结果页时自动适配只读系统图", async ({ page }) => {
|
test("切换到结果页时自动适配只读系统图", async ({ page }) => {
|
||||||
await prepareApp(page);
|
await prepareApp(page);
|
||||||
await page.addInitScript((snapshot) => {
|
await page.addInitScript(({ project, snapshot }) => {
|
||||||
window.sessionStorage.setItem(
|
window.sessionStorage.setItem(
|
||||||
"system-simulation-flow:latest-result",
|
"system-simulation-flow:latest-result",
|
||||||
JSON.stringify(snapshot),
|
JSON.stringify(snapshot),
|
||||||
);
|
);
|
||||||
}, resultSnapshot);
|
window.localStorage.setItem(
|
||||||
|
"system-simulation-flow:project:demo-system",
|
||||||
|
JSON.stringify(project),
|
||||||
|
);
|
||||||
|
}, { project: wideProject, snapshot: resultSnapshot });
|
||||||
|
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "加载工程" }).click();
|
||||||
|
const freeModelingHandle = page.locator(
|
||||||
|
'.flow-canvas .react-flow__node[data-id="generic_sensor_1"] .port-handle[data-port-name="port_a"]',
|
||||||
|
);
|
||||||
|
await freeModelingHandle.dispatchEvent("click");
|
||||||
|
await expect(freeModelingHandle).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"origin",
|
||||||
|
);
|
||||||
await page.getByRole("tab", { name: /^结果/ }).click();
|
await page.getByRole("tab", { name: /^结果/ }).click();
|
||||||
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
|
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
|
||||||
await expect(
|
await expect(
|
||||||
@@ -67,6 +80,10 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
|
|||||||
).toHaveCount(0);
|
).toHaveCount(0);
|
||||||
|
|
||||||
await page.getByRole("tab", { name: "建模" }).click();
|
await page.getByRole("tab", { name: "建模" }).click();
|
||||||
|
await expect(freeModelingHandle).toHaveAttribute(
|
||||||
|
"data-connection-state",
|
||||||
|
"idle",
|
||||||
|
);
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
|
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
@@ -78,4 +95,15 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
|
|||||||
await expect(page.locator(".results-system-canvas .react-flow__edge")).toHaveCount(
|
await expect(page.locator(".results-system-canvas .react-flow__edge")).toHaveCount(
|
||||||
wideProject.edges.length,
|
wideProject.edges.length,
|
||||||
);
|
);
|
||||||
|
const connectedResultHandles = page.locator(
|
||||||
|
'.results-system-canvas .port-handle[data-connected="true"]',
|
||||||
|
);
|
||||||
|
const idleResultHandles = page.locator(
|
||||||
|
'.results-system-canvas .port-handle[data-connected="false"]',
|
||||||
|
);
|
||||||
|
await expect(connectedResultHandles).toHaveCount(
|
||||||
|
wideProject.edges.length * 2,
|
||||||
|
);
|
||||||
|
await expect(idleResultHandles).toHaveCount(2);
|
||||||
|
await expect(connectedResultHandles.first()).toHaveCSS("opacity", "0");
|
||||||
});
|
});
|
||||||
@@ -202,6 +202,88 @@ test("参数表列宽可调整并在刷新后保留", async ({ page }) => {
|
|||||||
await expect(reloadedSeparator).toHaveAttribute("aria-valuenow", "120");
|
await expect(reloadedSeparator).toHaveAttribute("aria-valuenow", "120");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("组件参数和仿真设置是可独立滚动并可调高度的子栏", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await addPneumaticTestComponent(page);
|
||||||
|
|
||||||
|
const componentPane = page.getByRole("region", {
|
||||||
|
name: "组件参数子栏",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
const simulationPane = page.getByRole("region", {
|
||||||
|
name: "仿真设置子栏",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
const separator = page.getByRole("separator", {
|
||||||
|
name: "调整组件参数和仿真设置区域高度",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(componentPane).toBeVisible();
|
||||||
|
await expect(simulationPane).toBeVisible();
|
||||||
|
await expect(separator).toBeVisible();
|
||||||
|
|
||||||
|
const initialValue = Number(await separator.getAttribute("aria-valuenow"));
|
||||||
|
const initialMaximum = Number(await separator.getAttribute("aria-valuemax"));
|
||||||
|
expect(initialMaximum).toBeGreaterThanOrEqual(initialValue);
|
||||||
|
const initialComponentBox = await componentPane.boundingBox();
|
||||||
|
const initialSimulationBox = await simulationPane.boundingBox();
|
||||||
|
const separatorBox = await separator.boundingBox();
|
||||||
|
expect(initialComponentBox).not.toBeNull();
|
||||||
|
expect(initialSimulationBox).not.toBeNull();
|
||||||
|
expect(separatorBox).not.toBeNull();
|
||||||
|
expect(initialSimulationBox!.height).toBeGreaterThanOrEqual(120);
|
||||||
|
|
||||||
|
await page.mouse.move(
|
||||||
|
separatorBox!.x + separatorBox!.width / 2,
|
||||||
|
separatorBox!.y + separatorBox!.height / 2,
|
||||||
|
);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(
|
||||||
|
separatorBox!.x + separatorBox!.width / 2,
|
||||||
|
separatorBox!.y + separatorBox!.height / 2 + 40,
|
||||||
|
{ steps: 4 },
|
||||||
|
);
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
await expect(separator).toHaveAttribute(
|
||||||
|
"aria-valuenow",
|
||||||
|
String(initialValue + 40),
|
||||||
|
);
|
||||||
|
const resizedComponentBox = await componentPane.boundingBox();
|
||||||
|
const resizedSimulationBox = await simulationPane.boundingBox();
|
||||||
|
expect(resizedComponentBox!.height).toBeGreaterThan(
|
||||||
|
initialComponentBox!.height + 38,
|
||||||
|
);
|
||||||
|
expect(resizedSimulationBox!.height).toBeLessThan(
|
||||||
|
initialSimulationBox!.height - 38,
|
||||||
|
);
|
||||||
|
|
||||||
|
const overflowModes = await page
|
||||||
|
.locator(".properties-subpane-content")
|
||||||
|
.evaluateAll((elements) =>
|
||||||
|
elements.map((element) => getComputedStyle(element).overflowY),
|
||||||
|
);
|
||||||
|
expect(overflowModes).toEqual(["auto", "auto"]);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
const reloadedSeparator = page.getByRole("separator", {
|
||||||
|
name: "调整组件参数和仿真设置区域高度",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(reloadedSeparator).toHaveAttribute(
|
||||||
|
"aria-valuenow",
|
||||||
|
String(initialValue + 40),
|
||||||
|
);
|
||||||
|
await reloadedSeparator.focus();
|
||||||
|
await page.keyboard.press("ArrowUp");
|
||||||
|
await expect(reloadedSeparator).toHaveAttribute(
|
||||||
|
"aria-valuenow",
|
||||||
|
String(initialValue + 20),
|
||||||
|
);
|
||||||
|
await reloadedSeparator.dblclick();
|
||||||
|
await expect(reloadedSeparator).toHaveAttribute("aria-valuenow", "340");
|
||||||
|
});
|
||||||
|
|
||||||
test("组件参数公式保留原式,并在说明中显示计算值且 XML 只写数值", async ({
|
test("组件参数公式保留原式,并在说明中显示计算值且 XML 只写数值", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { prepareApp, resultSnapshot } from "./fixtures";
|
||||||
|
|
||||||
|
const chartWindowSnapshot = {
|
||||||
|
...resultSnapshot,
|
||||||
|
id: "e2e-pending-chart-windows",
|
||||||
|
result: {
|
||||||
|
...resultSnapshot.result,
|
||||||
|
variables: [
|
||||||
|
...resultSnapshot.result.variables,
|
||||||
|
{
|
||||||
|
key: "generic_sensor_1.pressure",
|
||||||
|
componentId: "generic_sensor_1",
|
||||||
|
componentType: "generic_sensor",
|
||||||
|
scope: "component",
|
||||||
|
portName: null,
|
||||||
|
name: "pressure",
|
||||||
|
label: "压力",
|
||||||
|
quantity: "pressure",
|
||||||
|
unit: "Pa",
|
||||||
|
category: "state",
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
final: {
|
||||||
|
...resultSnapshot.result.final,
|
||||||
|
"generic_sensor_1.pressure": 120000,
|
||||||
|
},
|
||||||
|
series: {
|
||||||
|
...resultSnapshot.result.series,
|
||||||
|
"generic_sensor_1.pressure": [100000, 110000, 120000],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function openResults(page: Page) {
|
||||||
|
await page.getByRole("tab", { name: "结果", exact: true }).click();
|
||||||
|
await expect(page.locator(".results-chart-workspace")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await prepareApp(page);
|
||||||
|
await page.addInitScript((snapshot) => {
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
"system-simulation-flow:latest-result",
|
||||||
|
JSON.stringify(snapshot),
|
||||||
|
);
|
||||||
|
}, chartWindowSnapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("组合曲线窗口先创建为空窗,并在删除最后一条曲线后恢复待定", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await openResults(page);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "新建同单位多曲线对比窗口", exact: true })
|
||||||
|
.click();
|
||||||
|
let sameUnitWindow = page.locator(
|
||||||
|
'.result-chart-window[data-chart-kind="multi"]',
|
||||||
|
);
|
||||||
|
await expect(sameUnitWindow).toHaveCount(1);
|
||||||
|
await expect(sameUnitWindow).toHaveAttribute("data-pending", "true");
|
||||||
|
await expect(sameUnitWindow).toContainText("等待第一条曲线");
|
||||||
|
|
||||||
|
const valueVariable = page
|
||||||
|
.locator(".results-variable-list button")
|
||||||
|
.filter({ hasText: "数值" })
|
||||||
|
.first();
|
||||||
|
await valueVariable.dragTo(sameUnitWindow);
|
||||||
|
await expect(sameUnitWindow).toHaveAttribute("data-pending", "false");
|
||||||
|
await expect(sameUnitWindow).toContainText("1/1 条显示");
|
||||||
|
await expect(sameUnitWindow).toContainText("无量纲");
|
||||||
|
|
||||||
|
await sameUnitWindow
|
||||||
|
.getByRole("button", {
|
||||||
|
name: "管理多曲线窗口中的曲线",
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
await sameUnitWindow.locator(".result-chart-multi-menu-row .remove").click();
|
||||||
|
await expect(sameUnitWindow).toHaveCount(1);
|
||||||
|
await expect(sameUnitWindow).toHaveAttribute("data-pending", "true");
|
||||||
|
await expect(sameUnitWindow).toContainText("等待第一条曲线");
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await openResults(page);
|
||||||
|
sameUnitWindow = page.locator(
|
||||||
|
'.result-chart-window[data-chart-kind="multi"]',
|
||||||
|
);
|
||||||
|
await expect(sameUnitWindow).toHaveCount(1);
|
||||||
|
await expect(sameUnitWindow).toHaveAttribute("data-pending", "true");
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "新建异单位上下对比窗口", exact: true })
|
||||||
|
.click();
|
||||||
|
const mixedWindow = page.locator(
|
||||||
|
'.result-chart-window[data-chart-kind="mixed"]',
|
||||||
|
);
|
||||||
|
await expect(mixedWindow).toHaveCount(1);
|
||||||
|
await expect(mixedWindow).toHaveAttribute("data-pending", "true");
|
||||||
|
|
||||||
|
const pressureVariable = page
|
||||||
|
.locator(".results-variable-list button")
|
||||||
|
.filter({ hasText: "压力" })
|
||||||
|
.first();
|
||||||
|
await pressureVariable.dragTo(mixedWindow);
|
||||||
|
await expect(mixedWindow).toHaveAttribute("data-pending", "false");
|
||||||
|
await expect(mixedWindow).toContainText("Pa");
|
||||||
|
|
||||||
|
await mixedWindow
|
||||||
|
.getByRole("button", {
|
||||||
|
name: "管理多曲线窗口中的曲线",
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
await mixedWindow.locator(".result-chart-multi-menu-row .remove").click();
|
||||||
|
await expect(mixedWindow).toHaveCount(1);
|
||||||
|
await expect(mixedWindow).toHaveAttribute("data-pending", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a pending window recovered from stale variable keys accepts a new first curve", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.addInitScript((snapshotId) => {
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
`system-simulation-flow:result-layout:${snapshotId}`,
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
id: "chart-1",
|
||||||
|
kind: "multi",
|
||||||
|
variableKey: "removed.variable",
|
||||||
|
variableKeys: ["removed.variable"],
|
||||||
|
hiddenVariableKeys: [],
|
||||||
|
variableUnits: { "removed.variable": "Pa" },
|
||||||
|
x: 40,
|
||||||
|
y: 40,
|
||||||
|
width: 620,
|
||||||
|
height: 330,
|
||||||
|
unit: "Pa",
|
||||||
|
cursorEnabled: false,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}, chartWindowSnapshot.id);
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await openResults(page);
|
||||||
|
|
||||||
|
const pendingWindow = page.locator(
|
||||||
|
'.result-chart-window[data-chart-kind="multi"]',
|
||||||
|
);
|
||||||
|
await expect(pendingWindow).toHaveAttribute("data-pending", "true");
|
||||||
|
|
||||||
|
await page.locator(".results-variable-list button").first().dragTo(pendingWindow);
|
||||||
|
await expect(pendingWindow).toHaveAttribute("data-pending", "false");
|
||||||
|
});
|
||||||
Reference in new issue
Block a user