640 lines
15 KiB
Vue
640 lines
15 KiB
Vue
<template>
|
|
<div ref="chartElement" class="chart" />
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { ECharts, EChartsOption } from "echarts";
|
|
import { GraphChart } from "echarts/charts";
|
|
import { TooltipComponent } from "echarts/components";
|
|
import { init, use } from "echarts/core";
|
|
import { CanvasRenderer } from "echarts/renderers";
|
|
import type { Customer } from "@/types/Customer";
|
|
|
|
use([GraphChart, TooltipComponent, CanvasRenderer]);
|
|
|
|
type RelationNode = {
|
|
id: string;
|
|
name: string;
|
|
x: number;
|
|
y: number;
|
|
draggable: boolean;
|
|
symbol: string;
|
|
symbolSize: [number, number];
|
|
cursor: "move";
|
|
itemStyle: {
|
|
color: string;
|
|
};
|
|
label: {
|
|
color: string;
|
|
fontSize: number;
|
|
show: boolean;
|
|
silent: boolean;
|
|
overflow: "truncate";
|
|
position: "inside";
|
|
width: number;
|
|
};
|
|
};
|
|
|
|
type RelationLink = {
|
|
source: string;
|
|
target: string;
|
|
lineStyle: {
|
|
color: string;
|
|
width: number;
|
|
opacity: number;
|
|
};
|
|
};
|
|
|
|
type RelationGraphPoint = [number, number];
|
|
|
|
type RelationGraphNode = {
|
|
dataIndex: number;
|
|
getLayout: () => RelationGraphPoint;
|
|
getGraphicEl: () => {
|
|
scaleX?: number;
|
|
};
|
|
};
|
|
|
|
type RelationGraphLinePath = {
|
|
pointAt: (percent: number) => RelationGraphPoint;
|
|
tangentAt: (percent: number) => RelationGraphPoint;
|
|
};
|
|
|
|
type RelationGraphArrow = {
|
|
setPosition: (position: RelationGraphPoint) => void;
|
|
markRedraw: () => void;
|
|
};
|
|
|
|
type RelationGraphLine = {
|
|
setLinePoints: (points: RelationGraphPoint[]) => void;
|
|
childOfName: (name: string) => RelationGraphLinePath | RelationGraphArrow | undefined;
|
|
beforeUpdate?: (this: RelationGraphLine) => void;
|
|
relationArrowOffset?: number;
|
|
relationArrowPatched?: boolean;
|
|
};
|
|
|
|
type RelationGraphEdge = {
|
|
node1: RelationGraphNode;
|
|
node2: RelationGraphNode;
|
|
getLayout: () => RelationGraphPoint[];
|
|
setLayout: (layout: RelationGraphPoint[]) => void;
|
|
getGraphicEl: () => RelationGraphLine;
|
|
};
|
|
|
|
type RelationGraph = {
|
|
eachEdge: (callback: (edge: RelationGraphEdge) => void) => void;
|
|
getNodeById: (id: string) => RelationGraphNode | undefined;
|
|
};
|
|
|
|
type RelationGraphSeriesModel = {
|
|
get: (path: string) => unknown;
|
|
getGraph: () => RelationGraph;
|
|
};
|
|
|
|
type RelationChartModel = {
|
|
getSeriesByIndex: (index: number) => RelationGraphSeriesModel | undefined;
|
|
};
|
|
|
|
type RelationChart = {
|
|
getModel: () => RelationChartModel;
|
|
};
|
|
|
|
defineOptions({
|
|
name: "CustomerInviteRelationChart"
|
|
});
|
|
|
|
const props = defineProps<{
|
|
customers: Customer[];
|
|
}>();
|
|
|
|
const chartElement = ref<HTMLElement>();
|
|
const chartOption = computed<EChartsOption>(() => buildChartOption(props.customers));
|
|
const nodeSymbol = "path://M 6 0 H 50 A 6 6 0 0 1 56 6 V 18 A 6 6 0 0 1 50 24 H 6 A 6 6 0 0 1 0 18 V 6 A 6 6 0 0 1 6 0 Z";
|
|
const nodeSize: [number, number] = [56, 24];
|
|
const nodeCornerRadius = 6;
|
|
const relationArrowLength = 4;
|
|
const relationArrowTipOffset = 2;
|
|
const relationEdgeGap = 0;
|
|
const nodeLabelFontSize = 10;
|
|
const nodeLabelWidth = 48;
|
|
const centerTransitionDuration = 300;
|
|
let chart: ECharts | undefined;
|
|
let centerAnimationFrame: number | undefined;
|
|
|
|
function getChartZoom(): number {
|
|
const seriesModel = getRelationSeriesModel();
|
|
const modelZoom = seriesModel?.get("zoom");
|
|
if (typeof modelZoom === "number") {
|
|
return modelZoom;
|
|
}
|
|
|
|
const option = chart?.getOption();
|
|
const series = Array.isArray(option?.series) ? option.series[0] : option?.series;
|
|
const zoom = (series as { zoom?: unknown } | undefined)?.zoom;
|
|
return typeof zoom === "number" ? zoom : 1;
|
|
}
|
|
|
|
function getRelationSeriesModel(): RelationGraphSeriesModel | undefined {
|
|
return (chart as unknown as RelationChart | undefined)?.getModel().getSeriesByIndex(0);
|
|
}
|
|
|
|
function getNodeGraphicScale(node: RelationGraphNode): number {
|
|
const scale = node.getGraphicEl().scaleX;
|
|
return typeof scale === "number" && 0 < scale ? scale : 1;
|
|
}
|
|
|
|
function getRoundedRectBoundary(
|
|
directionX: number,
|
|
directionY: number,
|
|
halfWidth: number,
|
|
halfHeight: number,
|
|
cornerRadius: number
|
|
): number {
|
|
const absoluteX = Math.abs(directionX);
|
|
const absoluteY = Math.abs(directionY);
|
|
if (absoluteX < Number.EPSILON) {
|
|
return halfHeight;
|
|
}
|
|
if (absoluteY < Number.EPSILON) {
|
|
return halfWidth;
|
|
}
|
|
|
|
const sideDistance = Math.min(halfWidth / absoluteX, halfHeight / absoluteY);
|
|
const cornerX = halfWidth - cornerRadius;
|
|
const cornerY = halfHeight - cornerRadius;
|
|
const sidePointX = sideDistance * absoluteX;
|
|
const sidePointY = sideDistance * absoluteY;
|
|
if (sidePointX <= cornerX || sidePointY <= cornerY) {
|
|
return sideDistance;
|
|
}
|
|
|
|
const projection = cornerX * absoluteX + cornerY * absoluteY;
|
|
const cross = cornerX * absoluteY - cornerY * absoluteX;
|
|
const discriminant = Math.max(0, cornerRadius ** 2 - cross ** 2);
|
|
return projection + Math.sqrt(discriminant);
|
|
}
|
|
|
|
function patchRelationArrowPosition(line: RelationGraphLine): void {
|
|
if (!line.beforeUpdate || line.relationArrowPatched) {
|
|
return;
|
|
}
|
|
|
|
const beforeUpdate = line.beforeUpdate;
|
|
line.relationArrowPatched = true;
|
|
line.beforeUpdate = function (this: RelationGraphLine): void {
|
|
beforeUpdate.call(this);
|
|
const offset = this.relationArrowOffset || 0;
|
|
if (!offset) {
|
|
return;
|
|
}
|
|
|
|
const linePath = this.childOfName("line") as RelationGraphLinePath | undefined;
|
|
const arrow = this.childOfName("toSymbol") as RelationGraphArrow | undefined;
|
|
if (!linePath || !arrow) {
|
|
return;
|
|
}
|
|
|
|
const endpoint = linePath.pointAt(1);
|
|
const tangent = linePath.tangentAt(1);
|
|
const tangentLength = Math.hypot(tangent[0], tangent[1]);
|
|
if (tangentLength < Number.EPSILON) {
|
|
return;
|
|
}
|
|
arrow.setPosition([
|
|
endpoint[0] + tangent[0] / tangentLength * offset,
|
|
endpoint[1] + tangent[1] / tangentLength * offset
|
|
]);
|
|
arrow.markRedraw();
|
|
};
|
|
}
|
|
|
|
function adjustRelationEdges(): void {
|
|
if (!chart) {
|
|
return;
|
|
}
|
|
|
|
const series = (chart as unknown as RelationChart).getModel().getSeriesByIndex(0);
|
|
const graph = series?.getGraph();
|
|
if (!graph) {
|
|
return;
|
|
}
|
|
|
|
graph.eachEdge(edge => {
|
|
const source = edge.node1.getLayout();
|
|
const target = edge.node2.getLayout();
|
|
const directionX = target[0] - source[0];
|
|
const directionY = target[1] - source[1];
|
|
const distance = Math.hypot(directionX, directionY);
|
|
if (distance < Number.EPSILON) {
|
|
return;
|
|
}
|
|
|
|
const unitX = directionX / distance;
|
|
const unitY = directionY / distance;
|
|
const sourceScale = getNodeGraphicScale(edge.node1);
|
|
const targetScale = getNodeGraphicScale(edge.node2);
|
|
const sourceBoundary = getRoundedRectBoundary(
|
|
unitX,
|
|
unitY,
|
|
nodeSize[0] / 2 * sourceScale,
|
|
nodeSize[1] / 2 * sourceScale,
|
|
nodeCornerRadius * sourceScale
|
|
);
|
|
const targetBoundary = getRoundedRectBoundary(
|
|
unitX,
|
|
unitY,
|
|
nodeSize[0] / 2 * targetScale,
|
|
nodeSize[1] / 2 * targetScale,
|
|
nodeCornerRadius * targetScale
|
|
);
|
|
const sourceInset = sourceBoundary + relationEdgeGap * sourceScale;
|
|
const targetInset = targetBoundary + (relationArrowLength + relationEdgeGap) * targetScale;
|
|
if (distance <= sourceInset + targetInset) {
|
|
return;
|
|
}
|
|
|
|
const line = edge.getGraphicEl();
|
|
patchRelationArrowPosition(line);
|
|
line.relationArrowOffset = relationArrowTipOffset * targetScale;
|
|
const points = edge.getLayout();
|
|
if (points.length !== 2) {
|
|
return;
|
|
}
|
|
|
|
const sourcePoint = points[0];
|
|
const targetPoint = points[1];
|
|
const nextSourceX = source[0] + unitX * sourceInset;
|
|
const nextSourceY = source[1] + unitY * sourceInset;
|
|
const nextTargetX = target[0] - unitX * targetInset;
|
|
const nextTargetY = target[1] - unitY * targetInset;
|
|
if (
|
|
sourcePoint[0] === nextSourceX &&
|
|
sourcePoint[1] === nextSourceY &&
|
|
targetPoint[0] === nextTargetX &&
|
|
targetPoint[1] === nextTargetY
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// 保留 ECharts 写入的原始中心点,避免下一次布局继续累积缩短距离。
|
|
sourcePoint[0] = nextSourceX;
|
|
sourcePoint[1] = nextSourceY;
|
|
targetPoint[0] = nextTargetX;
|
|
targetPoint[1] = nextTargetY;
|
|
edge.setLayout(points);
|
|
line.setLinePoints(points);
|
|
});
|
|
}
|
|
|
|
function getRelationGraph(): RelationGraph | undefined {
|
|
return getRelationSeriesModel()?.getGraph();
|
|
}
|
|
|
|
function focusCustomer(customer: Customer): void {
|
|
const id = customer.id;
|
|
if (!id || !chart) {
|
|
return;
|
|
}
|
|
|
|
const node = getRelationGraph()?.getNodeById(id);
|
|
if (!node) {
|
|
return;
|
|
}
|
|
|
|
chart.dispatchAction({ type: "downplay", seriesIndex: 0 }, { silent: true });
|
|
chart.dispatchAction({ type: "unfocusNodeAdjacency", seriesIndex: 0 }, { silent: true });
|
|
chart.dispatchAction({
|
|
type: "highlight",
|
|
seriesIndex: 0,
|
|
dataIndex: node.dataIndex
|
|
}, { silent: true });
|
|
centerChartOnNode(node);
|
|
}
|
|
|
|
function centerChartOnNode(node: RelationGraphNode): void {
|
|
if (!chart) {
|
|
return;
|
|
}
|
|
|
|
stopCenterAnimation();
|
|
const pixel = chart.convertToPixel({ seriesIndex: 0 }, node.getLayout());
|
|
if (!Array.isArray(pixel) || 2 > pixel.length) {
|
|
return;
|
|
}
|
|
|
|
const deltaX = chart.getWidth() / 2 - pixel[0];
|
|
const deltaY = chart.getHeight() / 2 - pixel[1];
|
|
if (Math.abs(deltaX) < 1 && Math.abs(deltaY) < 1) {
|
|
return;
|
|
}
|
|
|
|
const beginAt = window.performance.now();
|
|
let previousProgress = 0;
|
|
const animate = (timestamp: number): void => {
|
|
if (!chart) {
|
|
centerAnimationFrame = undefined;
|
|
return;
|
|
}
|
|
|
|
const progress = Math.min(1, (timestamp - beginAt) / centerTransitionDuration);
|
|
const easedProgress = 1 - (1 - progress) ** 3;
|
|
const deltaProgress = easedProgress - previousProgress;
|
|
if (0 < deltaProgress) {
|
|
chart.dispatchAction({
|
|
type: "graphRoam",
|
|
seriesIndex: 0,
|
|
dx: deltaX * deltaProgress,
|
|
dy: deltaY * deltaProgress
|
|
}, { silent: true });
|
|
}
|
|
previousProgress = easedProgress;
|
|
if (1 <= progress) {
|
|
centerAnimationFrame = undefined;
|
|
return;
|
|
}
|
|
centerAnimationFrame = window.requestAnimationFrame(animate);
|
|
};
|
|
|
|
centerAnimationFrame = window.requestAnimationFrame(animate);
|
|
}
|
|
|
|
function stopCenterAnimation(): void {
|
|
if (centerAnimationFrame === undefined) {
|
|
return;
|
|
}
|
|
window.cancelAnimationFrame(centerAnimationFrame);
|
|
centerAnimationFrame = undefined;
|
|
}
|
|
|
|
function clearFocus(): void {
|
|
if (!chart) {
|
|
return;
|
|
}
|
|
chart.dispatchAction({ type: "downplay", seriesIndex: 0 }, { silent: true });
|
|
chart.dispatchAction({ type: "unfocusNodeAdjacency", seriesIndex: 0 }, { silent: true });
|
|
}
|
|
|
|
function buildChartOption(list: Customer[]): EChartsOption {
|
|
const customerMap = new Map<string, Customer>();
|
|
for (const customer of list) {
|
|
if (customer.id) {
|
|
customerMap.set(customer.id, customer);
|
|
}
|
|
}
|
|
|
|
const depthMap = new Map<string, number>();
|
|
const levelMap = new Map<number, Customer[]>();
|
|
for (const customer of customerMap.values()) {
|
|
const depth = getCustomerDepth(customer, customerMap, depthMap, new Set<string>());
|
|
const levelList = levelMap.get(depth) || [];
|
|
levelList.push(customer);
|
|
levelMap.set(depth, levelList);
|
|
}
|
|
|
|
const nodeList: RelationNode[] = [];
|
|
const linkList: RelationLink[] = [];
|
|
for (const [depth, levelList] of levelMap) {
|
|
const levelOffset = (levelList.length - 1) / 2;
|
|
levelList.forEach((customer, index) => {
|
|
const id = customer.id;
|
|
if (!id) {
|
|
return;
|
|
}
|
|
nodeList.push({
|
|
id,
|
|
name: getCustomerName(customer),
|
|
x: depth * 220 + 120,
|
|
y: (index - levelOffset) * 96 + 1200,
|
|
draggable: true,
|
|
symbol: nodeSymbol,
|
|
symbolSize: nodeSize,
|
|
cursor: "move",
|
|
itemStyle: {
|
|
color: getNodeColor(customer)
|
|
},
|
|
label: {
|
|
color: "#ffffff",
|
|
fontSize: nodeLabelFontSize,
|
|
show: true,
|
|
silent: true,
|
|
overflow: "truncate",
|
|
position: "inside",
|
|
width: nodeLabelWidth
|
|
}
|
|
});
|
|
|
|
const introducerId = customer.introducerCustomerId;
|
|
if (introducerId && customerMap.has(introducerId)) {
|
|
linkList.push({
|
|
source: introducerId,
|
|
target: id,
|
|
lineStyle: {
|
|
color: "#0f766e",
|
|
width: 2,
|
|
opacity: 1
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
return {
|
|
animationDuration: 400,
|
|
tooltip: {
|
|
formatter: params => {
|
|
const data = Array.isArray(params) ? params[0]?.data : params.data;
|
|
const node = data as RelationNode | undefined;
|
|
if (!node) {
|
|
return "";
|
|
}
|
|
const customer = customerMap.get(node.id);
|
|
if (!customer) {
|
|
return node.name;
|
|
}
|
|
return [
|
|
`客户:${getCustomerName(customer)}`,
|
|
`编码:${customer.code || "未填写"}`,
|
|
`邀请人数:${customer.invitedCount ?? 0} 人`
|
|
].join("<br />");
|
|
}
|
|
},
|
|
series: [
|
|
{
|
|
type: "graph",
|
|
layout: "force",
|
|
roam: true,
|
|
roamTrigger: "global",
|
|
draggable: true,
|
|
nodeScaleRatio: 0,
|
|
scaleLimit: {
|
|
min: .5,
|
|
max: 4
|
|
},
|
|
focusNodeAdjacency: true,
|
|
force: {
|
|
initLayout: "none",
|
|
repulsion: 1000,
|
|
edgeLength: 640,
|
|
gravity: .01,
|
|
layoutAnimation: true
|
|
},
|
|
labelLayout: () => {
|
|
const scale = getChartZoom();
|
|
return {
|
|
fontSize: nodeLabelFontSize * scale,
|
|
width: nodeLabelWidth * scale
|
|
};
|
|
},
|
|
edgeSymbol: ["none", "arrow"],
|
|
edgeSymbolSize: [0, 10],
|
|
data: nodeList,
|
|
links: linkList,
|
|
lineStyle: {
|
|
color: "#0f766e"
|
|
},
|
|
emphasis: {
|
|
scale: false,
|
|
focus: "adjacency"
|
|
},
|
|
blur: {
|
|
itemStyle: {
|
|
opacity: .5
|
|
},
|
|
lineStyle: {
|
|
opacity: .3
|
|
},
|
|
label: {
|
|
opacity: 1
|
|
}
|
|
},
|
|
stateAnimation: {
|
|
duration: 300,
|
|
easing: "cubicOut"
|
|
}
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
function getCustomerDepth(customer: Customer, customerMap: Map<string, Customer>, depthMap: Map<string, number>, visiting: Set<string>): number {
|
|
const id = customer.id;
|
|
if (!id) {
|
|
return 0;
|
|
}
|
|
const cachedDepth = depthMap.get(id);
|
|
if (cachedDepth !== undefined) {
|
|
return cachedDepth;
|
|
}
|
|
if (visiting.has(id)) {
|
|
return 0;
|
|
}
|
|
|
|
const introducerId = customer.introducerCustomerId;
|
|
if (!introducerId || !customerMap.has(introducerId)) {
|
|
depthMap.set(id, 0);
|
|
return 0;
|
|
}
|
|
|
|
visiting.add(id);
|
|
const depth = getCustomerDepth(customerMap.get(introducerId) as Customer, customerMap, depthMap, visiting) + 1;
|
|
visiting.delete(id);
|
|
depthMap.set(id, depth);
|
|
return depth;
|
|
}
|
|
|
|
function getCustomerName(customer: Customer): string {
|
|
return customer.name || customer.code || customer.id || "未命名客户";
|
|
}
|
|
|
|
function getNodeColor(customer: Customer): string {
|
|
const invitedCount = customer.invitedCount ?? 0;
|
|
if (10 <= invitedCount) {
|
|
return "#dc2626";
|
|
}
|
|
if (6 <= invitedCount) {
|
|
return "#f59e0b";
|
|
}
|
|
if (3 <= invitedCount) {
|
|
return "#2563eb";
|
|
}
|
|
if (1 <= invitedCount) {
|
|
return "#60a5fa";
|
|
}
|
|
return "#94a3b8";
|
|
}
|
|
|
|
function renderChart(): void {
|
|
if (!chartElement.value || !props.customers.length) {
|
|
return;
|
|
}
|
|
if (!chart) {
|
|
chart = init(chartElement.value);
|
|
chart.getZr().animation.on("frame", adjustRelationEdges);
|
|
chart.on("graphroam", updateLabelLayout);
|
|
}
|
|
chart.setOption(chartOption.value, true);
|
|
chart.resize();
|
|
adjustRelationEdges();
|
|
chart.getZr().refreshImmediately();
|
|
}
|
|
|
|
function resizeChart(): void {
|
|
if (!chart) {
|
|
return;
|
|
}
|
|
chart.resize();
|
|
adjustRelationEdges();
|
|
chart.getZr().refreshImmediately();
|
|
}
|
|
|
|
function updateLabelLayout(...args: unknown[]): void {
|
|
const payload = args[0] as { zoom?: unknown } | undefined;
|
|
if (typeof payload?.zoom !== "number") {
|
|
return;
|
|
}
|
|
chart?.updateLabelLayout();
|
|
}
|
|
|
|
watch(chartOption, async () => {
|
|
await nextTick();
|
|
renderChart();
|
|
}, { deep: true });
|
|
|
|
onMounted(() => {
|
|
window.addEventListener("resize", resizeChart);
|
|
renderChart();
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
stopCenterAnimation();
|
|
window.removeEventListener("resize", resizeChart);
|
|
chart?.getZr().animation.off("frame", adjustRelationEdges);
|
|
chart?.off("graphroam", updateLabelLayout);
|
|
chart?.dispose();
|
|
chart = undefined;
|
|
});
|
|
|
|
defineExpose({
|
|
focusCustomer,
|
|
clearFocus
|
|
});
|
|
</script>
|
|
|
|
<style scoped lang="less">
|
|
.chart {
|
|
width: 100%;
|
|
height: 100%;
|
|
touch-action: none;
|
|
overscroll-behavior: none;
|
|
cursor: grab;
|
|
|
|
&:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
:deep(canvas) {
|
|
touch-action: none;
|
|
}
|
|
}
|
|
</style>
|