所有文章都是免费查看的,如果有无法查看的情况,烦请联系我修改哈~
序言
先看效果图:
主要功能:中间一张上传的图片,从图片任一点位引出一根线,线的尾端是一张报表(也可以是其他需要的图片或功能)
框架:vue2(2.6.14) + element-ui(2.15.14)+ echarts(5.6.0)+ less(4.3.0)
一、报表组件
报表组件主要就是展示以及数值传递,功能比较简单,需要注意页面变化时报表的渲染、报表内存的销毁以及穿透操作,其他我就不多赘述,直接上代码:
// WrapCharts
<template>
<div>
<div ref="echartsContainer" class="chart-container"></div>
</div>
</template>
<script>
import * as echarts from "echarts";
export default {
name: "LineChart",
props: {
data: {
type: Object,
required: true,
default: () => {},
},
},
data() {
return {
chart: null, // 存储图表实例
};
},
methods: {
initChart() {
// 获取容器 DOM 元素
const chartContainer = this.$refs.echartsContainer;
if (!chartContainer) {
console.error("Chart container is not available");
return;
}
// 初始化图表
this.chart = echarts.init(chartContainer);
// 设置图表配置项
const option = {
grid: {
top: "15%", // 上边距(百分比)
bottom: "5%", // 下边距(百分比)
left: "5%", // 左边距
right: "15%", // 右边距
containLabel: true, // 确保坐标轴标签不超出 grid
},
tooltip: {
trigger: "axis",
appendToBody: true,
confine: false,
className: "echart-tooltip-overlay",
textStyle: {
fontSize: 12, // 设置tooltip字体大小
},
position: function (pos, params, dom, rect, size) {
// 自定义定位逻辑
return [pos[0], pos[1] - size.contentSize[1] - 10];
},
},
xAxis: {
type: "category",
data: this.data.xAxisData, // 从 props 中获取数据数组,
axisLabel: {
textStyle: {
fontSize: 10, // 设置字体大小
},
},
},
yAxis: {
type: "value",
max: this.data.maxY,
min: this.data.minY,
axisLabel: {
textStyle: {
fontSize: 10, // 设置字体大小
},
},
},
series: [
{
data: this.data.seriesData, // 从 props 中获取数据数组,
type: "line",
name: "实际值",
markLine: {
symbol: "none",
data: [
{
name: "上限",
yAxis: this.data.upperLimit,
lineStyle: {
type: "dashed",
color: "#ff0000",
},
label: {
show: true,
fontSize: 10,
formatter: "{c}",
},
},
{
name: "下限",
yAxis: this.data.lowerLimit,
lineStyle: {
type: "dashed",
color: "#ff0000",
},
label: {
show: true,
fontSize: 10,
formatter: "{c}",
},
}
],
},
},
],
};
// 使用刚定义的配置项和数据显示图表
option && this.chart.setOption(option);
},
},
mounted() {
this.$nextTick(() => {
this.initChart();
});
},
beforeDestroy() {
// 销毁图表实例,避免内存泄漏
if (this.chart) {
this.chart.dispose();
}
},
};
</script>
<style scoped lang="less">
.chart-container {
width: 100%;
height: 100%;
overflow: visible !important;
}
.echart-tooltip-overlay {
z-index: 9999 !important;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2) !important;
pointer-events: none; /* 允许穿透操作 */
}
</style>
二、核心组件
主要思路是在图片的上下左右用el的布局隔离出一片区域以展示报表,其中上下是横向展示,满了之后换行展示,左右的则是一直往下展示,所以为了美观上下展示的报表最大数应是可被24整除的正整数(实际根据span确定,代码中我span写死了,实际情况可以用个传入参数来实现可变),左右的数量为了美观不宜超过图片的高度。
图片的点位我使用了上下百分比来确定位置,与市面上大多数都不相同。所以后面我又增加了一个获取图片百分比点位的组件(后面展示)。
线条使用canvas绘制,落点根据上下左右分别计算(主要根据报表主件的长宽确定)。展示使用的是直线,曲线部分被我注释了(实际效果一般),如有需要自行取用。
下面是代码:
// WrapMain
<template>
<div>
<el-container class="margin-top-10">
<el-header>
<el-row
:gutter="20"
class="header-row"
type="flex"
justify="space-between"
>
<el-col
v-for="(item, index) in wrapTemp.top"
:key="'top' + index"
:span="3"
>
<el-card :ref="'top' + index" class="grid-content">
<div slot="header" class="clearfix-header">
<span>{{ item.title }}</span>
</div>
<line-chart
:data="item.data"
class="grid-chart-wrap"
/>
</el-card>
</el-col>
</el-row>
</el-header>
<el-main>
<el-row :gutter="20" class="header-row">
<el-col :span="3" class="flex-col">
<el-card
v-for="(item, index) in wrapTemp.left"
:key="'left' + index"
class="grid-content"
:class="{ 'margin-top-10': index > 0 }"
:ref="'left' + index"
>
<div slot="header" class="clearfix-header">
<span>{{ item.title }}</span>
</div>
<line-chart
:data="item.data"
class="grid-chart-wrap"
/>
</el-card>
</el-col>
<el-col
:span="18"
v-if="
(wrapTemp.top && wrapTemp.top.length > 0) ||
wrapTemp.left.length > 0 ||
wrapTemp.right.length > 0 ||
wrapTemp.bottom.length > 0
"
>
<div class="image-container-wrapper">
<div class="image-container" ref="imageContainer">
<el-image
:src="wrapTemp.img"
fit="contain"
@load="drawImageAndLines"
/>
</div>
</div>
</el-col>
<el-col :span="3" class="flex-col">
<el-card
v-for="(item, index) in wrapTemp.right"
:key="'right' + index"
class="grid-content"
:class="{ 'margin-top-10': index > 0 }"
:ref="'right' + index"
>
<div slot="header" class="clearfix-header">
<span>{{ item.title }}</span>
</div>
<line-chart
:data="item.data"
class="grid-chart-wrap"
/>
</el-card>
</el-col>
</el-row>
</el-main>
<el-footer>
<el-row
:gutter="20"
class="header-row"
type="flex"
justify="space-between"
>
<el-col
v-for="(item, index) in wrapTemp.bottom"
:key="'bottom' + index"
:span="3"
>
<el-card class="grid-content" :ref="'bottom' + index">
<div slot="header" class="clearfix-header">
<span>{{ item.title }}</span>
</div>
<line-chart
:data="item.data"
class="grid-chart-wrap"
/>
</el-card>
</el-col>
</el-row>
</el-footer>
<!-- Canvas 用于绘制多条线条 -->
<canvas ref="lineCanvas" class="line-canvas"></canvas>
</el-container>
</div>
</template>
<script>
import LineChart from "./LineChart.vue";
export default {
components: {
LineChart,
},
props: {
wrapTemp: {},
},
data() {
return {
startPoints: [],
targetDivs: [],
imageDimensions: null, // 保存图片的宽度和高度
dialogData: {}, // 用于传递给 chartDialog 的数据
lineColor: "#000000", // 线条颜色
};
},
methods: {
// 绘制图片和线条
drawImageAndLines(event) {
// 获取图片 DOM 元素
const imageElement = event.target;
// 获取图片的宽度和高度
this.imageDimensions = {
width: imageElement.naturalWidth,
height: imageElement.naturalHeight,
};
console.log("图片尺寸:", this.imageDimensions.width, this.imageDimensions.height);
this.drawLines();
},
// 绘制线条(核心代码)
drawLines() {
if (!this.imageDimensions || !this.imageDimensions.width || !this.imageDimensions.height) {
console.error("图片尺寸无效,无法绘制线条");
return;
}
const canvas = this.$refs.lineCanvas;
const ctx = canvas.getContext("2d");
// 设置 canvas 尺寸为整个页面的大小
canvas.width = this.$el.clientWidth;
canvas.height = this.$el.clientHeight;
// 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制每条线
this.startPoints.forEach((startPoint, index) => {
const targetItem = this.targetDivs[index];
if (targetItem?.el) {
const targetRect = targetItem.el.getBoundingClientRect();
const canvasRect = canvas.getBoundingClientRect();
// 直接获取方向信息
const direction = targetItem.dir;
// 根据方向计算终点坐标
let endX, endY;
switch (direction) {
case "top":
endX =
targetRect.left +
targetRect.width / 2 -
canvasRect.left; // 相对于 canvas 的 X 坐标
endY =
targetRect.top +
targetRect.height -
canvasRect.top; // 相对于 canvas 的 Y 坐标
break;
case "bottom":
endX =
targetRect.left +
targetRect.width / 2 -
canvasRect.left;
endY =
targetRect.top +
targetRect.height / 2 -
canvasRect.top;
break;
case "left":
endX =
targetRect.left +
targetRect.width -
canvasRect.left;
endY =
targetRect.top +
targetRect.height -
canvasRect.top;
break;
case "right":
endX = targetRect.left - canvasRect.left;
endY =
targetRect.top +
targetRect.height -
canvasRect.top;
break;
}
// 假设图片在页面中居中显示,并且大小适应(需要根据实际情况调整)
// 这里假设图片的显示区域是 imageContainer 的尺寸
const imageContainer = this.$refs.imageContainer;
const imageContainerRect =
imageContainer.getBoundingClientRect();
// 计算图片在 canvas 中的缩放和位置
// 这里假设图片按比例缩放以适应 imageContainer,并且居中
const imgScaleX =
imageContainerRect.width / this.imageDimensions.width;
const imgScaleY =
imageContainerRect.height / this.imageDimensions.height;
const imgScale = Math.min(imgScaleX, imgScaleY); // 保持比例
// 图片左上角相对于 canvas 的位置
const imgOffsetX =
imageContainerRect.left -
canvasRect.left +
(imageContainerRect.width -
this.imageDimensions.width * imgScale) /
2;
const imgOffsetY =
imageContainerRect.top -
canvasRect.top +
(imageContainerRect.height -
this.imageDimensions.height * imgScale) /
2;
// 计算起点在 canvas 上的坐标
const startX =
imgOffsetX +
startPoint.x * this.imageDimensions.width * imgScale;
const startY =
imgOffsetY +
startPoint.y * this.imageDimensions.height * imgScale;
// 计算控制点(在中间位置增加偏移量形成曲线)
// const controlX = (startX + endX) / 2 + 40; // 水平偏移量
// const controlY = (startY + endY) / 2 - 40; // 垂直偏移量
// 绘制起点圆点
this.drawStartPoint(ctx, startX, startY);
// 绘制线条
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY); // 直线
// ctx.quadraticCurveTo(controlX, controlY, endX, endY); // 贝塞尔曲线
ctx.strokeStyle = this.lineColor;
ctx.lineWidth = 1;
ctx.stroke();
}
});
},
// 重新绘制线条
redrawLines() {
if (this.$refs.lineCanvas) {
this.drawLines();
}
},
// 绘制起点圆点
drawStartPoint(ctx, x, y) {
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2); // 半径为4的圆点
ctx.fillStyle = this.lineColor;
ctx.fill();
},
// 初始化
init(wrapTemp) {
this.$nextTick(() => {
this.startPoints = [
...(wrapTemp.top?.map((t) => t.startPoint) || []),
...(wrapTemp.left?.map((t) => t.startPoint) || []),
...(wrapTemp.right?.map((t) => t.startPoint) || []),
...(wrapTemp.bottom?.map((t) => t.startPoint) || []),
];
// 动态获取卡片DOM引用
const getCardRefs = (prefix, dir, count) =>
Array.from({ length: count }, (_, i) => {
const refArray = this.$refs[`${prefix}${i}`];
const component = Array.isArray(refArray)
? refArray[0]
: refArray;
return component?.$el
? { dir, el: component.$el }
: null;
}).filter(Boolean); // 过滤掉可能为null的引用
this.targetDivs = [
...getCardRefs("top", "top", wrapTemp.top.length),
...getCardRefs("left", "left", wrapTemp.left.length),
...getCardRefs("right", "right", wrapTemp.right.length),
...getCardRefs(
"bottom",
"bottom",
wrapTemp.bottom.length
),
];
// 确保 startPoints 和 targetDivs 长度一致
const maxLen = Math.max(
this.startPoints.length,
this.targetDivs.length
);
while (this.startPoints.length < maxLen) {
this.startPoints.push({ x: 0.5, y: 0.5 }); // 填充默认起点
}
while (this.targetDivs.length < maxLen) {
this.targetDivs.push(""); // 填充默认引用
}
// 修正startPoints中可能超出图片范围的点
this.startPoints = this.startPoints.map((point, index) => {
// 对于bottom1-bottom4,确保y值不会超出图片底部
if (index >= 7) {
// bottom1是第7个(索引6),bottom4是第10个(索引9)
return {
x: Math.min(Math.max(point.x, 0), 1),
y: Math.min(Math.max(point.y, 0), 1),
};
}
return point;
});
window.addEventListener("resize", this.redrawLines);
// 初始绘制
if (
this.$refs.imageContainer &&
this.$refs.imageContainer.querySelector("img")
) {
const img = this.$refs.imageContainer.querySelector("img");
if (img.complete) {
this.drawImageAndLines({ target: img });
}
}
});
},
// 调整 imageContainer 的高度
adjustImageContainerHeight() {
if (!this.$refs.imageContainer) return;
const hasTop =
this.wrapTemp.top && this.wrapTemp.top.length > 0;
const elMain = this.$el.querySelector(".el-main");
// 如果没有 top 数据,让 el-main 的布局不受影响
if (!hasTop) {
elMain.style.minHeight = "auto";
elMain.style.height = "auto";
} else {
elMain.style.minHeight = "auto"; // 确保不会被固定高度限制
}
// 强制重新计算布局
this.$nextTick(() => {
window.dispatchEvent(new Event("resize")); // 触发 resize 事件重新绘制
});
},
},
mounted() {
this.init(this.wrapTemp);
this.adjustImageContainerHeight();
},
beforeDestroy() {
window.removeEventListener("resize", this.redrawLines);
},
watch: {
wrapTemp: {
handler(newVal) {
this.init(newVal);
this.$nextTick(() => {
this.adjustImageContainerHeight(); // 数据变化时重新调整
});
},
deep: true,
},
},
};
</script>
<style lang="less" scoped>
.image-container {
display: flex; /* 启用 Flexbox */
justify-content: center; /* 水平居中 */
align-items: center; /* 垂直居中 */
width: 100%; /* 确保宽度占满父容器 */
height: 100%; /* 确保高度占满父容器 */
overflow: hidden; /* 防止图片溢出 */
}
.line-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none; /* 使 Canvas 元素不响应鼠标事件 */
z-index: 1; /* 确保 canvas 在其他元素之上 */
}
.bg-purple {
background: #d3dce6;
}
.margin-top-10 {
margin-top: 10px;
}
/* 确保 el-container 及其子元素正确布局 */
.el-container {
position: relative; /* 为绝对定位的 canvas 提供参考 */
}
.el-header,
.el-footer {
position: relative;
min-height: auto !important;
height: auto !important;
display: block;
.header-row {
height: auto;
.el-col {
height: auto;
.grid-content {
height: 100%;
min-height: 100px; /* 设置最小高度 */
z-index: 2;
position: relative;
::v-deep .el-card__body {
padding: 0 !important;
height: 100%;
}
}
}
}
}
.el-main {
position: relative;
min-height: auto !important;
height: auto !important;
display: flex;
flex-direction: column;
.header-row {
height: auto;
display: flex;
.el-col {
height: auto;
.grid-content {
z-index: 2;
::v-deep .el-card__body {
padding: 0 !important;
height: 100%;
}
}
}
}
}
.el-row {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
}
.image-container-wrapper {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
/* 确保图表容器撑满 */
.grid-chart-wrap {
height: 100px;
flex: 1;
overflow: hidden;
}
.flex-col {
display: flex;
flex-direction: column; /* 垂直排列 */
justify-content: space-around; /* 卡片均匀分布 */
height: 100%; /* 确保高度占满父容器 */
}
::v-deep .el-card__header {
padding: 15px 8px;
min-height: 0;
}
.clearfix-header {
display: flex;
justify-content: space-between;
align-items: center;
height: 0;
}
</style>
PS:获取图片的百分比点位组件
这个没啥好说的,输出格式是:x,y 格式,直接上代码吧:
// ImageMaker
<template>
<div class="image-marker-container" ref="container">
<el-image
ref="imageEl"
:src="imageUrl"
fit="contain"
@load="handleImageLoad"
@click="handleImageClick"
class="image-marker-image"
/>
<div
v-if="markerPoint"
:style="getMarkerStyle(markerPoint)"
class="marker-point"
@click.stop
></div>
</div>
</template>
<script>
export default {
name: "ImageMarker",
props: {
imageUrl: {
type: String,
required: true,
},
point: {
type: String,
default: null, // 格式: "x,y",其中 x 和 y 在 0-1 之间
},
},
data() {
return {
imageDimensions: null,
markerPoint: null,
};
},
watch: {
point: {
immediate: true,
handler(newPoint) {
if (newPoint) {
const [x, y] = newPoint.split(",").map(Number);
if (
!isNaN(x) &&
!isNaN(y) &&
x >= 0 &&
x <= 1 &&
y >= 0 &&
y <= 1
) {
this.markerPoint = { x, y };
} else {
console.warn(
"Invalid point format or value. Expected 'x,y' where x and y are between 0 and 1."
);
this.markerPoint = null;
}
} else {
this.markerPoint = null;
}
},
},
imageUrl() {
this.imageDimensions = null;
this.markerPoint = null;
// 延迟观察,等待新图片加载
this.$nextTick(() => {
this.observeImageSize();
});
},
},
mounted() {
this.observeImageSize();
},
beforeDestroy() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
},
methods: {
observeImageSize() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
const imgEl = this.$refs.imageEl?.$el?.querySelector("img");
if (!imgEl) return;
this.resizeObserver = new ResizeObserver((entries) => {
for (let entry of entries) {
this.imageDimensions = {
width: entry.contentRect.width,
height: entry.contentRect.height,
};
}
});
this.resizeObserver.observe(imgEl);
},
handleImageLoad() {
if (!this.imageDimensions) {
this.$nextTick(() => {
this.observeImageSize(); // 重新观察
});
}
},
handleImageClick(event) {
if (!this.imageDimensions) return;
const imgRect = event.target.getBoundingClientRect();
const clickX = event.clientX - imgRect.left;
const clickY = event.clientY - imgRect.top;
// 计算百分比位置
const xPercent = clickX / imgRect.width;
const yPercent = clickY / imgRect.height;
this.markerPoint = { x: xPercent, y: yPercent };
// 触发事件,将新点传递给父组件
this.$emit("point-clicked", `${xPercent},${yPercent}`);
},
getMarkerStyle(point) {
if (!this.imageDimensions || !point) {
console.log("点位渲染异常!", this.imageDimensions, point);
return {};
}
// 计算标记点在图片上的位置(百分比转像素)
const x = point.x * this.imageDimensions.width;
const y = point.y * this.imageDimensions.height;
return {
left: `${x}px`,
top: `${y}px`,
};
},
},
};
</script>
<style scoped lang="less">
.image-marker-container {
position: relative;
width: 100%;
height: 100%;
}
.image-marker-image {
display: block;
width: 100%;
height: auto;
}
.marker-point {
position: absolute;
width: 10px;
height: 10px;
background-color: red;
border-radius: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
}
</style>
三、测试数据
{
"img": "https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg",
"top": [
{
"startPoint": { "x": 0.2, "y": 0.2 },
"title": "间隙top1",
"data": {
"seriesData": [100, 120, 110, 80, 70, 100, 110, 100, 120, 111, 80, 70, 100, 110, 100, 120, 110, 80, 70, 100, 111, 100, 120, 110, 80, 70, 100, 110, 100, 120, 111, 80, 70, 100, 110, 100, 120, 110, 80, 70, 100, 111, 100, 120, 110, 80, 70, 100, 110, 100, 120, 110, 80, 70, 100, 110, 100, 120, 110, 80, 70, 100, 110, 100, 120, 110, 80, 70, 100, 110],
"xAxisData": ["x1", "x2", "x3", "x4", "x5", "x6", "x7", "x8", "x9", "x10", "x11", "x12", "x13", "x14", "x15", "x16", "x17", "x18", "x19", "x20", "x21", "x22", "x23", "x24", "x25", "x26", "x27", "x28", "x29", "x30", "x31", "x32", "x33", "x34", "x35", "x36", "x37", "x38", "x39", "x40", "x41", "x42", "x43", "x44", "x45", "x46", "x47", "x48", "x49", "x50", "x51", "x52", "x53", "x54", "x55", "x56", "x57", "x58", "x59", "x60", "x61", "x62", "x63", "x64", "x65", "x66", "x67", "x68", "x69", "x70"],
"upperLimit": 100,
"lowerLimit": 80
}
},
{
"startPoint": { "x": 0.3, "y": 0.1 },
"title": "间隙top2",
"data": {
"seriesData": [150, 180, 170, 120, 110, 140, 160],
"xAxisData": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
"upperLimit": 200,
"lowerLimit": 120
}
}
],
"left": [
{
"startPoint": { "x": 0.1, "y": 0.2 },
"title": "间隙left1",
"data": {
"seriesData": [100, 120, 110, 80, 70, 100, 110],
"xAxisData": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
"upperLimit": 100,
"lowerLimit": 80
}
},
{
"startPoint": { "x": 0.2, "y": 0.3 },
"title": "间隙left2",
"data": {
"seriesData": [150, 180, 170, 120, 110, 140, 160],
"xAxisData": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
"upperLimit": 120,
"lowerLimit": 30
}
}
],
"right": [
{
"startPoint": { "x": 0.7, "y": 0.4 },
"title": "间隙right1",
"data": {
"seriesData": [250, 280, 270, 220, 210, 240, 260],
"xAxisData": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
"upperLimit": 300,
"lowerLimit": 220
}
}
],
"bottom": [
{
"startPoint": { "x": 0.4, "y": 0.6 },
"title": "间隙bottom1",
"data": {
"seriesData": [300, 330, 320, 270, 260, 290, 310],
"xAxisData": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
"upperLimit": 350,
"lowerLimit": 270
}
},
{
"startPoint": { "x": 0.5, "y": 0.7 },
"title": "间隙bottom2",
"data": {
"seriesData": [350, 380, 370, 320, 310, 340, 360],
"xAxisData": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
"upperLimit": 400,
"lowerLimit": 320
}
}
]
}
完结撒盐!