2D模型图区域操作方案

关于对2D模型图不规则区域进行操作的方案

本篇文档主要记录自己对目前完成的2d模型图进行相关操作的心得,下面进行简单的阐述,不喜勿喷!

1、需求背景

  1. 1、需要在页面展示模型图,模型图存在多个不规则的区域
  2. 2、需要对这些不规则的区域进行相关的操作,例如:hover效果、点击某个区域长高亮并对其进行相关操作、根据后端返回的数据进行某个区域长高亮渲染、对某个区域进行引导线并在另一端写相关的文字已经都相关文字设置相关的动态效果等操作
  3. 3、设置相关的图例显示
  4. 4、需要封装成公共组件,做好相关操作的控制便于在不同地方的不用用法

2、技术选型

  1. 1、使用canvas+js实现点击更改图片中指定部位的颜色:该方法适合多种不用颜色区域的选择换色,是利用主色调,近似色,互补色,以及根据图片颜色获取主题配色。对应人体图单一的轮廓图不适用。
  2. 2、使用img+map+area标签实现「图像局部区域映射」;此方法适用于规则以及不规则图像区域点击跳转或者执行事件。但此方法不好对区域进行更多的操作。
  3. 3、使用img+svg定位实现:在页面设置图片的大小,然后使用确定定位设置svg,svg大小需要与图片的大小保持一致,避免发生偏差。

3、注意事项

  1. 1、图像需要根据页面1920x1080 100%缩放比率 提供图片最佳。
  2. 2、通过图像可以进行区域的描点输出,目前提供三种方式取值circle(圆形)、rect(进行)、poly(多边形),优先推荐使用poly取值方式。设计师可以根据ps取值或者通过上传图像在线取区域值(推荐)。 在线网站地址:https://www.image-map.net/ 选择完区域会生成 这样的区域标签。然后把各个区域的标签和图像提供给开发即可。
  3. 此方法实现比较精确,不需要计算点坐标,兼容性强,不受缩放分辨率的影响,无论使用img+map+area标签实现还是img+svg定位实现都需要注意以上实现。只是img+svg 操作更强更加灵活。

4、组件封装

新建名为ModelChart2D.vue的全局公共组件,代码如下:

<!-- 2d 模型图组件 -->
<template>
    <div class="w100 ofh pr flex-c pr">
        <!-- 2d模型图 -->
        <img :src="props.chartImg"
             alt="2d模型图"
             usemap="#modelBodyChart"
             :width="props.imgWidth"
             :height="props.imgHeight" />

        <!-- 区域映射 -->
        <!-- <map name="modelBodyChart">
            <area v-for="(item,index) in props.chartArea"
                  :key="item.code"
                  target=""
                  class="area"
                  :alt="item.title"
                  :title="item.title"
                  @click="clickArea(item)"
                  href="javascript:void(0)"
                  :coords="item.coords"
                  :shape="item.shape" />
        </map> -->

        <!-- 使用svg绘制区域 -->
        <svg :width="props.imgWidth"
             :height="props.imgHeight"
             class="svg-box"
             :style="{'--top':`${props.offsetTop}px`}">
            <!-- 不规则区域 -->
            <polygon v-for="(item,index) in props.chartArea"
                     :key="item.code"
                     :id="`myPolygon${index}`"
                     :points="item.coords"
                     @click="clickArea(item)"
                     opacity="0.3"
                     :fill="fillColor(item,props.data)"
                     :class="{'polygon-active': !props.isShow}">
                <title>{{item.title}}</title>
            </polygon>

            <!-- 绘制线段 -->
            <line v-for="(item,index) in lineData"
                  :x1="item.startX"
                  :y1="item.startY"
                  :x2="item.endX"
                  :y2="item.endY"
                  :stroke="item.color"
                  stroke-width="1" />

            <!-- 在线段另一端添加文字 -->
            <text v-for="(item,index) in textData"
                  :x="item.x"
                  :y="item.y"
                  :class="{'blinking-text':item.isTip == 1}"
                  :text-anchor="item.textAnchor"
                  fill="black"
                  font-size="14"
                  font-family="Arial">
                {{item.name}}
                <title>{{item.name}}</title>
            </text>
        </svg>

        <!-- svg定义不规则区域(点击某区域高亮填充) -->
        <svg :width="props.imgWidth"
             :height="props.imgHeight"
             class="svg-box"
             :style="{'--z-index':zIndex,'--top':`${props.offsetTop}px`}">
            <polygon :points="highlightAreaPoints"
                     class="highlight-area" />
        </svg>

        <!-- 图例v-draggable -->
        <div v-if="props.isShowLegend && props.data.length>0"
             class="legend-box">
            <div class="header">
                <div class="item"
                     v-for="(item,index) in props.data"
                     :key="index"
                     :style="{'--bg-color': item.legendColor}">{{item.name}}({{item.value}})</div>
            </div>
        </div>
    </div>
</template>

<script setup>
import { nextTick, onMounted, ref } from "vue";
import testImgUrl from "./test_chart.png" // 默认人体图像

const emit = defineEmits(['callback'])
const props = defineProps({
    // 2d模型图宽度
    imgWidth: {
        type: Number,
        default: 628,
    },
    // 2d模型图高度
    imgHeight: {
        type: Number,
        default: 700,
    },
    // 2d模型图名称 
    chartArea: {
        type: Array,
        default: () => [
            {
                code: 'A',
                title: '头',
                shape: "poly",
                coords: "176,70,186,68,195,69,204,80,206,90,206,101,204,107,203,119,201,126,197,131,190,135,177,139,169,138,164,133,160,122,159,104,159,89,165,76"
            }
        ],
    },
    // 2d模型图图像
    chartImg: {
        type: String,
        default: testImgUrl,
    },
    // 是否只需要展示
    isShow: {
        type: Boolean,
        default: false,
    },
    //是否显示图例信息
    isShowLegend: {
        type: Boolean,
        default: true,
    },
    // 图例数据--用于展示图例信息
    data: {
        type: Array,
        default: () => [],
    },
    // 是否显示线段和文字信息
    isShowLineText: {
        type: Boolean,
        default: true,
    },
    //向上偏移量(用绘制区域不规范)
    offsetTop: {
        type: Number,
        default: 0
    }
})


const highlightAreaPoints = ref("") // 高亮的区域
const zIndex = ref(-1) // 高亮区域的层级

// 取消高亮区域层级
const cancelHighlightZindex = () => {
    zIndex.value = -1
}

// 填充的区域颜色
const fillColor = (item, data) => {
    if (data.length > 0) {

        let index = data.find(f => f?.catheterSiteList?.map((m) => m.code)?.includes(item.code))

        if (index) {
            return index.legendColor
        } else {
            return 'transparent'
        }

    } else {
        return 'transparent'
    }
}
// 点击区域回调函数
const clickArea = (data) => {
    // console.log(`点击了=》${data.title}`, data);
    if (!props.isShow) {
        emit('callback', data)
        highlightAreaPoints.value = data.coords
        zIndex.value = 1
    }
}



const lineData = ref([]) // 线段数据
const textData = ref([]) // 文字数据
// 线段和文字数据设置函数
const setData = (index, el, item) => {
    nextTick(() => {
        const polygonEle = document.getElementById(`myPolygon${index}`);

        // 计算多边形的中心点
        const bbox = polygonEle.getBBox();
        const centerX = bbox.x + bbox.width / 2;
        const centerY = bbox.y + bbox.height / 2;

        // 定义线段的起点和终点
        const startX = centerX; // 线段起点 X 坐标
        const startY = centerY; // 线段起点 Y 坐标

        let endX, endY, textAnchor;
        if (item.direction === 'right') {
            endX = centerX + 100; // 线段终点 X 坐标
            endY = centerY; // 线段终点 Y 坐标
            textAnchor = 'end'
        } else if (item.direction === 'bottom-left') {
            endX = centerX + 20; // 线段终点 X 坐标
            endY = centerY + 50; // 线段终点 Y 坐标
        } else if (item.direction === 'bottom-right') {
            endX = centerX - 20; // 线段终点 X 坐标
            endY = centerY + 50; // 线段终点 Y 坐标
        } else {
            endX = centerX - 100; // 线段终点 X 坐标
            endY = centerY; // 线段终点 Y 坐标
            textAnchor = 'start'
        }
        // console.log(el, 'el-9000');
        let list = el?.catheterSiteList?.filter((f) => f.code === item.code)
        let name = list?.map((f) => f.name)?.join(',')
        let isTipList = list?.map((f) => f.isExpiredWithinOneDay)
        let isTip = Math.max(...isTipList)

        lineData.value.push({ startX, startY, endX, endY, color: el.legendColor });
        textData.value.push({ x: endX, y: endY, name: name, textAnchor: textAnchor || 'middle', isTip: isTip || 0 });

        // console.log(startX, startY, endX, endY, '线段起点和终点坐标');
        // console.log(lineData.value, 'lineData');
        // console.log(textData.value, 'textData');

    })
}
// 设置线段和文字数据函数
const setLineTextData = (areaArea, data) => {
    areaArea?.forEach((item, index) => {
        let el = data.find(f => f?.catheterSiteList?.map((m) => m.code)?.includes(item.code))
        if (el) {
            setData(index, el, item)
        }
    });
}
// 初始化设置线段和文字数据函数
const init = () => {
    lineData.value = textData.value = []
    props.isShowLineText && setLineTextData(props.chartArea, props.data)
}
onMounted(() => {
    setTimeout(() => {
        init()
    }, 500)
})
defineExpose({
    cancelHighlightZindex, init
})
</script>

<style lang="scss" scoped>
@keyframes blink {
    0%,
    100% {
        opacity: 1;
    }
    50% {
        opacity: 0;
    }
}
.blinking-text {
    fill: red;
    animation: blink 1s infinite;
}
.svg-box {
    position: absolute;
    top: var(--top);
    z-index: var(--z-index);
}

.polygon-active {
    cursor: pointer;
    &:hover {
        fill: var(--g-color-base);
        opacity: 0.3;
    }
}
/* 样式设置区域的过渡效果 */
.highlight-area {
    fill: var(--g-color-base);
    opacity: 0.6;
    transition: fill 0.3s ease;
}
.legend-box {
    position: absolute;
    top: 16px;
    right: 36px;
    // transform: translateX(-50%);
    width: 185px;
    min-height: 50px;
    flex-shrink: 0;
    border-radius: 6px;
    background: #fff;
    box-shadow: 0 0 10px 4px #0000001a;
    .header {
        padding: 10px 10px 10px 30px;
        .item {
            position: relative;
            color: #000000;
            font-size: 14px;
            font-weight: 400;
            &::before {
                content: '';
                position: absolute;
                left: -17px;
                top: 3px;
                width: 12px;
                height: 12px;
                border-radius: 50%;
                background-color: var(--bg-color);
            }
        }
    }
}
</style>

5、配置文件

新建modelConfig.js文件,用于配置组件相关的信息。同时可以引入不同类型的图像和区域,并且可以在区域配置中配置自己所需的相关信息。

import modelTest from "xxx.png" // 引入组件图像名称

// 模型图配置
export const imageConfig = {
    'test': modelTest,
}
// 区域配置
export const areaConfig = {
    'test': [
        {
            code: 'A',
            title: '头部',
            shape: "poly",
            direction: "left",
            coords: "178,18,171,18,164,19,159,23,159,29,159,34,160,42,159,50,166,50,174,50,180,50,186,50,192,50,197,48,196,41,197,34,196,26,193,20,185,18"
        },
        ......
     ]
}

6、组件引用

可以在自己所需的页面引入组件即可。

import { imageConfig, areaConfig } from 'modelConfig.js' // 模型图配置信息

<ModelChart2D ref="modelChart2DRef"
              :chartImg="imageConfig['test']"
              :chartArea="areaConfig['test']"
              @callback="callback">
</ModelChart2D>

callback:组件回调函数

7、心得总结

当第一次遇到这样的需求时,总是那么的猝不及防,当时内心非常的不安。但是通过自己不断地查阅资料,不断地去尝试,慢慢的有了组件的最初的样子,再到后面的慢慢优化,不断地得到了需求的认可。 虽然不是很完美,但是相关的功能还是实现了。实现了心里总是很欣慰的。 所以每当我们第一次接触的东西时,不要一看就说不,需要自己先调研然后给出可行的实现方案。

不喜勿喷!

二分
JSRUN前端笔记, 是针对前端工程师开放的一个笔记分享平台,是前端工程师记录重点、分享经验的一个笔记本。JSRUN前端采用的 MarkDown 语法 (极客专用语法), 这里属于IT工程师。