【 SVG动态拼接】前端生成阻抗电路图
引言
最近遇到一个前端问题,期望根据表格数据生成(阻抗电路图)图片,生成条件是判断数据类型,进线与非进线,生成后以图片的形式传递给后端,后端拼接到excel中,于是想到了svg拆分拼接元素,动态生成图片,如图:
需要拼接的要素:
指令拼接后:
完整代码传送门
核心功能实现
1. SVG文件加载机制
该工具采用XMLHttpRequest异步加载SVG文件,并将其缓存在内存中以供后续使用。主要实现如下:
const svgCache = {'1': '','2': '','3': ''
};function loadSVGsWithXHR() {for (let i = 1; i <= 3; i++) {const xhr = new XMLHttpRequest();xhr.open('GET', `SVG/${i}.svg`, true);xhr.responseType = 'text';xhr.onload = function() {if (xhr.status === 200) {svgCache[i] = xhr.responseText;// ... 处理加载状态}};xhr.send();}
}
2. SVG拼接算法
拼接算法的核心思路是:
- 根据用户输入的模式(如"2,3")确定上下SVG的数量
- 动态计算每个SVG元素的位置
- 创建中间连接线
- 组合所有元素成为最终SVG
关键实现包括:
2.1 位置计算逻辑
// 上方元素位置计算
const leftCount = Math.floor(topCount / 2);
const rightCount = topCount - leftCount;
const leftWidth = isLargeNumber ? (580 - 163) : 400;
const leftSpacing = leftWidth / (leftCount + 1);
2.2 元素克隆与变换
const topGroup = svg1Group.cloneNode(true);
topGroup.setAttribute("transform", `translate(${xPos}, 8) scale(0.65)`);
mainGroup.appendChild(topGroup);
3. 中间连接线的实现
中间连接线由多个SVG基本元素组成:
- 左右两条主线
- 中间断开的连接器
- 装饰性圆形和矩形
// 左侧长线
const leftLine = document.createElementNS("http://www.w3.org/2000/svg", "line");
leftLine.setAttribute("x1", "50");
leftLine.setAttribute("y1", "180");
leftLine.setAttribute("x2", "590");
leftLine.setAttribute("y2", "180");
4. 导出功能
工具提供两种导出方式:
- 下载SVG文件
- 上传到后端服务器
4.1 下载实现
function downloadSVG() {const serializer = new XMLSerializer();let svgString = serializer.serializeToString(svgEl);svgString = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' + svgString;const blob = new Blob([svgString], {type: 'image/svg+xml'});// ... 创建下载链接
}
4.2 上传实现
function uploadSVGToBackend() {const formData = new FormData();formData.append('svg', new Blob([svgString], {type: 'image/svg+xml'}));formData.append('pattern', patternInput);fetch('/api/upload-svg', {method: 'POST',body: formData});
}
用户界面设计
1. 响应式布局
工具采用Flexbox布局,确保在不同屏幕尺寸下都能良好显示:
.container {display: flex;flex-direction: column;align-items: center;max-width: 1200px;margin: 0 auto;
}
2. 状态反馈
通过状态提示区域实时反馈操作结果:
.status {color: #0d6efd;margin: 15px 0;text-align: center;padding: 10px;background-color: #f8f9fa;
}
技术要点总结
- SVG操作:使用DOM API动态创建和操作SVG元素
- 异步加载:采用XMLHttpRequest异步加载SVG资源
- 动态布局:根据输入参数动态计算元素位置
- 文件处理:实现SVG的序列化、下载和上传功能
- 错误处理:完善的错误处理和用户反馈机制
优化(我懒没做系列)
- 考虑添加SVG预览功能
- 实现更多样化的拼接模式
- 添加SVG编辑功能
- 优化大量元素时的性能
- 增加更多自定义选项(如颜色、大小等)
这个只是html的示例,后续我封装的vue3的模块组件,
使用方式
组件代码如下:
<template><div class="svg-combiner"><div class="loading-container"><n-card :bordered="false" size="small" style="width: 400px"><template #header><div class="progress-header p-2">{{ statusMessage }}</div></template><div class="pr-4"><n-progresstype="line":percentage="progress":processing="isProcessing":indicator-placement="'inside'":height="24"/></div><div class="progress-detail">{{ progressDetail }}</div></n-card></div><div class="svg-container" style="display: none"><div id="result-svg" ref="resultSvg"></div></div></div>
</template><script>
import { http } from "@/utils/http";
import { storageLocal, downloadByData } from "@pureadmin/utils";
import { uploadFile } from "@/utils/file";export default {name: "SvgCombiner",props: {topCount: {type: Number,required: true,validator: (value) => value >= 0 && value <= 99,},bottomCount: {type: Number,required: true,validator: (value) => value >= 0 && value <= 99,},transformerEntityList: {type: Array,required: true,},},data() {return {svgCache: {1: "",2: "",3: "",},SvgUrl: "",statusMessage: "正在处理",progressDetail: "",progress: 0,isProcessing: true,loadedCount: 0,};},emits: ["close"],mounted() {this.loadSVGsWithXHR();},watch: {topCount: {handler() {this.generateAndUpload();},},bottomCount: {handler() {this.generateAndUpload();},},},methods: {async updateProgress(step, detail = "") {const steps = {init: 0,load: 20,generate: 40,convert: 60,upload: 80,complete: 100,};const currentProgress = this.progress;const targetProgress = steps[step] || 0;// 平滑过渡到目标进度const step_size = 2;const delay = 20;for (let i = currentProgress; i <= targetProgress; i += step_size) {this.progress = i;await new Promise((resolve) => setTimeout(resolve, delay));}if (detail) {this.progressDetail = detail;}},async loadSVGsWithXHR() {this.loadedCount = 0;this.statusMessage = "加载SVG文件";this.isProcessing = true;await this.updateProgress("init", "准备加载文件...");for (let i = 1; i <= 3; i++) {try {const response = await fetch(`/src/assets/PinSvg/${i}.svg`);if (response.ok) {this.svgCache[i] = await response.text();this.loadedCount++;await this.updateProgress("load", `已加载 ${this.loadedCount}/拼接文件`);await new Promise((resolve) => setTimeout(resolve, 200)); // 每个文件加载后稍作停顿if (this.loadedCount === 3) {this.generateAndUpload();}} else {throw new Error(`状态码: ${response.status}`);}} catch (error) {console.error(`加载SVG ${i} 时出错:`, error);this.statusMessage = "加载失败";this.progressDetail = `加载SVG/${i}.svg 时出错,请检查文件是否存在`;this.isProcessing = false;setTimeout(() => {this.$emit("close");}, 2000);}}},async generateAndUpload() {if (!this.svgCache["1"] || !this.svgCache["2"] || !this.svgCache["3"]) {this.statusMessage = "文件未完全加载";this.progressDetail = "请等待或刷新页面重试";return;}try {this.statusMessage = "生成图形";this.isProcessing = true;await new Promise((resolve) => setTimeout(resolve, 500)); // 开始生成前稍作停顿await this.generateSVG();await this.updateProgress("generate", "图形生成完成");await new Promise((resolve) => setTimeout(resolve, 300)); // 生成完成后稍作停顿await this.uploadSVGToBackend();} catch (error) {console.error("处理出错:", error);this.statusMessage = "处理失败";this.progressDetail = error.message;this.isProcessing = false;setTimeout(() => {this.$emit("close");}, 2000);}},async generateSVG() {if (!this.svgCache["1"] || !this.svgCache["2"] || !this.svgCache["3"]) {this.statusMessage = "SVG文件未完全加载,请等待或刷新页面";return;}try {const resultContainer = this.$refs.resultSvg;resultContainer.innerHTML = "";// 创建SVG元素const combinedSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");combinedSvg.setAttribute("xmlns", "http://www.w3.org/2000/svg");combinedSvg.setAttribute("width", "100%");combinedSvg.setAttribute("height", "100%");combinedSvg.setAttribute("viewBox", "0 0 1200 500");combinedSvg.setAttribute("id", "combined-svg");// 添加样式const styleElement = document.createElementNS("http://www.w3.org/2000/svg", "style");styleElement.textContent = `.cls-1 {fill: none;stroke: #000;stroke-linecap: round;stroke-miterlimit: 10;stroke-width: 2.83px;}.cls-2 {fill: #000;}`;combinedSvg.appendChild(styleElement);// 创建主组const mainGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");mainGroup.setAttribute("id", "combined-group");// 解析SVG字符串为文档const parser = new DOMParser();const svg1Doc = parser.parseFromString(this.svgCache["1"], "image/svg+xml");const svg2Doc = parser.parseFromString(this.svgCache["2"], "image/svg+xml");const svg3Doc = parser.parseFromString(this.svgCache["3"], "image/svg+xml");// 获取各个SVG的主要内容组const svg1Group = svg1Doc.querySelector("g > g").cloneNode(true);const svg2Group = svg2Doc.querySelector("g > g").cloneNode(true);const svg3Group = svg3Doc.querySelector("g > g").cloneNode(true);// 放置上方元素this.placeTopElements(mainGroup, svg1Group);// 添加中间分隔线this.addMiddleDivider(mainGroup);// 放置下方元素this.placeBottomElements(mainGroup, svg2Group);// 添加主组到SVGcombinedSvg.appendChild(mainGroup);// 添加到页面resultContainer.appendChild(combinedSvg);await this.updateProgress("generate", "正在生成图形");await new Promise((resolve) => setTimeout(resolve, 500)); // 生成过程中的延时} catch (error) {console.error("生成SVG时出错:", error);this.statusMessage = "生成SVG失败";this.progressDetail = error.message;this.isProcessing = false;setTimeout(() => {this.$emit("close");}, 2000);}},placeTopElements(mainGroup, svg1Group) {if (this.topCount === 0) {return;}if (this.topCount === 1) {const topGroup = svg1Group.cloneNode(true);let xPos = 400;// if (this.bottomCount === 1) {// xPos = 400;// }topGroup.setAttribute("transform", `translate(${xPos}, 8) scale(0.65)`);mainGroup.appendChild(topGroup);return;}const leftCount = Math.floor(this.topCount / 2);const rightCount = this.topCount - leftCount;const isLargeNumber = this.topCount > 20;// 左侧元素const leftWidth = isLargeNumber ? 580 - 163 : 400;const leftSpacing = leftWidth / (leftCount + 1);for (let i = 0; i < leftCount; i++) {const topGroup = svg1Group.cloneNode(true);const xPos = 150 + (i + 1) * leftSpacing;topGroup.setAttribute("transform", `translate(${xPos}, 8) scale(0.65)`);mainGroup.appendChild(topGroup);}// 右侧元素const rightWidth = isLargeNumber ? 1050 - 630 : 400;const rightSpacing = rightWidth / (rightCount + 1);for (let i = 0; i < rightCount; i++) {const topGroup = svg1Group.cloneNode(true);const xPos = 630 + (i + 1) * rightSpacing;topGroup.setAttribute("transform", `translate(${xPos}, 8) scale(0.65)`);mainGroup.appendChild(topGroup);}},placeBottomElements(mainGroup, svg2Group) {if (this.bottomCount === 0) {return;}if (this.bottomCount === 1) {const bottomGroup = svg2Group.cloneNode(true);let xPos = 800;// if (this.topCount === 1) {// xPos = 800;// }bottomGroup.setAttribute("transform", `translate(${xPos}, 182) scale(0.65)`);mainGroup.appendChild(bottomGroup);return;}const leftCount = Math.floor(this.bottomCount / 2);const rightCount = this.bottomCount - leftCount;const isLargeNumber = this.bottomCount > 20;// 左侧元素const leftWidth = isLargeNumber ? 580 - 172 : 400;const leftSpacing = leftWidth / (leftCount + 1);for (let i = 0; i < leftCount; i++) {const bottomGroup = svg2Group.cloneNode(true);const xPos = 150 + (i + 1) * leftSpacing;bottomGroup.setAttribute("transform", `translate(${xPos}, 182) scale(0.65)`);mainGroup.appendChild(bottomGroup);}// 右侧元素const rightWidth = isLargeNumber ? 1050 - 630 : 400;const rightSpacing = rightWidth / (rightCount + 1);for (let i = 0; i < rightCount; i++) {const bottomGroup = svg2Group.cloneNode(true);const xPos = 630 + (i + 1) * rightSpacing;bottomGroup.setAttribute("transform", `translate(${xPos}, 182) scale(0.65)`);mainGroup.appendChild(bottomGroup);}},addMiddleDivider(mainGroup) {const middleGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");middleGroup.setAttribute("id", "middle-divider");// 左侧长线const leftLine = document.createElementNS("http://www.w3.org/2000/svg", "line");leftLine.setAttribute("x1", "50");leftLine.setAttribute("y1", "180");leftLine.setAttribute("x2", "590");leftLine.setAttribute("y2", "180");leftLine.setAttribute("stroke", "#000");leftLine.setAttribute("stroke-width", "2.83");leftLine.setAttribute("stroke-linecap", "round");leftLine.setAttribute("class", "cls-2");middleGroup.appendChild(leftLine);// 右侧长线const rightLine = document.createElementNS("http://www.w3.org/2000/svg", "line");rightLine.setAttribute("x1", "620");rightLine.setAttribute("y1", "180");rightLine.setAttribute("x2", "1150");rightLine.setAttribute("y2", "180");rightLine.setAttribute("stroke", "#000");rightLine.setAttribute("stroke-width", "2.83");rightLine.setAttribute("stroke-linecap", "round");rightLine.setAttribute("class", "cls-2");middleGroup.appendChild(rightLine);// 中间连接器const middleConnector = document.createElementNS("http://www.w3.org/2000/svg", "g");const pathsData = [{ d: "M583,171l-8.34,8.5c-.51.52-.48,1.36.06,1.85l8.28,7.47", class: "cls-1" },{ d: "M589,171l-8.34,8.5c-.51.52-.48,1.36.06,1.85l8.28,7.47", class: "cls-1" },{ d: "M629,171l8.34,8.5c.51.52.48,1.36-.06,1.85l-8.28,7.47", class: "cls-1" },{ d: "M623,171l8.34,8.5c.51.52.48,1.36-.06,1.85l-8.28,7.47", class: "cls-1" },];pathsData.forEach((data) => {const path = document.createElementNS("http://www.w3.org/2000/svg", "path");path.setAttribute("d", data.d);path.setAttribute("class", data.class);middleConnector.appendChild(path);});const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");rect.setAttribute("x", "590");rect.setAttribute("y", "174");rect.setAttribute("width", "32");rect.setAttribute("height", "11");rect.setAttribute("rx", "2.18");rect.setAttribute("ry", "2.18");rect.setAttribute("class", "cls-1");middleConnector.appendChild(rect);const circles = [{ cx: "601", cy: "180" },{ cx: "611", cy: "180" },];circles.forEach((data) => {const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");circle.setAttribute("cx", data.cx);circle.setAttribute("cy", data.cy);circle.setAttribute("r", "3");circle.setAttribute("class", "cls-1");middleConnector.appendChild(circle);});middleGroup.appendChild(middleConnector);mainGroup.appendChild(middleGroup);},async uploadSVGToBackend() {try {const svgEl = document.getElementById("combined-svg");if (!svgEl) {throw new Error("找不到SVG元素");}this.statusMessage = "转换图像";await this.updateProgress("convert", "正在转换为PNG格式");await new Promise((resolve) => setTimeout(resolve, 300)); // 转换过程的延时// 创建Canvas并转换图像const canvas = document.createElement("canvas");const ctx = canvas.getContext("2d");const svgRect = svgEl.getBoundingClientRect();canvas.width = svgRect.width || 1100;canvas.height = svgRect.height || 500;const serializer = new XMLSerializer();const svgString = serializer.serializeToString(svgEl);const svgBlob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });const svgUrl = URL.createObjectURL(svgBlob);const img = new Image();img.src = svgUrl;await new Promise((resolve, reject) => {img.onload = resolve;img.onerror = reject;});ctx.fillStyle = "white";ctx.fillRect(0, 0, canvas.width, canvas.height);ctx.drawImage(img, 0, 0);const pngBlob = await new Promise((resolve) => {canvas.toBlob(resolve, "image/png");});URL.revokeObjectURL(svgUrl);this.statusMessage = "上传文件";await this.updateProgress("upload", "正在上传到服务器");await new Promise((resolve) => setTimeout(resolve, 500)); // 上传过程的延时const result = await uploadFile(pngBlob, {fileName: "combined_image.png",extraData: {pattern: `${this.topCount},${this.bottomCount}`,},showSuccessMessage: false,});this.SvgUrl = result.data;let infoKey = storageLocal().getItem("infoKey");this.statusMessage = "生成Excel";await this.updateProgress("upload", "正在生成Excel文件");await new Promise((resolve) => setTimeout(resolve, 500)); // Excel生成过程的延时const biz_content = {depId: infoKey,imagePath: result.data,transformerEntityList: this.transformerEntityList};const res = await http.request("post", "/shbg/transformer/export/excel", {data: JSON.stringify(biz_content),responseType: "blob",});if (res.status === 200) {const regex = /filename=([^;]+)/;const match = res.headers["content-disposition"].match(regex);if (match) {const filename = decodeURIComponent(match[1]) || "数据列表.xlsx";downloadByData(res.data, filename);}this.statusMessage = "处理完成";await this.updateProgress("complete", "文件已下载");this.isProcessing = false;// 完成后等待较长时间再关闭await new Promise((resolve) => setTimeout(resolve, 1300));this.$emit("close");} else {throw new Error("导出Excel失败");}} catch (error) {console.error("处理出错:", error);this.statusMessage = "处理失败";this.progressDetail = error.message;this.isProcessing = false;setTimeout(() => {this.$emit("close");}, 2000);}},},
};
</script><style scoped>
.svg-combiner {font-family: Arial, sans-serif;display: flex;justify-content: center;align-items: center;position: fixed;top: 0;left: 0;right: 0;bottom: 0;background-color: rgba(0, 0, 0, 0.5);z-index: 1000;
}.loading-container {padding: 20px;
}.progress-header {font-size: 16px;font-weight: 500;margin-bottom: 8px;
}.progress-detail {margin-top: 8px;font-size: 14px;color: #999;
}.svg-container {display: none;
}
</style>
结语
这个SVG拼接工具展示了如何通过Web技术实现SVG的动态操作和组合。通过合理的架构设计和用户界面,为用户提供了简单易用的SVG处理工具。