# Vue OpenLayers Plugin

一个基于 Vue 3 和 OpenLayers 的地图插件，提供丰富的地图功能和组件。

## 特性

- 🗺️ **完整的地图功能** - 基于 OpenLayers 8.x 构建
- 🎨 **Vue 3 组件** - 完全支持 Composition API
- 📏 **测量工具** - 距离、面积、角度测量
- ✏️ **标绘功能** - 点、线、面、文字等标绘工具
- 🎛️ **图层管理** - 图层控制面板
- 🏷️ **图标图例** - 支持 PNG、JPG、SVG 等格式的图标图例
- 📱 **响应式设计** - 适配各种屏幕尺寸
- 🔧 **TypeScript 支持** - 完整的类型定义
- 📦 **轻量级** - 按需引入，减小包体积

## 安装

```bash
npm install vue-openlayers-plugin
# 或
yarn add vue-openlayers-plugin
# 或
pnpm add vue-openlayers-plugin
```

## 快速开始

### 全局注册

```typescript
import { createApp } from 'vue'
import VueOpenLayersPlugin from 'vue-openlayers-plugin'
import 'vue-openlayers-plugin/style'

const app = createApp(App)
app.use(VueOpenLayersPlugin)
app.mount('#app')
```

如果使用移动端组件（`MobileMap`、`MobileLegend` 等），请额外安装并注册 Vant：

```bash
npm install vant
# 或
yarn add vant
# 或
pnpm add vant
```

```typescript
import { createApp } from 'vue'
import Vant from 'vant'
import 'vant/lib/index.css'
import VueOpenLayersPlugin from 'vue-openlayers-plugin'
import 'vue-openlayers-plugin/style'

const app = createApp(App)
app.use(Vant)
app.use(VueOpenLayersPlugin)
app.mount('#app')
```

### 按需引入

```typescript
import { CustomOpenlayer, CustomDialog, SvgIcon, MobileMap } from 'vue-openlayers-plugin'
import 'vue-openlayers-plugin/style'

// 在组件中使用
export default {
  components: {
    CustomOpenlayer,
    CustomDialog,
    SvgIcon,
    MobileMap
  }
}
```

### CDN 引入

```html
<!-- 引入 Vue 3 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- 引入 OpenLayers -->
<script src="https://cdn.jsdelivr.net/npm/ol@8.2.0/dist/ol.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ol@8.2.0/ol.css">
<!-- 引入插件 -->
<script src="https://unpkg.com/vue-openlayers-plugin/lib/index.js"></script>
<link rel="stylesheet" href="https://unpkg.com/vue-openlayers-plugin/lib/style.css">
```

### 基础使用

```vue
<template>
  <div class="map-container">
    <CustomOpenlayer
      :center="[116.397428, 39.90923]"
      :zoom="10"
      :show-layer-panel="true"
      :show-measurement-tools="true"
      :show-drawing-tools="true"
      style="width: 100%; height: 500px;"
      @map-ready="onMapReady"
      @feature-selected="onFeatureSelected"
    />
  </div>
</template>

<script setup lang="ts">
import { CustomOpenlayer } from 'vue-openlayers-plugin'

const onMapReady = (map: unknown) => {
  console.log('地图已准备就绪', map)
}

const onFeatureSelected = (feature: any) => {
  console.log('选中要素', feature)
}
</script>

<style >
.map-container {
  width: 100%;
  height: 500px;
  border: 1px solid #ddd;
  border-radius: 4px;
}
</style>
```

### 视角书签使用

视角书签的数据由父组件统一管理，通过 `viewBookmarks` 传给地图组件。地图组件内部只负责展示、应用视角和触发事件，不直接请求后端接口。

推荐接入方式：

- 父组件初始化时调用查询接口，拿到书签列表后传给 `CustomOpenlayer`
- 用户在地图中新增、编辑、删除、清空、应用书签时，地图组件通过 `bookmark-action` 向外抛出事件
- 父组件在 `onBookmarkAction` 中根据 `action` 调用新增、更新、删除、清空等接口
- 接口成功后重新查询书签列表，或本地同步更新后再回传给 `viewBookmarks`

```vue
<template>
  <CustomOpenlayer
    :map-config="mapConfig"
    :view-bookmarks="viewBookmarks"
    @bookmark-action="onBookmarkAction"
  />
</template>

<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { CustomOpenlayer } from 'vue-openlayers-plugin'
import type { BookmarkAction, MapConfig, ViewBookmark } from 'vue-openlayers-plugin'

const viewBookmarks = ref<ViewBookmark[]>([])

const mapConfig: MapConfig = {
  center: [116.397428, 39.90923],
  zoom: 10,
  projection: 'EPSG:4326',
  controls: {
    viewBookmarks: true
  }
}

const fetchBookmarks = async () => {
  const list = await bookmarkApi.list()
  viewBookmarks.value = list
}

const onBookmarkAction = async (event: BookmarkAction) => {
  const { action, bookmark } = event

  if (action === 'add' && bookmark) {
    await bookmarkApi.create(bookmark)
  }

  if (action === 'update' && bookmark) {
    await bookmarkApi.update(bookmark.id, bookmark)
  }

  if (action === 'delete' && bookmark) {
    await bookmarkApi.remove(bookmark.id)
  }

  if (action === 'clear') {
    await bookmarkApi.clear()
  }

  if (action === 'apply' && bookmark) {
    console.log('已应用书签视角', bookmark)
  }

  await fetchBookmarks()
}

onMounted(() => {
  fetchBookmarks()
})

const bookmarkApi = {
  async list(): Promise<ViewBookmark[]> {
    return []
  },
  async create(bookmark: ViewBookmark) {
    return bookmark
  },
  async update(id: string, bookmark: ViewBookmark) {
    return { id, ...bookmark }
  },
  async remove(id: string) {
    return id
  },
  async clear() {
    return true
  }
}
</script>
```

#### 数据传递说明

- `viewBookmarks`：父组件传入的书签数组，作为地图组件的书签数据源
- `controls.viewBookmarks: true`：开启视角书签按钮，用户点击后会打开“视角书签”面板
- 地图组件不会自动持久化书签；是否存本地、是否调后端接口，都由父组件决定

#### 视角书签数据结构

`viewBookmarks` 的元素类型为 `ViewBookmark`，结构如下：

```typescript
interface ViewBookmark {
  id: string
  name: string
  category: string
  description: string
  center: [number, number]
  zoom: number
  rotation: number
  createTime: string | Date
  creator?: string
  thumbnail?: string
  layerStates?: {
    id: string
    visible: boolean
    opacity: number
    title?: string
  }[]
}
```

字段说明：

- `id`：书签唯一标识，通常对应后端主键
- `name`：书签名称
- `category`：书签分类，便于筛选和分组
- `description`：书签描述信息
- `center`：视角中心点坐标，格式为 `[经度, 纬度]`
- `zoom`：缩放级别
- `rotation`：旋转角度，单位为度
- `createTime`：创建时间，支持字符串或 `Date`
- `creator`：创建人，可选
- `thumbnail`：视角缩略图，可选，通常为图片 URL 或 Base64
- `layerStates`：保存书签时记录的图层状态，可选，用于恢复图层显隐和透明度

示例数据：

```typescript
const viewBookmarks: ViewBookmark[] = [
  {
    id: 'bk-1',
    name: '北京中心',
    category: '城市',
    description: '北京中心视角',
    center: [116.407526, 39.90403],
    zoom: 12,
    rotation: 0,
    createTime: '2026-05-05 10:00:00',
    creator: 'admin',
    thumbnail: 'https://example.com/bookmarks/bk-1.jpg',
    layerStates: [
      {
        id: 'osm',
        visible: true,
        opacity: 1,
        title: 'OpenStreetMap'
      },
      {
        id: 'land_acquisition',
        visible: false,
        opacity: 0.8,
        title: '征地图层'
      }
    ]
  }
]
```

#### 事件说明

地图组件会抛出 `bookmark-action` 事件，事件参数类型如下：

```typescript
type BookmarkAction = {
  action: 'add' | 'update' | 'delete' | 'apply' | 'clear'
  bookmark?: ViewBookmark
}
```

不同 `action` 的含义如下：

- `add`：用户保存了一个新书签
- `update`：用户修改了已有书签
- `delete`：用户删除了某个书签
- `clear`：用户清空了全部书签
- `apply`：用户点击应用书签，地图已切换到对应视角

### 移动端使用（MobileMap）

`MobileMap` 内置了移动端常用 UI（搜索、图层面板、图例入口等），通过 Props 控制显示：

```vue
<template>
  <div class="mobile-page">
    <MobileMap
      :config="mapConfig"
      :show-search="true"
      :show-layer-panel="true"
      :show-legend="true"
      @map-ready="onMapReady"
    >
      <MobileBasemapSwitcher
        class="basemap-switcher"
        :base-layers="mapConfig.baseLayers"
      />
    </MobileMap>
  </div>
</template>

<script setup lang="ts">
import { MobileBasemapSwitcher, MobileMap } from 'vue-openlayers-plugin'
import type { MapConfig } from 'vue-openlayers-plugin'

const mapConfig: MapConfig = {
  center: [116.397428, 39.90923],
  zoom: 10,
  baseLayers: [
    {
      id: 'osm',
      name: 'OpenStreetMap',
      type: 'osm',
      visible: true,
      isBasemap: true,
      legend: {
        title: '开源地图',
        items: [
          { label: '道路', color: '#ffffff' },
          { label: '建筑', color: '#f2f2f2' }
        ]
      }
    },
    {
      id: 'arcgis_imagery',
      name: 'ArcGIS影像',
      type: 'xyz',
      url: 'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
      visible: false,
      isBasemap: true,
      legend: {
        title: '全球影像',
        items: [{ label: '高清卫星图', color: '#8B4513' }]
      }
    }
  ],
  overlayLayers: []
}

const onMapReady = (map: unknown) => {
  console.log('移动端地图已准备就绪', map)
}
</script>

<style>
.mobile-page {
  position: relative;
  width: 100%;
  height: 100vh;
  overflow: hidden;
}
.basemap-switcher {
  position: absolute;
  right: 12px;
  bottom: 160px;
  z-index: 230;
}
</style>
```

## 组件

### CustomOpenlayer

主要的地图组件，提供完整的地图功能。

#### Props

| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| center | `[number, number]` | `[0, 0]` | 地图中心点坐标 |
| zoom | `number` | `2` | 地图缩放级别 |
| minZoom | `number` | `0` | 最小缩放级别 |
| maxZoom | `number` | `28` | 最大缩放级别 |
| showLayerPanel | `boolean` | `false` | 是否显示图层面板 |
| showMeasurementTools | `boolean` | `false` | 是否显示测量工具 |
| showDrawingTools | `boolean` | `false` | 是否显示标绘工具 |
| viewBookmarks | `ViewBookmark[]` | `[]` | 视角书签列表，由父组件传入 |

#### Events

| 事件名 | 参数 | 描述 |
|------|------|------|
| bookmark-action | `BookmarkAction` | 视角书签新增、编辑、删除、清空、应用时触发 |

### CustomDialog

可拖拽、可缩放的对话框组件。

#### Props

| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| modelValue | `boolean` | `false` | 对话框显示状态 |
| title | `string` | `''` | 对话框标题 |
| width | `string` | `'500px'` | 对话框宽度 |
| height | `string` | `'300px'` | 对话框高度 |
| draggable | `boolean` | `true` | 是否可拖拽 |
| resizable | `boolean` | `true` | 是否可缩放 |

### LegendPanel

图例面板组件，支持多种图例类型包括图标图例。

#### Props

| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| position | `'left' \| 'right'` | `'right'` | 面板位置 |
| width | `number` | `280` | 面板宽度 |
| collapsible | `boolean` | `true` | 是否可折叠 |
| defaultCollapsed | `boolean` | `false` | 默认是否折叠 |
| showOnlyVisible | `boolean` | `true` | 只显示可见图层的图例 |
| leftOffset | `string \| number` | `undefined` | 左侧偏移 |
| rightOffset | `string \| number` | `undefined` | 右侧偏移 |
| topOffset | `string \| number` | `undefined` | 顶部偏移 |
| bottomOffset | `string \| number` | `undefined` | 底部偏移 |

#### 支持的图例类型

- **icon** - 图标图例，支持 PNG、JPG、SVG 等格式
- **color** - 颜色图例
- **symbol** - 符号图例
- **gradient** - 渐变图例
- **category** - 分类图例
- **custom** - 自定义图例

#### 图标图例配置示例

```typescript
const layerConfig = {
  id: 'poi-layer',
  name: 'POI图层',
  legend: {
    type: 'icon',
    title: 'POI类型',
    items: [
      {
        label: '餐厅',
        value: 'restaurant',
        image: '/icons/restaurant.svg',
        size: 18
      },
      {
        label: '酒店',
        value: 'hotel',
        image: '/icons/hotel.png',
        size: 16
      }
    ]
  }
}
```

### SvgIcon

SVG 图标组件。

#### Props

| 属性 | 类型 | 默认值 | 描述 |
|------|------|--------|------|
| name | `string` | `''` | 图标名称 |
| size | `string \| number` | `'1em'` | 图标大小 |
| color | `string` | `'currentColor'` | 图标颜色 |

## 工具函数

### 测量工具

```typescript
import { MeasurementTool } from 'vue-openlayers-plugin'

const measurementTool = new MeasurementTool(map)
measurementTool.startMeasurement('length') // 'length' | 'area' | 'angle'
```

### 图层管理

```typescript
import { LayerManager } from 'vue-openlayers-plugin'

const layerManager = new LayerManager(map)
layerManager.addLayer({
  id: 'osm',
  name: 'OpenStreetMap',
  type: 'tile',
  url: 'https://{a-c}.tile.openstreetmap.org/{z}/{x}/{y}.png'
})
```

#### GeoJSON 请求图层配置

GeoJSON 图层除了支持直接传入 `data`，也支持通过 `geojsonConfig.request` 发起远程请求，适合按行政区、业务类型、时间范围等条件动态加载矢量数据。

```typescript
const mapConfig = {
  center: [121.473, 31.23],
  zoom: 12,
  projection: 'EPSG:4326',
  vectorLayers: [
    {
      id: 'land_acquisition',
      name: '征地',
      type: 'GeoJSON',
      visible: true,
      opacity: 80,
      geojsonLandType: '征地未结案',
      geojsonConfig: {
        request: {
          url: '/zzd/parcel/geojson/by-user-land-type',
          method: 'GET',
          headers: {
            Authorization: 'Bearer <token>',
            'X-Tenant-Id': 'demo'
          },
          params: {
            landType: '征地未结案'
          },
          managementUnitParamName: 'managementUnit',
          landTypeParamName: 'landType',
          dataPath: 'data.features'
        }
      },
      style: {
        fill: {
          color: 'rgba(255, 0, 0, 0.2)'
        },
        stroke: {
          color: '#FF0000',
          width: 2
        }
      }
    }
  ]
}
```

#### GeoJSON 请求配置项

| 字段 | 类型 | 说明 |
|------|------|------|
| `geojsonConfig.data` | `object \| string` | 直接传入 GeoJSON 数据，适合本地静态数据 |
| `geojsonConfig.request.url` | `string` | 请求地址 |
| `geojsonConfig.request.method` | `'GET' \| 'POST' \| 'PUT' \| 'DELETE'` | 请求方法，默认 `GET` |
| `geojsonConfig.request.headers` | `Record<string, string>` | 自定义请求头，比如 `Authorization` |
| `geojsonConfig.request.params` | `Record<string, any>` | 查询参数，会自动拼接到 URL 上 |
| `geojsonConfig.request.body` | `Record<string, any> \| string` | 非 `GET` 请求体 |
| `geojsonConfig.request.dataPath` | `string` | 返回结果中的 GeoJSON 数据路径，例如 `data.features` |
| `geojsonConfig.request.managementUnit` | `string` | 管理单位值 |
| `geojsonConfig.request.managementUnitParamName` | `string` | 管理单位参数名，默认 `managementUnit` |
| `geojsonConfig.request.landType` | `string` | 地类值 |
| `geojsonConfig.request.landTypeParamName` | `string` | 地类参数名，默认 `landType` |
| `geojsonConfig.refresh` | `object` | 刷新配置，支持定时刷新、缩放后刷新、移动后刷新 |

#### 请求头设置方式

最直接的方式是在 `geojsonConfig.request.headers` 中传入：

```typescript
geojsonConfig: {
  request: {
    url: '/api/parcel/geojson',
    method: 'GET',
    headers: {
      Authorization: `Bearer ${token}`,
      'X-Project-Id': 'project-a'
    }
  }
}
```

如果不希望把 token 写死在配置中，可以使用请求拦截器动态注入：

```typescript
geojsonConfig: {
  request: {
    url: '/api/parcel/geojson',
    method: 'GET',
    requestInterceptor: async (request) => {
      const token = localStorage.getItem('token') || ''

      return {
        headers: {
          ...request.headers,
          Authorization: `Bearer ${token}`
        }
      }
    }
  }
}
```

#### 响应转换与数据解析

如果后端返回结构不固定，除了 `dataPath` 之外，还可以使用 `responseInterceptor` 或 `responseParser` 做二次处理：

```typescript
geojsonConfig: {
  request: {
    url: '/api/parcel/geojson',
    method: 'GET',
    responseParser: async (response) => {
      return response.result.list
    }
  }
}
```

#### 运行时刷新 GeoJSON 图层

组件实例暴露了 `refreshLayer` 和 `updateLayerRequestParams`，可以在不重建地图的情况下更新请求参数并重新拉取数据：

```vue
<script setup lang="ts">
import { ref } from 'vue'
import { CustomOpenlayer } from 'vue-openlayers-plugin'

const mapRef = ref<InstanceType<typeof CustomOpenlayer> | null>(null)

const updateLandType = async () => {
  mapRef.value?.updateLayerRequestParams('land_acquisition', {
    landType: '征地已结案'
  })

  await mapRef.value?.refreshLayer('land_acquisition')
}
</script>
```

#### 使用建议

- 需要鉴权时，优先使用 `headers` 或 `requestInterceptor`
- 参数频繁变化时，优先使用 `updateLayerRequestParams`
- 后端返回结构复杂时，优先使用 `dataPath`，不够再使用 `responseParser`
- 如果鉴权依赖 Cookie，也可以不传 `headers`，由浏览器自动携带

### 标绘工具

```typescript
import { DrawingManager } from 'vue-openlayers-plugin'

const drawingManager = new DrawingManager(map)
drawingManager.startDrawing('polygon')
```

#### GeoJSON 数据导入

支持从标准 GeoJSON 格式导入地理数据：

```typescript
// 导入单个 GeoJSON Feature
const feature = {
  type: "Feature",
  properties: {
    name: "示例多边形",
    style: {
      fillColor: "#ffcc00",
      fillOpacity: 0.4,
      strokeColor: "#ff6600",
      strokeWidth: 2
    }
  },
  geometry: {
    type: "Polygon",
    coordinates: [[[93.2, 42.45], [93.6, 42.95], [94.2, 42.8], [93.2, 42.45]]]
  }
}

const drawing = drawingManager.createDrawingFromJSON(feature)
drawingManager.addDrawing(drawing)

// 批量导入 GeoJSON FeatureCollection
const featureCollection = {
  type: "FeatureCollection",
  features: [feature1, feature2, feature3]
}

drawingManager.addGeoJSONData(featureCollection)
```

支持的几何类型：
- Point（点）
- LineString（线）
- Polygon（多边形）
- MultiPoint（多点）
- MultiLineString（多线）
- MultiPolygon（多多边形）

支持的样式属性：
- `fillColor` - 填充颜色
- `fillOpacity` - 填充透明度
- `strokeColor` - 边框颜色
- `strokeWidth` - 边框宽度
- `markerColor` - 点标记颜色
- `markerSize` - 点标记大小
- `markerSymbol` - 点标记符号
- `lineDash` - 虚线样式

## 开发

```bash
# 克隆项目
git clone https://github.com/your-username/vue-openlayers-plugin.git
cd vue-openlayers-plugin

# 安装依赖
npm install

# 启动开发服务器
npm run dev

# 构建库
npm run build:lib

# 类型检查
npm run type-check
```

## 浏览器支持

- Chrome >= 87
- Firefox >= 78
- Safari >= 14
- Edge >= 88

## 许可证

MIT License

## 贡献

欢迎提交 Issue 和 Pull Request！

## 更新日志

### 1.2.0

- ✨ 新增图标图例功能，支持 PNG、JPG、SVG、GIF、WebP 等图像格式
- ✨ 新增 `LegendPanel` 组件的 `icon` 图例类型
- ✨ 支持图标大小自定义和错误处理
- ✨ 新增图标图例配置指南和示例
- 🔧 更新 `LegendItem` 和 `LegendConfig` 接口，增加图标相关属性
- 📚 完善文档，添加图标图例使用说明

### 1.1.0

- ✨ 新增 GeoJSON 数据导入支持
- ✨ 新增 `createDrawingFromJSON` 方法，支持标准 GeoJSON Feature 转换
- ✨ 新增 `addGeoJSONData` 方法，支持批量导入 GeoJSON FeatureCollection
- ✨ 支持 GeoJSON 样式属性映射（fillColor、strokeColor、markerColor 等）
- ✨ 支持多种几何类型：Point、LineString、Polygon、MultiPoint、MultiLineString、MultiPolygon
- 🐛 修复私有方法命名冲突问题

### 1.0.0

- 🎉 初始版本发布
- ✨ 支持基础地图功能
- ✨ 测量工具
- ✨ 标绘功能
- ✨ 图层管理
- ✨ 自定义对话框
- ✨ SVG 图标组件
