MoneyFlowGraph.vue 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122
  1. <template>
  2. <div ref="wrapperRef" class="money-flow-graph">
  3. <div ref="containerRef" class="money-flow-graph__canvas"></div>
  4. <div class="money-flow-graph__overlay">
  5. <button
  6. v-for="area in edgeLabelHitAreas"
  7. :key="area.id"
  8. type="button"
  9. class="money-flow-graph__edge-hit-area"
  10. :style="{
  11. left: `${area.left}px`,
  12. top: `${area.top}px`,
  13. width: `${area.width}px`,
  14. height: `${area.height}px`,
  15. }"
  16. :title="area.title"
  17. @click.stop="handleEdgeOverlayClick(area.id)"
  18. @wheel.stop.prevent="handleOverlayWheel"
  19. ></button>
  20. <button
  21. v-for="area in nodeHitAreas"
  22. :key="area.id"
  23. type="button"
  24. class="money-flow-graph__hit-area"
  25. :class="{ 'is-collapsible': area.collapsible }"
  26. :style="{
  27. left: `${area.left}px`,
  28. top: `${area.top}px`,
  29. width: `${area.width}px`,
  30. height: `${area.height}px`,
  31. }"
  32. :title="area.title"
  33. @click.stop="handleNodeOverlayClick(area.id)"
  34. @wheel.stop.prevent="handleOverlayWheel"
  35. ></button>
  36. </div>
  37. </div>
  38. </template>
  39. <script lang="ts" setup>
  40. import { Circle as GCircle, Text as GText } from '@antv/g';
  41. import {
  42. BaseNode,
  43. EdgeEvent,
  44. Graph,
  45. GraphEvent,
  46. register,
  47. treeToGraphData,
  48. type BaseNodeStyleProps,
  49. type EdgeData,
  50. type GraphData,
  51. type IElementEvent,
  52. type NodeData,
  53. } from '@antv/g6';
  54. import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
  55. export interface MoneyFlowGraphNode {
  56. id?: string;
  57. key?: string;
  58. name?: string;
  59. otherName?: string;
  60. photo?: string;
  61. personTag?: string;
  62. personCardNos?: string[];
  63. otherCardNos?: string[];
  64. transCount?: number;
  65. totalTransAmount?: string | number;
  66. lineData?: string;
  67. ids?: Array<string>;
  68. minDate?: string;
  69. maxDate?: string;
  70. maxTime?: string;
  71. debitCreditFlag?: string;
  72. collapsed?: boolean;
  73. hasChildrenFlag?: boolean;
  74. state?: 'left' | 'right';
  75. children?: MoneyFlowGraphNode[];
  76. [key: string]: any;
  77. }
  78. export interface MoneyFlowGraphEdgeClickPayload {
  79. ids?: Array<string>;
  80. edgeId?: string;
  81. sourceId?: string;
  82. targetId?: string;
  83. raw?: MoneyFlowGraphNode | null;
  84. sourceNode?: MoneyFlowGraphNode | null;
  85. targetNode?: MoneyFlowGraphNode | null;
  86. }
  87. const CARD_WIDTH = 120;
  88. const CARD_HEIGHT = 120;
  89. const H_GAP = 112;
  90. const V_GAP = 52;
  91. const MIN_WIDTH = 640;
  92. const MIN_HEIGHT = 360;
  93. const MONEY_FLOW_CARD_NODE_TYPE = 'money-flow-card';
  94. const EDGE_ARROW_SIZE = 8;
  95. // G6 falls back to a built-in inward offset when the value is exactly 0,
  96. // so use a tiny negative value to keep arrowheads visually flush with the node edge.
  97. const EDGE_ARROW_OFFSET = -0.01;
  98. let moneyFlowCardNodeRegistered = false;
  99. interface MoneyFlowCardNodeStyle extends BaseNodeStyleProps {
  100. cardTitle?: string;
  101. cardIsRoot?: boolean;
  102. cardCollapsible?: boolean;
  103. }
  104. interface NodeHitArea {
  105. id: string;
  106. left: number;
  107. top: number;
  108. width: number;
  109. height: number;
  110. title: string;
  111. collapsible: boolean;
  112. }
  113. interface EdgeLabelHitArea {
  114. id: string;
  115. left: number;
  116. top: number;
  117. width: number;
  118. height: number;
  119. title: string;
  120. }
  121. class MoneyFlowCardNode extends BaseNode<MoneyFlowCardNodeStyle> {
  122. override getKeyStyle(attributes: MoneyFlowCardNodeStyle) {
  123. const isRoot = Boolean(attributes.cardIsRoot);
  124. const radius = CARD_WIDTH / 2 - 8;
  125. return {
  126. cx: 0,
  127. cy: 0,
  128. r: radius,
  129. fill: isRoot ? '#3aa0ff' : '#53a8ff',
  130. stroke: isRoot ? '#8ec9ff' : '#a7d1ff',
  131. lineWidth: 1.5,
  132. shadowColor: 'rgba(51, 144, 255, 0.22)',
  133. shadowBlur: 18,
  134. shadowOffsetX: 0,
  135. shadowOffsetY: 8,
  136. cursor: 'pointer' as const,
  137. };
  138. }
  139. override drawKeyShape(attributes: Required<MoneyFlowCardNodeStyle>, container: any = this) {
  140. return this.upsert('key', GCircle, this.getKeyStyle(attributes), container);
  141. }
  142. override render(
  143. attributes: Required<MoneyFlowCardNodeStyle> = this.parsedAttributes as Required<MoneyFlowCardNodeStyle>,
  144. container: any = this,
  145. ) {
  146. const titleText = attributes.cardTitle || '未知';
  147. const canToggle = Boolean(attributes.cardCollapsible);
  148. const isCollapsed = Boolean((attributes as MoneyFlowCardNodeStyle & { collapsed?: boolean }).collapsed);
  149. const toggleLabel = isCollapsed ? '展开' : '收起';
  150. const radius = CARD_WIDTH / 2 - 8;
  151. const toggleCenterX = radius - 16;
  152. const toggleCenterY = -radius + 10;
  153. super.render(attributes, container);
  154. this.upsert(
  155. 'toggle-bg',
  156. GCircle,
  157. canToggle
  158. ? {
  159. cx: toggleCenterX,
  160. cy: toggleCenterY,
  161. r: 14,
  162. fill: '#ffffff',
  163. stroke: '#dbeafe',
  164. lineWidth: 1,
  165. shadowColor: 'rgba(15, 23, 42, 0.12)',
  166. shadowBlur: 8,
  167. shadowOffsetX: 0,
  168. shadowOffsetY: 3,
  169. cursor: 'pointer' as const,
  170. }
  171. : false,
  172. container,
  173. );
  174. this.upsert(
  175. 'toggle-text',
  176. GText,
  177. canToggle
  178. ? {
  179. x: toggleCenterX,
  180. y: toggleCenterY,
  181. text: isCollapsed ? '+' : '-',
  182. fill: '#2563eb',
  183. fontSize: 16,
  184. fontWeight: 700,
  185. textAlign: 'center',
  186. textBaseline: 'middle',
  187. cursor: 'pointer' as const,
  188. }
  189. : false,
  190. container,
  191. );
  192. this.upsert(
  193. 'toggle-label',
  194. GText,
  195. canToggle
  196. ? {
  197. x: toggleCenterX,
  198. y: toggleCenterY + 22,
  199. text: toggleLabel,
  200. fill: '#2563eb',
  201. fontSize: 10,
  202. textAlign: 'center',
  203. textBaseline: 'middle',
  204. cursor: 'pointer' as const,
  205. }
  206. : false,
  207. container,
  208. );
  209. this.upsert(
  210. 'title-text',
  211. GText,
  212. {
  213. x: 0,
  214. y: 0,
  215. text: titleText,
  216. fill: '#ffffff',
  217. fontSize: 15,
  218. fontWeight: 500,
  219. textAlign: 'center',
  220. textBaseline: 'middle',
  221. wordWrap: true,
  222. maxLines: 2,
  223. wordWrapWidth: CARD_WIDTH - 32,
  224. textOverflow: '...',
  225. cursor: 'pointer' as const,
  226. },
  227. container,
  228. );
  229. }
  230. }
  231. const ensureMoneyFlowCardNodeRegistered = () => {
  232. if (moneyFlowCardNodeRegistered) return;
  233. register('node', MONEY_FLOW_CARD_NODE_TYPE, MoneyFlowCardNode);
  234. moneyFlowCardNodeRegistered = true;
  235. };
  236. const props = defineProps<{
  237. data?: MoneyFlowGraphNode | MoneyFlowGraphNode[] | null;
  238. direction?: 'forward' | 'backward';
  239. }>();
  240. const emit = defineEmits<{
  241. (event: 'node-click', node: MoneyFlowGraphNode): void;
  242. (event: 'edge-click', payload: MoneyFlowGraphEdgeClickPayload): void;
  243. }>();
  244. const wrapperRef = ref<HTMLDivElement | null>(null);
  245. const containerRef = ref<HTMLDivElement | null>(null);
  246. const graph = shallowRef<Graph | null>(null);
  247. const resizeObserver = ref<ResizeObserver | null>(null);
  248. const resizeFrameId = ref<number | null>(null);
  249. const hitAreaFrameId = ref<number | null>(null);
  250. const hitAreaTimeoutId = ref<number | null>(null);
  251. const lastResizeSize = ref<[number, number] | null>(null);
  252. const nodeRawMap = ref<Record<string, MoneyFlowGraphNode>>({});
  253. const edgeRawMap = ref<Record<string, MoneyFlowGraphNode>>({});
  254. const nodeHitAreas = ref<NodeHitArea[]>([]);
  255. const edgeLabelHitAreas = ref<EdgeLabelHitArea[]>([]);
  256. const direction = computed(() => props.direction || 'backward');
  257. const collapsingNodeId = ref('');
  258. let renderVersion = 0;
  259. let renderQueue: Promise<void> = Promise.resolve();
  260. const toText = (value: unknown) => {
  261. if (value === 0) return '0';
  262. if (value === null || value === undefined) return '';
  263. return String(value).trim();
  264. };
  265. const toNumber = (value: unknown) => {
  266. if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
  267. const normalized = toText(value).replace(/,/g, '');
  268. if (!normalized) return 0;
  269. const parsed = Number(normalized);
  270. return Number.isFinite(parsed) ? parsed : 0;
  271. };
  272. const formatAmount = (value: unknown) => {
  273. if (typeof value === 'number' && Number.isFinite(value)) {
  274. return value.toLocaleString('zh-CN', {
  275. minimumFractionDigits: 2,
  276. maximumFractionDigits: 2,
  277. });
  278. }
  279. return toText(value);
  280. };
  281. const escapeHtml = (value: unknown) => {
  282. return toText(value)
  283. .replace(/&/g, '&amp;')
  284. .replace(/</g, '&lt;')
  285. .replace(/>/g, '&gt;')
  286. .replace(/"/g, '&quot;')
  287. .replace(/'/g, '&#39;');
  288. };
  289. const parseIds = (value: unknown): Array<string> => {
  290. if (Array.isArray(value)) {
  291. return value.map((item) => toText(item)).filter(Boolean);
  292. }
  293. const normalized = toText(value);
  294. if (!normalized) return [];
  295. return normalized
  296. .split(',')
  297. .map((item) => item.trim())
  298. .filter(Boolean);
  299. };
  300. const cloneNodeSnapshot = (raw?: MoneyFlowGraphNode | null): MoneyFlowGraphNode | null => {
  301. if (!raw) return null;
  302. return {
  303. id: raw.id,
  304. key: raw.key,
  305. name: raw.name,
  306. otherName: raw.otherName,
  307. photo: raw.photo,
  308. personTag: raw.personTag ?? undefined,
  309. personCardNos: Array.isArray(raw.personCardNos) ? [...raw.personCardNos] : [],
  310. otherCardNos: Array.isArray(raw.otherCardNos) ? [...raw.otherCardNos] : [],
  311. transCount: raw.transCount,
  312. totalTransAmount: raw.totalTransAmount,
  313. lineData: raw.lineData,
  314. ids: Array.isArray(raw.ids) ? [...raw.ids] : raw.ids,
  315. minDate: raw.minDate,
  316. maxDate: raw.maxDate,
  317. maxTime: raw.maxTime,
  318. debitCreditFlag: raw.debitCreditFlag,
  319. collapsed: raw.collapsed,
  320. hasChildrenFlag: raw.hasChildrenFlag,
  321. state: raw.state,
  322. dataLevel: raw.dataLevel,
  323. children: [],
  324. };
  325. };
  326. const createNodeId = (raw: MoneyFlowGraphNode, depth: number) => {
  327. return String(raw.id ?? raw.key ?? `${raw.name || raw.otherName || 'node'}-${depth}`);
  328. };
  329. const isMeaningfulNode = (raw?: MoneyFlowGraphNode | null) => {
  330. if (!raw || typeof raw !== 'object') return false;
  331. if (toText(raw.id ?? raw.key ?? raw.name ?? raw.otherName)) return true;
  332. if (Array.isArray(raw.personCardNos) && raw.personCardNos.length > 0) return true;
  333. if (Array.isArray(raw.otherCardNos) && raw.otherCardNos.length > 0) return true;
  334. if (toText(raw.lineData) || toText(raw.minDate) || toText(raw.maxDate) || toText(raw.maxTime)) return true;
  335. if (toText(raw.transCount) || toText(raw.totalTransAmount)) return true;
  336. return Array.isArray(raw.children) && raw.children.some((child) => isMeaningfulNode(child));
  337. };
  338. const normalizeNode = (raw: MoneyFlowGraphNode, depth = 0): MoneyFlowGraphNode => {
  339. const nodeId = createNodeId(raw, depth);
  340. const sourceChildren = Array.isArray(raw.children) ? raw.children.filter((item) => isMeaningfulNode(item)) : [];
  341. const children = sourceChildren.map((item) => normalizeNode(item, depth + 1));
  342. const normalizedNode = {
  343. ...raw,
  344. id: nodeId,
  345. key: raw.key ?? nodeId,
  346. name: raw.name || raw.otherName || '未知',
  347. collapsed: Boolean(raw.collapsed),
  348. hasChildrenFlag: sourceChildren.length > 0,
  349. state: raw.state || (direction.value === 'forward' ? 'left' : 'right'),
  350. children,
  351. };
  352. nodeRawMap.value[nodeId] = normalizedNode;
  353. return normalizedNode;
  354. };
  355. const normalizeData = (): MoneyFlowGraphNode | null => {
  356. const raw = props.data;
  357. if (!raw) return null;
  358. nodeRawMap.value = {};
  359. if (Array.isArray(raw)) {
  360. if (!raw.length) return null;
  361. return normalizeNode({
  362. id: 'root',
  363. name: raw[0].otherName || raw[0].name || '当前对象',
  364. state: direction.value === 'forward' ? 'left' : 'right',
  365. children: raw,
  366. });
  367. }
  368. return normalizeNode(raw);
  369. };
  370. const getNodeRaw = (datum?: NodeData | null): MoneyFlowGraphNode | null => {
  371. const payload = datum?.data as { raw?: MoneyFlowGraphNode } | undefined;
  372. if (payload?.raw) return payload.raw;
  373. const nodeId = toText(datum?.id);
  374. if (nodeId && nodeRawMap.value[nodeId]) {
  375. return nodeRawMap.value[nodeId];
  376. }
  377. const fallbackDatum = datum as unknown as MoneyFlowGraphNode | null | undefined;
  378. if (fallbackDatum?.name || fallbackDatum?.otherName) {
  379. return fallbackDatum;
  380. }
  381. return null;
  382. };
  383. const getEdgeRaw = (datum?: EdgeData | null): MoneyFlowGraphNode | null => {
  384. const payload = datum?.data as { raw?: MoneyFlowGraphNode } | undefined;
  385. if (payload?.raw) return payload.raw;
  386. const edgeId = toText(datum?.id);
  387. if (edgeId && edgeRawMap.value[edgeId]) {
  388. return edgeRawMap.value[edgeId];
  389. }
  390. return null;
  391. };
  392. const getEventData = <T extends NodeData | EdgeData>(event: IElementEvent): T | undefined => {
  393. const inlineData = (event as IElementEvent & { data?: T }).data;
  394. if (inlineData) return inlineData;
  395. const targetId = toText(event?.target?.id);
  396. if (!graph.value || !targetId) return undefined;
  397. try {
  398. return graph.value.getElementData(targetId) as T;
  399. } catch (error) {
  400. return undefined;
  401. }
  402. };
  403. const getNodeDataById = (id?: string | number | null) => {
  404. if (!graph.value || id === null || id === undefined || id === '') return null;
  405. try {
  406. return graph.value.getNodeData(String(id)) as NodeData;
  407. } catch (error) {
  408. return null;
  409. }
  410. };
  411. const hasChildren = (node?: MoneyFlowGraphNode | null) => {
  412. if (!node) return false;
  413. if (typeof node.hasChildrenFlag === 'boolean') return node.hasChildrenFlag;
  414. return Array.isArray(node.children) && node.children.length > 0;
  415. };
  416. const isCollapsibleNode = (nodeDatum?: NodeData | null) => {
  417. const style = nodeDatum?.style as Record<string, unknown> | undefined;
  418. if (typeof style?.cardCollapsible === 'boolean') {
  419. return Boolean(style.cardCollapsible);
  420. }
  421. return hasChildren(getNodeRaw(nodeDatum));
  422. };
  423. const isIncomeFlag = (flag?: string) => /[收入贷]/.test(toText(flag));
  424. const isExpenseFlag = (flag?: string) => /[支出借]/.test(toText(flag));
  425. const buildEdgeTitle = (node?: MoneyFlowGraphNode | null) => {
  426. if (!node) return '交易关系';
  427. const minDate = toText(node.minDate);
  428. const maxDate = toText(node.maxDate) || toText(node.maxTime);
  429. if (minDate && maxDate) return `${minDate}至${maxDate}`;
  430. return minDate || maxDate || '交易关系';
  431. };
  432. const buildEdgeLineText = (node?: MoneyFlowGraphNode | null) => {
  433. if (!node) return `转账:${formatAmount(0)}`;
  434. const lineData = toText(node.lineData);
  435. if (!lineData) {
  436. return `转账:${formatAmount(node.totalTransAmount)}`;
  437. }
  438. const matched = lineData.match(/^([^::]+)[::]\s*(.*)$/);
  439. if (!matched) {
  440. return lineData;
  441. }
  442. const [, actionText, rawAmount] = matched;
  443. const normalizedAmount = toText(rawAmount);
  444. const invalidAmount = !normalizedAmount || /^(null|undefined|nan|-)$/i.test(normalizedAmount);
  445. const amountText = invalidAmount ? formatAmount(node.totalTransAmount) : formatAmount(normalizedAmount);
  446. return `${actionText.trim()}:${amountText}`;
  447. };
  448. const buildEdgeValue = (node?: MoneyFlowGraphNode | null) => {
  449. if (!node) return '-';
  450. if (toText(node.lineData)) return buildEdgeLineText(node);
  451. return `共${toNumber(node.transCount)}笔 / ¥${formatAmount(node.totalTransAmount)}`;
  452. };
  453. const buildEdgeLabelText = (node?: MoneyFlowGraphNode | null) => {
  454. if (!node) return '-';
  455. const dateText = toText(node.maxDate) || toText(node.maxTime);
  456. const lineText = buildEdgeLineText(node);
  457. return dateText ? `${dateText} ${lineText}` : lineText;
  458. };
  459. const buildTooltipHtml = (edgeDatum?: EdgeData | null) => {
  460. const raw = getEdgeRaw(edgeDatum);
  461. if (!raw) {
  462. return `
  463. <div style="min-width:156px;padding:8px 10px;box-sizing:border-box;">
  464. <div style="color:#334155;font-size:12px;font-weight:600;line-height:18px;">交易关系</div>
  465. </div>
  466. `;
  467. }
  468. return `
  469. <div style="min-width:156px;padding:8px 10px;box-sizing:border-box;">
  470. <div style="color:#334155;font-size:12px;font-weight:600;line-height:18px;">${escapeHtml(buildEdgeTitle(raw))}</div>
  471. <div style="margin-top:4px;color:#64748b;font-size:11px;line-height:16px;">${escapeHtml(buildEdgeValue(raw))}</div>
  472. </div>
  473. `;
  474. };
  475. const buildGraphData = (root: MoneyFlowGraphNode): GraphData => {
  476. edgeRawMap.value = {};
  477. return treeToGraphData(root as any, {
  478. getChildren: (datum) => datum.children || [],
  479. getNodeData: (datum, depth) => {
  480. const titleText = toText(datum.name || datum.otherName) || '未知';
  481. const hasChildrenFlag = hasChildren(datum);
  482. return {
  483. id: String(datum.id ?? datum.key ?? `${datum.name || datum.otherName || 'node'}-${depth}`),
  484. type: MONEY_FLOW_CARD_NODE_TYPE,
  485. depth,
  486. side: 'right',
  487. children: (datum.children || []).map((child) =>
  488. String(child.id ?? child.key ?? `${child.name || child.otherName || 'node'}-${depth + 1}`),
  489. ),
  490. data: { raw: datum },
  491. style: {
  492. size: [CARD_WIDTH, CARD_HEIGHT],
  493. cursor: 'pointer',
  494. collapsed: Boolean(datum.collapsed),
  495. cardTitle: titleText,
  496. cardIsRoot: depth === 0,
  497. cardCollapsible: hasChildrenFlag,
  498. label: false,
  499. icon: false,
  500. badge: false,
  501. port: false,
  502. halo: false,
  503. } as any,
  504. };
  505. },
  506. getEdgeData: (source, target) => {
  507. const edgeId = `${String(source.id ?? source.key)}->${String(target.id ?? target.key)}`;
  508. edgeRawMap.value[edgeId] = target;
  509. return {
  510. id: edgeId,
  511. source: String(source.id ?? source.key),
  512. target: String(target.id ?? target.key),
  513. data: {
  514. raw: target,
  515. ids: parseIds(target.ids),
  516. },
  517. };
  518. },
  519. });
  520. };
  521. const getContainerSize = (): [number, number] => {
  522. const width = Math.max(containerRef.value?.clientWidth || 0, MIN_WIDTH);
  523. const height = Math.max(containerRef.value?.clientHeight || 0, MIN_HEIGHT);
  524. return [width, height];
  525. };
  526. const isGraphUsable = (instance: Graph | null | undefined): instance is Graph => {
  527. return Boolean(instance && !(instance as Graph & { destroyed?: boolean }).destroyed);
  528. };
  529. const destroyGraph = () => {
  530. if (hitAreaFrameId.value !== null) {
  531. cancelAnimationFrame(hitAreaFrameId.value);
  532. hitAreaFrameId.value = null;
  533. }
  534. if (hitAreaTimeoutId.value !== null) {
  535. clearTimeout(hitAreaTimeoutId.value);
  536. hitAreaTimeoutId.value = null;
  537. }
  538. const currentGraph = graph.value;
  539. if (!isGraphUsable(currentGraph)) {
  540. graph.value = null;
  541. nodeHitAreas.value = [];
  542. edgeLabelHitAreas.value = [];
  543. return;
  544. }
  545. currentGraph.destroy();
  546. graph.value = null;
  547. nodeHitAreas.value = [];
  548. edgeLabelHitAreas.value = [];
  549. };
  550. const syncCollapsedState = (id: string, collapsed: boolean) => {
  551. const raw = nodeRawMap.value[id];
  552. if (raw) {
  553. raw.collapsed = collapsed;
  554. }
  555. };
  556. const updateDebugText = (_message: string) => {};
  557. const handleOverlayWheel = (event: WheelEvent) => {
  558. const currentGraph = graph.value;
  559. if (!isGraphUsable(currentGraph)) return;
  560. const delta = -(event.deltaY || event.deltaX || 0);
  561. const normalized = Math.max(-50, Math.min(50, delta));
  562. const ratio = 1 + normalized / 100;
  563. const canvasPoint = currentGraph.getCanvasByClient([event.clientX, event.clientY]);
  564. const origin = currentGraph.getViewportByCanvas(canvasPoint);
  565. void currentGraph.zoomTo(currentGraph.getZoom() * ratio, false, origin);
  566. };
  567. const emitEdgeClick = (edgeDatum?: EdgeData | null) => {
  568. const raw = getEdgeRaw(edgeDatum);
  569. const edgePayload = edgeDatum?.data as { ids?: unknown } | undefined;
  570. const ids = parseIds(raw?.ids ?? edgePayload?.ids);
  571. if (!ids.length) return false;
  572. const sourceId = toText(edgeDatum?.source);
  573. const targetId = toText(edgeDatum?.target);
  574. const sourceNode = sourceId ? getNodeRaw(getNodeDataById(sourceId)) : null;
  575. const targetNode = targetId ? getNodeRaw(getNodeDataById(targetId)) : null;
  576. updateDebugText(`点击关系线,交易明细 ${ids.length} 条`);
  577. emit('edge-click', {
  578. ids: [...ids],
  579. edgeId: toText(edgeDatum?.id),
  580. sourceId: sourceId || undefined,
  581. targetId: targetId || undefined,
  582. raw: cloneNodeSnapshot(raw),
  583. sourceNode: cloneNodeSnapshot(sourceNode),
  584. targetNode: cloneNodeSnapshot(targetNode),
  585. });
  586. return true;
  587. };
  588. const getEdgeDataById = (id?: string | number | null) => {
  589. const currentGraph = graph.value;
  590. if (!currentGraph || id === null || id === undefined || id === '') return null;
  591. try {
  592. return currentGraph.getEdgeData(String(id)) as EdgeData;
  593. } catch (error) {
  594. try {
  595. return currentGraph.getElementData(String(id)) as EdgeData;
  596. } catch (fallbackError) {
  597. return null;
  598. }
  599. }
  600. };
  601. const updateHitAreas = () => {
  602. const currentGraph = graph.value;
  603. if (!isGraphUsable(currentGraph) || !wrapperRef.value) {
  604. nodeHitAreas.value = [];
  605. edgeLabelHitAreas.value = [];
  606. return;
  607. }
  608. const wrapperRect = wrapperRef.value.getBoundingClientRect();
  609. let nodeDataList: NodeData[] = [];
  610. let edgeDataList: EdgeData[] = [];
  611. try {
  612. nodeDataList = currentGraph.getNodeData();
  613. edgeDataList = currentGraph.getEdgeData();
  614. } catch (error) {
  615. nodeHitAreas.value = [];
  616. edgeLabelHitAreas.value = [];
  617. return;
  618. }
  619. const nextAreas = nodeDataList
  620. .map((datum) => {
  621. const id = toText(datum.id);
  622. if (!id) return null;
  623. try {
  624. if (currentGraph.getElementVisibility(id) === 'hidden') return null;
  625. } catch (error) {
  626. // ignore visibility lookup failure and keep best-effort hit area creation
  627. }
  628. const raw = getNodeRaw(datum);
  629. if (!raw) return null;
  630. try {
  631. const bounds = currentGraph.getElementRenderBounds(id);
  632. const [minX, minY] = currentGraph.getClientByCanvas(bounds.min);
  633. const [maxX, maxY] = currentGraph.getClientByCanvas(bounds.max);
  634. const width = Math.max(24, maxX - minX);
  635. const height = Math.max(24, maxY - minY);
  636. return {
  637. id,
  638. left: minX - wrapperRect.left,
  639. top: minY - wrapperRect.top,
  640. width,
  641. height,
  642. title: hasChildren(raw)
  643. ? `${raw.name || raw.otherName || '节点'}(单击展开/收起)`
  644. : raw.name || raw.otherName || '节点',
  645. collapsible: hasChildren(raw),
  646. } satisfies NodeHitArea;
  647. } catch (error) {
  648. return null;
  649. }
  650. })
  651. .filter((item): item is NodeHitArea => Boolean(item));
  652. nodeHitAreas.value = nextAreas;
  653. const zoom = typeof currentGraph.getZoom === 'function' ? currentGraph.getZoom() : 1;
  654. const labelScale = Math.max(0.65, Math.min(1.35, zoom || 1));
  655. const nextEdgeLabelAreas = edgeDataList
  656. .map((datum) => {
  657. const id = toText(datum.id);
  658. if (!id) return null;
  659. try {
  660. if (currentGraph.getElementVisibility(id) === 'hidden') return null;
  661. } catch (error) {
  662. // ignore visibility lookup failure and keep best-effort hit area creation
  663. }
  664. const raw = getEdgeRaw(datum);
  665. const ids = parseIds(raw?.ids ?? (datum.data as { ids?: unknown } | undefined)?.ids);
  666. const labelText = buildEdgeLabelText(raw);
  667. if (!ids.length || !labelText || labelText === '-') return null;
  668. try {
  669. const sourceId = toText(datum.source);
  670. const targetId = toText(datum.target);
  671. if (!sourceId || !targetId) return null;
  672. const sourcePosition = currentGraph.getElementPosition(sourceId);
  673. const targetPosition = currentGraph.getElementPosition(targetId);
  674. const [sourceClientX, sourceClientY] = currentGraph.getClientByCanvas(sourcePosition);
  675. const [targetClientX, targetClientY] = currentGraph.getClientByCanvas(targetPosition);
  676. const centerX = (sourceClientX + targetClientX) / 2 - wrapperRect.left;
  677. const centerY = (sourceClientY + targetClientY) / 2 - wrapperRect.top;
  678. const width = Math.max(96, Math.min(240, labelText.length * 7 + 24)) * labelScale;
  679. const height = 32 * labelScale;
  680. return {
  681. id,
  682. left: centerX - width / 2,
  683. top: centerY - height / 2,
  684. width,
  685. height,
  686. title: `点击查看交易明细:${labelText}`,
  687. } satisfies EdgeLabelHitArea;
  688. } catch (error) {
  689. return null;
  690. }
  691. })
  692. .filter((item): item is EdgeLabelHitArea => Boolean(item));
  693. edgeLabelHitAreas.value = nextEdgeLabelAreas;
  694. updateDebugText(`可点击节点 ${nextAreas.length} 个,关系线文本 ${nextEdgeLabelAreas.length} 个`);
  695. };
  696. const scheduleHitAreaSync = (delay = 0) => {
  697. if (hitAreaTimeoutId.value !== null) {
  698. clearTimeout(hitAreaTimeoutId.value);
  699. hitAreaTimeoutId.value = null;
  700. }
  701. if (hitAreaFrameId.value !== null) {
  702. cancelAnimationFrame(hitAreaFrameId.value);
  703. }
  704. const run = () => {
  705. hitAreaFrameId.value = window.requestAnimationFrame(() => {
  706. hitAreaFrameId.value = null;
  707. updateHitAreas();
  708. });
  709. };
  710. if (delay > 0) {
  711. hitAreaTimeoutId.value = window.setTimeout(() => {
  712. hitAreaTimeoutId.value = null;
  713. run();
  714. }, delay);
  715. return;
  716. }
  717. run();
  718. };
  719. const handleNodeOverlayClick = (id: string) => {
  720. const nodeDatum = getNodeDataById(id);
  721. const raw = getNodeRaw(nodeDatum);
  722. if (!nodeDatum || !raw) return;
  723. emit('node-click', raw);
  724. if (!isCollapsibleNode(nodeDatum) || !isGraphUsable(graph.value) || collapsingNodeId.value) {
  725. updateDebugText(`点击节点 ${raw.name || raw.otherName || id}`);
  726. return;
  727. }
  728. const collapsed = Boolean((nodeDatum.style as Record<string, unknown> | undefined)?.collapsed);
  729. collapsingNodeId.value = id;
  730. updateDebugText(`${collapsed ? '展开' : '收起'} ${raw.name || raw.otherName || id}`);
  731. void (async () => {
  732. try {
  733. const currentGraph = graph.value;
  734. if (!isGraphUsable(currentGraph)) return;
  735. if (collapsed) {
  736. await currentGraph.expandElement(id, { animation: true, align: true });
  737. syncCollapsedState(id, false);
  738. } else {
  739. await currentGraph.collapseElement(id, { animation: true, align: true });
  740. syncCollapsedState(id, true);
  741. }
  742. } catch (error) {
  743. updateDebugText(`折叠失败: ${raw.name || raw.otherName || id}`);
  744. console.error('[MoneyFlowGraph] toggle collapse failed:', error);
  745. } finally {
  746. collapsingNodeId.value = '';
  747. scheduleHitAreaSync(120);
  748. }
  749. })();
  750. };
  751. const handleEdgeOverlayClick = (id: string) => {
  752. const edgeDatum = getEdgeDataById(id);
  753. emitEdgeClick(edgeDatum);
  754. };
  755. const bindEvents = () => {
  756. const currentGraph = graph.value;
  757. if (!isGraphUsable(currentGraph)) return;
  758. currentGraph.on<IElementEvent>(EdgeEvent.CLICK, (event) => {
  759. const edgeDatum = getEventData<EdgeData>(event);
  760. emitEdgeClick(edgeDatum);
  761. });
  762. currentGraph.on<IElementEvent>(EdgeEvent.POINTER_ENTER, (event) => {
  763. const edgeId = getEventData<EdgeData>(event)?.id;
  764. if (!edgeId || !isGraphUsable(graph.value)) return;
  765. void graph.value.setElementState(String(edgeId), 'active');
  766. });
  767. currentGraph.on<IElementEvent>(EdgeEvent.POINTER_LEAVE, (event) => {
  768. const edgeId = getEventData<EdgeData>(event)?.id;
  769. if (!edgeId || !isGraphUsable(graph.value)) return;
  770. void graph.value.setElementState(String(edgeId), []);
  771. });
  772. currentGraph.on(GraphEvent.AFTER_RENDER, () => {
  773. scheduleHitAreaSync();
  774. });
  775. currentGraph.on(GraphEvent.AFTER_TRANSFORM, () => {
  776. scheduleHitAreaSync(80);
  777. });
  778. };
  779. const handleResize = () => {
  780. const currentGraph = graph.value;
  781. if (!isGraphUsable(currentGraph)) return;
  782. const [width, height] = getContainerSize();
  783. const previousSize = lastResizeSize.value;
  784. if (previousSize && previousSize[0] === width && previousSize[1] === height) {
  785. return;
  786. }
  787. lastResizeSize.value = [width, height];
  788. currentGraph.resize(width, height);
  789. scheduleHitAreaSync();
  790. };
  791. const scheduleResize = () => {
  792. if (resizeFrameId.value !== null) {
  793. cancelAnimationFrame(resizeFrameId.value);
  794. }
  795. resizeFrameId.value = window.requestAnimationFrame(() => {
  796. resizeFrameId.value = null;
  797. handleResize();
  798. });
  799. };
  800. const createGraph = (graphData: GraphData, width: number, height: number) => {
  801. ensureMoneyFlowCardNodeRegistered();
  802. return new Graph({
  803. container: containerRef.value!,
  804. width,
  805. height,
  806. autoResize: false,
  807. data: graphData,
  808. layout: {
  809. type: 'compact-box',
  810. direction: 'LR',
  811. getId: (datum: any) => String(datum.id),
  812. getWidth: () => CARD_WIDTH,
  813. getHeight: () => CARD_HEIGHT,
  814. getHGap: () => H_GAP,
  815. getVGap: () => V_GAP,
  816. },
  817. node: {
  818. type: MONEY_FLOW_CARD_NODE_TYPE,
  819. style: {
  820. size: [CARD_WIDTH, CARD_HEIGHT],
  821. cursor: 'pointer',
  822. },
  823. },
  824. edge: {
  825. type: 'cubic-horizontal',
  826. style: {
  827. stroke: '#9db7d6',
  828. lineWidth: 2.5,
  829. cursor: (datum: EdgeData) => (parseIds(getEdgeRaw(datum)?.ids).length ? 'pointer' : 'default'),
  830. label: (datum: EdgeData) => Boolean(buildEdgeLabelText(getEdgeRaw(datum))),
  831. labelText: (datum: EdgeData) => buildEdgeLabelText(getEdgeRaw(datum)),
  832. labelPlacement: 'center',
  833. labelAutoRotate: true,
  834. labelWordWrap: true,
  835. labelMaxWidth: 220,
  836. labelFill: '#5e5e5e',
  837. labelFontSize: 12,
  838. labelBackground: false,
  839. startArrow: (datum: EdgeData) => getEdgeRaw(datum)?.state === 'left',
  840. startArrowFill: '#9db7d6',
  841. startArrowSize: EDGE_ARROW_SIZE,
  842. startArrowOffset: EDGE_ARROW_OFFSET,
  843. endArrow: (datum: EdgeData) => getEdgeRaw(datum)?.state !== 'left',
  844. endArrowFill: '#9db7d6',
  845. endArrowSize: EDGE_ARROW_SIZE,
  846. endArrowOffset: EDGE_ARROW_OFFSET,
  847. },
  848. state: {
  849. active: {
  850. stroke: '#3b82f6',
  851. lineWidth: 3,
  852. labelFill: '#334155',
  853. },
  854. },
  855. },
  856. behaviors: ['drag-canvas', 'zoom-canvas'],
  857. plugins: [
  858. {
  859. type: 'tooltip',
  860. key: 'capital-flow-edge-tooltip',
  861. enable: (event) => event.targetType === 'edge',
  862. getContent: async (_event, items) => buildTooltipHtml(items?.[0] as EdgeData | undefined),
  863. },
  864. ],
  865. });
  866. };
  867. const updateGraphData = async (graphData: GraphData, width: number, height: number) => {
  868. const currentGraph = graph.value;
  869. if (!isGraphUsable(currentGraph)) return;
  870. currentGraph.setSize(width, height);
  871. currentGraph.setData(graphData);
  872. await currentGraph.render();
  873. };
  874. const renderGraph = async (currentRenderVersion: number) => {
  875. const data = normalizeData();
  876. if (!containerRef.value || !data) {
  877. if (currentRenderVersion === renderVersion) {
  878. destroyGraph();
  879. }
  880. return;
  881. }
  882. await nextTick();
  883. if (currentRenderVersion !== renderVersion || !containerRef.value) return;
  884. const graphData = buildGraphData(data);
  885. const [width, height] = getContainerSize();
  886. lastResizeSize.value = [width, height];
  887. if (!graph.value) {
  888. const nextGraph = createGraph(graphData, width, height);
  889. if (currentRenderVersion !== renderVersion) {
  890. nextGraph.destroy();
  891. return;
  892. }
  893. graph.value = nextGraph;
  894. bindEvents();
  895. await nextGraph.render();
  896. if (currentRenderVersion !== renderVersion) {
  897. nextGraph.destroy();
  898. if (graph.value === nextGraph) {
  899. graph.value = null;
  900. }
  901. return;
  902. }
  903. } else {
  904. await updateGraphData(graphData, width, height);
  905. if (currentRenderVersion !== renderVersion) {
  906. return;
  907. }
  908. }
  909. scheduleHitAreaSync();
  910. };
  911. const scheduleRenderGraph = () => {
  912. const currentRenderVersion = ++renderVersion;
  913. renderQueue = renderQueue
  914. .catch(() => {})
  915. .then(async () => {
  916. await renderGraph(currentRenderVersion);
  917. });
  918. };
  919. watch(
  920. () => props.data,
  921. () => {
  922. scheduleRenderGraph();
  923. },
  924. );
  925. onMounted(() => {
  926. resizeObserver.value = new ResizeObserver(() => {
  927. scheduleResize();
  928. });
  929. if (containerRef.value) {
  930. resizeObserver.value.observe(containerRef.value);
  931. }
  932. scheduleRenderGraph();
  933. });
  934. onBeforeUnmount(() => {
  935. if (resizeFrameId.value !== null) {
  936. cancelAnimationFrame(resizeFrameId.value);
  937. resizeFrameId.value = null;
  938. }
  939. if (hitAreaTimeoutId.value !== null) {
  940. clearTimeout(hitAreaTimeoutId.value);
  941. hitAreaTimeoutId.value = null;
  942. }
  943. if (hitAreaFrameId.value !== null) {
  944. cancelAnimationFrame(hitAreaFrameId.value);
  945. hitAreaFrameId.value = null;
  946. }
  947. lastResizeSize.value = null;
  948. resizeObserver.value?.disconnect();
  949. resizeObserver.value = null;
  950. destroyGraph();
  951. });
  952. </script>
  953. <style scoped lang="less">
  954. .money-flow-graph {
  955. position: relative;
  956. width: 100%;
  957. height: 100%;
  958. min-height: 320px;
  959. }
  960. .money-flow-graph__canvas,
  961. .money-flow-graph__overlay {
  962. position: absolute;
  963. top: 0;
  964. left: 0;
  965. width: 100%;
  966. height: 100%;
  967. }
  968. .money-flow-graph__overlay {
  969. pointer-events: none;
  970. z-index: 2;
  971. }
  972. .money-flow-graph__hit-area {
  973. position: absolute;
  974. border: none;
  975. background: transparent;
  976. pointer-events: auto;
  977. cursor: pointer;
  978. outline: none;
  979. z-index: 2;
  980. }
  981. .money-flow-graph__hit-area.is-collapsible {
  982. cursor: pointer;
  983. }
  984. .money-flow-graph__edge-hit-area {
  985. position: absolute;
  986. border: none;
  987. background: transparent;
  988. pointer-events: auto;
  989. cursor: pointer;
  990. outline: none;
  991. z-index: 1;
  992. }
  993. </style>