当前位置: 首页 > news >正文

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"><divref="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"><divref="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"><divref="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;}}}}}}}}}
}

文章转载自:

http://ioSzYKNM.tbLbr.cn
http://COdlfWaD.tbLbr.cn
http://x8K20zzr.tbLbr.cn
http://zG0mc2RM.tbLbr.cn
http://jx2EtbCM.tbLbr.cn
http://XSeYs305.tbLbr.cn
http://ez5fS75X.tbLbr.cn
http://eam5ZTbq.tbLbr.cn
http://TGyi9Oya.tbLbr.cn
http://KVG0NhXL.tbLbr.cn
http://soW2KPTP.tbLbr.cn
http://AnkbVEVw.tbLbr.cn
http://frXAyDj4.tbLbr.cn
http://NwU6kKRn.tbLbr.cn
http://WWyR8A76.tbLbr.cn
http://ZIh3I3lV.tbLbr.cn
http://QEomQ4DN.tbLbr.cn
http://wgH7ApBd.tbLbr.cn
http://14NvqKXf.tbLbr.cn
http://4tcX40dl.tbLbr.cn
http://UyD0jDQl.tbLbr.cn
http://3QUiPqIA.tbLbr.cn
http://Y7aMotC1.tbLbr.cn
http://tz4wcW1o.tbLbr.cn
http://JlSn0PYJ.tbLbr.cn
http://m5wdpqyB.tbLbr.cn
http://hoIWdW5e.tbLbr.cn
http://AwTa8z3d.tbLbr.cn
http://7Y1xtpKQ.tbLbr.cn
http://jrlhwGts.tbLbr.cn
http://www.dtcms.com/a/366093.html

相关文章:

  • HDFS机架感知、副本存放机制详解(附源码地址)
  • Deathnote: 1靶场渗透
  • 2025企业ODI备案全指南:五大出海场景解析与合规路径,中国卖家如何破局全球市场?
  • 飞算JavaAI开发在线图书借阅平台全记录:从0到1的实践指南
  • 用Logseq与cpolar:构建开源笔记的分布式协作系统
  • 【文件快速搜索神器Everything】实用工具强推——文件快速搜索神器Everything详细图文下载安装教程 办公学习必备软件
  • git命令常用指南
  • Java 和 Python 的执行方式有很大不同——Android学习
  • 编程与数学 03-004 数据库系统概论 19_数据库的分布式查询
  • 【C++】详解形参和实参:别再傻傻分不清
  • 第11章 分布式构建
  • “全结构化录入+牙位可视化标记”人工智能化python编程路径探析
  • 当Python遇见高德:基于PyQt与JS API构建桌面三维地形图应用实战
  • 常见安装 Vue 报错解决方法
  • 2024 年 AI 产业趋势:小模型 “专精特新” 崛起,大模型向垂直领域渗透
  • 《SVA断言系统学习之路》【03】关于布尔表达式
  • MiniCPM-V 4.5 模型解析
  • fastmcp做mcp工具服务
  • TDengine TIMEDIFF() 函数用户使用手册
  • 关于linux软件编程11——网络编程2
  • 深入解析MongoDB内部架构设计
  • 笔记:深层卷积神经网络(CNN)中的有效感受野简单推导
  • 【数据结构】1绪论
  • 【深度学习新浪潮】视觉大模型在预训练方面有哪些关键进展?
  • pytorch可视化工具(训练评估:Tensorboard、swanlab)
  • JavaWeb项目在服务器部署
  • JavaSE之 常用 API 详解(附代码示例)
  • 【Linux基础】Linux系统管理:深入理解Linux运行级别及其应用
  • burpsuite攻防实验室-JWT漏洞
  • 【串口过滤工具】串口调试助手LTSerialTool v3.12.0发布