| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122 |
- <template>
- <div ref="wrapperRef" class="money-flow-graph">
- <div ref="containerRef" class="money-flow-graph__canvas"></div>
- <div class="money-flow-graph__overlay">
- <button
- v-for="area in edgeLabelHitAreas"
- :key="area.id"
- type="button"
- class="money-flow-graph__edge-hit-area"
- :style="{
- left: `${area.left}px`,
- top: `${area.top}px`,
- width: `${area.width}px`,
- height: `${area.height}px`,
- }"
- :title="area.title"
- @click.stop="handleEdgeOverlayClick(area.id)"
- @wheel.stop.prevent="handleOverlayWheel"
- ></button>
- <button
- v-for="area in nodeHitAreas"
- :key="area.id"
- type="button"
- class="money-flow-graph__hit-area"
- :class="{ 'is-collapsible': area.collapsible }"
- :style="{
- left: `${area.left}px`,
- top: `${area.top}px`,
- width: `${area.width}px`,
- height: `${area.height}px`,
- }"
- :title="area.title"
- @click.stop="handleNodeOverlayClick(area.id)"
- @wheel.stop.prevent="handleOverlayWheel"
- ></button>
- </div>
- </div>
- </template>
- <script lang="ts" setup>
- import { Circle as GCircle, Text as GText } from '@antv/g';
- import {
- BaseNode,
- EdgeEvent,
- Graph,
- GraphEvent,
- register,
- treeToGraphData,
- type BaseNodeStyleProps,
- type EdgeData,
- type GraphData,
- type IElementEvent,
- type NodeData,
- } from '@antv/g6';
- import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
- export interface MoneyFlowGraphNode {
- id?: string;
- key?: string;
- name?: string;
- otherName?: string;
- photo?: string;
- personTag?: string;
- personCardNos?: string[];
- otherCardNos?: string[];
- transCount?: number;
- totalTransAmount?: string | number;
- lineData?: string;
- ids?: Array<string>;
- minDate?: string;
- maxDate?: string;
- maxTime?: string;
- debitCreditFlag?: string;
- collapsed?: boolean;
- hasChildrenFlag?: boolean;
- state?: 'left' | 'right';
- children?: MoneyFlowGraphNode[];
- [key: string]: any;
- }
- export interface MoneyFlowGraphEdgeClickPayload {
- ids?: Array<string>;
- edgeId?: string;
- sourceId?: string;
- targetId?: string;
- raw?: MoneyFlowGraphNode | null;
- sourceNode?: MoneyFlowGraphNode | null;
- targetNode?: MoneyFlowGraphNode | null;
- }
- const CARD_WIDTH = 120;
- const CARD_HEIGHT = 120;
- const H_GAP = 112;
- const V_GAP = 52;
- const MIN_WIDTH = 640;
- const MIN_HEIGHT = 360;
- const MONEY_FLOW_CARD_NODE_TYPE = 'money-flow-card';
- const EDGE_ARROW_SIZE = 8;
- // G6 falls back to a built-in inward offset when the value is exactly 0,
- // so use a tiny negative value to keep arrowheads visually flush with the node edge.
- const EDGE_ARROW_OFFSET = -0.01;
- let moneyFlowCardNodeRegistered = false;
- interface MoneyFlowCardNodeStyle extends BaseNodeStyleProps {
- cardTitle?: string;
- cardIsRoot?: boolean;
- cardCollapsible?: boolean;
- }
- interface NodeHitArea {
- id: string;
- left: number;
- top: number;
- width: number;
- height: number;
- title: string;
- collapsible: boolean;
- }
- interface EdgeLabelHitArea {
- id: string;
- left: number;
- top: number;
- width: number;
- height: number;
- title: string;
- }
- class MoneyFlowCardNode extends BaseNode<MoneyFlowCardNodeStyle> {
- override getKeyStyle(attributes: MoneyFlowCardNodeStyle) {
- const isRoot = Boolean(attributes.cardIsRoot);
- const radius = CARD_WIDTH / 2 - 8;
- return {
- cx: 0,
- cy: 0,
- r: radius,
- fill: isRoot ? '#3aa0ff' : '#53a8ff',
- stroke: isRoot ? '#8ec9ff' : '#a7d1ff',
- lineWidth: 1.5,
- shadowColor: 'rgba(51, 144, 255, 0.22)',
- shadowBlur: 18,
- shadowOffsetX: 0,
- shadowOffsetY: 8,
- cursor: 'pointer' as const,
- };
- }
- override drawKeyShape(attributes: Required<MoneyFlowCardNodeStyle>, container: any = this) {
- return this.upsert('key', GCircle, this.getKeyStyle(attributes), container);
- }
- override render(
- attributes: Required<MoneyFlowCardNodeStyle> = this.parsedAttributes as Required<MoneyFlowCardNodeStyle>,
- container: any = this,
- ) {
- const titleText = attributes.cardTitle || '未知';
- const canToggle = Boolean(attributes.cardCollapsible);
- const isCollapsed = Boolean((attributes as MoneyFlowCardNodeStyle & { collapsed?: boolean }).collapsed);
- const toggleLabel = isCollapsed ? '展开' : '收起';
- const radius = CARD_WIDTH / 2 - 8;
- const toggleCenterX = radius - 16;
- const toggleCenterY = -radius + 10;
- super.render(attributes, container);
- this.upsert(
- 'toggle-bg',
- GCircle,
- canToggle
- ? {
- cx: toggleCenterX,
- cy: toggleCenterY,
- r: 14,
- fill: '#ffffff',
- stroke: '#dbeafe',
- lineWidth: 1,
- shadowColor: 'rgba(15, 23, 42, 0.12)',
- shadowBlur: 8,
- shadowOffsetX: 0,
- shadowOffsetY: 3,
- cursor: 'pointer' as const,
- }
- : false,
- container,
- );
- this.upsert(
- 'toggle-text',
- GText,
- canToggle
- ? {
- x: toggleCenterX,
- y: toggleCenterY,
- text: isCollapsed ? '+' : '-',
- fill: '#2563eb',
- fontSize: 16,
- fontWeight: 700,
- textAlign: 'center',
- textBaseline: 'middle',
- cursor: 'pointer' as const,
- }
- : false,
- container,
- );
- this.upsert(
- 'toggle-label',
- GText,
- canToggle
- ? {
- x: toggleCenterX,
- y: toggleCenterY + 22,
- text: toggleLabel,
- fill: '#2563eb',
- fontSize: 10,
- textAlign: 'center',
- textBaseline: 'middle',
- cursor: 'pointer' as const,
- }
- : false,
- container,
- );
- this.upsert(
- 'title-text',
- GText,
- {
- x: 0,
- y: 0,
- text: titleText,
- fill: '#ffffff',
- fontSize: 15,
- fontWeight: 500,
- textAlign: 'center',
- textBaseline: 'middle',
- wordWrap: true,
- maxLines: 2,
- wordWrapWidth: CARD_WIDTH - 32,
- textOverflow: '...',
- cursor: 'pointer' as const,
- },
- container,
- );
- }
- }
- const ensureMoneyFlowCardNodeRegistered = () => {
- if (moneyFlowCardNodeRegistered) return;
- register('node', MONEY_FLOW_CARD_NODE_TYPE, MoneyFlowCardNode);
- moneyFlowCardNodeRegistered = true;
- };
- const props = defineProps<{
- data?: MoneyFlowGraphNode | MoneyFlowGraphNode[] | null;
- direction?: 'forward' | 'backward';
- }>();
- const emit = defineEmits<{
- (event: 'node-click', node: MoneyFlowGraphNode): void;
- (event: 'edge-click', payload: MoneyFlowGraphEdgeClickPayload): void;
- }>();
- const wrapperRef = ref<HTMLDivElement | null>(null);
- const containerRef = ref<HTMLDivElement | null>(null);
- const graph = shallowRef<Graph | null>(null);
- const resizeObserver = ref<ResizeObserver | null>(null);
- const resizeFrameId = ref<number | null>(null);
- const hitAreaFrameId = ref<number | null>(null);
- const hitAreaTimeoutId = ref<number | null>(null);
- const lastResizeSize = ref<[number, number] | null>(null);
- const nodeRawMap = ref<Record<string, MoneyFlowGraphNode>>({});
- const edgeRawMap = ref<Record<string, MoneyFlowGraphNode>>({});
- const nodeHitAreas = ref<NodeHitArea[]>([]);
- const edgeLabelHitAreas = ref<EdgeLabelHitArea[]>([]);
- const direction = computed(() => props.direction || 'backward');
- const collapsingNodeId = ref('');
- let renderVersion = 0;
- let renderQueue: Promise<void> = Promise.resolve();
- const toText = (value: unknown) => {
- if (value === 0) return '0';
- if (value === null || value === undefined) return '';
- return String(value).trim();
- };
- const toNumber = (value: unknown) => {
- if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
- const normalized = toText(value).replace(/,/g, '');
- if (!normalized) return 0;
- const parsed = Number(normalized);
- return Number.isFinite(parsed) ? parsed : 0;
- };
- const formatAmount = (value: unknown) => {
- if (typeof value === 'number' && Number.isFinite(value)) {
- return value.toLocaleString('zh-CN', {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- });
- }
- return toText(value);
- };
- const escapeHtml = (value: unknown) => {
- return toText(value)
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
- };
- const parseIds = (value: unknown): Array<string> => {
- if (Array.isArray(value)) {
- return value.map((item) => toText(item)).filter(Boolean);
- }
- const normalized = toText(value);
- if (!normalized) return [];
- return normalized
- .split(',')
- .map((item) => item.trim())
- .filter(Boolean);
- };
- const cloneNodeSnapshot = (raw?: MoneyFlowGraphNode | null): MoneyFlowGraphNode | null => {
- if (!raw) return null;
- return {
- id: raw.id,
- key: raw.key,
- name: raw.name,
- otherName: raw.otherName,
- photo: raw.photo,
- personTag: raw.personTag ?? undefined,
- personCardNos: Array.isArray(raw.personCardNos) ? [...raw.personCardNos] : [],
- otherCardNos: Array.isArray(raw.otherCardNos) ? [...raw.otherCardNos] : [],
- transCount: raw.transCount,
- totalTransAmount: raw.totalTransAmount,
- lineData: raw.lineData,
- ids: Array.isArray(raw.ids) ? [...raw.ids] : raw.ids,
- minDate: raw.minDate,
- maxDate: raw.maxDate,
- maxTime: raw.maxTime,
- debitCreditFlag: raw.debitCreditFlag,
- collapsed: raw.collapsed,
- hasChildrenFlag: raw.hasChildrenFlag,
- state: raw.state,
- dataLevel: raw.dataLevel,
- children: [],
- };
- };
- const createNodeId = (raw: MoneyFlowGraphNode, depth: number) => {
- return String(raw.id ?? raw.key ?? `${raw.name || raw.otherName || 'node'}-${depth}`);
- };
- const isMeaningfulNode = (raw?: MoneyFlowGraphNode | null) => {
- if (!raw || typeof raw !== 'object') return false;
- if (toText(raw.id ?? raw.key ?? raw.name ?? raw.otherName)) return true;
- if (Array.isArray(raw.personCardNos) && raw.personCardNos.length > 0) return true;
- if (Array.isArray(raw.otherCardNos) && raw.otherCardNos.length > 0) return true;
- if (toText(raw.lineData) || toText(raw.minDate) || toText(raw.maxDate) || toText(raw.maxTime)) return true;
- if (toText(raw.transCount) || toText(raw.totalTransAmount)) return true;
- return Array.isArray(raw.children) && raw.children.some((child) => isMeaningfulNode(child));
- };
- const normalizeNode = (raw: MoneyFlowGraphNode, depth = 0): MoneyFlowGraphNode => {
- const nodeId = createNodeId(raw, depth);
- const sourceChildren = Array.isArray(raw.children) ? raw.children.filter((item) => isMeaningfulNode(item)) : [];
- const children = sourceChildren.map((item) => normalizeNode(item, depth + 1));
- const normalizedNode = {
- ...raw,
- id: nodeId,
- key: raw.key ?? nodeId,
- name: raw.name || raw.otherName || '未知',
- collapsed: Boolean(raw.collapsed),
- hasChildrenFlag: sourceChildren.length > 0,
- state: raw.state || (direction.value === 'forward' ? 'left' : 'right'),
- children,
- };
- nodeRawMap.value[nodeId] = normalizedNode;
- return normalizedNode;
- };
- const normalizeData = (): MoneyFlowGraphNode | null => {
- const raw = props.data;
- if (!raw) return null;
- nodeRawMap.value = {};
- if (Array.isArray(raw)) {
- if (!raw.length) return null;
- return normalizeNode({
- id: 'root',
- name: raw[0].otherName || raw[0].name || '当前对象',
- state: direction.value === 'forward' ? 'left' : 'right',
- children: raw,
- });
- }
- return normalizeNode(raw);
- };
- const getNodeRaw = (datum?: NodeData | null): MoneyFlowGraphNode | null => {
- const payload = datum?.data as { raw?: MoneyFlowGraphNode } | undefined;
- if (payload?.raw) return payload.raw;
- const nodeId = toText(datum?.id);
- if (nodeId && nodeRawMap.value[nodeId]) {
- return nodeRawMap.value[nodeId];
- }
- const fallbackDatum = datum as unknown as MoneyFlowGraphNode | null | undefined;
- if (fallbackDatum?.name || fallbackDatum?.otherName) {
- return fallbackDatum;
- }
- return null;
- };
- const getEdgeRaw = (datum?: EdgeData | null): MoneyFlowGraphNode | null => {
- const payload = datum?.data as { raw?: MoneyFlowGraphNode } | undefined;
- if (payload?.raw) return payload.raw;
- const edgeId = toText(datum?.id);
- if (edgeId && edgeRawMap.value[edgeId]) {
- return edgeRawMap.value[edgeId];
- }
- return null;
- };
- const getEventData = <T extends NodeData | EdgeData>(event: IElementEvent): T | undefined => {
- const inlineData = (event as IElementEvent & { data?: T }).data;
- if (inlineData) return inlineData;
- const targetId = toText(event?.target?.id);
- if (!graph.value || !targetId) return undefined;
- try {
- return graph.value.getElementData(targetId) as T;
- } catch (error) {
- return undefined;
- }
- };
- const getNodeDataById = (id?: string | number | null) => {
- if (!graph.value || id === null || id === undefined || id === '') return null;
- try {
- return graph.value.getNodeData(String(id)) as NodeData;
- } catch (error) {
- return null;
- }
- };
- const hasChildren = (node?: MoneyFlowGraphNode | null) => {
- if (!node) return false;
- if (typeof node.hasChildrenFlag === 'boolean') return node.hasChildrenFlag;
- return Array.isArray(node.children) && node.children.length > 0;
- };
- const isCollapsibleNode = (nodeDatum?: NodeData | null) => {
- const style = nodeDatum?.style as Record<string, unknown> | undefined;
- if (typeof style?.cardCollapsible === 'boolean') {
- return Boolean(style.cardCollapsible);
- }
- return hasChildren(getNodeRaw(nodeDatum));
- };
- const isIncomeFlag = (flag?: string) => /[收入贷]/.test(toText(flag));
- const isExpenseFlag = (flag?: string) => /[支出借]/.test(toText(flag));
- const buildEdgeTitle = (node?: MoneyFlowGraphNode | null) => {
- if (!node) return '交易关系';
- const minDate = toText(node.minDate);
- const maxDate = toText(node.maxDate) || toText(node.maxTime);
- if (minDate && maxDate) return `${minDate}至${maxDate}`;
- return minDate || maxDate || '交易关系';
- };
- const buildEdgeLineText = (node?: MoneyFlowGraphNode | null) => {
- if (!node) return `转账:${formatAmount(0)}`;
- const lineData = toText(node.lineData);
- if (!lineData) {
- return `转账:${formatAmount(node.totalTransAmount)}`;
- }
- const matched = lineData.match(/^([^::]+)[::]\s*(.*)$/);
- if (!matched) {
- return lineData;
- }
- const [, actionText, rawAmount] = matched;
- const normalizedAmount = toText(rawAmount);
- const invalidAmount = !normalizedAmount || /^(null|undefined|nan|-)$/i.test(normalizedAmount);
- const amountText = invalidAmount ? formatAmount(node.totalTransAmount) : formatAmount(normalizedAmount);
- return `${actionText.trim()}:${amountText}`;
- };
- const buildEdgeValue = (node?: MoneyFlowGraphNode | null) => {
- if (!node) return '-';
- if (toText(node.lineData)) return buildEdgeLineText(node);
- return `共${toNumber(node.transCount)}笔 / ¥${formatAmount(node.totalTransAmount)}`;
- };
- const buildEdgeLabelText = (node?: MoneyFlowGraphNode | null) => {
- if (!node) return '-';
- const dateText = toText(node.maxDate) || toText(node.maxTime);
- const lineText = buildEdgeLineText(node);
- return dateText ? `${dateText} ${lineText}` : lineText;
- };
- const buildTooltipHtml = (edgeDatum?: EdgeData | null) => {
- const raw = getEdgeRaw(edgeDatum);
- if (!raw) {
- return `
- <div style="min-width:156px;padding:8px 10px;box-sizing:border-box;">
- <div style="color:#334155;font-size:12px;font-weight:600;line-height:18px;">交易关系</div>
- </div>
- `;
- }
- return `
- <div style="min-width:156px;padding:8px 10px;box-sizing:border-box;">
- <div style="color:#334155;font-size:12px;font-weight:600;line-height:18px;">${escapeHtml(buildEdgeTitle(raw))}</div>
- <div style="margin-top:4px;color:#64748b;font-size:11px;line-height:16px;">${escapeHtml(buildEdgeValue(raw))}</div>
- </div>
- `;
- };
- const buildGraphData = (root: MoneyFlowGraphNode): GraphData => {
- edgeRawMap.value = {};
- return treeToGraphData(root as any, {
- getChildren: (datum) => datum.children || [],
- getNodeData: (datum, depth) => {
- const titleText = toText(datum.name || datum.otherName) || '未知';
- const hasChildrenFlag = hasChildren(datum);
- return {
- id: String(datum.id ?? datum.key ?? `${datum.name || datum.otherName || 'node'}-${depth}`),
- type: MONEY_FLOW_CARD_NODE_TYPE,
- depth,
- side: 'right',
- children: (datum.children || []).map((child) =>
- String(child.id ?? child.key ?? `${child.name || child.otherName || 'node'}-${depth + 1}`),
- ),
- data: { raw: datum },
- style: {
- size: [CARD_WIDTH, CARD_HEIGHT],
- cursor: 'pointer',
- collapsed: Boolean(datum.collapsed),
- cardTitle: titleText,
- cardIsRoot: depth === 0,
- cardCollapsible: hasChildrenFlag,
- label: false,
- icon: false,
- badge: false,
- port: false,
- halo: false,
- } as any,
- };
- },
- getEdgeData: (source, target) => {
- const edgeId = `${String(source.id ?? source.key)}->${String(target.id ?? target.key)}`;
- edgeRawMap.value[edgeId] = target;
- return {
- id: edgeId,
- source: String(source.id ?? source.key),
- target: String(target.id ?? target.key),
- data: {
- raw: target,
- ids: parseIds(target.ids),
- },
- };
- },
- });
- };
- const getContainerSize = (): [number, number] => {
- const width = Math.max(containerRef.value?.clientWidth || 0, MIN_WIDTH);
- const height = Math.max(containerRef.value?.clientHeight || 0, MIN_HEIGHT);
- return [width, height];
- };
- const isGraphUsable = (instance: Graph | null | undefined): instance is Graph => {
- return Boolean(instance && !(instance as Graph & { destroyed?: boolean }).destroyed);
- };
- const destroyGraph = () => {
- if (hitAreaFrameId.value !== null) {
- cancelAnimationFrame(hitAreaFrameId.value);
- hitAreaFrameId.value = null;
- }
- if (hitAreaTimeoutId.value !== null) {
- clearTimeout(hitAreaTimeoutId.value);
- hitAreaTimeoutId.value = null;
- }
- const currentGraph = graph.value;
- if (!isGraphUsable(currentGraph)) {
- graph.value = null;
- nodeHitAreas.value = [];
- edgeLabelHitAreas.value = [];
- return;
- }
- currentGraph.destroy();
- graph.value = null;
- nodeHitAreas.value = [];
- edgeLabelHitAreas.value = [];
- };
- const syncCollapsedState = (id: string, collapsed: boolean) => {
- const raw = nodeRawMap.value[id];
- if (raw) {
- raw.collapsed = collapsed;
- }
- };
- const updateDebugText = (_message: string) => {};
- const handleOverlayWheel = (event: WheelEvent) => {
- const currentGraph = graph.value;
- if (!isGraphUsable(currentGraph)) return;
- const delta = -(event.deltaY || event.deltaX || 0);
- const normalized = Math.max(-50, Math.min(50, delta));
- const ratio = 1 + normalized / 100;
- const canvasPoint = currentGraph.getCanvasByClient([event.clientX, event.clientY]);
- const origin = currentGraph.getViewportByCanvas(canvasPoint);
- void currentGraph.zoomTo(currentGraph.getZoom() * ratio, false, origin);
- };
- const emitEdgeClick = (edgeDatum?: EdgeData | null) => {
- const raw = getEdgeRaw(edgeDatum);
- const edgePayload = edgeDatum?.data as { ids?: unknown } | undefined;
- const ids = parseIds(raw?.ids ?? edgePayload?.ids);
- if (!ids.length) return false;
- const sourceId = toText(edgeDatum?.source);
- const targetId = toText(edgeDatum?.target);
- const sourceNode = sourceId ? getNodeRaw(getNodeDataById(sourceId)) : null;
- const targetNode = targetId ? getNodeRaw(getNodeDataById(targetId)) : null;
- updateDebugText(`点击关系线,交易明细 ${ids.length} 条`);
- emit('edge-click', {
- ids: [...ids],
- edgeId: toText(edgeDatum?.id),
- sourceId: sourceId || undefined,
- targetId: targetId || undefined,
- raw: cloneNodeSnapshot(raw),
- sourceNode: cloneNodeSnapshot(sourceNode),
- targetNode: cloneNodeSnapshot(targetNode),
- });
- return true;
- };
- const getEdgeDataById = (id?: string | number | null) => {
- const currentGraph = graph.value;
- if (!currentGraph || id === null || id === undefined || id === '') return null;
- try {
- return currentGraph.getEdgeData(String(id)) as EdgeData;
- } catch (error) {
- try {
- return currentGraph.getElementData(String(id)) as EdgeData;
- } catch (fallbackError) {
- return null;
- }
- }
- };
- const updateHitAreas = () => {
- const currentGraph = graph.value;
- if (!isGraphUsable(currentGraph) || !wrapperRef.value) {
- nodeHitAreas.value = [];
- edgeLabelHitAreas.value = [];
- return;
- }
- const wrapperRect = wrapperRef.value.getBoundingClientRect();
- let nodeDataList: NodeData[] = [];
- let edgeDataList: EdgeData[] = [];
- try {
- nodeDataList = currentGraph.getNodeData();
- edgeDataList = currentGraph.getEdgeData();
- } catch (error) {
- nodeHitAreas.value = [];
- edgeLabelHitAreas.value = [];
- return;
- }
- const nextAreas = nodeDataList
- .map((datum) => {
- const id = toText(datum.id);
- if (!id) return null;
- try {
- if (currentGraph.getElementVisibility(id) === 'hidden') return null;
- } catch (error) {
- // ignore visibility lookup failure and keep best-effort hit area creation
- }
- const raw = getNodeRaw(datum);
- if (!raw) return null;
- try {
- const bounds = currentGraph.getElementRenderBounds(id);
- const [minX, minY] = currentGraph.getClientByCanvas(bounds.min);
- const [maxX, maxY] = currentGraph.getClientByCanvas(bounds.max);
- const width = Math.max(24, maxX - minX);
- const height = Math.max(24, maxY - minY);
- return {
- id,
- left: minX - wrapperRect.left,
- top: minY - wrapperRect.top,
- width,
- height,
- title: hasChildren(raw)
- ? `${raw.name || raw.otherName || '节点'}(单击展开/收起)`
- : raw.name || raw.otherName || '节点',
- collapsible: hasChildren(raw),
- } satisfies NodeHitArea;
- } catch (error) {
- return null;
- }
- })
- .filter((item): item is NodeHitArea => Boolean(item));
- nodeHitAreas.value = nextAreas;
- const zoom = typeof currentGraph.getZoom === 'function' ? currentGraph.getZoom() : 1;
- const labelScale = Math.max(0.65, Math.min(1.35, zoom || 1));
- const nextEdgeLabelAreas = edgeDataList
- .map((datum) => {
- const id = toText(datum.id);
- if (!id) return null;
- try {
- if (currentGraph.getElementVisibility(id) === 'hidden') return null;
- } catch (error) {
- // ignore visibility lookup failure and keep best-effort hit area creation
- }
- const raw = getEdgeRaw(datum);
- const ids = parseIds(raw?.ids ?? (datum.data as { ids?: unknown } | undefined)?.ids);
- const labelText = buildEdgeLabelText(raw);
- if (!ids.length || !labelText || labelText === '-') return null;
- try {
- const sourceId = toText(datum.source);
- const targetId = toText(datum.target);
- if (!sourceId || !targetId) return null;
- const sourcePosition = currentGraph.getElementPosition(sourceId);
- const targetPosition = currentGraph.getElementPosition(targetId);
- const [sourceClientX, sourceClientY] = currentGraph.getClientByCanvas(sourcePosition);
- const [targetClientX, targetClientY] = currentGraph.getClientByCanvas(targetPosition);
- const centerX = (sourceClientX + targetClientX) / 2 - wrapperRect.left;
- const centerY = (sourceClientY + targetClientY) / 2 - wrapperRect.top;
- const width = Math.max(96, Math.min(240, labelText.length * 7 + 24)) * labelScale;
- const height = 32 * labelScale;
- return {
- id,
- left: centerX - width / 2,
- top: centerY - height / 2,
- width,
- height,
- title: `点击查看交易明细:${labelText}`,
- } satisfies EdgeLabelHitArea;
- } catch (error) {
- return null;
- }
- })
- .filter((item): item is EdgeLabelHitArea => Boolean(item));
- edgeLabelHitAreas.value = nextEdgeLabelAreas;
- updateDebugText(`可点击节点 ${nextAreas.length} 个,关系线文本 ${nextEdgeLabelAreas.length} 个`);
- };
- const scheduleHitAreaSync = (delay = 0) => {
- if (hitAreaTimeoutId.value !== null) {
- clearTimeout(hitAreaTimeoutId.value);
- hitAreaTimeoutId.value = null;
- }
- if (hitAreaFrameId.value !== null) {
- cancelAnimationFrame(hitAreaFrameId.value);
- }
- const run = () => {
- hitAreaFrameId.value = window.requestAnimationFrame(() => {
- hitAreaFrameId.value = null;
- updateHitAreas();
- });
- };
- if (delay > 0) {
- hitAreaTimeoutId.value = window.setTimeout(() => {
- hitAreaTimeoutId.value = null;
- run();
- }, delay);
- return;
- }
- run();
- };
- const handleNodeOverlayClick = (id: string) => {
- const nodeDatum = getNodeDataById(id);
- const raw = getNodeRaw(nodeDatum);
- if (!nodeDatum || !raw) return;
- emit('node-click', raw);
- if (!isCollapsibleNode(nodeDatum) || !isGraphUsable(graph.value) || collapsingNodeId.value) {
- updateDebugText(`点击节点 ${raw.name || raw.otherName || id}`);
- return;
- }
- const collapsed = Boolean((nodeDatum.style as Record<string, unknown> | undefined)?.collapsed);
- collapsingNodeId.value = id;
- updateDebugText(`${collapsed ? '展开' : '收起'} ${raw.name || raw.otherName || id}`);
- void (async () => {
- try {
- const currentGraph = graph.value;
- if (!isGraphUsable(currentGraph)) return;
- if (collapsed) {
- await currentGraph.expandElement(id, { animation: true, align: true });
- syncCollapsedState(id, false);
- } else {
- await currentGraph.collapseElement(id, { animation: true, align: true });
- syncCollapsedState(id, true);
- }
- } catch (error) {
- updateDebugText(`折叠失败: ${raw.name || raw.otherName || id}`);
- console.error('[MoneyFlowGraph] toggle collapse failed:', error);
- } finally {
- collapsingNodeId.value = '';
- scheduleHitAreaSync(120);
- }
- })();
- };
- const handleEdgeOverlayClick = (id: string) => {
- const edgeDatum = getEdgeDataById(id);
- emitEdgeClick(edgeDatum);
- };
- const bindEvents = () => {
- const currentGraph = graph.value;
- if (!isGraphUsable(currentGraph)) return;
- currentGraph.on<IElementEvent>(EdgeEvent.CLICK, (event) => {
- const edgeDatum = getEventData<EdgeData>(event);
- emitEdgeClick(edgeDatum);
- });
- currentGraph.on<IElementEvent>(EdgeEvent.POINTER_ENTER, (event) => {
- const edgeId = getEventData<EdgeData>(event)?.id;
- if (!edgeId || !isGraphUsable(graph.value)) return;
- void graph.value.setElementState(String(edgeId), 'active');
- });
- currentGraph.on<IElementEvent>(EdgeEvent.POINTER_LEAVE, (event) => {
- const edgeId = getEventData<EdgeData>(event)?.id;
- if (!edgeId || !isGraphUsable(graph.value)) return;
- void graph.value.setElementState(String(edgeId), []);
- });
- currentGraph.on(GraphEvent.AFTER_RENDER, () => {
- scheduleHitAreaSync();
- });
- currentGraph.on(GraphEvent.AFTER_TRANSFORM, () => {
- scheduleHitAreaSync(80);
- });
- };
- const handleResize = () => {
- const currentGraph = graph.value;
- if (!isGraphUsable(currentGraph)) return;
- const [width, height] = getContainerSize();
- const previousSize = lastResizeSize.value;
- if (previousSize && previousSize[0] === width && previousSize[1] === height) {
- return;
- }
- lastResizeSize.value = [width, height];
- currentGraph.resize(width, height);
- scheduleHitAreaSync();
- };
- const scheduleResize = () => {
- if (resizeFrameId.value !== null) {
- cancelAnimationFrame(resizeFrameId.value);
- }
- resizeFrameId.value = window.requestAnimationFrame(() => {
- resizeFrameId.value = null;
- handleResize();
- });
- };
- const createGraph = (graphData: GraphData, width: number, height: number) => {
- ensureMoneyFlowCardNodeRegistered();
- return new Graph({
- container: containerRef.value!,
- width,
- height,
- autoResize: false,
- data: graphData,
- layout: {
- type: 'compact-box',
- direction: 'LR',
- getId: (datum: any) => String(datum.id),
- getWidth: () => CARD_WIDTH,
- getHeight: () => CARD_HEIGHT,
- getHGap: () => H_GAP,
- getVGap: () => V_GAP,
- },
- node: {
- type: MONEY_FLOW_CARD_NODE_TYPE,
- style: {
- size: [CARD_WIDTH, CARD_HEIGHT],
- cursor: 'pointer',
- },
- },
- edge: {
- type: 'cubic-horizontal',
- style: {
- stroke: '#9db7d6',
- lineWidth: 2.5,
- cursor: (datum: EdgeData) => (parseIds(getEdgeRaw(datum)?.ids).length ? 'pointer' : 'default'),
- label: (datum: EdgeData) => Boolean(buildEdgeLabelText(getEdgeRaw(datum))),
- labelText: (datum: EdgeData) => buildEdgeLabelText(getEdgeRaw(datum)),
- labelPlacement: 'center',
- labelAutoRotate: true,
- labelWordWrap: true,
- labelMaxWidth: 220,
- labelFill: '#5e5e5e',
- labelFontSize: 12,
- labelBackground: false,
- startArrow: (datum: EdgeData) => getEdgeRaw(datum)?.state === 'left',
- startArrowFill: '#9db7d6',
- startArrowSize: EDGE_ARROW_SIZE,
- startArrowOffset: EDGE_ARROW_OFFSET,
- endArrow: (datum: EdgeData) => getEdgeRaw(datum)?.state !== 'left',
- endArrowFill: '#9db7d6',
- endArrowSize: EDGE_ARROW_SIZE,
- endArrowOffset: EDGE_ARROW_OFFSET,
- },
- state: {
- active: {
- stroke: '#3b82f6',
- lineWidth: 3,
- labelFill: '#334155',
- },
- },
- },
- behaviors: ['drag-canvas', 'zoom-canvas'],
- plugins: [
- {
- type: 'tooltip',
- key: 'capital-flow-edge-tooltip',
- enable: (event) => event.targetType === 'edge',
- getContent: async (_event, items) => buildTooltipHtml(items?.[0] as EdgeData | undefined),
- },
- ],
- });
- };
- const updateGraphData = async (graphData: GraphData, width: number, height: number) => {
- const currentGraph = graph.value;
- if (!isGraphUsable(currentGraph)) return;
- currentGraph.setSize(width, height);
- currentGraph.setData(graphData);
- await currentGraph.render();
- };
- const renderGraph = async (currentRenderVersion: number) => {
- const data = normalizeData();
- if (!containerRef.value || !data) {
- if (currentRenderVersion === renderVersion) {
- destroyGraph();
- }
- return;
- }
- await nextTick();
- if (currentRenderVersion !== renderVersion || !containerRef.value) return;
- const graphData = buildGraphData(data);
- const [width, height] = getContainerSize();
- lastResizeSize.value = [width, height];
- if (!graph.value) {
- const nextGraph = createGraph(graphData, width, height);
- if (currentRenderVersion !== renderVersion) {
- nextGraph.destroy();
- return;
- }
- graph.value = nextGraph;
- bindEvents();
- await nextGraph.render();
- if (currentRenderVersion !== renderVersion) {
- nextGraph.destroy();
- if (graph.value === nextGraph) {
- graph.value = null;
- }
- return;
- }
- } else {
- await updateGraphData(graphData, width, height);
- if (currentRenderVersion !== renderVersion) {
- return;
- }
- }
- scheduleHitAreaSync();
- };
- const scheduleRenderGraph = () => {
- const currentRenderVersion = ++renderVersion;
- renderQueue = renderQueue
- .catch(() => {})
- .then(async () => {
- await renderGraph(currentRenderVersion);
- });
- };
- watch(
- () => props.data,
- () => {
- scheduleRenderGraph();
- },
- );
- onMounted(() => {
- resizeObserver.value = new ResizeObserver(() => {
- scheduleResize();
- });
- if (containerRef.value) {
- resizeObserver.value.observe(containerRef.value);
- }
- scheduleRenderGraph();
- });
- onBeforeUnmount(() => {
- if (resizeFrameId.value !== null) {
- cancelAnimationFrame(resizeFrameId.value);
- resizeFrameId.value = null;
- }
- if (hitAreaTimeoutId.value !== null) {
- clearTimeout(hitAreaTimeoutId.value);
- hitAreaTimeoutId.value = null;
- }
- if (hitAreaFrameId.value !== null) {
- cancelAnimationFrame(hitAreaFrameId.value);
- hitAreaFrameId.value = null;
- }
- lastResizeSize.value = null;
- resizeObserver.value?.disconnect();
- resizeObserver.value = null;
- destroyGraph();
- });
- </script>
- <style scoped lang="less">
- .money-flow-graph {
- position: relative;
- width: 100%;
- height: 100%;
- min-height: 320px;
- }
- .money-flow-graph__canvas,
- .money-flow-graph__overlay {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- }
- .money-flow-graph__overlay {
- pointer-events: none;
- z-index: 2;
- }
- .money-flow-graph__hit-area {
- position: absolute;
- border: none;
- background: transparent;
- pointer-events: auto;
- cursor: pointer;
- outline: none;
- z-index: 2;
- }
- .money-flow-graph__hit-area.is-collapsible {
- cursor: pointer;
- }
- .money-flow-graph__edge-hit-area {
- position: absolute;
- border: none;
- background: transparent;
- pointer-events: auto;
- cursor: pointer;
- outline: none;
- z-index: 1;
- }
- </style>
|