添加行为决策树管理的场景数据到节点数据的自动转换逻辑

This commit is contained in:
2026-04-14 17:27:31 +08:00
parent e1fe2cf7da
commit 5a8c707340
4 changed files with 456 additions and 191 deletions

View File

@@ -8,7 +8,7 @@
*/
import { HttpRequestClient } from '@/utils/request';
import type { Scenario, ScenarioDetailsResponse, ScenarioPageableResponse, ScenarioRequest } from './types';
import type { Scenario, ScenarioDetailsResponse, ScenarioPageableResponse, ScenarioRequest, CommunicationRelationsResponse } from './types';
import type { PlatformWithComponentsResponse } from '../types';
import type { BasicResponse } from '@/types';
@@ -32,6 +32,15 @@ export const findPlatformWithComponents = (id: number): Promise<PlatformWithComp
return req.get<PlatformWithComponentsResponse>(`/system/firerule/platforms/${id}`);
};
/**
* 获取场景的所有通信关系
* @param id 场景ID
* @returns 通信关系列表
*/
export const findRelations = (id: number): Promise<CommunicationRelationsResponse> => {
return req.get<CommunicationRelationsResponse>(`/system/scene/getAllRelation/${id}`);
};
export const saveScenario = (scenario: Scenario): Promise<BasicResponse> => {
return req.postJson<BasicResponse>(`/system/scene/saveSceneConfig`,scenario);
};

View File

@@ -72,7 +72,7 @@ import { createGraphScenarioElement, createGraphTaskElementFromScenario } from '
import PlatformCard from './platform-card.vue';
import NodesCard from './nodes-card.vue';
import { findOneScenarioById, saveScenario } from './api';
import { findOneScenarioById, saveScenario, findRelations } from './api';
import { resolveConnectionRelation } from './relation';
import { generateRandomCommunicationData } from './random-data-generator';
import { convertRecordsToGraphContainer, type CommunicationRecord } from './data-converter';
@@ -223,27 +223,77 @@ export default defineComponent({
}
};
const handleSelect = (scenario: Scenario) => {
const handleSelect = async (scenario: Scenario) => {
let nodeGraph: GraphContainer | null = null;
try {
nodeGraph = JSON.parse(scenario.communicationGraph as unknown as string) as unknown as GraphContainer;
} catch (e: any) {
console.error('parse error,cause:', e);
}
if (!nodeGraph) {
nodeGraph = {
nodes: [],
edges: [],
};
}
// 设置当前场景
currentScenario.value = {
...scenario,
graph: nodeGraph,
graph: nodeGraph || { nodes: [], edges: [] },
relations: []
};
console.log(currentScenario.value);
console.log('选中场景:', currentScenario.value);
currentScenarioEditing.value = true;
// 如果场景有ID且没有已保存的图数据尝试从后端加载通信关系
if (scenario.id > 0 && !nodeGraph) {
try {
message.loading({ content: '正在加载通信关系...', key: 'loading-relations' });
const response = await findRelations(scenario.id);
console.log('API完整响应:', response);
// 解析API响应支持多种格式
let relations: any[] = [];
if (Array.isArray(response.data)) {
relations = response.data;
} else if (response.data && Array.isArray((response.data as any).data)) {
relations = (response.data as any).data;
} else if (response.data && Array.isArray((response.data as any).rows)) {
relations = (response.data as any).rows;
} else if (response.data && Array.isArray((response.data as any).list)) {
relations = (response.data as any).list;
}
console.log('解析后的通信关系数量:', relations.length);
if (relations.length > 0) {
// 字段名标准化(驼峰转下划线)
const normalizedRelations = relations.map((item: any) => ({
id: item.id,
command_platform: item.commandPlatform || item.command_platform,
subordinate_platform: item.subordinatePlatform || item.subordinate_platform,
command_comm: item.commandComm || item.command_comm,
subordinate_comm: item.subordinateComm || item.subordinate_comm,
scenary_id: item.scenaryId || item.scenary_id,
}));
console.log('标准化后的第一条记录:', normalizedRelations[0]);
// 转换为图数据
const convertedGraph = convertRecordsToGraphContainer(normalizedRelations);
console.log('转换后的图数据:', convertedGraph);
// 更新当前场景的图数据
currentScenario.value.graph = convertedGraph;
currentScenario.value.communicationGraph = JSON.stringify(convertedGraph);
message.success({ content: `成功加载 ${normalizedRelations.length} 条通信关系`, key: 'loading-relations' });
} else {
message.warning({ content: '该场景暂无通信关系数据', key: 'loading-relations' });
}
} catch (error) {
console.error('从后端加载通信关系失败:', error);
message.error({ content: '加载通信关系失败,请手动点击"从后端加载"', key: 'loading-relations' });
}
}
createElements();
};
@@ -505,25 +555,69 @@ export default defineComponent({
try {
message.loading({ content: '正在加载通信关系数据...', key: 'loading' });
// TODO: 等待后端提供真实接口后替换为实际API调用
// const response = await findCommunicationRelations(currentScenario.value.id);
// const relations = response.data || [];
// 调用真实API获取通信关系
console.log(`正在从后端加载场景 ${currentScenario.value.id} 的通信关系...`);
const response = await findRelations(currentScenario.value.id);
// 临时使用模拟数据
console.log('使用模拟通信关系数据');
const mockRelations: CommunicationRecord[] = [
{ id: 1, command_platform: '指挥中心', subordinate_platform: '预警机', command_comm: undefined, subordinate_comm: undefined, scenary_id: currentScenario.value.id },
{ id: 2, command_platform: '指挥中心', subordinate_platform: '驱逐舰', command_comm: undefined, subordinate_comm: undefined, scenary_id: currentScenario.value.id },
{ id: 3, command_platform: '指挥中心', subordinate_platform: '战斗机', command_comm: undefined, subordinate_comm: undefined, scenary_id: currentScenario.value.id },
{ id: 4, command_platform: '预警机', subordinate_platform: '电子战飞机', command_comm: undefined, subordinate_comm: undefined, scenary_id: currentScenario.value.id },
{ id: 5, command_platform: '驱逐舰', subordinate_platform: '潜艇', command_comm: undefined, subordinate_comm: undefined, scenary_id: currentScenario.value.id },
{ id: 6, command_platform: '战斗机', subordinate_platform: '无人机', command_comm: undefined, subordinate_comm: undefined, scenary_id: currentScenario.value.id },
];
console.log('API完整响应:', response);
console.log('response.data类型:', typeof response.data, Array.isArray(response.data) ? 'Array' : 'Object');
// API返回的是 CommunicationRelationRecord[],与 CommunicationRecord 结构兼容
// 处理可能的多种返回格式
let relations: any[] = [];
if (Array.isArray(response.data)) {
relations = response.data;
} else if (response.data && Array.isArray((response.data as any).data)) {
relations = (response.data as any).data;
} else if (response.data && Array.isArray((response.data as any).rows)) {
relations = (response.data as any).rows;
} else if (response.data && Array.isArray((response.data as any).list)) {
relations = (response.data as any).list;
}
console.log('解析后的通信关系数量:', relations.length);
if (relations.length > 0) {
console.log('第一条记录:', JSON.stringify(relations[0], null, 2));
}
// 后端返回的是驼峰命名,需要转换为下划线命名以匹配前端类型
const normalizedRelations = relations.map((item: any) => ({
id: item.id,
command_platform: item.commandPlatform || item.command_platform,
subordinate_platform: item.subordinatePlatform || item.subordinate_platform,
command_comm: item.commandComm || item.command_comm,
subordinate_comm: item.subordinateComm || item.subordinate_comm,
scenary_id: item.scenaryId || item.scenary_id,
}));
console.log('标准化后的第一条记录:', normalizedRelations[0]);
if (normalizedRelations.length === 0) {
console.warn('API未返回任何通信关系数据使用模拟数据作为fallback');
// Fallback到模拟数据保留以便测试
relations.push(
{ id: 6, command_platform: 'chief', subordinate_platform: 'task1_commander', command_comm: 'radio', subordinate_comm: 'radio', scenary_id: currentScenario.value.id },
{ id: 7, command_platform: 'chief', subordinate_platform: 'task2_commander', command_comm: 'radio', subordinate_comm: 'radio', scenary_id: currentScenario.value.id },
{ id: 8, command_platform: 'chief', subordinate_platform: 'task3_commander', command_comm: 'radio', subordinate_comm: 'radio', scenary_id: currentScenario.value.id },
{ id: 9, command_platform: 'task1_commander', subordinate_platform: 'platform1', command_comm: 'radio', subordinate_comm: 'radio', scenary_id: currentScenario.value.id },
{ id: 10, command_platform: 'task1_commander', subordinate_platform: 'platform3', command_comm: 'radio', subordinate_comm: 'radio', scenary_id: currentScenario.value.id },
{ id: 11, command_platform: 'task1_commander', subordinate_platform: 'platform4', command_comm: 'radio', subordinate_comm: 'radio', scenary_id: currentScenario.value.id },
{ id: 12, command_platform: 'task1_commander', subordinate_platform: 'platform5', command_comm: 'radio', subordinate_comm: 'radio', scenary_id: currentScenario.value.id },
{ id: 13, command_platform: 'task1_commander', subordinate_platform: 'platform6', command_comm: 'radio', subordinate_comm: 'radio', scenary_id: currentScenario.value.id },
{ id: 14, command_platform: 'task2_commander', subordinate_platform: 'platform3', command_comm: 'radio', subordinate_comm: 'radio', scenary_id: currentScenario.value.id },
{ id: 15, command_platform: 'task2_commander', subordinate_platform: 'platform5', command_comm: 'radio', subordinate_comm: 'radio', scenary_id: currentScenario.value.id },
{ id: 16, command_platform: 'task2_commander', subordinate_platform: 'platform4', command_comm: 'radio', subordinate_comm: 'radio', scenary_id: currentScenario.value.id },
{ id: 17, command_platform: 'task3_commander', subordinate_platform: 'platform6', command_comm: 'radio', subordinate_comm: 'radio', scenary_id: currentScenario.value.id },
{ id: 18, command_platform: 'task3_commander', subordinate_platform: 'platform6', command_comm: 'radio', subordinate_comm: 'radio', scenary_id: currentScenario.value.id },
{ id: 19, command_platform: 'task3_commander', subordinate_platform: 'platform7', command_comm: 'radio', subordinate_comm: 'radio', scenary_id: currentScenario.value.id },
{ id: 20, command_platform: 'task3_commander', subordinate_platform: 'platform8', command_comm: 'radio', subordinate_comm: 'radio', scenary_id: currentScenario.value.id },
);
}
console.log('模拟的通信记录:', mockRelations);
console.log('最终使用的通信记录:', normalizedRelations);
// 使用模拟数据进行转换
const convertedGraph = convertRecordsToGraphContainer(mockRelations);
// 使用数据进行转换
const convertedGraph = convertRecordsToGraphContainer(normalizedRelations);
console.log('转换后的图数据:', convertedGraph);

View File

@@ -12,6 +12,22 @@ import { generateKey } from '@/utils/strings';
// ==================== 类型定义 ====================
/**
* 布局方向类型
*/
export type LayoutDirection = 'horizontal' | 'vertical';
/**
* 布局配置接口
*/
export interface LayoutConfig {
direction: LayoutDirection;
rootPosition?: number; // 根节点起始位置
colSpacing?: number; // 列间距
rowSpacing?: number; // 行间距
startPosition?: number; // 起始位置
}
/**
* 数据库通信关系记录接口
*/
@@ -30,10 +46,11 @@ interface ParentChildMappings {
}
interface LayoutParams {
ROOT_X: number;
COL_SPACING: number;
MIN_ROW_SPACING: number;
START_Y: number;
ROOT_POSITION: number; // 根节点起始位置(horizontal时为X, vertical时为Y)
LEVEL_SPACING: number; // 层级间距(horizontal时为X间距, vertical时为Y间距)
SIBLING_SPACING: number; // 同级间距(horizontal时为Y间距, vertical时为X间距)
START_POSITION: number; // 起始位置(horizontal时为Y, vertical时为X)
direction: LayoutDirection;
}
interface NodePosition {
@@ -42,31 +59,107 @@ interface NodePosition {
}
interface SubtreeRange {
minY: number;
maxY: number;
min: number;
max: number;
}
/**
* 坐标映射器接口 - 用于抽象水平和垂直布局的坐标转换
*/
interface CoordinateMapper {
/** 将层级索引映射到主轴坐标 */
mapLevelToAxis(level: number): number;
/** 将同级索引映射到次轴坐标 */
mapSiblingIndexToAxis(index: number, totalInLevel?: number): number;
/** 获取节点的主轴坐标 */
getPrimaryCoord(pos: NodePosition): number;
/** 获取节点的次轴坐标 */
getSecondaryCoord(pos: NodePosition): number;
/** 设置节点坐标 */
setCoords(primary: number, secondary: number): NodePosition;
/** 获取子树范围的主轴坐标 */
getSubtreePrimaryCoord(range: SubtreeRange): { min: number; max: number };
/** 创建子树范围对象 */
createSubtreeRange(min: number, max: number): SubtreeRange;
}
/**
* 创建水平布局的坐标映射器 (X轴表示层级,Y轴表示同级分布)
*/
const createHorizontalMapper = (params: LayoutParams): CoordinateMapper => ({
mapLevelToAxis: (level) => params.ROOT_POSITION + (level + 1) * params.LEVEL_SPACING,
mapSiblingIndexToAxis: (index, totalInLevel) => {
// 均匀分布在START_POSITION开始的区域内
return params.START_POSITION + index * params.SIBLING_SPACING + params.SIBLING_SPACING / 2;
},
getPrimaryCoord: (pos) => pos.x,
getSecondaryCoord: (pos) => pos.y,
setCoords: (primary, secondary) => ({ x: primary, y: secondary }),
getSubtreePrimaryCoord: (range) => ({ min: range.min, max: range.max }),
createSubtreeRange: (min, max) => ({ min, max }),
});
/**
* 创建垂直布局的坐标映射器 (Y轴表示层级,X轴表示同级分布)
*/
const createVerticalMapper = (params: LayoutParams): CoordinateMapper => ({
mapLevelToAxis: (level) => params.ROOT_POSITION + (level + 1) * params.LEVEL_SPACING,
mapSiblingIndexToAxis: (index, totalInLevel) => {
// 均匀分布在START_POSITION开始的区域内
return params.START_POSITION + index * params.SIBLING_SPACING + params.SIBLING_SPACING / 2;
},
getPrimaryCoord: (pos) => pos.y,
getSecondaryCoord: (pos) => pos.x,
setCoords: (primary, secondary) => ({ x: secondary, y: primary }),
getSubtreePrimaryCoord: (range) => ({ min: range.min, max: range.max }),
createSubtreeRange: (min, max) => ({ min, max }),
});
// ==================== 主函数:数据转换 ====================
/**
* 从数据库记录转换为 GraphContainer 格式
* @param records 数据库通信关系记录数组
* @param config 布局配置(可选),默认为水平布局
* @returns GraphContainer 格式的节点和边数据
*/
export const convertRecordsToGraphContainer = (records: CommunicationRecord[]): GraphContainer => {
export const convertRecordsToGraphContainer = (
records: CommunicationRecord[],
config?: Partial<LayoutConfig>
): GraphContainer => {
if (!records || records.length === 0) {
return { nodes: [], edges: [] };
}
// 收集所有唯一的平台名称
// 合并默认配置
const finalConfig: LayoutConfig = {
direction: config?.direction || 'horizontal',
rootPosition: typeof config?.rootPosition === 'number' ? config.rootPosition : 50,
colSpacing: typeof config?.colSpacing === 'number' ? config.colSpacing : 280,
rowSpacing: typeof config?.rowSpacing === 'number' ? config.rowSpacing : 140,
startPosition: typeof config?.startPosition === 'number' ? config.startPosition : 80,
};
// 收集所有唯一的平台名称(过滤无效值)
const platformSet = new Set<string>();
records.forEach(record => {
platformSet.add(record.command_platform);
platformSet.add(record.subordinate_platform);
if (record.command_platform && typeof record.command_platform === 'string' && record.command_platform.trim()) {
platformSet.add(record.command_platform);
}
if (record.subordinate_platform && typeof record.subordinate_platform === 'string' && record.subordinate_platform.trim()) {
platformSet.add(record.subordinate_platform);
}
});
const allPlatforms = Array.from(platformSet);
console.log(`收集到 ${allPlatforms.length} 个唯一平台:`, allPlatforms);
if (allPlatforms.length === 0) {
console.warn('没有有效的平台数据');
return { nodes: [], edges: [] };
}
// 构建父子映射关系
const { parentToChildren, childToParent } = buildParentChildMappings(records);
@@ -79,13 +172,11 @@ export const convertRecordsToGraphContainer = (records: CommunicationRecord[]):
const allNodes: GraphTaskElement[] = [];
const allEdges: GraphEdgeElement[] = [];
let xOffset = 50; // 第一个分量的起始X坐标
let offset: number = finalConfig.rootPosition ?? 50; // 第一个分量的起始位置
connectedComponents.forEach((component, componentIndex) => {
console.log(`处理第 ${componentIndex + 1} 个连通分量,包含 ${component.size} 个节点`);
// 为该分量确定根节点
const rootPlatformName = determineRootNode(Array.from(component));
// 为该分量确定根节点选择入度为0的节点
const rootPlatformName = determineRootNode(Array.from(component), childToParent);
if (!rootPlatformName) {
console.warn(`${componentIndex + 1} 个分量未找到根节点,跳过`);
@@ -100,29 +191,48 @@ export const convertRecordsToGraphContainer = (records: CommunicationRecord[]):
childToParent
);
console.log(` 分量 ${componentIndex + 1} 的层级映射:`, Object.fromEntries(levelMap));
// 按层级分组
const levelsMap = groupNodesByLevel(levelMap);
// 布局参数 - 紧凑型布局
console.log(` 分量 ${componentIndex + 1} 的层级分组:`, Object.fromEntries(levelsMap));
// 根据方向创建布局参数
const layoutParams: LayoutParams = {
ROOT_X: xOffset, // 当前分量的起始X坐标
COL_SPACING: 280, // 列间距
MIN_ROW_SPACING: 140, // 最小行间距
START_Y: 80, // 起始Y坐标
ROOT_POSITION: offset ?? 50,
LEVEL_SPACING: finalConfig.colSpacing ?? 280,
SIBLING_SPACING: finalConfig.rowSpacing ?? 140,
START_POSITION: finalConfig.startPosition ?? 80,
direction: finalConfig.direction,
};
// 创建坐标映射器
const mapper = finalConfig.direction === 'horizontal'
? createHorizontalMapper(layoutParams)
: createVerticalMapper(layoutParams);
// 计算节点位置
const nodePositions = calculateNodePositions(
levelsMap,
parentToChildren,
layoutParams
parentToChildren,
childToParent,
layoutParams,
mapper
);
// 创建节点对象
const nodeMap = createNodeObjects(levelsMap, nodePositions);
// 创建根节点
createRootNode(nodeMap, rootPlatformName, parentToChildren);
console.log(` 分量 ${componentIndex + 1} 创建了 ${nodeMap.size} 个节点:`, Array.from(nodeMap.keys()));
// 创建根节点(如果不存在则添加)
if (!nodeMap.has(rootPlatformName)) {
console.warn(` 根节点 "${rootPlatformName}" 未在 levelsMap 中找到,手动创建`);
}
createRootNode(nodeMap, rootPlatformName, parentToChildren, layoutParams, mapper);
console.log(` 最终节点数: ${nodeMap.size}`);
// 过滤出属于当前分量的边
const componentEdges = filterEdgesForComponent(records, component);
@@ -134,7 +244,7 @@ export const convertRecordsToGraphContainer = (records: CommunicationRecord[]):
// 计算下一个分量的偏移量(基于当前分量的宽度)
const maxLevel = Math.max(...Array.from(levelsMap.keys()));
xOffset += (maxLevel + 1) * layoutParams.COL_SPACING + 150; // 分量之间留150px间隔
offset += (maxLevel + 1) * layoutParams.LEVEL_SPACING + 150; // 分量之间留150px间隔
});
return {
@@ -153,6 +263,9 @@ const findConnectedComponents = (
parentToChildren: Map<string, string[]>,
childToParent: Map<string, string>
): Set<string>[] => {
// 过滤掉undefined、null和空字符串
const validPlatforms = platforms.filter(p => p && typeof p === 'string' && p.trim().length > 0);
const visited = new Set<string>();
const components: Set<string>[] = [];
@@ -165,21 +278,28 @@ const findConnectedComponents = (
// 访问子节点
const children = parentToChildren.get(platform) || [];
children.forEach(child => dfs(child, component));
children.forEach(child => {
// 只处理有效的子节点
if (child && typeof child === 'string' && child.trim().length > 0) {
dfs(child, component);
}
});
// 访问父节点
const parent = childToParent.get(platform);
if (parent && !visited.has(parent)) {
if (parent && !visited.has(parent) && typeof parent === 'string' && parent.trim().length > 0) {
dfs(parent, component);
}
};
// 对每个未访问的节点启动一次DFS
platforms.forEach(platform => {
// 对每个未访问的有效节点启动一次DFS
validPlatforms.forEach(platform => {
if (!visited.has(platform)) {
const component = new Set<string>();
dfs(platform, component);
components.push(component);
if (component.size > 0) {
components.push(component);
}
}
});
@@ -188,18 +308,26 @@ const findConnectedComponents = (
/**
* 为连通分量确定根节点
* 优先级1. 包含"指挥"关键词 2. 入度为0的节点 3. 第一个节点
* 策略选择入度为0的节点没有父节点的节点如果有多个则选择第一个
*/
const determineRootNode = (platforms: string[]): string | null => {
if (platforms.length === 0) return null;
const determineRootNode = (
platforms: string[],
childToParent: Map<string, string>
): string | null => {
// 过滤掉undefined、null和空字符串
const validPlatforms = platforms.filter(p => p && typeof p === 'string' && p.trim().length > 0);
// 优先选择包含"指挥"的节点
const commandNode = platforms.find(p => p.includes('指挥'));
if (commandNode) return commandNode;
if (validPlatforms.length === 0) return null;
// 其次选择入度为0的节点没有父节点
// 这里简化处理,直接返回第一个
return platforms[0] || null;
// 优先选择入度为0的节点没有父节点
const rootCandidates = validPlatforms.filter(platform => !childToParent.has(platform));
if (rootCandidates.length > 0) {
return rootCandidates[0] || null;
}
// 如果所有节点都有父节点(可能是环形结构),返回第一个有效节点
return validPlatforms[0] || null;
};
/**
@@ -244,16 +372,16 @@ const computeNodeLevels = (
const processed = new Set<string>();
const queue: Array<{ platform: string; level: number }> = [];
// 分离根节点和子节
const childPlatforms = platforms.filter(p => p !== rootPlatformName);
// 找出所有入度为0的节点没有父节点的节点作为BFS的起
const rootNodes = platforms.filter(p => !childToParent.has(p));
// 第一层:直接与根节点相连的节点
childPlatforms.forEach(platform => {
if (childToParent.get(platform) === rootPlatformName) {
levelMap.set(platform, 0);
processed.add(platform);
queue.push({ platform, level: 0 });
}
console.log(` BFS起点入度为0的节点: [${rootNodes.join(', ')}]`);
// 将所有根节点加入队列层级为0
rootNodes.forEach(root => {
levelMap.set(root, 0);
processed.add(root);
queue.push({ platform: root, level: 0 });
});
// BFS遍历后续层级
@@ -270,13 +398,23 @@ const computeNodeLevels = (
});
}
// 处理未连接的孤立节点
childPlatforms.forEach(platform => {
if (!processed.has(platform)) {
const maxLevel = Math.max(...Array.from(levelMap.values()), -1);
levelMap.set(platform, maxLevel + 1);
}
});
// 理论上不应该有未处理的节点,因为所有节点都应该从某个根节点可达
// 如果有,说明图中存在环或者数据有问题
const unprocessed = platforms.filter(p => !processed.has(p));
if (unprocessed.length > 0) {
console.warn(` 警告以下节点未被BFS遍历到可能存在环或数据问题: [${unprocessed.join(', ')}]`);
// 给这些节点分配一个合理的层级(基于它们的父节点)
unprocessed.forEach(platform => {
const parent = childToParent.get(platform);
if (parent && levelMap.has(parent)) {
levelMap.set(platform, levelMap.get(parent)! + 1);
} else {
// 如果连父节点都没有,放到最后一层
const maxLevel = Math.max(...Array.from(levelMap.values()), 0);
levelMap.set(platform, maxLevel + 1);
}
});
}
return levelMap;
};
@@ -299,25 +437,30 @@ const groupNodesByLevel = (levelMap: Map<string, number>): Map<number, string[]>
const calculateNodePositions = (
levelsMap: Map<number, string[]>,
parentToChildren: Map<string, string[]>,
params: LayoutParams
childToParent: Map<string, string>,
params: LayoutParams,
mapper: CoordinateMapper
): Map<string, NodePosition> => {
const nodePositions = new Map<string, NodePosition>();
const subtreeRanges = new Map<string, SubtreeRange>();
// 第一步:预计算每列需要的总高度
precomputeColumnHeights(levelsMap, parentToChildren, params.MIN_ROW_SPACING, subtreeRanges);
precomputeColumnHeights(levelsMap, parentToChildren, params.SIBLING_SPACING, subtreeRanges, mapper);
// 第二步:初步分配位置
initialPositionAssignment(levelsMap, parentToChildren, params, subtreeRanges, nodePositions);
initialPositionAssignment(levelsMap, parentToChildren, params, subtreeRanges, nodePositions, mapper);
// 第三步:调整父节点使其位于子节点中心
adjustParentsToCenterOfChildren(levelsMap, parentToChildren, nodePositions);
// 第三步:调整父节点使其位于子节点中心(处理共享子节点情况)
adjustParentsToCenterOfChildren(levelsMap, parentToChildren, childToParent, nodePositions, mapper);
// 第四步:同级节点防重叠检测与修正
preventOverlapsWithinSameLevel(levelsMap, parentToChildren, params.MIN_ROW_SPACING, nodePositions);
// 第四步:全局防重叠检测与修正(跨所有层级)
preventGlobalOverlaps(parentToChildren, params.SIBLING_SPACING, nodePositions, mapper);
// 第五步:重新调整父节点恢复等腰三角形
restoreIsoscelesTriangles(levelsMap, parentToChildren, nodePositions);
restoreIsoscelesTriangles(levelsMap, parentToChildren, nodePositions, mapper);
// 第六步:最终全局防重叠检查
preventGlobalOverlaps(parentToChildren, params.SIBLING_SPACING, nodePositions, mapper);
return nodePositions;
};
@@ -326,90 +469,68 @@ const calculateNodePositions = (
const precomputeColumnHeights = (
levelsMap: Map<number, string[]>,
parentToChildren: Map<string, string[]>,
minRowSpacing: number,
subtreeRanges: Map<string, SubtreeRange>
siblingSpacing: number,
subtreeRanges: Map<string, SubtreeRange>,
mapper: CoordinateMapper
): void => {
const sortedLevels = Array.from(levelsMap.keys()).sort((a, b) => b - a);
sortedLevels.forEach(level => {
const platformsInLevel = levelsMap.get(level)!;
let totalHeight = 0;
let totalSize = 0;
platformsInLevel.forEach(platform => {
const children = parentToChildren.get(platform) || [];
if (children.length > 0) {
let maxSubtreeHeight = 0;
let maxSubtreeSize = 0;
children.forEach(child => {
const childRange = subtreeRanges.get(child);
if (childRange) {
const childHeight = childRange.maxY - childRange.minY;
maxSubtreeHeight = Math.max(maxSubtreeHeight, childHeight);
const range = mapper.getSubtreePrimaryCoord(childRange);
const childSize = range.max - range.min;
maxSubtreeSize = Math.max(maxSubtreeSize, childSize);
}
});
const extraSpace = children.length > 1 ? (children.length - 1) * minRowSpacing : 0;
const subtreeHeight = maxSubtreeHeight + extraSpace;
const subtreeSize = maxSubtreeSize;
totalHeight += Math.max(minRowSpacing, subtreeHeight);
subtreeRanges.set(platform, {
minY: totalHeight - subtreeHeight,
maxY: totalHeight
});
totalSize += Math.max(siblingSpacing, subtreeSize);
subtreeRanges.set(platform, mapper.createSubtreeRange(
totalSize - subtreeSize,
totalSize
));
} else {
totalHeight += minRowSpacing;
subtreeRanges.set(platform, {
minY: totalHeight - minRowSpacing,
maxY: totalHeight
});
totalSize += siblingSpacing;
subtreeRanges.set(platform, mapper.createSubtreeRange(
totalSize - siblingSpacing,
totalSize
));
}
});
});
};
// 初步分配位置
// 初步分配位置(简化版:所有节点均匀分布,不考虑子树高度)
const initialPositionAssignment = (
levelsMap: Map<number, string[]>,
parentToChildren: Map<string, string[]>,
params: LayoutParams,
subtreeRanges: Map<string, SubtreeRange>,
nodePositions: Map<string, NodePosition>
nodePositions: Map<string, NodePosition>,
mapper: CoordinateMapper
): void => {
const forwardLevels = Array.from(levelsMap.keys()).sort((a, b) => a - b);
const columnOffsets = new Map<number, number>();
forwardLevels.forEach(level => {
const platformsInLevel = levelsMap.get(level)!;
const x = params.ROOT_X + (level + 1) * params.COL_SPACING;
let currentYOffset = columnOffsets.get(level) || params.START_Y;
platformsInLevel.forEach(platform => {
const children = parentToChildren.get(platform) || [];
let y: number;
if (children.length > 0) {
// 有子节点的父节点:根据子树高度计算位置
const subtreeRange = subtreeRanges.get(platform);
if (subtreeRange) {
const subtreeHeight = subtreeRange.maxY - subtreeRange.minY;
y = currentYOffset + subtreeHeight / 2;
// 重要:为下一个节点预留足够的垂直空间
currentYOffset += Math.max(subtreeHeight, params.MIN_ROW_SPACING);
} else {
y = currentYOffset + params.MIN_ROW_SPACING / 2;
currentYOffset += params.MIN_ROW_SPACING;
}
} else {
// 叶子节点:占用固定空间
y = currentYOffset + params.MIN_ROW_SPACING / 2;
currentYOffset += params.MIN_ROW_SPACING;
}
nodePositions.set(platform, { x, y });
platformsInLevel.forEach((platform, index) => {
const primaryCoord = mapper.mapLevelToAxis(level);
const secondaryCoord = mapper.mapSiblingIndexToAxis(index, platformsInLevel.length);
nodePositions.set(platform, mapper.setCoords(primaryCoord, secondaryCoord));
});
columnOffsets.set(level, currentYOffset);
});
};
@@ -417,10 +538,13 @@ const initialPositionAssignment = (
const adjustParentsToCenterOfChildren = (
levelsMap: Map<number, string[]>,
parentToChildren: Map<string, string[]>,
nodePositions: Map<string, NodePosition>
childToParent: Map<string, string>,
nodePositions: Map<string, NodePosition>,
mapper: CoordinateMapper
): void => {
const forwardLevels = Array.from(levelsMap.keys()).sort((a, b) => a - b);
// 从最底层向上遍历,确保子节点位置已经确定后再调整父节点
[...forwardLevels].reverse().forEach(level => {
const platformsInLevel = levelsMap.get(level)!;
@@ -431,64 +555,78 @@ const adjustParentsToCenterOfChildren = (
const childPositions = children.map(child => nodePositions.get(child)).filter(Boolean);
if (childPositions.length > 0) {
const avgChildY = childPositions.reduce((sum, pos) => sum + pos!.y, 0) / childPositions.length;
// 计算所有子节点的平均次轴坐标垂直布局时是X水平布局时是Y
const avgSecondaryCoord = childPositions.reduce((sum, pos) =>
sum + mapper.getSecondaryCoord(pos!), 0) / childPositions.length;
// 将父节点的次轴坐标移动到子节点的中心
const pos = nodePositions.get(platform)!;
pos.y = avgChildY;
nodePositions.set(platform, pos);
const primaryCoord = mapper.getPrimaryCoord(pos);
nodePositions.set(platform, mapper.setCoords(primaryCoord, avgSecondaryCoord));
}
}
});
});
};
// 防重叠检测与修正(迭代式确保完全无重叠)
const preventOverlapsWithinSameLevel = (
levelsMap: Map<number, string[]>,
// 全局防重叠检测与修正(跨所有层级,迭代式确保完全无重叠)
const preventGlobalOverlaps = (
parentToChildren: Map<string, string[]>,
minRowSpacing: number,
nodePositions: Map<string, NodePosition>
siblingSpacing: number,
nodePositions: Map<string, NodePosition>,
mapper: CoordinateMapper
): void => {
const forwardLevels = Array.from(levelsMap.keys()).sort((a, b) => a - b);
// 收集所有节点并按主轴坐标分组(同层的节点)
const nodesByLevel = new Map<number, Array<{ platform: string; pos: NodePosition }>>();
forwardLevels.forEach(level => {
const platformsInLevel = levelsMap.get(level)!;
// 迭代检测直到没有重叠
nodePositions.forEach((pos, platform) => {
const primaryCoord = mapper.getPrimaryCoord(pos);
if (!nodesByLevel.has(primaryCoord)) {
nodesByLevel.set(primaryCoord, []);
}
nodesByLevel.get(primaryCoord)!.push({ platform, pos });
});
// 对每一层进行防重叠处理
nodesByLevel.forEach(nodes => {
let hasOverlap = true;
let iterations = 0;
const maxIterations = 20; // 防止无限循环
const maxIterations = 30; // 防止无限循环
while (hasOverlap && iterations < maxIterations) {
hasOverlap = false;
iterations++;
// 每次都重新按Y坐标排序
const sortedByY = platformsInLevel
.map(p => ({ platform: p, pos: nodePositions.get(p)! }))
.sort((a, b) => a.pos.y - b.pos.y);
// 按次轴坐标排序
nodes.sort((a, b) => mapper.getSecondaryCoord(a.pos) - mapper.getSecondaryCoord(b.pos));
for (let i = 1; i < sortedByY.length; i++) {
const prev = sortedByY[i - 1];
const curr = sortedByY[i];
for (let i = 1; i < nodes.length; i++) {
const prev = nodes[i - 1];
const curr = nodes[i];
if (!prev || !curr) continue;
const actualDistance = curr.pos.y - prev.pos.y;
const actualDistance = mapper.getSecondaryCoord(curr.pos) - mapper.getSecondaryCoord(prev.pos);
if (actualDistance < minRowSpacing) {
if (actualDistance < siblingSpacing) {
hasOverlap = true;
const offset = minRowSpacing - actualDistance;
curr.pos.y += offset;
const offset = siblingSpacing - actualDistance;
const currSecondary = mapper.getSecondaryCoord(curr.pos);
const currPrimary = mapper.getPrimaryCoord(curr.pos);
curr.pos = mapper.setCoords(currPrimary, currSecondary + offset);
// 递归调整所有后代节点
const adjustDescendants = (platform: string, yOffset: number) => {
const adjustDescendants = (platform: string, secondaryOffset: number) => {
const children = parentToChildren.get(platform) || [];
children.forEach(child => {
const childPos = nodePositions.get(child);
if (childPos) {
childPos.y += yOffset;
adjustDescendants(child, yOffset);
const childPrimary = mapper.getPrimaryCoord(childPos);
const childSecondary = mapper.getSecondaryCoord(childPos);
const newPos = mapper.setCoords(childPrimary, childSecondary + secondaryOffset);
nodePositions.set(child, newPos);
adjustDescendants(child, secondaryOffset);
}
});
};
@@ -502,7 +640,7 @@ const preventOverlapsWithinSameLevel = (
}
if (iterations >= maxIterations) {
console.warn(`Level ${level}: 防重叠达到最大迭代次数(${maxIterations}),可能存在未解决的重叠`);
console.warn(`某层防重叠达到最大迭代次数(${maxIterations}),可能存在未解决的重叠`);
}
});
};
@@ -511,7 +649,8 @@ const preventOverlapsWithinSameLevel = (
const restoreIsoscelesTriangles = (
levelsMap: Map<number, string[]>,
parentToChildren: Map<string, string[]>,
nodePositions: Map<string, NodePosition>
nodePositions: Map<string, NodePosition>,
mapper: CoordinateMapper
): void => {
const forwardLevels = Array.from(levelsMap.keys()).sort((a, b) => a - b);
@@ -525,10 +664,11 @@ const restoreIsoscelesTriangles = (
const childPositions = children.map(child => nodePositions.get(child)).filter(Boolean);
if (childPositions.length > 0) {
const avgChildY = childPositions.reduce((sum, pos) => sum + pos!.y, 0) / childPositions.length;
const avgSecondaryCoord = childPositions.reduce((sum, pos) =>
sum + mapper.getSecondaryCoord(pos!), 0) / childPositions.length;
const pos = nodePositions.get(platform)!;
pos.y = avgChildY;
nodePositions.set(platform, pos);
const primaryCoord = mapper.getPrimaryCoord(pos);
nodePositions.set(platform, mapper.setCoords(primaryCoord, avgSecondaryCoord));
}
}
});
@@ -594,32 +734,36 @@ const createNodeObjects = (
const createRootNode = (
nodeMap: Map<string, GraphTaskElement>,
rootPlatformName: string,
parentToChildren: Map<string, string[]>
parentToChildren: Map<string, string[]>,
params: LayoutParams,
mapper: CoordinateMapper
): void => {
if (nodeMap.size === 0 || !rootPlatformName) return;
const directChildren = parentToChildren.get(rootPlatformName) || [];
let centerY: number;
let centerSecondaryCoord: number;
if (directChildren.length > 0) {
const childYPositions = directChildren.map(child => {
const childSecondaryPositions = directChildren.map(child => {
const childNode = nodeMap.get(child);
return childNode ? childNode.position.y : 0;
}).filter(y => y > 0);
return childNode ? mapper.getSecondaryCoord(childNode.position) : 0;
}).filter(coord => coord > 0);
if (childYPositions.length > 0) {
centerY = childYPositions.reduce((sum, y) => sum + y, 0) / childYPositions.length;
if (childSecondaryPositions.length > 0) {
centerSecondaryCoord = childSecondaryPositions.reduce((sum, coord) => sum + coord, 0) / childSecondaryPositions.length;
} else {
const allNodes = Array.from(nodeMap.values());
const minY = Math.min(...allNodes.map(n => n.position.y));
const maxY = Math.max(...allNodes.map(n => n.position.y));
centerY = (minY + maxY) / 2;
const coords = allNodes.map(n => mapper.getSecondaryCoord(n.position));
const minCoord = Math.min(...coords);
const maxCoord = Math.max(...coords);
centerSecondaryCoord = (minCoord + maxCoord) / 2;
}
} else {
const allNodes = Array.from(nodeMap.values());
const minY = Math.min(...allNodes.map(n => n.position.y));
const maxY = Math.max(...allNodes.map(n => n.position.y));
centerY = (minY + maxY) / 2;
const coords = allNodes.map(n => mapper.getSecondaryCoord(n.position));
const minCoord = Math.min(...coords);
const maxCoord = Math.max(...coords);
centerSecondaryCoord = (minCoord + maxCoord) / 2;
}
const rootNode: GraphTaskElement = {
@@ -644,10 +788,7 @@ const createRootNode = (
group: null,
description: rootPlatformName,
order: 0,
position: {
x: 50, // ROOT_X
y: Math.round(centerY),
},
position: mapper.setCoords(params.ROOT_POSITION, Math.round(centerSecondaryCoord)),
width: 250,
height: 145,
inputs: null,
@@ -895,12 +1036,14 @@ const createStarConnection = (
* 便捷函数:直接将平台列表转换为图容器
* @param platforms 平台列表
* @param strategy 推断策略
* @param layoutConfig 布局配置(可选)
* @returns 图容器对象
*/
export const convertPlatformsToGraphContainer = (
platforms: PlatformWithComponents[],
strategy: InferenceStrategy = 'by-name-pattern'
strategy: InferenceStrategy = 'by-name-pattern',
layoutConfig?: Partial<LayoutConfig>
): GraphContainer => {
const records = convertPlatformsToCommunicationRecords(platforms, strategy);
return convertRecordsToGraphContainer(records);
return convertRecordsToGraphContainer(records, layoutConfig);
};

View File

@@ -48,3 +48,22 @@ export interface ScenarioDetailsResponse extends ApiDataResponse<Scenario> {
}
/**
* 通信关系记录(对应数据库表结构)
*/
export interface CommunicationRelationRecord {
id: number;
command_platform: string; // 指挥平台名称
subordinate_platform: string; // 下属平台名称
command_comm?: string; // 指挥端通信方式
subordinate_comm?: string; // 下属端通信方式
scenary_id?: number; // 场景ID
}
/**
* 获取场景所有通信关系的响应
*/
export interface CommunicationRelationsResponse extends ApiDataResponse<CommunicationRelationRecord[]> {
}