|
|
@@ -0,0 +1,545 @@
|
|
|
+import { ref } from 'vue';
|
|
|
+import * as echarts from 'echarts';
|
|
|
+import type { EChartsOption } from 'echarts';
|
|
|
+import { message, Modal } from 'ant-design-vue';
|
|
|
+import { useMineDepartmentStore } from '/@/store/modules/mine';
|
|
|
+import {
|
|
|
+ createGraphOption,
|
|
|
+ categories,
|
|
|
+ statusColorMap,
|
|
|
+ nodeDetailFields,
|
|
|
+ weightKey,
|
|
|
+ transformToTopologyData,
|
|
|
+ collectOrgSubtree,
|
|
|
+ LAYOUT_WIDTH,
|
|
|
+ LAYOUT_HEIGHT,
|
|
|
+} from '../windTopology.data';
|
|
|
+import type { TopologyData, TopoNodeData, OrgPathNode } from '../windTopology.data';
|
|
|
+import { getTopologyData, updateArea } from '../windTopology.api';
|
|
|
+
|
|
|
+/** 节点分类索引:0=部门(只读),1=测风点位,2=数据布点 */
|
|
|
+const CAT_ORG = 0;
|
|
|
+const CAT_POINT = 1;
|
|
|
+const CAT_DEVICE = 2;
|
|
|
+
|
|
|
+export function useTopology() {
|
|
|
+ const chartRef = ref<HTMLDivElement | null>(null);
|
|
|
+ let chartInstance: echarts.ECharts | null = null;
|
|
|
+ let topologyData: TopologyData = { nodes: [], links: [] };
|
|
|
+ let resizeObserver: ResizeObserver | null = null;
|
|
|
+
|
|
|
+ // —— 模式状态 ——
|
|
|
+ const editMode = ref(false);
|
|
|
+ /** 绑定选择态:双击「测风点位/数据布点」后等待单击目标 */
|
|
|
+ const selectMode = ref(false);
|
|
|
+ const selectedSourceId = ref('');
|
|
|
+ /** 待绑定源节点 id(放大+描边标记,进入选择态后生效,退出后清除) */
|
|
|
+ const markedSourceId = ref('');
|
|
|
+ /** 操作栏部门选择:选中任意层级部门后过滤部门树(该部门及其后代)并按该部门查询数据;空=全局 */
|
|
|
+ const selectedDeptId = ref('');
|
|
|
+
|
|
|
+ // —— 选中节点详情 ——
|
|
|
+ const selectedNode = ref<TopoNodeData | null>(null);
|
|
|
+ const detailFields = ref<any[]>([]);
|
|
|
+
|
|
|
+ // —— 初始化 ——
|
|
|
+ function initChart(el: HTMLDivElement) {
|
|
|
+ chartInstance = echarts.init(el);
|
|
|
+ window.addEventListener('resize', handleResize);
|
|
|
+ // 容器尺寸变化(如 Tab 由隐藏变为可见 / 窗口尺寸变化)时自适应重绘,
|
|
|
+ // 避免 0×0 初始化导致节点布局越界而空白
|
|
|
+ if (typeof ResizeObserver !== 'undefined') {
|
|
|
+ resizeObserver = new ResizeObserver(() => {
|
|
|
+ if (!chartInstance) return;
|
|
|
+ const rect = el.getBoundingClientRect();
|
|
|
+ if (rect.width > 0 && rect.height > 0) {
|
|
|
+ chartInstance.resize();
|
|
|
+ renderTopology(topologyData);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ resizeObserver.observe(el);
|
|
|
+ }
|
|
|
+ return chartInstance;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 三层布局 ====================
|
|
|
+
|
|
|
+ function applyHierarchicalLayout(echartsNodes: any[], forceUnfixed = false, chartW = 1200, chartH = 700) {
|
|
|
+ // 容器过小/未初始化时用参考尺寸布局,避免节点全部落在画布外
|
|
|
+ if (chartW < 100) chartW = LAYOUT_WIDTH;
|
|
|
+ if (chartH < 100) chartH = LAYOUT_HEIGHT;
|
|
|
+
|
|
|
+ // 行序列:部门节点按树层级(depth)逐层分组为多行,测风点位/数据布点各占一行
|
|
|
+ const rows: { list: any[]; weight?: string }[] = [];
|
|
|
+ const depthGroups: Record<number, any[]> = {};
|
|
|
+ const catGroups: Record<number, any[]> = {};
|
|
|
+ for (const en of echartsNodes) {
|
|
|
+ if (en.category === 0) {
|
|
|
+ const depth = en.raw?.depth ?? 0;
|
|
|
+ if (!depthGroups[depth]) depthGroups[depth] = [];
|
|
|
+ depthGroups[depth].push(en);
|
|
|
+ } else {
|
|
|
+ if (!catGroups[en.category]) catGroups[en.category] = [];
|
|
|
+ catGroups[en.category].push(en);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const depths = Object.keys(depthGroups)
|
|
|
+ .map(Number)
|
|
|
+ .sort((a, b) => a - b);
|
|
|
+ for (const d of depths) rows.push({ list: depthGroups[d] });
|
|
|
+ if (catGroups[1]) rows.push({ list: catGroups[1], weight: weightKey[1] });
|
|
|
+ if (catGroups[2]) rows.push({ list: catGroups[2], weight: weightKey[2] });
|
|
|
+
|
|
|
+ const rowCount = rows.length || 1;
|
|
|
+ const topMargin = 40;
|
|
|
+ const bottomMargin = 40;
|
|
|
+ // 层级行间距(加宽间隔,可调)
|
|
|
+ const rowGap = 60;
|
|
|
+ const rowHeight = (chartH - topMargin - bottomMargin - rowGap * (rowCount - 1)) / rowCount;
|
|
|
+ const margin = 60;
|
|
|
+ const tw = chartW - margin * 2;
|
|
|
+
|
|
|
+ rows.forEach((row, idx) => {
|
|
|
+ const list = row.list;
|
|
|
+ if (!list.length) return;
|
|
|
+ // 行内排序(按权重字段,值越大越靠左)
|
|
|
+ const wk = row.weight;
|
|
|
+ list.sort((a, b) => {
|
|
|
+ const va = wk ? Number(a.raw?.[wk]) || 0 : 0;
|
|
|
+ const vb = wk ? Number(b.raw?.[wk]) || 0 : 0;
|
|
|
+ return vb - va;
|
|
|
+ });
|
|
|
+ const gap = list.length > 1 ? tw / (list.length - 1) : 0;
|
|
|
+ const sx = margin + (tw - (list.length - 1) * gap) / 2;
|
|
|
+ const y = topMargin + idx * (rowHeight + rowGap) + rowHeight / 2;
|
|
|
+ list.forEach((en, i) => {
|
|
|
+ en.x = sx + i * gap;
|
|
|
+ en.y = y;
|
|
|
+ en.fixed = !forceUnfixed;
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // —— 加载并渲染拓扑 ——
|
|
|
+ async function loadTopology() {
|
|
|
+ try {
|
|
|
+ const mineStore = useMineDepartmentStore();
|
|
|
+ // 任意层级部门均可过滤:选中部门 → 部门层为其子树(该部门及全部后代),数据按该部门 deptId 查询
|
|
|
+ const dept = selectedDeptId.value ? mineStore.findDepartById(selectedDeptId.value) : undefined;
|
|
|
+
|
|
|
+ let orgNodes: OrgPathNode[];
|
|
|
+ let queryDeptId: string | undefined;
|
|
|
+ if (dept) {
|
|
|
+ orgNodes = collectOrgSubtree(dept);
|
|
|
+ queryDeptId = dept.id;
|
|
|
+ } else {
|
|
|
+ let root = mineStore.getRoot;
|
|
|
+ if (!root) {
|
|
|
+ // 组织树未就绪(极端情况):短暂等待后重试一次
|
|
|
+ console.warn('组织树未就绪,300ms 后重试加载拓扑');
|
|
|
+ await new Promise((r) => setTimeout(r, 300));
|
|
|
+ root = mineStore.getRoot;
|
|
|
+ }
|
|
|
+ if (!root) {
|
|
|
+ console.error('组织树仍未就绪,无法加载拓扑');
|
|
|
+ message.error('组织树未就绪,无法加载拓扑');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // 全局视图:部门层 = 当前用户部门(getRoot)及其全部后代
|
|
|
+ orgNodes = collectOrgSubtree(root);
|
|
|
+ }
|
|
|
+
|
|
|
+ const apiData = await getTopologyData(queryDeptId ? { deptId: queryDeptId } : {});
|
|
|
+ renderTopology(transformToTopologyData(orgNodes, apiData.mineAreaList, apiData.windrectList));
|
|
|
+ // 数据为空时给出诊断信息(部门树仍会渲染)
|
|
|
+ if (!apiData.mineAreaList.length && !apiData.windrectList.length) {
|
|
|
+ console.warn('拓扑数据为空:未获取到测风点位/数据布点(deptId=' + (queryDeptId || mineStore.getRootId || '') + ')');
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('拓扑数据加载失败:', e);
|
|
|
+ message.error('拓扑数据加载失败,请查看控制台日志');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 操作栏部门选择:过滤部门树(该部门及后代)并按所选部门查询数据。
|
|
|
+ * 注意:v-model 已先行更新 selectedDeptId,此处只需直接触发加载(不能按值去抖,否则恒等跳过) */
|
|
|
+ function setSelectedDept(id: string) {
|
|
|
+ selectedDeptId.value = id || '';
|
|
|
+ loadTopology();
|
|
|
+ }
|
|
|
+
|
|
|
+ // —— 渲染拓扑 ——
|
|
|
+ function renderTopology(data: TopologyData, forceUnfixed = false) {
|
|
|
+ topologyData = data;
|
|
|
+ if (!chartInstance) return;
|
|
|
+
|
|
|
+ const option: EChartsOption = createGraphOption();
|
|
|
+ const series: any = (option.series as any[])[0];
|
|
|
+
|
|
|
+ // 最终按 id 去重兜底(数据层已去重,此处防止异常数据导致 ECharts 重复 id 报错)
|
|
|
+ const seenIds = new Set<string>();
|
|
|
+ const echartsNodes: any[] = [];
|
|
|
+ for (const n of data.nodes) {
|
|
|
+ if (seenIds.has(n.id)) {
|
|
|
+ console.warn('拓扑节点 id 重复,已跳过:', n.id);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ seenIds.add(n.id);
|
|
|
+ const cat = categories[n.category] || categories[0];
|
|
|
+ const statusColor = statusColorMap[n.status || 'normal'];
|
|
|
+ const isMarked = n.id === markedSourceId.value;
|
|
|
+ const itemStyle: any = { color: statusColor || cat.color };
|
|
|
+ // 待绑定源节点:放大 + 红色描边,让用户明确当前正在操作哪个节点
|
|
|
+ if (isMarked) {
|
|
|
+ itemStyle.borderColor = '#ff4d4f';
|
|
|
+ itemStyle.borderWidth = 3;
|
|
|
+ }
|
|
|
+ echartsNodes.push({
|
|
|
+ id: n.id,
|
|
|
+ name: n.name,
|
|
|
+ category: n.category,
|
|
|
+ value: n.name,
|
|
|
+ symbol: cat.symbol,
|
|
|
+ symbolSize: isMarked ? cat.symbolSize + 8 : cat.symbolSize,
|
|
|
+ itemStyle,
|
|
|
+ raw: n,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const cw = chartInstance.getWidth();
|
|
|
+ const ch = chartInstance.getHeight();
|
|
|
+ applyHierarchicalLayout(echartsNodes, forceUnfixed, cw, ch);
|
|
|
+ series.nodes = echartsNodes;
|
|
|
+ series.links = data.links.map((l) => ({
|
|
|
+ source: l.source,
|
|
|
+ target: l.target,
|
|
|
+ kind: l.kind,
|
|
|
+ }));
|
|
|
+
|
|
|
+ if (editMode.value) {
|
|
|
+ series.lineStyle = { ...series.lineStyle, type: 'dashed' as const, width: 2, opacity: 0.6 };
|
|
|
+ }
|
|
|
+
|
|
|
+ chartInstance.setOption(option, true);
|
|
|
+ registerEvents();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 交互事件 ====================
|
|
|
+
|
|
|
+ function registerEvents() {
|
|
|
+ if (!chartInstance) return;
|
|
|
+ chartInstance.off('click');
|
|
|
+ chartInstance.off('dblclick');
|
|
|
+ chartInstance.off('dragend');
|
|
|
+
|
|
|
+ // ——— 单击 ———
|
|
|
+ chartInstance.on('click', (params: any) => {
|
|
|
+ // 待绑定态下单击空白处 → 取消选择
|
|
|
+ if (!params.data) {
|
|
|
+ if (selectMode.value) {
|
|
|
+ exitSelectMode();
|
|
|
+ message.info('已取消绑定');
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (params.dataType === 'node') {
|
|
|
+ if (editMode.value && selectMode.value) {
|
|
|
+ // 选择态下单击另一节点 → 尝试绑定
|
|
|
+ attemptBind(selectedSourceId.value, params.data.id);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // 普通单击 → 显示详情
|
|
|
+ const raw = params.data.raw as TopoNodeData;
|
|
|
+ if (raw) showNodeDetail(raw);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // ——— 双击 ———
|
|
|
+ chartInstance.on('dblclick', (params: any) => {
|
|
|
+ if (!params.data) return;
|
|
|
+
|
|
|
+ if (params.dataType === 'edge' && editMode.value) {
|
|
|
+ // 双击连线 → 解绑(仅测风点位↔数据布点)
|
|
|
+ handleUnbind(params.data.source, params.data.target, params.data.kind);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (params.dataType === 'node' && editMode.value) {
|
|
|
+ // 「测风点位」作为绑定到矿井的源;「数据布点」作为绑定到测风点位的源
|
|
|
+ if (params.data.category === CAT_POINT || params.data.category === CAT_DEVICE) {
|
|
|
+ enterSelectMode(params.data.id);
|
|
|
+ } else {
|
|
|
+ message.info('请双击「测风点位」或「数据布点」节点作为绑定源');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // ——— 拖拽结束 ———
|
|
|
+ chartInstance.on('dragend', (params: any) => {
|
|
|
+ if (!params.data || params.dataType !== 'node') return;
|
|
|
+ highlightPath(params.data.id);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 选择目标态 ====================
|
|
|
+
|
|
|
+ function enterSelectMode(sourceId: string) {
|
|
|
+ const src = topologyData.nodes.find((n) => n.id === sourceId);
|
|
|
+ if (!src) return;
|
|
|
+ if (src.category !== CAT_POINT && src.category !== CAT_DEVICE) {
|
|
|
+ message.info('请双击「测风点位」或「数据布点」节点作为绑定源');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ selectMode.value = true;
|
|
|
+ selectedSourceId.value = sourceId;
|
|
|
+ markedSourceId.value = sourceId;
|
|
|
+ // 重绘应用放大 + 描边标记,并高亮关联连线
|
|
|
+ renderTopology(topologyData);
|
|
|
+ highlightPath(sourceId);
|
|
|
+ const tip =
|
|
|
+ src.category === CAT_POINT
|
|
|
+ ? `已选中"${src.name}",请单击一个「矿井」或「数据布点」绑定。单击空白处取消。`
|
|
|
+ : `已选中"${src.name}",请单击一个「测风点位」绑定。单击空白处取消。`;
|
|
|
+ message.info(tip);
|
|
|
+ }
|
|
|
+
|
|
|
+ function exitSelectMode() {
|
|
|
+ selectMode.value = false;
|
|
|
+ selectedSourceId.value = '';
|
|
|
+ markedSourceId.value = '';
|
|
|
+ chartInstance?.dispatchAction({ type: 'downplay' });
|
|
|
+ // 重绘移除待绑定源节点的放大/描边标记
|
|
|
+ renderTopology(topologyData);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 绑定逻辑 ====================
|
|
|
+
|
|
|
+ /** 按数据布点原始 id 解析其显示名(用于确认文案,避免直接展示 id) */
|
|
|
+ function deviceNameOf(rawId?: string) {
|
|
|
+ const dev = topologyData.nodes.find((n) => n.category === CAT_DEVICE && n.rawId === rawId);
|
|
|
+ return dev?.name || rawId || '';
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 统一确认弹窗并执行编辑(一次 updateArea 调用,后端负责用新 id 替换旧绑定) */
|
|
|
+ function confirmBind(confirmText: string, params: any) {
|
|
|
+ Modal.confirm({
|
|
|
+ title: '确认绑定',
|
|
|
+ content: confirmText,
|
|
|
+ okText: '确认绑定',
|
|
|
+ cancelText: '取消',
|
|
|
+ onOk: async () => {
|
|
|
+ try {
|
|
|
+ await updateArea(params);
|
|
|
+ await loadTopology();
|
|
|
+ message.success('绑定成功');
|
|
|
+ } catch {
|
|
|
+ message.error('绑定失败');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async function attemptBind(sourceId: string, targetId: string) {
|
|
|
+ exitSelectMode();
|
|
|
+ if (sourceId === targetId) {
|
|
|
+ message.warning('不能绑定到自身');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const src = topologyData.nodes.find((n) => n.id === sourceId);
|
|
|
+ const tgt = topologyData.nodes.find((n) => n.id === targetId);
|
|
|
+ if (!src || !tgt) return;
|
|
|
+
|
|
|
+ // 测风点位 → 矿井:编辑测风点位的 mineCode(允许改绑)
|
|
|
+ if (src.category === CAT_POINT && tgt.category === CAT_ORG) {
|
|
|
+ if (!tgt.isLeaf) {
|
|
|
+ message.warning('仅支持绑定到矿井(叶子节点)');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const fax = tgt.fax || '';
|
|
|
+ if (!fax) {
|
|
|
+ message.warning('目标矿井缺少矿编码,无法绑定');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (src.mineCode === fax) {
|
|
|
+ message.warning('该测风点位已绑定到该矿');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const confirmText = src.mineCode
|
|
|
+ ? `该测风点位已绑定到矿编码"${src.mineCode}",确认改绑到"${tgt.name}"?`
|
|
|
+ : `确认将"${src.name}"绑定到"${tgt.name}"?`;
|
|
|
+ confirmBind(confirmText, { id: src.rawId, mineCode: fax });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 测风点位 → 数据布点 / 数据布点 → 测风点位:均编辑测风点位的 windrectId(后端一次调用替换旧绑定)
|
|
|
+ let pointNode: TopoNodeData;
|
|
|
+ let deviceNode: TopoNodeData;
|
|
|
+ if (src.category === CAT_POINT && tgt.category === CAT_DEVICE) {
|
|
|
+ pointNode = src;
|
|
|
+ deviceNode = tgt;
|
|
|
+ } else if (src.category === CAT_DEVICE && tgt.category === CAT_POINT) {
|
|
|
+ pointNode = tgt;
|
|
|
+ deviceNode = src;
|
|
|
+ } else {
|
|
|
+ message.warning('仅支持「测风点位→矿井/数据布点」或「数据布点→测风点位」的绑定');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const alreadyLinked = topologyData.links.some((l) => l.kind === 'sensor' && l.source === pointNode.id && l.target === deviceNode.id);
|
|
|
+ if (alreadyLinked) {
|
|
|
+ message.warning('已存在该绑定关系');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const oldDeviceName = deviceNameOf(pointNode.windrectId);
|
|
|
+ const confirmText = pointNode.windrectId
|
|
|
+ ? `该测风点位已绑定数据布点"${oldDeviceName}",确认改绑到"${deviceNode.name}"?`
|
|
|
+ : `确认将"${deviceNode.name}"绑定到测风点位"${pointNode.name}"?`;
|
|
|
+ confirmBind(confirmText, { id: pointNode.rawId, windrectId: deviceNode.rawId });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 解绑逻辑 ====================
|
|
|
+
|
|
|
+ async function handleUnbind(sourceId: string, targetId: string, kind?: string) {
|
|
|
+ // 仅允许删除「测风点位↔数据布点」的绑定关系
|
|
|
+ if (kind !== 'sensor') {
|
|
|
+ message.warning('仅允许删除「测风点位」与「数据布点」的绑定关系');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const src = topologyData.nodes.find((n) => n.id === sourceId);
|
|
|
+ const tgt = topologyData.nodes.find((n) => n.id === targetId);
|
|
|
+ Modal.confirm({
|
|
|
+ title: '确认解绑',
|
|
|
+ content: `确定解除"${src?.name || sourceId}" → "${tgt?.name || targetId}"的绑定关系?`,
|
|
|
+ okText: '确认解绑',
|
|
|
+ cancelText: '取消',
|
|
|
+ onOk: async () => {
|
|
|
+ try {
|
|
|
+ // 解绑即清空测风点位(source 为测风点位)的 windrectId,用原始实体 id 调用接口
|
|
|
+ if (!src?.rawId) {
|
|
|
+ message.warning('缺少测风点位原始 id,无法解绑');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ await updateArea({ id: src.rawId, windrectId: '' });
|
|
|
+ await loadTopology();
|
|
|
+ message.success('解绑成功');
|
|
|
+ } catch {
|
|
|
+ message.error('解绑失败');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 节点详情 ====================
|
|
|
+
|
|
|
+ function showNodeDetail(node: TopoNodeData) {
|
|
|
+ selectedNode.value = node;
|
|
|
+ const catName = categories[node.category]?.name || '';
|
|
|
+ const fields = nodeDetailFields[catName] || [];
|
|
|
+ detailFields.value = fields.map((f) => ({
|
|
|
+ label: f.label,
|
|
|
+ value: (node as any)[f.key] ?? '-',
|
|
|
+ }));
|
|
|
+ }
|
|
|
+
|
|
|
+ function clearSelection() {
|
|
|
+ selectedNode.value = null;
|
|
|
+ detailFields.value = [];
|
|
|
+ exitSelectMode();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 高亮路径 ====================
|
|
|
+
|
|
|
+ function highlightPath(nodeId: string) {
|
|
|
+ if (!chartInstance) return;
|
|
|
+ const s = (chartInstance.getOption().series as any[])[0];
|
|
|
+ const nodes = s.data || [];
|
|
|
+ const links = s.links || [];
|
|
|
+ // 单个高亮派发失败不拖垮页面
|
|
|
+ const safeDispatch = (action: any) => {
|
|
|
+ try {
|
|
|
+ chartInstance?.dispatchAction(action);
|
|
|
+ } catch (e) {
|
|
|
+ console.warn('高亮派发失败:', e);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ safeDispatch({ type: 'downplay' });
|
|
|
+ // 按节点 id 定位 dataIndex 高亮(name 是显示名,不能用作定位)
|
|
|
+ const nodeIndex = nodes.findIndex((d: any) => d && d.id === nodeId);
|
|
|
+ if (nodeIndex >= 0) {
|
|
|
+ safeDispatch({ type: 'highlight', seriesIndex: 0, dataIndex: nodeIndex, dataType: 'node' });
|
|
|
+ }
|
|
|
+ const edgeIndices: number[] = [];
|
|
|
+ links.forEach((l: any, i: number) => {
|
|
|
+ if (l.source === nodeId || l.target === nodeId) edgeIndices.push(i);
|
|
|
+ });
|
|
|
+ edgeIndices.forEach((i) => safeDispatch({ type: 'highlight', seriesIndex: 0, dataIndex: i, dataType: 'edge' }));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 缩放 ====================
|
|
|
+
|
|
|
+ function zoomIn() {
|
|
|
+ const cur = chartInstance?.getOption()?.series?.[0] as any;
|
|
|
+ const z = cur?.zoom ?? 1;
|
|
|
+ chartInstance?.setOption({ series: [{ zoom: Math.min(z * 1.3, 5) }] as any });
|
|
|
+ }
|
|
|
+
|
|
|
+ function zoomOut() {
|
|
|
+ const cur = chartInstance?.getOption()?.series?.[0] as any;
|
|
|
+ const z = cur?.zoom ?? 1;
|
|
|
+ chartInstance?.setOption({ series: [{ zoom: Math.max(z / 1.3, 0.3) }] as any });
|
|
|
+ }
|
|
|
+
|
|
|
+ function resetView() {
|
|
|
+ chartInstance?.setOption({ series: [{ zoom: 1, center: undefined }] as any });
|
|
|
+ clearSelection();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 编辑模式切换 ====================
|
|
|
+
|
|
|
+ function setEditMode(on: boolean) {
|
|
|
+ editMode.value = on;
|
|
|
+ if (!chartInstance) return;
|
|
|
+ exitSelectMode();
|
|
|
+
|
|
|
+ if (on) {
|
|
|
+ renderTopology(topologyData, true);
|
|
|
+ chartInstance.setOption({ series: [{ roam: false }] as any });
|
|
|
+ message.info('绑定模式已开启:双击「测风点位」→ 单击「矿井」或「数据布点」绑定;双击「数据布点」→ 单击「测风点位」绑定;双击连线解绑');
|
|
|
+ } else {
|
|
|
+ renderTopology(topologyData);
|
|
|
+ message.info('已退出绑定模式');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== resize / dispose ====================
|
|
|
+
|
|
|
+ function handleResize() {
|
|
|
+ chartInstance?.resize();
|
|
|
+ }
|
|
|
+
|
|
|
+ function dispose() {
|
|
|
+ resizeObserver?.disconnect();
|
|
|
+ resizeObserver = null;
|
|
|
+ window.removeEventListener('resize', handleResize);
|
|
|
+ chartInstance?.dispose();
|
|
|
+ chartInstance = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ chartRef,
|
|
|
+ editMode,
|
|
|
+ selectedNode,
|
|
|
+ detailFields,
|
|
|
+ selectedDeptId,
|
|
|
+ setSelectedDept,
|
|
|
+ initChart,
|
|
|
+ loadTopology,
|
|
|
+ zoomIn,
|
|
|
+ zoomOut,
|
|
|
+ resetView,
|
|
|
+ setEditMode,
|
|
|
+ clearSelection,
|
|
|
+ dispose,
|
|
|
+ };
|
|
|
+}
|