通过模型驱动UI实现流动图,并实现一个元素沿着svg路径移动

使用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>

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值