Vue3+TS 交互式三层关系图
·
目录
一、效果展示



二、图表介绍
1.关系链:
这是一个三层的关系图,关系链对应如下: 左-中:1-n , '中-左:1-1' ,'中-右:1-n','右-中:1-1' 。
2.功能介绍:
(1).节点效果:
默认全部显示,无高亮。点击任意一个节点后,高亮显示点击的节点并添加outline属性,与其关联的节点同时高亮(仅与自己相关的所有节点)。除被点击节点及其关联节点以外的所有节点,降低透明度,突出高亮节点。再次点击拥有outline的节点,可退出高亮显示。在高亮期间,点击没有outline的节点,outline属性转移至被点击节点;点击高亮节点以外的节点时,已高亮节点降低透明度,被点击节点及其关联节点高亮。
(2).线条效果:
每个节点都被线条与关联节点连接,线条颜色为链接的两个节点的颜色渐变。线条由svg为绘制画板,结合贝塞尔(Bézier)曲线绘制。线条展示状态跟随其连接的两个节点,链接的两个节点高亮,则线条也高亮;否则,降低透明度。
三、代码展示
1.节点代码
(1)节点HTML:
<div class="leftLine">
<div class="content">
<ul>
<li v-for="item in leftAbout" :key="item.id" class="list">
<div
ref="listContentleft"
class="list-content"
:data-id="item.id"
:style="
checkTrueOrFalse(activeLeftId, item.id)
? activeStyle
: fadeStyle
"
@click="handleLeftClick(item.id, $event)"
>
<p class="left-bg" />
<p class="edit-detail">
<span class="edit-look">点击查看</span>
</p>
<div class="question-padding-1">{{ item.content }}</div>
<div class="difficult_list">
<p><span class="difficult" /></p>
<p class="knowledge_num">
<span class="knowledge">{{ count(item) }}个知识点</span>
</p>
</div>
<p class="circle">
<span class="circle-bg">{{ item.children.length }}</span>
</p>
</div>
</li>
</ul>
</div>
</div>
<div class="centerLine">
<div class="content">
<ul>
<li v-for="item in centerAbout" :key="item.id" class="list">
<div
ref="listContentCenter"
class="list-content"
:data-id="item.id"
:data-parent-id="item.parentId"
:style="
checkTrueOrFalse(activeCenterId, item.id)
? activeStyle
: fadeStyle
"
@click="handleCenterClick(item.id, item.parentId, $event)"
>
<p class="center-bg" />
<p class="edit-detail">
<span class="edit-look">点击查看</span>
</p>
<div class="question-padding-2">{{ item.content }}</div>
<div class="difficult_list">
<p><span class="difficult" /></p>
<p class="knowledge_num">
<span class="knowledge">{{ item.children.length }}个知识点</span>
</p>
</div>
<p class="circle">
<span class="circle-bg">{{ item.children.length }}</span>
</p>
</div>
</li>
</ul>
</div>
</div>
<div class="rightLine">
<div class="content">
<ul>
<li v-for="item in rightAbout" :key="item.id" class="list">
<div
ref="listContentRight"
class="list-content"
:data-id="item.id"
:data-parent-id="item.parentId"
:style="
checkTrueOrFalse(activeRightId, item.id)
? activeStyle
: fadeStyle
"
@click="handleRightClick(item.id, item.parentId, $event)"
>
<p class="right-bg" />
<p class="edit-detail">
<span class="edit-look">点击查看</span>
</p>
<div class="question-padding-1">{{ item.content }}</div>
<div class="difficult_list">
<p><span class="difficult" /></p>
<p class="knowledge_num">
<span class="knowledge">1个知识点</span>
</p>
</div>
<p class="circle"><span class="circle-bg">1</span></p>
</div>
</li>
</ul>
</div>
</div>
(2).节点TS:
const listContentleft = ref<HTMLDivElement[]>([]);
const listContentCenter = ref<HTMLDivElement[]>([]);
const listContentRight = ref<HTMLDivElement[]>([]);
const leftAbout = [
{
id: "1",
content: "人工智能的伦理和社会责任是什么?",
children: [
{
id: "1-1",
parentId: "1",
content:
"想象一下,你正在使用智能手机的语音助手来设置闹钟或查询天气...",
children: [
{
id: "1-1-1",
parentId: "1-1",
content: "人工智能的主要研究内容是什么?"
}
]
}
]
},
{
id: "2",
content: "如何将人类智能的理论应用于人工智能系统的设计?",
children: [
{
id: "2-1",
parentId: "2",
content:
"想象一下你在超市里面临选择购买哪种牛奶的决定...想象一下你在超市里面临选择购买哪种牛奶的决定...想象一下你在超市里面临选择购买哪种牛奶的决定...想象一下你在超市里面临选择购买哪种牛奶的决定...想象一下你在超市里面临选择购买哪种牛奶的决定...",
children: [
{
id: "2-1-1",
parentId: "2-1",
content: "人工智能的主要研究内容是什么?"
}
]
}
]
},
{
id: "3",
content: "人工智能如何通过不同的学习方法解决实际问题?",
children: [
{
id: "3-1",
parentId: "3",
content: "想象一下你在超市里面临选择购买哪种牛奶的决定...",
children: [
{
id: "3-1-1",
parentId: "3-1",
content: "人工智能的主要研究内容是什么?"
}
]
},
{
id: "3-2",
parentId: "3",
content: "想象一下你在超市里面临选择购买哪种牛奶的决定...",
children: [
{ id: "3-2-1", parentId: "3-2", content: "人类智能的起源是什么" },
{ id: "3-2-2", parentId: "3-2", content: "awewda" }
]
}
]
}
];
const centerAbout = computed(() => leftAbout.map(item => item.children).flat());
const rightAbout = computed(() =>
centerAbout.value.map(item => item.children).flat()
);
const OPACITY = {
ACTIVE: 1, // 激活状态透明度
INACTIVE: 0.1 // 未激活状态透明度
};
const activeLeftId = ref<string | null>(null);
const activeCenterId = ref<string[]>([]);
const activeRightId = ref<string[]>([]);
const activeStyle = { opacity: `${OPACITY.ACTIVE}` };
const fadeStyle = { opacity: `${OPACITY.INACTIVE}` };
const lineColors = [
"rgb(255, 107, 169)",
"rgb(31, 161, 255)",
"rgb(0, 202, 142)"
];
const checkTrueOrFalse = (
value: string[] | null | string,
id: string
): boolean => {
if (value === null) return true;
if (typeof value === "string") return value === id;
if (Array.isArray(value)) return value.length > 0 ? value.includes(id) : true;
return true;
};
const clearAllOutline = (excludeEl?: HTMLElement) => {
listContentleft.value.forEach(
div =>
(div.style.outline =
div !== excludeEl
? "rgba(151, 151, 151, 0) solid 2px"
: div.style.outline)
);
listContentCenter.value.forEach(
div =>
(div.style.outline =
div !== excludeEl
? "rgba(151, 151, 151, 0) solid 2px"
: div.style.outline)
);
listContentRight.value.forEach(
div =>
(div.style.outline =
div !== excludeEl
? "rgba(151, 151, 151, 0) solid 2px"
: div.style.outline)
);
};
const checkIshaveOutline = (e: HTMLElement | null): boolean => {
if (!e) return false;
const hasOutline =
e.style.outline !== "rgba(151, 151, 151, 0) solid 2px" &&
e.style.outline !== "";
if (hasOutline) {
e.style.outline = "rgba(151, 151, 151, 0) solid 2px";
activeLeftId.value = null;
activeCenterId.value = [];
activeRightId.value = [];
return true;
}
return false;
};
const activeStyleFun = (e: HTMLElement) => {
const nodeColor = getComputedStyle(e).getPropertyValue("--node-color").trim();
e.style.setProperty("outline", `${nodeColor} solid 2px`);
};
const defaultColor = (div: HTMLDivElement) =>
div.style.setProperty("opacity", "1");
const handleLeftClick = async (id: string, e: MouseEvent) => {
const targetDiv = (e.target as HTMLElement).closest(
".list-content"
) as HTMLElement;
if (!targetDiv || checkIshaveOutline(targetDiv)) {
renderConnection();
return;
}
clearAllOutline(targetDiv);
activeCenterId.value = [];
activeRightId.value = [];
activeLeftId.value = id;
const targetLeftItem = leftAbout.find(item => item.id === id);
if (targetLeftItem?.children) {
targetLeftItem.children.forEach(item2 => {
activeCenterId.value.push(item2.id);
item2.children?.forEach(item3 => activeRightId.value.push(item3.id));
});
}
await nextTick();
activeStyleFun(targetDiv);
renderConnection();
};
const handleCenterClick = async (
id: string,
parentId: string,
e: MouseEvent
) => {
const targetDiv = (e.target as HTMLElement).closest(
".list-content"
) as HTMLElement;
if (!targetDiv || checkIshaveOutline(targetDiv)) {
renderConnection();
return;
}
clearAllOutline(targetDiv);
activeCenterId.value = [];
activeRightId.value = [];
activeCenterId.value.push(id);
activeLeftId.value = parentId;
const targetCenterItem = centerAbout.value.find(item => item.id === id);
if (targetCenterItem?.children) {
targetCenterItem.children.forEach(item2 =>
activeRightId.value.push(item2.id)
);
}
await nextTick();
activeStyleFun(targetDiv);
renderConnection();
};
const handleRightClick = async (
id: string,
parentId: string,
e: MouseEvent
) => {
const targetDiv = (e.target as HTMLElement).closest(
".list-content"
) as HTMLElement;
if (!targetDiv || checkIshaveOutline(targetDiv)) {
renderConnection();
return;
}
clearAllOutline(targetDiv);
activeCenterId.value = [];
activeRightId.value = [];
activeRightId.value.push(id);
activeCenterId.value.push(parentId);
const targetCenterItem = centerAbout.value.find(item => item.id === parentId);
if (targetCenterItem) activeLeftId.value = targetCenterItem.parentId;
await nextTick();
activeStyleFun(targetDiv);
renderConnection();
};
const count = (item)=>{
return item.children.reduce((total, value) => {
return total + value.children.length
}, 0);
}
2.svg线条绘制
(1).svgHTML:
<svg ref="svgRef" class="connection-svg" ></svg>
(2).svgTS:
type Point = { x: number; y: number };
type ConnectionConfig = {
strokeWidth: number;
curveRadius?: number;
};
type ConnectionStyleMap = Record<
"left-center" | "center-right",
ConnectionConfig
>;
const contentBoxRef: Ref<HTMLDivElement | null> = ref(null);
const svgRef: Ref<SVGSVGElement | null> = ref(null);
const connectionStyles: ConnectionStyleMap = {
"left-center": { strokeWidth: 4, curveRadius: -60 },
"center-right": { strokeWidth: 4, curveRadius: -60 }
};
const clearAllConnections = () => {
const svg = svgRef.value;
if (svg) svg.innerHTML = "";
};
const getRelativePoint = (
el: HTMLElement,
relationship: string
): Point | null => {
if (!contentBoxRef.value) return null;
const contentRect = contentBoxRef.value.getBoundingClientRect();
let elRect: DOMRect | null = null;
let divisorX, divisorY;
if (relationship === "parent") {
(elRect = el.childNodes[4].getBoundingClientRect()),
(divisorX = 1),
(divisorY = 0.5);
} else {
(elRect = el.childNodes[0].getBoundingClientRect()),
(divisorX = 0.2),
(divisorY = 0.5);
}
return {
x: elRect.left - contentRect.left + elRect.width * divisorX, // 水平中心
y: elRect.top - contentRect.top + elRect.height * divisorY // 垂直中心
};
};
// 判断节点是否处于激活状态
const isNodeActive = (
layer: "left" | "center" | "right",
nodeId: string
): boolean => {
switch (layer) {
case "left":
// 左节点激活:activeLeftId等于当前节点ID,或未点击任何节点(默认全部激活)
return activeLeftId.value === null || activeLeftId.value === nodeId;
case "center":
// 中节点激活:activeCenterId包含当前节点ID,或未点击任何节点
return (
activeCenterId.value.length === 0 ||
activeCenterId.value.includes(nodeId)
);
case "right":
// 右节点激活:activeRightId包含当前节点ID,或未点击任何节点
return (
activeRightId.value.length === 0 || activeRightId.value.includes(nodeId)
);
default:
return false;
}
};
const drawConnection = (
parentEl: HTMLElement,
childEl: HTMLElement,
styleKey: "left-center" | "center-right"
) => {
const svg = svgRef.value;
if (!svg || !contentBoxRef.value) return;
const parentPoint = getRelativePoint(parentEl, "parent");
const childPoint = getRelativePoint(childEl, "child");
if (!parentPoint || !childPoint) return;
// 关键修改:获取关联节点ID,并判断其激活状态
const parentId = parentEl.dataset.id || ""; // 父节点ID(左/中节点)
const childId = childEl.dataset.id || ""; // 子节点ID(中/右节点)
// 按层级判断父/子节点是否激活
const parentLayer = styleKey === "left-center" ? "left" : "center";
const childLayer = styleKey === "left-center" ? "center" : "right";
const isParentActive = isNodeActive(parentLayer, parentId);
const isChildActive = isNodeActive(childLayer, childId);
// 计算连线透明度:仅当父+子节点都激活时,连线才完全不透明;否则透明
const lineOpacity =
isParentActive && isChildActive ? OPACITY.ACTIVE : OPACITY.INACTIVE;
// 获取节点颜色、创建渐变
const parentColor = getComputedStyle(parentEl)
.getPropertyValue("--node-color")
.trim();
const childColor = getComputedStyle(childEl)
.getPropertyValue("--node-color")
.trim();
const gradientId = `gradient-${parentId}-${childId}`;
let gradient = svg.querySelector(`#${gradientId}`);
if (!gradient) {
let defs =
svg.querySelector("defs") ||
document.createElementNS("http://www.w3.org/2000/svg", "defs");
if (!svg.querySelector("defs")) svg.insertBefore(defs, svg.firstChild);
gradient = document.createElementNS(
"http://www.w3.org/2000/svg",
"linearGradient"
);
gradient.setAttribute("id", gradientId);
gradient.setAttribute("x1", "0%");
gradient.setAttribute("y1", "0%");
gradient.setAttribute("x2", "100%");
gradient.setAttribute("y2", "0%");
const startStop = document.createElementNS(
"http://www.w3.org/2000/svg",
"stop"
);
startStop.setAttribute("offset", "0%");
startStop.setAttribute("stop-color", parentColor);
startStop.setAttribute("stop-opacity", "1"); // 渐变颜色本身不透明,透明度通过line控制
const endStop = document.createElementNS(
"http://www.w3.org/2000/svg",
"stop"
);
endStop.setAttribute("offset", "100%");
endStop.setAttribute("stop-color", childColor);
endStop.setAttribute("stop-opacity", "1");
gradient.appendChild(startStop);
gradient.appendChild(endStop);
defs.appendChild(gradient);
}
// 绘制连线
const contentRect = contentBoxRef.value.getBoundingClientRect();
svg.setAttribute("width", `${contentRect.width}px`);
svg.setAttribute("height", `${contentRect.height}px`);
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const { strokeWidth } = connectionStyles[styleKey];
const controlX = (parentPoint.x + childPoint.x) / 2;
const controlY = parentPoint.y;
const pathD = `M${parentPoint.x},${parentPoint.y} Q${controlX},${controlY} ${childPoint.x},${childPoint.y}`;
path.setAttribute("d", pathD);
path.setAttribute("stroke", `url(#${gradientId})`);
path.setAttribute("stroke-width", `${strokeWidth}`);
path.setAttribute("fill", "none");
path.setAttribute("stroke-opacity", `${lineOpacity}`);
svg.appendChild(path);
};
const renderLine = (
parentId: string,
currentLayer: "left" | "center",
index: number = 0
) => {
if (!svgRef.value || !contentBoxRef.value) return;
const parentEl = document.querySelector(
`.${currentLayer}Line .list-content[data-id="${parentId}"]`
);
if (!parentEl) return;
let children: Array<{
id: string;
parentId: string;
children?: Array<{ id: string; parentId: string }>;
}> = [];
if (currentLayer === "left") {
const leftItem = leftAbout.find(item => item.id === parentId);
children = leftItem?.children || [];
} else {
children = centerAbout.value
.filter(item => item.id === parentId && item.children?.length)
.flatMap(item => item.children || []);
}
children.forEach(child => {
const childLayer = currentLayer === "left" ? "center" : "right";
const childEl = document.querySelector(
`.${childLayer}Line .list-content[data-id="${child.id}"]`
);
if (childEl) {
const styleKey = currentLayer === "left" ? "left-center" : "center-right";
drawConnection(parentEl as HTMLElement, childEl as HTMLElement, styleKey);
if (index === 0) {
renderLine(child.id, "center", index + 1);
}
}
});
};
const renderConnection = async () => {
await nextTick(); // 等待DOM更新后再绘制
clearAllConnections(); // 先清除旧连线
leftAbout.forEach(leftItem => {
renderLine(leftItem.id, "left");
});
};
3.默认样式
onMounted(() => {
listContentleft.value.forEach(div => defaultColor(div));
listContentCenter.value.forEach(div => {
div.style.setProperty("--content-item-color", lineColors[1]);
defaultColor(div);
});
listContentRight.value.forEach(div => {
div.style.setProperty("--content-item-color", lineColors[2]);
defaultColor(div);
});
renderConnection();
const handleResize = () => renderConnection();
window.addEventListener("resize", handleResize);
const cleanup = () => {
window.removeEventListener("resize", handleResize);
clearAllConnections();
};
return cleanup;
});
4.less样式
.main-box() {
width: 38%;
margin-left: 10%;
padding-top: .61667vw;
overflow: auto;
}
.edit-mixin() {
.edit-detail {
position: absolute;
right: .26042vw;
top: .3125vw;
.edit-look {
padding-right: .52083vw;
font-size: .625vw;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #fff;
opacity: 0;
cursor: pointer;
height: 1.04167vw;
display: flex;
align-items: center;
}
}
}
.content-bg() {
background-size: 100% 100%;
width: 1.5625vw;
height: 1.5625vw;
display: block;
position: absolute;
top: 50%;
left: -3%;
z-index: 99;
transform: translateY(-50%);
}
.list-content-color(@color) {
background-color: @color;
}
* {
padding: 0;
margin: 0;
list-style: none;
user-select: none;
}
.alone {
width: 100%;
position: relative;
padding-top: 4.6875vw;
.problem-graph {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
// height: 90vh;
overflow: hidden;
border: 0.2083vw solid #fff;
border-radius: 0.8333vw;
padding: 1.5625vw 2.08333vw;
.header {
display: flex;
width: 100%;
background-size: 100% 100%;
position: relative;
z-index: 10;
.head {
width: 26.5%;
// display: flex;
// justify-content: space-between;
padding: 1.25vw 0px 0.83333vw;
margin-left: .8vw;
.primary {
letter-spacing: 0px;
color: var(---, #000);
font-family: "Alibaba PuHuiTi 3.0";
font-size: 0.9375vw;
font-style: normal;
font-weight: 700;
line-height: 1.35417vw;
padding: 0px 0px 0.41667vw;
}
.second {
opacity: 0.5;
letter-spacing: 0px;
text-align: justify;
color: var(---, #000);
font-family: "Alibaba PuHuiTi 3.0";
font-size: 0.72917vw;
font-style: normal;
font-weight: 400;
line-height: 1.04167vw;
}
&:nth-child(2),
&:nth-child(3) {
margin-left: 10%;
}
}
}
.content-box {
position: relative;
display: flex;
width: 100%;
height: 100%;
padding-bottom: 2.08333vw;
.connection-svg {
position: absolute;
top: 0;
left: 0;
z-index: 1;
pointer-events: none;
}
.leftLine {
width: 38%;
padding-top: 0.61667vw;
position: relative;
overflow: auto;
--node-color: rgb(255 107 169);
.left-bg {
.content-bg();
}
}
.centerLine {
.main-box();
--node-color: rgb(31 161 255);
.center-bg {
background: url('@/assets/empty/problem-blue.png');
.content-bg();
}
}
.rightLine {
.main-box();
--node-color: rgb(0 202 142);
.right-bg {
background: url('@/assets/empty/problem-green.png');
.content-bg();
}
}
.content {
width: 100%;
ul {
height: 100%;
.list {
width: 95%;
border-radius: .9375vw;
margin: 0 auto .41667vw;
border: .26042vw solid rgba(151, 151, 151, 0);
position: relative;
--content-item-color: #FF6BA9;
.list-content {
background: var(--content-item-color);
border-radius: .9375vw;
cursor: pointer;
padding: .83333vw 1.04167vw;
position: relative;
outline-offset: 6px;
z-index: 2;
outline: rgb(151 151 151 / 0%) solid 2px;
transition:
outline 0.2s ease,
opacity 0.2s ease;
.question-padding-1 {
width: 100%;
color: #fff;
font-family: "Alibaba PuHuiTi 3.0";
font-size: .9375vw;
font-style: normal;
font-weight: 700;
line-height: 1.35417vw;
padding-bottom: .52083vw;
}
.edit-mixin();
.question-padding-2 {
width: 100%;
color: #fff;
font-family: "Alibaba PuHuiTi 3.0";
font-size: .9375vw;
font-style: normal;
font-weight: 700;
line-height: 1.35417vw;
}
.difficult_list {
display: flex;
align-items: center;
justify-content: space-between;
.difficult {
display: flex;
height: 1.35417vw;
justify-content: center;
align-items: center;
gap: .3125vw;
}
.knowledge_num {
display: flex;
height: 1.35417vw;
padding: .52083vw .625vw;
justify-content: center;
align-items: center;
gap: .52083vw;
border-radius: 2.03125vw;
border: 1px solid rgba(255, 255, 255, .5);
color: #fff;
font-family: "Alibaba PuHuiTi 3.0";
font-size: .72917vw;
font-style: normal;
font-weight: 400;
line-height: 1.04167vw;
}
}
.circle {
width: 1.5625vw;
height: 1.5625vw;
border-radius: 50%;
font-size: .625vw;
line-height: .72917vw;
text-align: center;
position: absolute;
top: 50%;
transform: translateY(-50%);
right: -3%;
z-index: 97;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--content-item-color);
border: var(--content-item-color);
.circle-bg {
color: var(--content-item-color);
background-color: #fff;
border-radius: 50%;
height: 1.25vw;
width: 1.25vw;
line-height: 1.25vw;
font-family: "Alibaba PuHuiTi 3.0";
font-size: 1.04167vw;
font-style: normal;
font-weight: 900;
}
}
p:nth-child(2) {
display: flex;
justify-content: space-between;
padding: 0 .83333vw;
height: 1.14583vw;
}
&:hover {
.edit-detail {
.edit-look {
opacity: 1;
transition: .5s;
}
}
}
}
}
}
}
}
}
}更多推荐
所有评论(0)