使用svg画电站流动图。通过useElementSize拿到流动图区域的响应式宽高,定义数据结构,计算每个节点的坐标,再计算设置每一条path的路径,再模型驱动UI循环展示出来
以下是具体实现示例,包含:
-
节点和路径坐标的具体计算方法
-
使用svg画节点内容和路径,以及一个元素如何沿着运动路径移动
<template>
<div
ref="container"
class="w-full"
:class="data.GEN?.showGen ? 'h-520px' : 'h-320px'"
>
<!-- 动态设置SVG的 viewBox 属性,可以让SVG根据容器大小自适应缩放 -->
<svg :view-box="`0 0 ${width} ${height}`" class="w-full h-full">
<!-- <defs> 标签用于定义可重用的SVG元素 -->
<defs>
<!-- filter:定义滤镜效果 -->
<filter id="innerCircleShadow">
<!-- feDropShadow 元素创建了一个内阴影效果: -->
<feDropShadow
dx="0"
dy="0"
stdDeviation="8"
flood-opacity="0.1"
flood-color="#313131"
/>
</filter>
</defs>
<!-- 四条线 -->
<path
v-for="path in paths"
:id="`path-${path.id}`"
:key="`path-${path.id}`"
:d="path.d"
:stroke="path.isActive ? path.stroke : '#EBEBEB'"
stroke-width="4"
stroke-dasharray="0"
stroke-linecap="round"
fill="none"
/>
<template v-for="path in paths">
<circle
v-if="path.isFlowing"
:key="`dot-${path.id}`"
r="5"
:fill="path.stroke"
>
<!-- isReversed如果true的话keyPoints是1;0(反方向),false的话是0,1(正方向) -->
<!-- <animateMotion> 元素定义了一个元素如何沿着运动路径进行移动。 -->
<animateMotion
dur="3s"
repeatCount="indefinite"
:keyPoints="path.isReversed ? '1;0' : '0;1'"
keyTimes="0;1"
calcMode="linear"
:path="path.d"
/>
</circle>
</template>
<!-- 周围节点 -->
<!-- <g> SVG 元素是一个容器,用于将其他 SVG 元素进行分组。 -->
<g
v-for="(circle, index) in circles"
:key="index"
stroke="#EBEBEB"
stroke-width="2"
fill="white"
>
<circle :cx="circle.x" :cy="circle.y" :r="circle.r" />
<!-- filter: 作为一个外观属性,复用上面定义的defs 中的滤镜效果 -->
<circle
:cx="circle.x"
:cy="circle.y"
:r="circle.innerR"
stroke="none"
filter="url(#innerCircleShadow)"
/>
<!-- icon -->
<!-- <foreignObject> 元素允许包含来自不同的 XML 命名空间的元素 -->
<foreignObject
:x="circle.x - circle.innerR"
:y="circle.y - circle.innerR"
:width="circle.innerR * 2"
:height="circle.innerR * 2"
>
<div class="h-full w-full flex items-center justify-center">
<Img
:src="circle.icon"
:class="circle.iconClass ?? 'w-[40px] h-[40px]'"
/>
</div>
</foreignObject>
<!-- text -->
<foreignObject
v-if="circle.text"
:x="circle.text.x"
:y="circle.text.y"
:width="circle.text.width"
:height="circle.text.height"
>
<div
class="h-full w-full flex flex-col gap-[2px] items-center justify-center text-sm font-bold"
>
<div>{{ circle.text.title }}</div>
<div>{{ circle.text.sub }}</div>
</div>
</foreignObject>
</g>
</svg>
</div>
</template>
<script setup lang="ts">
import { useElementSize } from '@vueuse/core';
import { computed, PropType, ref } from 'vue';
type DataItemType = {
title: string;
sub: string;
isActive: boolean;
isFlowing: boolean;
// 流动方向 发电功率和负载功率都是从左流向右,方向不变;这里只针对电池功率和电网功率改变方向
isReversed?: boolean;
showGen?: boolean;
};
type DataType = {
solar: DataItemType;
payload: DataItemType;
battery: DataItemType;
powerGrid: DataItemType;
GEN: DataItemType;
};
const props = defineProps({
data: {
type: Object as PropType<DataType>,
required: true,
},
});
const container = ref<HTMLDivElement | null>(null);
const { width, height } = useElementSize(container);
const PADDING = [22, 164];
const TEXT_BOX_WIDTH = 160;
const MAIN_CIRCLE_R = 60;
const MAIN_CIRCLE_INNER_R = 45;
const SLAVE_CIRCLE_R = 50;
const SLAVE_CIRCLE_INNER_R = 40;
const GEN_ADJUST_HEIGHT = 80;
const GEN_TEXT_BOX_HEIGHT = 50;
const GEN_TEXT_BOX_WIDTH = 350;
const MAIN_CIRCLE_PATH_CONNECTION_OFFSET = 14;
const circles = computed(() => {
const adjustHeight = props.data.GEN?.showGen
? height.value - GEN_ADJUST_HEIGHT * 2
: height.value;
const circlesValue = [
{
x: width.value / 2,
y: props.data.GEN?.showGen
? height.value / 2 - GEN_ADJUST_HEIGHT
: height.value / 2,
r: MAIN_CIRCLE_R,
innerR: MAIN_CIRCLE_INNER_R,
icon: InvIconPng,
iconClass: 'w-27px h-42px',
},
{
x: SLAVE_CIRCLE_R + PADDING[1],
y: SLAVE_CIRCLE_R + PADDING[0],
r: SLAVE_CIRCLE_R,
innerR: SLAVE_CIRCLE_INNER_R,
icon: SolarIconPng,
text: {
title: props.data.solar.title,
sub: props.data.solar.sub,
x: PADDING[1] - TEXT_BOX_WIDTH,
y: PADDING[0],
width: TEXT_BOX_WIDTH,
height: SLAVE_CIRCLE_R * 2,
},
},
{
x: width.value - SLAVE_CIRCLE_R - PADDING[1],
y: SLAVE_CIRCLE_R + PADDING[0],
r: SLAVE_CIRCLE_R,
innerR: SLAVE_CIRCLE_INNER_R,
icon: HouseIconPng,
text: {
title: props.data.payload.title,
sub: props.data.payload.sub,
x: width.value - PADDING[1],
y: PADDING[0],
width: TEXT_BOX_WIDTH,
height: SLAVE_CIRCLE_R * 2,
},
},
{
x: SLAVE_CIRCLE_R + PADDING[1],
y: adjustHeight - SLAVE_CIRCLE_R - PADDING[0],
r: SLAVE_CIRCLE_R,
innerR: SLAVE_CIRCLE_INNER_R,
icon: EnergyIconPng,
text: {
title: props.data.battery.title,
sub: props.data.battery.sub,
x: PADDING[1] - TEXT_BOX_WIDTH,
y: adjustHeight - SLAVE_CIRCLE_R * 2 - PADDING[0],
width: TEXT_BOX_WIDTH,
height: SLAVE_CIRCLE_R * 2,
},
},
{
x: width.value - SLAVE_CIRCLE_R - PADDING[1],
y: adjustHeight - SLAVE_CIRCLE_R - PADDING[0],
r: SLAVE_CIRCLE_R,
innerR: SLAVE_CIRCLE_INNER_R,
icon: PowerIconPng,
text: {
title: props.data.powerGrid.title,
sub: props.data.powerGrid.sub,
x: width.value - PADDING[1],
y: adjustHeight - SLAVE_CIRCLE_R * 2 - PADDING[0],
width: TEXT_BOX_WIDTH,
height: SLAVE_CIRCLE_R * 2,
},
},
];
if (props.data.GEN?.showGen) {
circlesValue.push({
x: width.value / 2,
y: height.value - SLAVE_CIRCLE_R - PADDING[0] - SLAVE_CIRCLE_R - 10,
r: SLAVE_CIRCLE_R,
innerR: SLAVE_CIRCLE_INNER_R,
icon: GenIconPng,
text: {
title: props.data.GEN.title,
sub: props.data.GEN.sub,
x:
width.value / 2 -
SLAVE_CIRCLE_R -
(GEN_TEXT_BOX_WIDTH - SLAVE_CIRCLE_R * 2) / 2,
y: height.value - SLAVE_CIRCLE_R - PADDING[0],
width: GEN_TEXT_BOX_WIDTH,
height: GEN_TEXT_BOX_HEIGHT,
},
});
}
return circlesValue;
});
const paths = computed(() => {
const pathsValue = [
{
id: 'solar',
d: `M${circles.value[1].x + SLAVE_CIRCLE_R} ${circles.value[1].y} H${circles.value[1].x + (circles.value[0].x - circles.value[1].x) / 2 - 16} q 16 0 16 16 V${circles.value[0].y - MAIN_CIRCLE_PATH_CONNECTION_OFFSET - 16} q 0 16 16 16 H ${
circles.value[0].x - MAIN_CIRCLE_R
}`,
stroke: '#E3A27F',
isFlowing: props.data.solar.isFlowing,
isActive: props.data.solar.isActive,
},
{
id: 'house',
d: `M${circles.value[0].x + MAIN_CIRCLE_R} ${circles.value[0].y - MAIN_CIRCLE_PATH_CONNECTION_OFFSET} H${circles.value[0].x + (circles.value[2].x - circles.value[0].x) / 2 - 16} q 16 0 16 -16 V${circles.value[2].y + 16} q 0 -16 16 -16 H ${
circles.value[2].x - SLAVE_CIRCLE_R
}`,
stroke: '#886FFF',
isFlowing: props.data.payload.isFlowing,
isActive: props.data.payload.isActive,
},
{
id: 'energy',
d: `M${circles.value[3].x + SLAVE_CIRCLE_R} ${circles.value[3].y} H${circles.value[3].x + (circles.value[0].x - circles.value[3].x) / 2 - 16} q 16 0 16 -16 V${circles.value[0].y + MAIN_CIRCLE_PATH_CONNECTION_OFFSET + 16} q 0 -16 16 -16 H ${
circles.value[0].x - MAIN_CIRCLE_R
}`,
stroke: '#ACDC64',
isFlowing: props.data.battery.isFlowing,
isActive: props.data.battery.isActive,
// 电池功率≥150W,逆变器流向电池;电池功率≤-150W,电池流向逆变器;
// -150W<电池功率<150W,该条线不流动(此版本不置灰);电池功率展示为绝对值
isReversed: props.data.battery.isReversed,
},
{
id: 'pwrGrid',
d: `M${circles.value[4].x - SLAVE_CIRCLE_R} ${circles.value[4].y} H${circles.value[0].x + (circles.value[4].x - circles.value[0].x) / 2 + 16} q -16,0 -16,-16 V${circles.value[0].y + MAIN_CIRCLE_PATH_CONNECTION_OFFSET + 16} q 0,-16 -16,-16 H ${
circles.value[0].x + MAIN_CIRCLE_R
}`,
stroke: '#02A25B',
isFlowing: props.data.powerGrid.isFlowing,
isActive: props.data.powerGrid.isActive,
// 电网功率≥150W,电网流向逆变器;电网功率≤-150W,逆变器流向电网;
// -150W<电网功率<150W,该条线不流动(此版本不置灰);电网功率展示为绝对值
isReversed: props.data.powerGrid.isReversed,
},
];
if (props.data.GEN?.showGen) {
pathsValue.push({
id: 'gen',
d: `M${circles.value[5].x} ${circles.value[5].y - SLAVE_CIRCLE_R} V${circles.value[0].y + MAIN_CIRCLE_R}`,
stroke: '#FFB048',
// 发电机功率≥150W,发电机流向逆变器;发电机功率<150W,该条线不流动并置灰
isFlowing: props.data.GEN.isFlowing,
isActive: props.data.GEN.isActive,
});
}
return pathsValue;
});
</script>
703

被折叠的 条评论
为什么被折叠?



