From 5a8c7073409fbb8d649c3bda70ed7b79a4625dbb Mon Sep 17 00:00:00 2001 From: yitaikarma Date: Tue, 14 Apr 2026 17:27:31 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=A1=8C=E4=B8=BA=E5=86=B3?= =?UTF-8?q?=E7=AD=96=E6=A0=91=E7=AE=A1=E7=90=86=E7=9A=84=E5=9C=BA=E6=99=AF?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=88=B0=E8=8A=82=E7=82=B9=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=9A=84=E8=87=AA=E5=8A=A8=E8=BD=AC=E6=8D=A2=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/views/decision/communication/api.ts | 11 +- .../decision/communication/communication.vue | 146 +++++- .../decision/communication/data-converter.ts | 471 ++++++++++++------ .../src/views/decision/communication/types.ts | 19 + 4 files changed, 456 insertions(+), 191 deletions(-) diff --git a/modeler/src/views/decision/communication/api.ts b/modeler/src/views/decision/communication/api.ts index 65a6f1d..5019ae5 100644 --- a/modeler/src/views/decision/communication/api.ts +++ b/modeler/src/views/decision/communication/api.ts @@ -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(`/system/firerule/platforms/${id}`); }; +/** + * 获取场景的所有通信关系 + * @param id 场景ID + * @returns 通信关系列表 + */ +export const findRelations = (id: number): Promise => { + return req.get(`/system/scene/getAllRelation/${id}`); +}; + export const saveScenario = (scenario: Scenario): Promise => { return req.postJson(`/system/scene/saveSceneConfig`,scenario); }; \ No newline at end of file diff --git a/modeler/src/views/decision/communication/communication.vue b/modeler/src/views/decision/communication/communication.vue index b5b6549..2c6a436 100644 --- a/modeler/src/views/decision/communication/communication.vue +++ b/modeler/src/views/decision/communication/communication.vue @@ -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); diff --git a/modeler/src/views/decision/communication/data-converter.ts b/modeler/src/views/decision/communication/data-converter.ts index e5b55d8..01b0880 100644 --- a/modeler/src/views/decision/communication/data-converter.ts +++ b/modeler/src/views/decision/communication/data-converter.ts @@ -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 +): 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(); 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, childToParent: Map ): Set[] => { + // 过滤掉undefined、null和空字符串 + const validPlatforms = platforms.filter(p => p && typeof p === 'string' && p.trim().length > 0); + const visited = new Set(); const components: Set[] = []; @@ -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(); 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 | 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(); 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): Map const calculateNodePositions = ( levelsMap: Map, parentToChildren: Map, - params: LayoutParams + childToParent: Map, + params: LayoutParams, + mapper: CoordinateMapper ): Map => { const nodePositions = new Map(); const subtreeRanges = new Map(); // 第一步:预计算每列需要的总高度 - 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, parentToChildren: Map, - minRowSpacing: number, - subtreeRanges: Map + siblingSpacing: number, + subtreeRanges: Map, + 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, parentToChildren: Map, params: LayoutParams, subtreeRanges: Map, - nodePositions: Map + nodePositions: Map, + mapper: CoordinateMapper ): void => { const forwardLevels = Array.from(levelsMap.keys()).sort((a, b) => a - b); - const columnOffsets = new Map(); 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, parentToChildren: Map, - nodePositions: Map + childToParent: Map, + nodePositions: Map, + 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, +// 全局防重叠检测与修正(跨所有层级,迭代式确保完全无重叠) +const preventGlobalOverlaps = ( parentToChildren: Map, - minRowSpacing: number, - nodePositions: Map + siblingSpacing: number, + nodePositions: Map, + mapper: CoordinateMapper ): void => { - const forwardLevels = Array.from(levelsMap.keys()).sort((a, b) => a - b); + // 收集所有节点并按主轴坐标分组(同层的节点) + const nodesByLevel = new Map>(); - 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, parentToChildren: Map, - nodePositions: Map + nodePositions: Map, + 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, rootPlatformName: string, - parentToChildren: Map + parentToChildren: Map, + 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 ): GraphContainer => { const records = convertPlatformsToCommunicationRecords(platforms, strategy); - return convertRecordsToGraphContainer(records); + return convertRecordsToGraphContainer(records, layoutConfig); }; diff --git a/modeler/src/views/decision/communication/types.ts b/modeler/src/views/decision/communication/types.ts index fba34cd..a265095 100644 --- a/modeler/src/views/decision/communication/types.ts +++ b/modeler/src/views/decision/communication/types.ts @@ -48,3 +48,22 @@ export interface ScenarioDetailsResponse extends ApiDataResponse { } +/** + * 通信关系记录(对应数据库表结构) + */ +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 { + +} +