@tuoyuan/img-map-container
v1.2.11
Published
Vue 3 图片地图容器组件,支持在图片上展示和管理资源位置(点、线、面)
Readme
@tuoyuan/img-map-container
Vue 3 图片地图容器组件,支持在图片上展示和管理资源位置(点、线、面)。
特性
- 🗺️ 基于图片的地图展示
- 📍 支持点、线、面三种几何类型的资源
- 🎨 可自定义资源样式
- 🔍 支持缩放和拖拽
- 🎯 资源点击事件
- 📦 开箱即用,零配置
- 🎭 TypeScript 支持
- 🔄 自动加载资源类型列表
安装
npm install @tuoyuan/img-map-container
# 或
pnpm add @tuoyuan/img-map-container
# 或
yarn add @tuoyuan/img-map-container快速开始
1. 安装插件
在 main.ts 中注册插件:
import { createApp } from 'vue'
import App from './App.vue'
import ImgMapPlugin from '@tuoyuan/img-map-container'
const app = createApp(App)
// 注册插件,配置 API 基础路径和认证信息
app.use(ImgMapPlugin, {
baseUrl: '/gateway-api', // 你的 API 网关地址
appId: 'your-app-id', // 地图管理平台的 app_id
appKey: 'your-app-key', // 地图管理平台的 app_key
getToken: (callback) => {
// 提供 access_token
callback('your-access-token')
}
})
app.mount('#app')重要说明:
appId和appKey:地图管理平台分配的应用凭证,用于 API 认证getToken:提供用户的 access_token,用于权限验证baseUrl:API 网关地址,默认为/gateway-api
2. 使用组件
<template>
<div style="width: 100vw; height: 100vh;">
<ImgMapContainer
ref="mapRef"
:map-id="mapId"
@resource-click="handleResourceClick"
@map-loaded="handleMapLoaded"
@resources-loaded="handleResourcesLoaded"
@resource-types-loaded="handleResourceTypesLoaded"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ImgMapContainer } from '@tuoyuan/img-map-container'
import type {
MapInfo,
ResourcePlacement,
ResourceClickEvent,
ResourceType
} from '@tuoyuan/img-map-container'
const mapRef = ref()
const mapId = ref('your-map-id')
function handleMapLoaded(mapInfo: MapInfo) {
console.log('地图加载成功:', mapInfo)
}
function handleResourceTypesLoaded(types: ResourceType[]) {
console.log('资源类型列表:', types)
// 地图加载后会自动获取资源类型列表
// 你可以在这里展示资源类型选择器
}
function handleResourcesLoaded(placements: ResourcePlacement[]) {
console.log('资源加载完成,共', placements.length, '个')
}
function handleResourceClick(event: ResourceClickEvent) {
console.log('点击了资源:', event.resource)
}
// 加载指定类型的资源
function loadResourceType(typeKey: string) {
mapRef.value?.loadResources(typeKey)
}
</script>核心概念
工作流程
1. 组件初始化
↓
2. 加载地图信息(自动)
↓
3. 加载资源类型列表(自动)
↓
4. 触发 resource-types-loaded 事件
↓
5. 用户选择资源类型
↓
6. 调用 loadResources(typeKey) 加载资源
↓
7. 资源自动渲染到地图上数据流
后端 API
↓
ImgMapContainer (组件内部)
├─ 地图信息 (mapInfo)
├─ 资源类型列表 (resourceTypes)
└─ 资源位置列表 (placements)
↓
计算属性自动分类
├─ pointPlacements (点资源)
├─ linePlacements (线资源)
└─ polygonPlacements (面资源)
↓
SVG 自动渲染
├─ <circle> 渲染点
├─ <polyline> 渲染线
└─ <polygon> 渲染面API 文档
Props
| 属性 | 类型 | 必填 | 说明 |
|------|------|------|------|
| map-id | string | 是 | 地图 ID,用于加载地图信息和资源 |
Events
| 事件名 | 参数 | 触发时机 | 说明 |
|--------|------|----------|------|
| map-loaded | (mapInfo: MapInfo) | 地图信息加载完成 | 返回地图的基本信息(名称、尺寸等) |
| resource-types-loaded | (types: ResourceType[]) | 资源类型列表加载完成 | 地图加载后自动获取,返回所有可用的资源类型 |
| resources-loaded | (placements: ResourcePlacement[]) | 资源位置列表加载完成 | 调用 loadResources 后触发 |
| resource-click | (event: ResourceClickEvent) | 用户点击资源 | 返回被点击的资源详情 |
暴露的方法
通过 ref 可以调用以下方法:
loadResources(typeKeyOrId, append)
加载指定类型的资源并显示在地图上。
参数:
typeKeyOrId(string): 资源类型的 key 或 IDappend(boolean, 可选): 是否追加模式,默认falsefalse: 替换模式,清空现有资源后加载新资源true: 追加模式,保留现有资源,追加新资源(自动去重)
返回值: Promise<string | null> - 返回实际使用的 type_id,失败返回 null
示例:
// 替换模式:清空现有资源,只显示停车位
await mapRef.value.loadResources('parking-space')
// 追加模式:保留现有资源,追加建筑物
await mapRef.value.loadResources('building', true)
// 加载多种类型的资源
await mapRef.value.loadResources('parking-space', false) // 先加载停车位
await mapRef.value.loadResources('building', true) // 追加建筑物
await mapRef.value.loadResources('camera', true) // 追加摄像头toggleResourceType(typeKeyOrId)
切换资源类型的显示/隐藏状态。如果该类型已加载则隐藏,如果未加载则显示。
参数:
typeKeyOrId(string): 资源类型的 key 或 ID
返回值: Promise<boolean> - 返回切换后的状态,true 表示已加载,false 表示已隐藏
示例:
// 切换停车位的显示/隐藏
const isLoaded = await mapRef.value.toggleResourceType('parking-space')
console.log(isLoaded ? '已显示' : '已隐藏')
// 实现多选资源类型
async function handleTypeToggle(typeKey: string) {
const isLoaded = await mapRef.value.toggleResourceType(typeKey)
console.log(`资源类型 ${typeKey} 现在${isLoaded ? '可见' : '隐藏'}`)
}使用场景:
- 多选资源类型展示(类似图层控制)
- 资源类型的开关切换
- 复选框控制资源显示
isTypeLoaded(typeId)
检查指定资源类型是否已加载。
参数:
typeId(string): 资源类型 ID
返回值: boolean - true 表示已加载,false 表示未加载
示例:
// 检查停车位是否已加载
const isLoaded = mapRef.value.isTypeLoaded('parking-space-type-id')
console.log(isLoaded ? '已加载' : '未加载')
// 在 UI 中显示加载状态
<button
v-for="type in resourceTypes"
:key="type.id"
@click="toggleType(type)"
:class="{ active: mapRef?.isTypeLoaded(type.id) }"
>
{{ type.name }}
</button>clearResources()
清空地图上的所有资源(不影响后端数据)。
参数: 无
返回值: void
示例:
mapRef.value.clearResources()deleteResource(placementId)
从地图上删除指定资源(仅前端删除,不调用后端 API)。
参数:
placementId(string): 资源位置 ID
返回值: boolean - 删除是否成功
示例:
const success = mapRef.value.deleteResource('placement-id-123')
if (success) {
console.log('删除成功')
}注意: 此方法只从前端的 placements 数组中移除资源,后端数据不受影响。
zoomIn()
放大地图。
参数: 无
返回值: void
示例:
mapRef.value.zoomIn()zoomOut()
缩小地图。
参数: 无
返回值: void
示例:
mapRef.value.zoomOut()resetView()
重置地图视图到初始状态(cover 模式)。
参数: 无
返回值: void
示例:
mapRef.value.resetView()类型定义
MapInfo
地图基本信息。
interface MapInfo {
id: string // 地图 ID
name: string // 地图名称
description?: string // 地图描述
image_url: string // 地图图片 URL
image_width: number // 图片宽度(像素)
image_height: number // 图片高度(像素)
center_x?: number // 中心点 X 坐标
center_y?: number // 中心点 Y 坐标
zoom_range?: [number, number] // 缩放范围
default_zoom?: number // 默认缩放级别
status: number // 状态
created_at?: string // 创建时间
updated_at?: string // 更新时间
}ResourceType
资源类型信息。
interface ResourceType {
id: string // 资源类型 ID
name: string // 资源类型名称
key: string // 资源类型唯一标识
geometry_type: 'point' | 'line' | 'polygon' // 几何类型
is_external: boolean // 是否为外部引用
icon_url?: string // 图标 URL
style?: Record<string, any> // 默认样式
updated_at?: string // 更新时间
}ResourcePlacement
资源位置信息。
interface ResourcePlacement {
id: string // 位置 ID
map_id: string // 地图 ID
resource_id?: string // 资源 ID(内部资源)
external_ref_id?: string // 外部引用 ID
geometry_type: 'point' | 'line' | 'polygon' // 几何类型
geometry: { // 几何数据
coordinates: number[] | number[][] | number[][][]
[key: string]: any
}
label_override?: string // 自定义标签
style_override?: Record<string, any> // 自定义样式
resource?: any // 关联的资源对象
externalRef?: any // 外部引用对象
}坐标格式说明:
- 点 (point):
[x, y]- 例如[100, 200] - 线 (line):
[[x1, y1], [x2, y2], ...]- 例如[[100, 200], [150, 250]] - 面 (polygon):
[[[x1, y1], [x2, y2], ...]]- 外环 + 可选的内环
ResourceClickEvent
资源点击事件数据。
interface ResourceClickEvent {
placement: ResourcePlacement // 资源位置信息
resource: any // 资源对象(resource 或 externalRef)
event: MouseEvent // 原始鼠标事件
}完整示例
基础示例:多选资源类型(图层控制)
<template>
<div class="map-container">
<ImgMapContainer
ref="mapRef"
:map-id="mapId"
@map-loaded="handleMapLoaded"
@resource-types-loaded="handleResourceTypesLoaded"
@resource-click="handleResourceClick"
/>
<!-- 资源类型多选控制器 -->
<div class="type-selector">
<h3>资源图层</h3>
<label
v-for="type in resourceTypes"
:key="type.id"
class="type-checkbox"
>
<input
type="checkbox"
:checked="isTypeLoaded(type.id)"
@change="toggleType(type)"
/>
<span>{{ type.name }}</span>
</label>
<button @click="clearAll" class="clear-btn">清空全部</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ImgMapContainer } from '@tuoyuan/img-map-container'
import type { MapInfo, ResourceType, ResourceClickEvent } from '@tuoyuan/img-map-container'
const mapRef = ref()
const mapId = ref('your-map-id')
const resourceTypes = ref<ResourceType[]>([])
function handleMapLoaded(mapInfo: MapInfo) {
console.log('地图加载成功:', mapInfo.name)
}
function handleResourceTypesLoaded(types: ResourceType[]) {
console.log('资源类型加载完成:', types)
resourceTypes.value = types
}
function handleResourceClick(event: ResourceClickEvent) {
console.log('点击了资源:', event.placement)
}
async function toggleType(type: ResourceType) {
if (!mapRef.value) return
const isLoaded = await mapRef.value.toggleResourceType(type.key)
console.log(`${type.name} 现在${isLoaded ? '可见' : '隐藏'}`)
}
function isTypeLoaded(typeId: string): boolean {
return mapRef.value?.isTypeLoaded(typeId) || false
}
function clearAll() {
mapRef.value?.clearResources()
}
</script>
<style scoped>
.map-container {
width: 100vw;
height: 100vh;
position: relative;
}
.type-selector {
position: absolute;
top: 20px;
left: 20px;
background: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 100;
min-width: 200px;
}
.type-selector h3 {
margin: 0 0 10px 0;
font-size: 16px;
}
.type-checkbox {
display: flex;
align-items: center;
padding: 8px 0;
cursor: pointer;
user-select: none;
}
.type-checkbox input {
margin-right: 8px;
}
.clear-btn {
width: 100%;
margin-top: 10px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
}
.clear-btn:hover {
background: #f5f5f5;
}
</style>基础示例:单选资源类型
<template>
<div class="map-container">
<ImgMapContainer
ref="mapRef"
:map-id="mapId"
@map-loaded="handleMapLoaded"
@resource-types-loaded="handleResourceTypesLoaded"
@resources-loaded="handleResourcesLoaded"
@resource-click="handleResourceClick"
/>
<!-- 资源类型选择器 -->
<div class="type-selector">
<h3>资源类型</h3>
<button
v-for="type in resourceTypes"
:key="type.id"
@click="loadType(type)"
:class="{ active: currentTypeKey === type.key }"
>
{{ type.name }}
</button>
<button @click="clearAll">清空</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ImgMapContainer } from '@tuoyuan/img-map-container'
import type { MapInfo, ResourceType, ResourcePlacement, ResourceClickEvent } from '@tuoyuan/img-map-container'
const mapRef = ref()
const mapId = ref('your-map-id')
const resourceTypes = ref<ResourceType[]>([])
const currentTypeKey = ref('')
function handleMapLoaded(mapInfo: MapInfo) {
console.log('地图加载成功:', mapInfo.name)
}
function handleResourceTypesLoaded(types: ResourceType[]) {
console.log('资源类型加载完成:', types)
resourceTypes.value = types
}
function handleResourcesLoaded(placements: ResourcePlacement[]) {
console.log('资源加载完成,共', placements.length, '个')
}
function handleResourceClick(event: ResourceClickEvent) {
console.log('点击了资源:', event.placement)
}
function loadType(type: ResourceType) {
currentTypeKey.value = type.key
mapRef.value?.loadResources(type.key)
}
function clearAll() {
currentTypeKey.value = ''
mapRef.value?.clearResources()
}
</script>
<style scoped>
.map-container {
width: 100vw;
height: 100vh;
position: relative;
}
.type-selector {
position: absolute;
top: 20px;
left: 20px;
background: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 100;
}
.type-selector button {
display: block;
width: 100%;
margin: 5px 0;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
}
.type-selector button.active {
background: #1890ff;
color: white;
border-color: #1890ff;
}
</style>高级示例:资源详情弹窗 + 删除功能
<template>
<div class="map-page">
<ImgMapContainer
ref="mapRef"
:map-id="currentMapId"
@resource-click="handleResourceClick"
@map-loaded="handleMapLoaded"
@resources-loaded="handleResourcesLoaded"
@resource-types-loaded="handleResourceTypesLoaded"
/>
<!-- 控制面板 -->
<div class="control-panel">
<button @click="clearResources" class="clear-btn">清空资源</button>
<div v-if="resourceTypes.length > 0" class="resource-types">
<div class="types-title">可用资源类型:</div>
<button
v-for="type in resourceTypes"
:key="type.id"
@click="loadResourcesByType(type)"
class="type-btn"
:class="{ active: currentTypeKey === type.key }"
>
{{ type.name }}
</button>
</div>
<!-- 缩放控制 -->
<div class="zoom-controls">
<button @click="zoomIn">放大</button>
<button @click="zoomOut">缩小</button>
<button @click="resetView">重置</button>
</div>
</div>
<!-- 资源详情弹窗 -->
<div v-if="selectedResource && selectedPlacement" class="modal" @click.self="closeModal">
<div class="modal-content">
<div class="modal-header">
<h3>资源详情</h3>
<button class="close" @click="closeModal">×</button>
</div>
<div class="modal-body">
<div class="info-row">
<span class="label">Placement ID:</span>
<span class="value">{{ selectedPlacement.id }}</span>
</div>
<div class="info-row">
<span class="label">名称:</span>
<span class="value">{{ selectedResource.name }}</span>
</div>
<div class="info-row">
<span class="label">几何类型:</span>
<span class="value">{{ selectedPlacement.geometry_type }}</span>
</div>
<div class="info-row">
<span class="label">坐标:</span>
<span class="value">{{ formatCoordinates(selectedPlacement.geometry?.coordinates) }}</span>
</div>
</div>
<div class="modal-footer">
<button @click="deleteSelectedResource" class="delete-btn">删除此资源</button>
<button @click="closeModal" class="cancel-btn">取消</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ImgMapContainer } from '@tuoyuan/img-map-container'
import type { MapInfo, ResourcePlacement, ResourceClickEvent, ResourceType } from '@tuoyuan/img-map-container'
const mapRef = ref()
const currentMapId = ref('your-map-id')
const selectedResource = ref<any>(null)
const selectedPlacement = ref<ResourcePlacement | null>(null)
const resourceTypes = ref<ResourceType[]>([])
const currentTypeKey = ref<string>('')
function handleMapLoaded(mapInfo: MapInfo) {
console.log('地图加载成功:', mapInfo.name)
}
function handleResourcesLoaded(placements: ResourcePlacement[]) {
console.log('资源加载完成,共', placements.length, '个')
}
function handleResourceTypesLoaded(types: ResourceType[]) {
console.log('资源类型加载完成:', types)
resourceTypes.value = types
}
function handleResourceClick(event: ResourceClickEvent) {
selectedPlacement.value = event.placement
selectedResource.value = {
name: event.placement.label_override ||
event.resource?.name ||
'未命名',
type: event.placement.geometry_type,
id: event.placement.id
}
}
function closeModal() {
selectedResource.value = null
selectedPlacement.value = null
}
function formatCoordinates(coordinates: any): string {
if (!coordinates) return '-'
if (Array.isArray(coordinates)) {
if (typeof coordinates[0] === 'number') {
return `[${coordinates[0]}, ${coordinates[1]}]`
} else if (Array.isArray(coordinates[0])) {
return `${coordinates.length} 个点`
}
}
return JSON.stringify(coordinates)
}
function deleteSelectedResource() {
if (!selectedPlacement.value || !mapRef.value) return
const placementId = selectedPlacement.value.id
const resourceName = selectedResource.value?.name || '未命名资源'
if (!confirm(`确定要删除资源 "${resourceName}" 吗?\n(注意:仅从地图上移除,不会删除后端数据)`)) {
return
}
const success = mapRef.value.deleteResource(placementId)
if (success) {
alert(`移除资源 "${resourceName}" 成功!`)
closeModal()
} else {
alert(`移除资源 "${resourceName}" 失败!`)
}
}
function loadResourcesByType(type: ResourceType) {
if (!mapRef.value) return
console.log('加载资源类型:', type.name, 'key:', type.key)
currentTypeKey.value = type.key
mapRef.value.loadResources(type.key)
}
function clearResources() {
if (mapRef.value) {
mapRef.value.clearResources()
currentTypeKey.value = ''
}
}
function zoomIn() {
mapRef.value?.zoomIn()
}
function zoomOut() {
mapRef.value?.zoomOut()
}
function resetView() {
mapRef.value?.resetView()
}
</script>
<style scoped>
.map-page {
width: 100%;
height: 100%;
position: relative;
}
.control-panel {
position: absolute;
top: 20px;
left: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 100;
max-width: 300px;
}
.clear-btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
background: white;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.resource-types {
background: white;
padding: 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.types-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
}
.type-btn {
display: block;
width: 100%;
margin: 5px 0;
padding: 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: white;
cursor: pointer;
text-align: left;
}
.type-btn.active {
background: #1890ff;
color: white;
border-color: #1890ff;
}
.zoom-controls {
background: white;
padding: 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
display: flex;
gap: 5px;
}
.zoom-controls button {
flex: 1;
padding: 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: white;
cursor: pointer;
}
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 8px;
width: 90%;
max-width: 500px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #e0e0e0;
}
.close {
width: 28px;
height: 28px;
border: none;
background: transparent;
font-size: 24px;
cursor: pointer;
}
.modal-body {
padding: 20px;
}
.info-row {
display: flex;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.info-row .label {
width: 120px;
color: #666;
}
.info-row .value {
flex: 1;
color: #333;
}
.modal-footer {
padding: 16px 20px;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.delete-btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
background: #ff4d4f;
color: white;
cursor: pointer;
}
.cancel-btn {
padding: 8px 16px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: white;
cursor: pointer;
}
</style>常见问题
1. 样式没有生效?
组件的样式会自动加载,无需手动导入。如果样式没有生效,请检查:
- 是否正确安装了插件
- 是否在
main.ts中调用了app.use(ImgMapPlugin)
2. 图片显示不出来?
检查以下几点:
- 确认
baseUrl配置正确 - 检查图片 URL 是否可访问
- 查看浏览器控制台是否有跨域错误
- 确认图片路径格式(相对路径会自动拼接
baseUrl)
3. 资源类型列表为空?
可能的原因:
- 后端数据库中没有资源类型数据
- API 权限问题
- 需要先在后台创建资源类型
打开浏览器控制台查看详细的 API 调用日志。
4. 资源坐标显示不正确?
确保:
- 资源坐标基于图片的像素坐标系
- 坐标原点在图片左上角
- X 轴向右,Y 轴向下
- 坐标值在图片尺寸范围内
5. 删除资源后刷新页面又出现了?
deleteResource 方法只从前端移除资源,不会删除后端数据。如果需要永久删除,需要调用后端的删除 API。
6. 如何自定义资源样式?
在 ResourcePlacement 的 style_override 字段中设置:
{
style_override: {
color: '#ff0000', // 颜色
radius: 10, // 点的半径
width: 3, // 线的宽度
fillColor: '#00ff00', // 面的填充色
strokeColor: '#0000ff' // 边框颜色
}
}技术支持
如有问题,请提交 Issue 或联系技术支持。
更新日志
1.1.18
- 🎉 新增
toggleResourceType方法,支持切换资源类型的显示/隐藏 - 🎉 新增
isTypeLoaded方法,检查资源类型是否已加载 - 🐛 修复图标 URL 重复添加
/gateway-api前缀的问题 - ✨ 支持多选资源类型展示(图层控制)
- 📝 完善文档,添加多选和单选示例
1.1.17
- 优化图标加载逻辑
- 改进资源类型追踪机制
1.1.9
- 添加详细的 API 调用日志
- 优化资源类型加载逻辑
- 完善文档说明
1.1.8
- 资源类型管理移到组件内部
- 自动加载资源类型列表
- 添加
resource-types-loaded事件
1.1.7
- 修改删除资源逻辑为仅前端删除
1.1.6
- 修复大尺寸地图显示问题
- 优化图片缩放逻辑
1.1.5
- 添加坐标验证
- 过滤无效坐标的资源
1.1.4
- 修改图片样式为 100% 宽高
1.1.3
- 修复 CSS 样式自动加载问题
许可证
MIT
