feat: project scaffold + bridge + maplibre engine
- Vite + Vue 3 + TypeScript strict - @yuto-water/js-bridge (types, detector, browser provider) - MapLibre engine (MapManager singleton, MapFactory, LayerFactory) - Map composables (useMap, useLayer, usePopup) - Tianditu tile source, 6 layer type factory - SCSS design tokens (water-blue theme, dark mode) - Vant 4, maplibre-gl, axios, pinia, echarts deps - Build passes (vue-tsc + vite)
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
5
README.md
Normal file
5
README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>yuto-water-h5</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5069
package-lock.json
generated
Normal file
5069
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "yuto-water-h5",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@turf/turf": "^7.3.5",
|
||||||
|
"axios": "^1.18.0",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
|
"dayjs": "^1.11.21",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
|
"encryptlong": "^3.1.4",
|
||||||
|
"maplibre-gl": "^5.24.0",
|
||||||
|
"mitt": "^3.0.1",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"supercluster": "^8.0.1",
|
||||||
|
"uuid": "^14.0.0",
|
||||||
|
"vant": "^4.9.24",
|
||||||
|
"vue": "^3.5.34"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.12.3",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"sass-embedded": "^1.100.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.0.12",
|
||||||
|
"vue-tsc": "^3.2.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
7
src/App.vue
Normal file
7
src/App.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import HelloWorld from './components/HelloWorld.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HelloWorld />
|
||||||
|
</template>
|
||||||
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/vite.svg
Normal file
1
src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
87
src/bridge/detector.ts
Normal file
87
src/bridge/detector.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* 环境检测器
|
||||||
|
*
|
||||||
|
* 检测当前运行环境类型,返回统一的环境标识,
|
||||||
|
* 供上层使用以决定加载哪个 Bridge 提供者。
|
||||||
|
*
|
||||||
|
* @module bridge/detector
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 可识别的运行环境 */
|
||||||
|
export type Environment = 'browser' | 'wechat-mp' | 'uniapp-webview'
|
||||||
|
|
||||||
|
/** wechat 全局声明扩展 */
|
||||||
|
interface WechatWindow extends Window {
|
||||||
|
/** 微信 JS-SDK 桥接对象 */
|
||||||
|
WeixinJSBridge?: Record<string, unknown>
|
||||||
|
/** 微信环境标识 */
|
||||||
|
__wxjs_environment?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局 uni 对象(uni-app WebView 中存在)
|
||||||
|
* 声明为 any 以避免对 @dcloudio 类型包的硬依赖
|
||||||
|
*/
|
||||||
|
declare const uni: Record<string, unknown> | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测当前运行环境
|
||||||
|
*
|
||||||
|
* 判断优先级:
|
||||||
|
* 1. 微信小程序 WebView —— userAgent 含 MicroMessenger 且标记为 miniprogram
|
||||||
|
* 2. uni-app WebView —— 全局存在 uni 对象(且非小程序场景)
|
||||||
|
* 3. 普通浏览器 —— 其余情况降级
|
||||||
|
*
|
||||||
|
* @returns 环境标识字符串
|
||||||
|
*/
|
||||||
|
export function detectEnvironment(): Environment {
|
||||||
|
const win = window as WechatWindow
|
||||||
|
|
||||||
|
// ── 检测微信环境 ──
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
const isWechat = /MicroMessenger/i.test(ua)
|
||||||
|
|
||||||
|
if (isWechat) {
|
||||||
|
// 检查是否为小程序 WebView
|
||||||
|
// 方式一:WeixinJSBridge 注入的环境标识
|
||||||
|
// 方式二:页面 URL 参数 __wxjs_environment(少数场景)
|
||||||
|
if (win.__wxjs_environment === 'miniprogram') {
|
||||||
|
return 'wechat-mp'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 微信 JS-SDK ready 后可通过 WeixinJSBridge 进一步确认,
|
||||||
|
// 但此处仅做同步检测,保守返回 wechat-mp(若 UA 匹配且环境变量已注入)
|
||||||
|
// 否则仍可能为普通微信内置浏览器,暂归为 wechat-mp
|
||||||
|
return 'wechat-mp'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 检测 uni-app WebView ──
|
||||||
|
// uni 对象在 uni-app 的 WebView 环境中由框架注入
|
||||||
|
if (typeof uni !== 'undefined' && uni !== null) {
|
||||||
|
return 'uniapp-webview'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 默认:普通浏览器 ──
|
||||||
|
return 'browser'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前是否在微信小程序 WebView 中
|
||||||
|
*/
|
||||||
|
export function isWechatMiniProgram(): boolean {
|
||||||
|
return detectEnvironment() === 'wechat-mp'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前是否在 uni-app WebView 中
|
||||||
|
*/
|
||||||
|
export function isUniappWebView(): boolean {
|
||||||
|
return detectEnvironment() === 'uniapp-webview'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前是否为普通浏览器环境
|
||||||
|
*/
|
||||||
|
export function isBrowser(): boolean {
|
||||||
|
return detectEnvironment() === 'browser'
|
||||||
|
}
|
||||||
67
src/bridge/index.ts
Normal file
67
src/bridge/index.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 桥接层统一入口
|
||||||
|
*
|
||||||
|
* 导出类型定义、环境检测器以及各环境提供者,
|
||||||
|
* 并提供 createBridge() 工厂函数自动选择合适的提供者实例。
|
||||||
|
*
|
||||||
|
* @module bridge
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── 类型定义 ──
|
||||||
|
export type {
|
||||||
|
IBridge,
|
||||||
|
GetLocationOptions,
|
||||||
|
LocationResult,
|
||||||
|
ScanType,
|
||||||
|
ScanCodeOptions,
|
||||||
|
ScanCodeResult,
|
||||||
|
OpenMapParams,
|
||||||
|
ImageSourceType,
|
||||||
|
ChooseImageOptions,
|
||||||
|
ChooseImageResult,
|
||||||
|
DeviceInfo,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
// ── 环境检测 ──
|
||||||
|
export {
|
||||||
|
detectEnvironment,
|
||||||
|
isWechatMiniProgram,
|
||||||
|
isUniappWebView,
|
||||||
|
isBrowser,
|
||||||
|
} from './detector'
|
||||||
|
export type { Environment } from './detector'
|
||||||
|
|
||||||
|
// ── 提供者 ──
|
||||||
|
export { default as browserProvider } from './providers/browser'
|
||||||
|
|
||||||
|
// ── 工厂函数 ──
|
||||||
|
import type { IBridge } from './types'
|
||||||
|
import { detectEnvironment } from './detector'
|
||||||
|
import browserProvider from './providers/browser'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据当前运行环境自动创建对应的 Bridge 实例
|
||||||
|
*
|
||||||
|
* - 普通浏览器:返回 browserProvider(基于 Web API 降级实现)
|
||||||
|
* - 微信小程序 WebView:待实现
|
||||||
|
* - uni-app WebView:待实现
|
||||||
|
*
|
||||||
|
* @returns 符合 IBridge 接口的提供者实例
|
||||||
|
*/
|
||||||
|
export function createBridge(): IBridge {
|
||||||
|
const env = detectEnvironment()
|
||||||
|
|
||||||
|
switch (env) {
|
||||||
|
case 'wechat-mp':
|
||||||
|
// TODO: 引入 WechatProvider 后替换
|
||||||
|
throw new Error('微信小程序 WebView Bridge 尚未实现')
|
||||||
|
|
||||||
|
case 'uniapp-webview':
|
||||||
|
// TODO: 引入 UniappProvider 后替换
|
||||||
|
throw new Error('uni-app WebView Bridge 尚未实现')
|
||||||
|
|
||||||
|
case 'browser':
|
||||||
|
default:
|
||||||
|
return browserProvider
|
||||||
|
}
|
||||||
|
}
|
||||||
327
src/bridge/providers/browser.ts
Normal file
327
src/bridge/providers/browser.ts
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
/**
|
||||||
|
* 浏览器环境 Bridge 提供者
|
||||||
|
*
|
||||||
|
* 作为普通浏览器中的降级实现,利用 Web API 尽可能模拟原生能力。
|
||||||
|
* 当 navigator.geolocation 不可用时,getLocation 会抛出清晰的错误。
|
||||||
|
*
|
||||||
|
* @module bridge/providers/browser
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IBridge,
|
||||||
|
GetLocationOptions,
|
||||||
|
LocationResult,
|
||||||
|
ScanCodeOptions,
|
||||||
|
ScanCodeResult,
|
||||||
|
OpenMapParams,
|
||||||
|
ChooseImageOptions,
|
||||||
|
ChooseImageResult,
|
||||||
|
DeviceInfo,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 浏览器降级提供者
|
||||||
|
*
|
||||||
|
* 实现了 IBridge 接口的六个方法:
|
||||||
|
* - getLocation —— 依赖 navigator.geolocation
|
||||||
|
* - scanCode —— 浏览器无法扫码,直接抛出错误
|
||||||
|
* - openMap —— 通过 URL Scheme 跳转高德/百度地图
|
||||||
|
* - chooseImage —— 通过 <input type="file"> 选择图片
|
||||||
|
* - getDeviceInfo —— 通过 navigator / screen 收集设备信息
|
||||||
|
* - setTitle —— 通过 document.title 设置标题
|
||||||
|
*/
|
||||||
|
const browserProvider: IBridge = {
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// 获取地理位置
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
async getLocation(options: GetLocationOptions = {}): Promise<LocationResult> {
|
||||||
|
const geolocation = navigator.geolocation
|
||||||
|
|
||||||
|
if (!geolocation) {
|
||||||
|
throw new Error('浏览器不支持定位功能(geolocation API 不可用)')
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
enableHighAccuracy = false,
|
||||||
|
timeout = 10000,
|
||||||
|
needAddress = false,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
return new Promise<LocationResult>((resolve, reject) => {
|
||||||
|
geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
const result: LocationResult = {
|
||||||
|
latitude: position.coords.latitude,
|
||||||
|
longitude: position.coords.longitude,
|
||||||
|
speed: position.coords.speed ?? undefined,
|
||||||
|
accuracy: position.coords.accuracy,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needAddress) {
|
||||||
|
// 浏览器原生 geolocation 不支持逆地理编码,
|
||||||
|
// address 留空,由调用方自行调用第三方服务补齐
|
||||||
|
result.address = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(result)
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
let message: string
|
||||||
|
switch (error.code) {
|
||||||
|
case error.PERMISSION_DENIED:
|
||||||
|
message = '用户拒绝定位授权'
|
||||||
|
break
|
||||||
|
case error.POSITION_UNAVAILABLE:
|
||||||
|
message = '无法获取位置信息'
|
||||||
|
break
|
||||||
|
case error.TIMEOUT:
|
||||||
|
message = '定位请求超时'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
message = `定位失败: ${error.message}`
|
||||||
|
}
|
||||||
|
reject(new Error(message))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enableHighAccuracy,
|
||||||
|
timeout,
|
||||||
|
maximumAge: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// 扫码
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
async scanCode(_options?: ScanCodeOptions): Promise<ScanCodeResult> {
|
||||||
|
throw new Error('浏览器不支持扫码功能,请使用移动端打开')
|
||||||
|
},
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// 打开地图
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
async openMap(params: OpenMapParams): Promise<boolean> {
|
||||||
|
const { latitude, longitude, name, address, scale = 16 } = params
|
||||||
|
|
||||||
|
const label = encodeURIComponent(name || address || '目标位置')
|
||||||
|
|
||||||
|
// 优先尝试高德地图 URI
|
||||||
|
// 格式参考: https://lbs.amap.com/api/uri-api/guide/mobile-web/open
|
||||||
|
const amapUrl = `https://uri.amap.com/marker?position=${longitude},${latitude}&name=${label}&callnative=1`
|
||||||
|
|
||||||
|
// 同时准备通用经纬度链接作为降级(打开系统默认地图)
|
||||||
|
const fallbackUrl = `https://maps.google.com/maps?q=${latitude},${longitude}(${label})&z=${scale}`
|
||||||
|
|
||||||
|
// 检测当前环境
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
let mapUrl: string
|
||||||
|
|
||||||
|
if (/iPhone|iPad|iPod/i.test(ua)) {
|
||||||
|
// iOS:使用 Apple Maps URL Scheme
|
||||||
|
mapUrl = `https://maps.apple.com/?q=${label}&ll=${latitude},${longitude}&z=${scale}`
|
||||||
|
} else if (/Android/i.test(ua)) {
|
||||||
|
// Android:优先使用高德,浏览器会自动唤起
|
||||||
|
mapUrl = amapUrl
|
||||||
|
} else {
|
||||||
|
// 桌面端:打开 Google Maps
|
||||||
|
mapUrl = fallbackUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.open(mapUrl, '_blank')
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// 选择图片
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
async chooseImage(options: ChooseImageOptions = {}): Promise<ChooseImageResult> {
|
||||||
|
const { count = 1, sourceType = ['album', 'camera'], compress = true } = options
|
||||||
|
|
||||||
|
return new Promise<ChooseImageResult>((resolve, reject) => {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'
|
||||||
|
input.accept = 'image/*'
|
||||||
|
|
||||||
|
// 根据 sourceType 设置 capture 属性
|
||||||
|
if (sourceType.length === 1 && sourceType[0] === 'camera') {
|
||||||
|
input.capture = 'environment'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支持多选
|
||||||
|
if (count > 1) {
|
||||||
|
input.multiple = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 压缩由 accept / 浏览器默认行为处理,
|
||||||
|
// 真实压缩需后续通过 Canvas 实现,此处仅标记
|
||||||
|
|
||||||
|
// 超时兜底:30s 后自动 reject
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
reject(new Error('选择图片超时,请重试'))
|
||||||
|
cleanup()
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
input.removeEventListener('change', onChange)
|
||||||
|
input.removeEventListener('cancel', onCancel)
|
||||||
|
input.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChange = () => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
const files = input.files
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
reject(new Error('未选择任何图片'))
|
||||||
|
cleanup()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempFilePaths: string[] = []
|
||||||
|
const tempFiles: Array<{ path: string; size: number }> = []
|
||||||
|
|
||||||
|
const validFiles: File[] = []
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files.item(i)
|
||||||
|
if (file) {
|
||||||
|
validFiles.push(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of validFiles) {
|
||||||
|
// 通过 URL.createObjectURL 创建临时可访问路径
|
||||||
|
const objectUrl = URL.createObjectURL(file)
|
||||||
|
tempFilePaths.push(objectUrl)
|
||||||
|
tempFiles.push({ path: objectUrl, size: file.size })
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
tempFilePaths,
|
||||||
|
tempFiles,
|
||||||
|
})
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户取消选择(部分浏览器支持)
|
||||||
|
const onCancel = () => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
reject(new Error('用户取消选择图片'))
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('change', onChange)
|
||||||
|
// 监听 cancel 事件(部分浏览器)
|
||||||
|
input.addEventListener('cancel', onCancel)
|
||||||
|
|
||||||
|
// 兼容移动端 webview 的取消操作:
|
||||||
|
// 通过 focus 丢失检测取消操作
|
||||||
|
let focusLost = false
|
||||||
|
const onFocus = () => {
|
||||||
|
focusLost = false
|
||||||
|
}
|
||||||
|
const onBlur = () => {
|
||||||
|
focusLost = true
|
||||||
|
// 延迟检测:如果 blur 后 change 未触发,视为取消
|
||||||
|
setTimeout(() => {
|
||||||
|
if (focusLost && input.files && input.files.length === 0) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
reject(new Error('用户取消选择图片'))
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('focus', onFocus)
|
||||||
|
input.addEventListener('blur', onBlur)
|
||||||
|
|
||||||
|
// 触发文件选择
|
||||||
|
input.click()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// 获取设备信息
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
async getDeviceInfo(): Promise<DeviceInfo> {
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
|
||||||
|
// 解析平台
|
||||||
|
let platform: string
|
||||||
|
if (/iPhone|iPad|iPod/i.test(ua)) {
|
||||||
|
platform = 'ios'
|
||||||
|
} else if (/Android/i.test(ua)) {
|
||||||
|
platform = 'android'
|
||||||
|
} else if (/Windows/i.test(ua)) {
|
||||||
|
platform = 'windows'
|
||||||
|
} else if (/Macintosh|Mac OS/i.test(ua)) {
|
||||||
|
platform = 'mac'
|
||||||
|
} else if (/Linux/i.test(ua)) {
|
||||||
|
platform = 'linux'
|
||||||
|
} else {
|
||||||
|
platform = 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试解析设备型号(粗糙方式)
|
||||||
|
let model = 'unknown'
|
||||||
|
const deviceMatch = ua.match(/\(([^)]+)\)/)
|
||||||
|
if (deviceMatch && deviceMatch[1]) {
|
||||||
|
// 从 UA 括号中取设备信息
|
||||||
|
const parts = deviceMatch[1].split(';')
|
||||||
|
// 最后一个不含版本号的 token 通常是设备型号
|
||||||
|
for (let i = parts.length - 1; i >= 0; i--) {
|
||||||
|
const part = parts[i].trim()
|
||||||
|
if (
|
||||||
|
part &&
|
||||||
|
!/AppleWebKit|KHTML|Gecko|Chrome|Safari|Firefox|Edge|Version|Mobile/i.test(part) &&
|
||||||
|
!/^[0-9._]+$/.test(part) &&
|
||||||
|
!/U;|CPU|iPhone|iPad|iPod|Mac OS|Windows|Android|Linux/i.test(part)
|
||||||
|
) {
|
||||||
|
model = part
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析版本
|
||||||
|
let version = 'unknown'
|
||||||
|
if (platform === 'android') {
|
||||||
|
const match = ua.match(/Android\s+([0-9.]+)/i)
|
||||||
|
if (match && match[1]) {
|
||||||
|
version = match[1]
|
||||||
|
}
|
||||||
|
} else if (platform === 'ios') {
|
||||||
|
const match = ua.match(/OS\s+([0-9_]+)/i)
|
||||||
|
if (match && match[1]) {
|
||||||
|
version = match[1].replace(/_/g, '.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
platform,
|
||||||
|
model,
|
||||||
|
version,
|
||||||
|
language: navigator.language,
|
||||||
|
pixelRatio: window.devicePixelRatio || 1,
|
||||||
|
screenWidth: screen.width,
|
||||||
|
screenHeight: screen.height,
|
||||||
|
windowWidth: window.innerWidth,
|
||||||
|
windowHeight: window.innerHeight,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
// 设置标题
|
||||||
|
// ────────────────────────────────────────────
|
||||||
|
async setTitle(title: string): Promise<boolean> {
|
||||||
|
document.title = title
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default browserProvider
|
||||||
184
src/bridge/types.ts
Normal file
184
src/bridge/types.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* 桥接层类型定义
|
||||||
|
*
|
||||||
|
* 定义统一的能力接口 IBridge,屏蔽不同运行环境(浏览器、微信小程序、uni-app WebView)的差异,
|
||||||
|
* 让上层业务代码只需依赖此接口即可调用原生能力。
|
||||||
|
*
|
||||||
|
* @module bridge/types
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 地理位置
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 获取地理位置时的配置选项 */
|
||||||
|
export interface GetLocationOptions {
|
||||||
|
/** 是否启用高精度模式(GPS),默认 false */
|
||||||
|
enableHighAccuracy?: boolean
|
||||||
|
/** 超时时间(毫秒),默认 10000 */
|
||||||
|
timeout?: number
|
||||||
|
/** 是否需要返回逆地理编码后的地址描述 */
|
||||||
|
needAddress?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 地理位置坐标 */
|
||||||
|
export interface LocationResult {
|
||||||
|
/** 纬度,范围 -90 ~ 90 */
|
||||||
|
latitude: number
|
||||||
|
/** 经度,范围 -180 ~ 180 */
|
||||||
|
longitude: number
|
||||||
|
/** 速度(m/s) */
|
||||||
|
speed?: number
|
||||||
|
/** 精度(米) */
|
||||||
|
accuracy?: number
|
||||||
|
/** 逆地理编码后的地址描述(需要 needAddress 选项) */
|
||||||
|
address?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 扫码
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 扫码类型 */
|
||||||
|
export type ScanType = 'qr' | 'barcode' | 'all'
|
||||||
|
|
||||||
|
/** 扫码配置选项 */
|
||||||
|
export interface ScanCodeOptions {
|
||||||
|
/** 扫码类型,默认 'all' */
|
||||||
|
type?: ScanType
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扫码结果 */
|
||||||
|
export interface ScanCodeResult {
|
||||||
|
/** 扫码得到的字符串内容 */
|
||||||
|
result: string
|
||||||
|
/** 码的类型,如 'QR_CODE'、'EAN_13' 等 */
|
||||||
|
format?: string
|
||||||
|
/** 字符集 */
|
||||||
|
charset?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 地图
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 打开地图查看位置所需的参数 */
|
||||||
|
export interface OpenMapParams {
|
||||||
|
/** 目标纬度 */
|
||||||
|
latitude: number
|
||||||
|
/** 目标经度 */
|
||||||
|
longitude: number
|
||||||
|
/** 位置名称 */
|
||||||
|
name?: string
|
||||||
|
/** 地址详情 */
|
||||||
|
address?: string
|
||||||
|
/** 缩放级别,默认 16 */
|
||||||
|
scale?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 图片选择
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 图片来源 */
|
||||||
|
export type ImageSourceType = 'album' | 'camera'
|
||||||
|
|
||||||
|
/** 选择图片的配置选项 */
|
||||||
|
export interface ChooseImageOptions {
|
||||||
|
/** 最多可选图片数量,默认 1 */
|
||||||
|
count?: number
|
||||||
|
/** 图片来源,默认 ['album', 'camera'] */
|
||||||
|
sourceType?: ImageSourceType[]
|
||||||
|
/** 是否允许压缩,默认 true */
|
||||||
|
compress?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选择图片的返回结果 */
|
||||||
|
export interface ChooseImageResult {
|
||||||
|
/** 选中图片的临时文件路径列表 */
|
||||||
|
tempFilePaths: string[]
|
||||||
|
/** 选中图片的临时文件对象列表 */
|
||||||
|
tempFiles?: Array<{
|
||||||
|
path: string
|
||||||
|
size: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 设备信息
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 设备信息 */
|
||||||
|
export interface DeviceInfo {
|
||||||
|
/** 操作系统平台,如 'ios'、'android'、'windows'、'mac' */
|
||||||
|
platform: string
|
||||||
|
/** 设备型号 */
|
||||||
|
model: string
|
||||||
|
/** 操作系统版本号 */
|
||||||
|
version: string
|
||||||
|
/** 系统语言 */
|
||||||
|
language?: string
|
||||||
|
/** 设备像素比 */
|
||||||
|
pixelRatio?: number
|
||||||
|
/** 屏幕宽度(px) */
|
||||||
|
screenWidth?: number
|
||||||
|
/** 屏幕高度(px) */
|
||||||
|
screenHeight?: number
|
||||||
|
/** 窗口宽度(px) */
|
||||||
|
windowWidth?: number
|
||||||
|
/** 窗口高度(px) */
|
||||||
|
windowHeight?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Bridge 接口 —— 所有提供者需实现此接口
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一桥接接口
|
||||||
|
*
|
||||||
|
* 所有环境提供者(浏览器、微信小程序 WebView、uni-app WebView)
|
||||||
|
* 都必须实现此接口,确保上层业务代码不感知底层差异。
|
||||||
|
*/
|
||||||
|
export interface IBridge {
|
||||||
|
/**
|
||||||
|
* 获取当前地理位置
|
||||||
|
* @param options - 配置选项
|
||||||
|
* @returns Promise 返回位置信息
|
||||||
|
*/
|
||||||
|
getLocation(options?: GetLocationOptions): Promise<LocationResult>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调起扫码
|
||||||
|
* @param options - 扫码配置
|
||||||
|
* @returns Promise 返回扫码结果
|
||||||
|
*/
|
||||||
|
scanCode(options?: ScanCodeOptions): Promise<ScanCodeResult>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开地图查看指定位置
|
||||||
|
* @param params - 目标位置参数
|
||||||
|
* @returns Promise 返回是否成功打开
|
||||||
|
*/
|
||||||
|
openMap(params: OpenMapParams): Promise<boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从相册或相机选择图片
|
||||||
|
* @param options - 选择配置
|
||||||
|
* @returns Promise 返回选中图片信息
|
||||||
|
*/
|
||||||
|
chooseImage(options?: ChooseImageOptions): Promise<ChooseImageResult>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取设备信息
|
||||||
|
* @returns Promise 返回设备信息
|
||||||
|
*/
|
||||||
|
getDeviceInfo(): Promise<DeviceInfo>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置页面标题
|
||||||
|
* @param title - 标题文本
|
||||||
|
* @returns Promise 返回是否设置成功
|
||||||
|
*/
|
||||||
|
setTitle(title: string): Promise<boolean>
|
||||||
|
}
|
||||||
18
src/components/HelloWorld.vue
Normal file
18
src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Minimal placeholder component
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="hello-world">
|
||||||
|
<h1>智慧水务</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hello-world {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
src/main.ts
Normal file
5
src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import './styles/index.scss'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
691
src/map/LayerFactory.ts
Normal file
691
src/map/LayerFactory.ts
Normal file
@@ -0,0 +1,691 @@
|
|||||||
|
/**
|
||||||
|
* LayerFactory - 旧版 GeoScene 图层配置 → MapLibre 样式规范转换器
|
||||||
|
*
|
||||||
|
* 将原 GeoScene 项目中 jsonToLayerFactory.js 的图层配置对象
|
||||||
|
* 转换为 maplibregl 兼容的 style spec(source + layer 组合)。
|
||||||
|
* 支持的图层类型与原工厂保持一致。
|
||||||
|
*
|
||||||
|
* @module map/LayerFactory
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { GeoJSON } from 'geojson'
|
||||||
|
import maplibregl from 'maplibre-gl'
|
||||||
|
import type {
|
||||||
|
SourceSpecification,
|
||||||
|
RasterSourceSpecification,
|
||||||
|
VectorSourceSpecification,
|
||||||
|
GeoJSONSourceSpecification,
|
||||||
|
LayerSpecification,
|
||||||
|
RasterLayerSpecification,
|
||||||
|
} from 'maplibre-gl'
|
||||||
|
import {
|
||||||
|
createTdtSource,
|
||||||
|
type TdtSourceOptions,
|
||||||
|
type TdtSourceResult,
|
||||||
|
} from './sources/tdt-source'
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 类型定义
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧版 GeoScene 图层配置(jsonToLayerFactory 输入格式)
|
||||||
|
*
|
||||||
|
* 对应原 GeoScene 项目中各个图层构造函数所需的 options 参数。
|
||||||
|
* type 字段决定使用哪种转换策略。
|
||||||
|
*/
|
||||||
|
export interface OldLayerConfig {
|
||||||
|
/**
|
||||||
|
* 图层类型标识(必填)
|
||||||
|
*
|
||||||
|
* - `'TdtLayer'`: 天地图 WMTS 图层
|
||||||
|
* - `'TileLayer'`: ArcGIS 切片服务 / 标准 XYZ 瓦片
|
||||||
|
* - `'MapImageLayer'`: ArcGIS 动态地图服务
|
||||||
|
* - `'VectorTileLayer'`: 矢量瓦片图层(Mapbox Vector Tile)
|
||||||
|
* - `'WMTSLayer'`: 通用 WMTS 图层(OGC 标准)
|
||||||
|
* - `'GeoJSONLayer'`: GeoJSON 图层(FeatureLayer)
|
||||||
|
*/
|
||||||
|
type: 'TdtLayer' | 'TileLayer' | 'MapImageLayer' | 'VectorTileLayer' | 'WMTSLayer' | 'GeoJSONLayer'
|
||||||
|
|
||||||
|
/** 图层 ID,缺失时自动生成 */
|
||||||
|
id?: string
|
||||||
|
|
||||||
|
/** 图层标题/名称 */
|
||||||
|
title?: string
|
||||||
|
|
||||||
|
/** 初始可见性 */
|
||||||
|
visible?: boolean
|
||||||
|
|
||||||
|
/** 图层不透明度 (0-1) */
|
||||||
|
opacity?: number
|
||||||
|
|
||||||
|
/** 最小可见缩放级别 */
|
||||||
|
minZoom?: number
|
||||||
|
|
||||||
|
/** 最大可见缩放级别 */
|
||||||
|
maxZoom?: number
|
||||||
|
|
||||||
|
// ---- TdtLayer 专用 ----
|
||||||
|
/** 天地图样式(vec/img/ter/cva/cia/cta) */
|
||||||
|
style?: string
|
||||||
|
/** 天地图瓦片矩阵集(c=经纬度, w=墨卡托) */
|
||||||
|
matrix?: string
|
||||||
|
/** 天地图 API 密钥 */
|
||||||
|
tk?: string
|
||||||
|
/** 天地图服务地址 */
|
||||||
|
url?: string
|
||||||
|
/** 天地图子域列表 */
|
||||||
|
subdomains?: string[]
|
||||||
|
|
||||||
|
// ---- TileLayer / MapImageLayer / WMTSLayer 专用 ----
|
||||||
|
/** 服务 URL 或 URL 模板 */
|
||||||
|
urlTemplate?: string
|
||||||
|
|
||||||
|
// ---- VectorTileLayer 专用 ----
|
||||||
|
/** 矢量瓦片样式 URL 或内联样式对象 */
|
||||||
|
styleUrl?: string | object
|
||||||
|
|
||||||
|
// ---- GeoJSONLayer 专用 ----
|
||||||
|
/** GeoJSON 数据 URL 或内联对象 */
|
||||||
|
data?: string | GeoJSON.GeoJSON
|
||||||
|
/** 矢量图层样式配置 */
|
||||||
|
layerConfig?: GeoJSONLayerStyleConfig
|
||||||
|
|
||||||
|
// ---- 通用扩展 ----
|
||||||
|
/** 其他任意配置,透传处理 */
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GeoJSON 图层渲染样式配置
|
||||||
|
*/
|
||||||
|
export interface GeoJSONLayerStyleConfig {
|
||||||
|
/** 渲染类型 */
|
||||||
|
type?: 'line' | 'fill' | 'circle' | 'symbol'
|
||||||
|
/** 线宽 */
|
||||||
|
lineWidth?: number
|
||||||
|
/** 线颜色 */
|
||||||
|
lineColor?: string
|
||||||
|
/** 线不透明度 */
|
||||||
|
lineOpacity?: number
|
||||||
|
/** 填充颜色 */
|
||||||
|
fillColor?: string
|
||||||
|
/** 填充不透明度 */
|
||||||
|
fillOpacity?: number
|
||||||
|
/** 圆形半径 */
|
||||||
|
circleRadius?: number
|
||||||
|
/** 圆形颜色 */
|
||||||
|
circleColor?: string
|
||||||
|
/** 圆形不透明度 */
|
||||||
|
circleOpacity?: number
|
||||||
|
/** 圆形描边宽度 */
|
||||||
|
circleStrokeWidth?: number
|
||||||
|
/** 圆形描边颜色 */
|
||||||
|
circleStrokeColor?: string
|
||||||
|
/** 其他 paint/layout 属性 */
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LayerFactory 返回结果
|
||||||
|
*/
|
||||||
|
export interface LayerFactoryResult {
|
||||||
|
/**
|
||||||
|
* 源配置列表(按顺序通过 map.addSource 添加)
|
||||||
|
*/
|
||||||
|
sources: Array<{ id: string; config: SourceSpecification }>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图层配置列表(按顺序通过 map.addLayer 添加)
|
||||||
|
*/
|
||||||
|
layers: LayerSpecification[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 源图层名称(用于追踪)
|
||||||
|
*/
|
||||||
|
originalType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 内部常量
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ArcGIS 服务标准导出参数
|
||||||
|
*/
|
||||||
|
const ARCGIS_EXPORT_PARAMS = {
|
||||||
|
f: 'image',
|
||||||
|
format: 'png32',
|
||||||
|
transparent: 'true',
|
||||||
|
dpi: '96',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ID 生成
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** ID 自增计数器 */
|
||||||
|
let idCounter = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成唯一 ID
|
||||||
|
*/
|
||||||
|
function generateId(prefix: string): string {
|
||||||
|
idCounter += 1
|
||||||
|
return `${prefix}-${idCounter}-${Date.now().toString(36)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 各类型转换函数
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换天地图图层 (TdtLayer)
|
||||||
|
*
|
||||||
|
* 委托给 {@link createTdtSource} 完成实际转换。
|
||||||
|
*/
|
||||||
|
function convertTdtLayer(config: OldLayerConfig): LayerFactoryResult {
|
||||||
|
const tdtOptions: TdtSourceOptions = {
|
||||||
|
style: (config.style as TdtSourceOptions['style']) ?? 'vec',
|
||||||
|
matrix: (config.matrix as TdtSourceOptions['matrix']) ?? 'c',
|
||||||
|
tk: config.tk,
|
||||||
|
url: config.url,
|
||||||
|
subdomains: config.subdomains,
|
||||||
|
minzoom: config.minZoom ?? 0,
|
||||||
|
maxzoom: config.maxZoom ?? 18,
|
||||||
|
layer: {
|
||||||
|
layout: {
|
||||||
|
visibility: config.visible === false ? 'none' : 'visible',
|
||||||
|
},
|
||||||
|
paint: config.opacity !== undefined
|
||||||
|
? { 'raster-opacity': config.opacity }
|
||||||
|
: undefined,
|
||||||
|
metadata: {
|
||||||
|
title: config.title,
|
||||||
|
},
|
||||||
|
} as RasterLayerSpecification,
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: TdtSourceResult = createTdtSource(tdtOptions)
|
||||||
|
|
||||||
|
return {
|
||||||
|
sources: [{ id: result.sourceId, config: result.source }],
|
||||||
|
layers: [result.layer],
|
||||||
|
originalType: 'TdtLayer',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换 ArcGIS 切片服务图层 (TileLayer)
|
||||||
|
*
|
||||||
|
* ArcGIS 切片服务(MapServer/tile/{z}/{y}/{x})→ raster source + layer
|
||||||
|
*/
|
||||||
|
function convertTileLayer(config: OldLayerConfig): LayerFactoryResult {
|
||||||
|
const layerId = config.id || generateId('tile')
|
||||||
|
const sourceId = `${layerId}-source`
|
||||||
|
const url = config.urlTemplate || config.url || ''
|
||||||
|
|
||||||
|
// ArcGIS 切片服务 URL 格式:{serviceUrl}/tile/{z}/{y}/{x}
|
||||||
|
// 如果已经是 {z}/{x}/{y} 模板,则直接使用
|
||||||
|
const tileUrl: string = url.includes('{z}')
|
||||||
|
? url
|
||||||
|
: url.replace(/\/?$/, '/tile/{z}/{y}/{x}')
|
||||||
|
|
||||||
|
const source: RasterSourceSpecification = {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: [tileUrl],
|
||||||
|
tileSize: 256,
|
||||||
|
minzoom: config.minZoom ?? 0,
|
||||||
|
maxzoom: config.maxZoom ?? 19,
|
||||||
|
attribution: config.title ?? '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const layer: RasterLayerSpecification = {
|
||||||
|
id: layerId,
|
||||||
|
type: 'raster',
|
||||||
|
source: sourceId,
|
||||||
|
minzoom: config.minZoom ?? 0,
|
||||||
|
maxzoom: config.maxZoom ?? 19,
|
||||||
|
layout: {
|
||||||
|
visibility: config.visible === false ? 'none' : 'visible',
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
...(config.opacity !== undefined ? { 'raster-opacity': config.opacity } : {}),
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
title: config.title ?? layerId,
|
||||||
|
type: 'TileLayer',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sources: [{ id: sourceId, config: source }],
|
||||||
|
layers: [layer],
|
||||||
|
originalType: 'TileLayer',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换 ArcGIS 动态地图服务图层 (MapImageLayer)
|
||||||
|
*
|
||||||
|
* ArcGIS MapServer/export → raster source(单张图片,非瓦片)
|
||||||
|
* 注意:MapLibre 的 raster 源需要瓦片,这里通过自定义 scheme 生成单瓦片
|
||||||
|
*/
|
||||||
|
function convertMapImageLayer(config: OldLayerConfig): LayerFactoryResult {
|
||||||
|
const layerId = config.id || generateId('map-image')
|
||||||
|
const sourceId = `${layerId}-source`
|
||||||
|
const url = config.urlTemplate || config.url || ''
|
||||||
|
|
||||||
|
// 构建 ArcGIS export 请求 URL 模板
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
...ARCGIS_EXPORT_PARAMS,
|
||||||
|
bbox: '{bbox-epsg-3857}',
|
||||||
|
bboxSR: '3857',
|
||||||
|
imageSR: '3857',
|
||||||
|
size: '256,256',
|
||||||
|
}).toString()
|
||||||
|
|
||||||
|
// ArcGIS MapServer export 端点
|
||||||
|
const exportUrl = url.replace(/\/?$/, '/export?' + params)
|
||||||
|
|
||||||
|
const source: RasterSourceSpecification = {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: [exportUrl],
|
||||||
|
tileSize: 256,
|
||||||
|
minzoom: config.minZoom ?? 0,
|
||||||
|
maxzoom: config.maxZoom ?? 19,
|
||||||
|
}
|
||||||
|
|
||||||
|
const layer: RasterLayerSpecification = {
|
||||||
|
id: layerId,
|
||||||
|
type: 'raster',
|
||||||
|
source: sourceId,
|
||||||
|
minzoom: config.minZoom ?? 0,
|
||||||
|
maxzoom: config.maxZoom ?? 19,
|
||||||
|
layout: {
|
||||||
|
visibility: config.visible === false ? 'none' : 'visible',
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
...(config.opacity !== undefined ? { 'raster-opacity': config.opacity } : {}),
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
title: config.title ?? layerId,
|
||||||
|
type: 'MapImageLayer',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sources: [{ id: sourceId, config: source }],
|
||||||
|
layers: [layer],
|
||||||
|
originalType: 'MapImageLayer',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换矢量瓦片图层 (VectorTileLayer)
|
||||||
|
*
|
||||||
|
* 支持两种模式:
|
||||||
|
* 1. URL 指向 MapLibre 样式 JSON(直接作为 style URL 使用)
|
||||||
|
* 2. 直接使用矢量瓦片源 URL
|
||||||
|
*/
|
||||||
|
function convertVectorTileLayer(config: OldLayerConfig): LayerFactoryResult {
|
||||||
|
const layerId = config.id || generateId('vector-tile')
|
||||||
|
const sourceId = `${layerId}-source`
|
||||||
|
const url = config.urlTemplate || config.url || ''
|
||||||
|
|
||||||
|
const source: VectorSourceSpecification = {
|
||||||
|
type: 'vector',
|
||||||
|
tiles: [url],
|
||||||
|
minzoom: config.minZoom ?? 0,
|
||||||
|
maxzoom: config.maxZoom ?? 14,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回一个占位图层,实际渲染样式需由调用方根据 vector source 的 source-layer 自行添加
|
||||||
|
const layer: LayerSpecification = {
|
||||||
|
id: layerId,
|
||||||
|
type: 'line',
|
||||||
|
source: sourceId,
|
||||||
|
'source-layer': '',
|
||||||
|
layout: {
|
||||||
|
visibility: config.visible === false ? 'none' : 'visible',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
title: config.title ?? layerId,
|
||||||
|
type: 'VectorTileLayer',
|
||||||
|
note: '需要调用方根据 source-layer 自行设置渲染样式',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sources: [{ id: sourceId, config: source }],
|
||||||
|
layers: [layer],
|
||||||
|
originalType: 'VectorTileLayer',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换 WMTS 图层 (WMTSLayer)
|
||||||
|
*
|
||||||
|
* OGC WMTS 标准 → raster source
|
||||||
|
* 使用 RESTful 风格的 WMTS URL 模板
|
||||||
|
*/
|
||||||
|
function convertWMTSLayer(config: OldLayerConfig): LayerFactoryResult {
|
||||||
|
const layerId = config.id || generateId('wmts')
|
||||||
|
const sourceId = `${layerId}-source`
|
||||||
|
const url = config.urlTemplate || config.url || ''
|
||||||
|
|
||||||
|
// 如果 URL 不含 {z}/{x}/{y},通常 WMTS RESTful URL 格式为:
|
||||||
|
// {serviceUrl}/{layer}/{style}/{TileMatrixSet}/{TileMatrix}/{TileRow}/{TileCol}
|
||||||
|
const tileUrl: string = url.includes('{z}')
|
||||||
|
? url
|
||||||
|
: `${url}/{z}/{y}/{x}`
|
||||||
|
|
||||||
|
const source: RasterSourceSpecification = {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: [tileUrl],
|
||||||
|
tileSize: 256,
|
||||||
|
minzoom: config.minZoom ?? 0,
|
||||||
|
maxzoom: config.maxZoom ?? 19,
|
||||||
|
}
|
||||||
|
|
||||||
|
const layer: RasterLayerSpecification = {
|
||||||
|
id: layerId,
|
||||||
|
type: 'raster',
|
||||||
|
source: sourceId,
|
||||||
|
minzoom: config.minZoom ?? 0,
|
||||||
|
maxzoom: config.maxZoom ?? 19,
|
||||||
|
layout: {
|
||||||
|
visibility: config.visible === false ? 'none' : 'visible',
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
...(config.opacity !== undefined ? { 'raster-opacity': config.opacity } : {}),
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
title: config.title ?? layerId,
|
||||||
|
type: 'WMTSLayer',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sources: [{ id: sourceId, config: source }],
|
||||||
|
layers: [layer],
|
||||||
|
originalType: 'WMTSLayer',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换 GeoJSON 图层 (GeoJSONLayer)
|
||||||
|
*
|
||||||
|
* GeoJSON 数据源 → source + 对应类型的渲染图层
|
||||||
|
*/
|
||||||
|
function convertGeoJSONLayer(config: OldLayerConfig): LayerFactoryResult {
|
||||||
|
const layerId = config.id || generateId('geojson')
|
||||||
|
const sourceId = `${layerId}-source`
|
||||||
|
const styleConfig = config.layerConfig || {}
|
||||||
|
|
||||||
|
// 数据源:支持 URL 和内联 GeoJSON
|
||||||
|
const data = config.data || undefined
|
||||||
|
|
||||||
|
let geoJsonData: string | GeoJSON.GeoJSON = ''
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
// URL 字符串
|
||||||
|
geoJsonData = data
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
// 内联 GeoJSON 对象
|
||||||
|
geoJsonData = data as GeoJSON.GeoJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
const source: GeoJSONSourceSpecification = {
|
||||||
|
type: 'geojson',
|
||||||
|
data: geoJsonData,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 layerConfig.type 确定 MapLibre 图层类型
|
||||||
|
const renderType = (styleConfig.type as string) || 'line'
|
||||||
|
|
||||||
|
// 构建 paint 属性
|
||||||
|
const paint: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
switch (renderType) {
|
||||||
|
case 'line':
|
||||||
|
if (styleConfig.lineColor) paint['line-color'] = styleConfig.lineColor
|
||||||
|
if (styleConfig.lineWidth !== undefined) paint['line-width'] = styleConfig.lineWidth
|
||||||
|
if (styleConfig.lineOpacity !== undefined) paint['line-opacity'] = styleConfig.lineOpacity
|
||||||
|
break
|
||||||
|
case 'fill':
|
||||||
|
if (styleConfig.fillColor) paint['fill-color'] = styleConfig.fillColor
|
||||||
|
if (styleConfig.fillOpacity !== undefined) paint['fill-opacity'] = styleConfig.fillOpacity
|
||||||
|
break
|
||||||
|
case 'circle':
|
||||||
|
if (styleConfig.circleRadius !== undefined) paint['circle-radius'] = styleConfig.circleRadius
|
||||||
|
if (styleConfig.circleColor) paint['circle-color'] = styleConfig.circleColor
|
||||||
|
if (styleConfig.circleOpacity !== undefined) paint['circle-opacity'] = styleConfig.circleOpacity
|
||||||
|
if (styleConfig.circleStrokeWidth !== undefined) paint['circle-stroke-width'] = styleConfig.circleStrokeWidth
|
||||||
|
if (styleConfig.circleStrokeColor) paint['circle-stroke-color'] = styleConfig.circleStrokeColor
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并其他未映射的 paint 属性
|
||||||
|
const knownKeys = [
|
||||||
|
'type', 'lineColor', 'lineWidth', 'lineOpacity',
|
||||||
|
'fillColor', 'fillOpacity',
|
||||||
|
'circleRadius', 'circleColor', 'circleOpacity',
|
||||||
|
'circleStrokeWidth', 'circleStrokeColor',
|
||||||
|
]
|
||||||
|
for (const key of Object.keys(styleConfig)) {
|
||||||
|
if (!knownKeys.includes(key)) {
|
||||||
|
paint[key] = styleConfig[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const layer = {
|
||||||
|
id: layerId,
|
||||||
|
type: renderType as LayerSpecification['type'],
|
||||||
|
source: sourceId,
|
||||||
|
layout: {
|
||||||
|
visibility: config.visible === false ? 'none' : 'visible',
|
||||||
|
},
|
||||||
|
paint: Object.keys(paint).length > 0 ? (paint as LayerSpecification['paint']) : undefined,
|
||||||
|
metadata: {
|
||||||
|
title: config.title ?? layerId,
|
||||||
|
type: 'GeoJSONLayer',
|
||||||
|
},
|
||||||
|
} as LayerSpecification
|
||||||
|
|
||||||
|
return {
|
||||||
|
sources: [{ id: sourceId, config: source }],
|
||||||
|
layers: [layer],
|
||||||
|
originalType: 'GeoJSONLayer',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 类型 → 转换函数映射表
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图层类型与转换函数的映射表
|
||||||
|
*
|
||||||
|
* 保持与原 jsonToLayerFactory.js 中 `layerConstructors` 一致的
|
||||||
|
* 图层类型支持范围。
|
||||||
|
*/
|
||||||
|
const TYPE_CONVERTERS: Record<string, (config: OldLayerConfig) => LayerFactoryResult> = {
|
||||||
|
TdtLayer: convertTdtLayer,
|
||||||
|
TileLayer: convertTileLayer,
|
||||||
|
MapImageLayer: convertMapImageLayer,
|
||||||
|
VectorTileLayer: convertVectorTileLayer,
|
||||||
|
WMTSLayer: convertWMTSLayer,
|
||||||
|
GeoJSONLayer: convertGeoJSONLayer,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 公开 API
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取支持的图层类型列表
|
||||||
|
*
|
||||||
|
* @returns 图层类型字符串数组
|
||||||
|
*/
|
||||||
|
export function getSupportedLayerTypes(): string[] {
|
||||||
|
return Object.keys(TYPE_CONVERTERS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查给定的图层类型是否受支持
|
||||||
|
*
|
||||||
|
* @param type - 图层类型字符串
|
||||||
|
* @returns 是否支持
|
||||||
|
*/
|
||||||
|
export function isLayerTypeSupported(type: string): boolean {
|
||||||
|
return type in TYPE_CONVERTERS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将旧版 GeoScene 图层配置转换为 MapLibre 样式配置
|
||||||
|
*
|
||||||
|
* 根据配置中的 `type` 字段自动选择对应的转换策略,
|
||||||
|
* 返回 MapLibre 兼容的 source + layer 配置,可直接传入
|
||||||
|
* `map.addSource()` 和 `map.addLayer()`。
|
||||||
|
*
|
||||||
|
* @param config - 旧版图层配置对象(必须有 `type` 字段)
|
||||||
|
* @returns 包含 sources 和 layers 数组的转换结果
|
||||||
|
* @throws 如果 config 为空或 type 不支持
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* import { convertLayer } from '@/map/LayerFactory'
|
||||||
|
*
|
||||||
|
* const map = MapFactory.getCurrentMap()
|
||||||
|
*
|
||||||
|
* // 转换天地图图层
|
||||||
|
* const result = convertLayer({
|
||||||
|
* type: 'TdtLayer',
|
||||||
|
* style: 'vec',
|
||||||
|
* matrix: 'c',
|
||||||
|
* tk: 'your-key',
|
||||||
|
* title: '天地图矢量底图',
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* result.sources.forEach(s => map?.addSource(s.id, s.config))
|
||||||
|
* result.layers.forEach(l => map?.addLayer(l))
|
||||||
|
*
|
||||||
|
* // 转换 GeoJSON 图层
|
||||||
|
* const geoResult = convertLayer({
|
||||||
|
* type: 'GeoJSONLayer',
|
||||||
|
* id: 'my-points',
|
||||||
|
* data: '/api/points.geojson',
|
||||||
|
* layerConfig: {
|
||||||
|
* type: 'circle',
|
||||||
|
* circleRadius: 8,
|
||||||
|
* circleColor: '#ff6600',
|
||||||
|
* },
|
||||||
|
* })
|
||||||
|
* geoResult.sources.forEach(s => map?.addSource(s.id, s.config))
|
||||||
|
* geoResult.layers.forEach(l => map?.addLayer(l))
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function convertLayer(config: OldLayerConfig): LayerFactoryResult {
|
||||||
|
// 校验配置
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('[LayerFactory] 配置为空,无法转换图层')
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = config.type
|
||||||
|
|
||||||
|
if (!type) {
|
||||||
|
throw new Error('[LayerFactory] 图层配置缺少 type 字段')
|
||||||
|
}
|
||||||
|
|
||||||
|
const converter = TYPE_CONVERTERS[type]
|
||||||
|
|
||||||
|
if (!converter) {
|
||||||
|
const supported = Object.keys(TYPE_CONVERTERS).join(', ')
|
||||||
|
throw new Error(
|
||||||
|
`[LayerFactory] 不支持的图层类型: "${type}",支持的类型: ${supported}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return converter(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量转换旧版 GeoScene 图层配置
|
||||||
|
*
|
||||||
|
* 依次调用 {@link convertLayer} 转换配置数组中的每一个图层,
|
||||||
|
* 遇到不支持的类型会跳过并打印警告日志。
|
||||||
|
*
|
||||||
|
* @param configs - 旧版图层配置数组
|
||||||
|
* @returns 转换结果数组(仅包含成功转换的项)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const results = convertLayers([
|
||||||
|
* { type: 'TdtLayer', style: 'vec', tk: '...' },
|
||||||
|
* { type: 'TdtLayer', style: 'cva', tk: '...' },
|
||||||
|
* { type: 'GeoJSONLayer', data: '/api/points.json' },
|
||||||
|
* ])
|
||||||
|
*
|
||||||
|
* results.forEach(r => {
|
||||||
|
* r.sources.forEach(s => map?.addSource(s.id, s.config))
|
||||||
|
* })
|
||||||
|
* results.forEach(r => {
|
||||||
|
* r.layers.forEach(l => map?.addLayer(l))
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function convertLayers(configs: OldLayerConfig[]): LayerFactoryResult[] {
|
||||||
|
const results: LayerFactoryResult[] = []
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
try {
|
||||||
|
results.push(convertLayer(config))
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`[LayerFactory] 跳过图层转换: ${config?.type ?? '未知类型'}`,
|
||||||
|
(err as Error).message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将转换结果应用到 MapLibre 地图实例
|
||||||
|
*
|
||||||
|
* 便捷方法:按先 source 后 layer 的顺序批量添加到地图中。
|
||||||
|
*
|
||||||
|
* @param map - MapLibre 地图实例
|
||||||
|
* @param results - 转换结果(单个或数组)
|
||||||
|
* @param beforeId - 在指定图层之前插入(可选)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const result = convertLayer(config)
|
||||||
|
* applyToMap(map, result)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function applyToMap(
|
||||||
|
map: maplibregl.Map,
|
||||||
|
results: LayerFactoryResult | LayerFactoryResult[],
|
||||||
|
beforeId?: string
|
||||||
|
): void {
|
||||||
|
const list = Array.isArray(results) ? results : [results]
|
||||||
|
|
||||||
|
for (const result of list) {
|
||||||
|
for (const src of result.sources) {
|
||||||
|
if (!map.getSource(src.id)) {
|
||||||
|
map.addSource(src.id, src.config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const layer of result.layers) {
|
||||||
|
if (!map.getLayer(layer.id)) {
|
||||||
|
map.addLayer(layer, beforeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
325
src/map/MapFactory.ts
Normal file
325
src/map/MapFactory.ts
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
/**
|
||||||
|
* MapFactory - 预配置 maplibregl.Map 实例工厂
|
||||||
|
*
|
||||||
|
* 提供快速创建带有默认底图图层、控件和事件处理的地图实例的工具方法。
|
||||||
|
* 内部使用 MapManager 单例管理地图生命周期,避免重复创建实例。
|
||||||
|
*
|
||||||
|
* @module map/MapFactory
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Map, MapOptions, NavigationControlOptions, StyleSpecification } from 'maplibre-gl'
|
||||||
|
import {
|
||||||
|
MapManager,
|
||||||
|
MapConfig,
|
||||||
|
DEFAULT_CENTER,
|
||||||
|
DEFAULT_ZOOM,
|
||||||
|
DEFAULT_NAVIGATION_CONTROL_OPTIONS,
|
||||||
|
} from './MapManager'
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 类型定义
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地图工厂配置选项
|
||||||
|
*
|
||||||
|
* 扩展自 MapConfig,增加工厂特有的便捷配置项
|
||||||
|
*/
|
||||||
|
export interface MapFactoryOptions extends Omit<MapConfig, 'container'> {
|
||||||
|
/**
|
||||||
|
* 地图容器元素或其 id(必填)
|
||||||
|
*/
|
||||||
|
container: string | HTMLElement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否自动添加 NavigationControl 导航控件
|
||||||
|
* @defaultValue true
|
||||||
|
*/
|
||||||
|
enableNavigationControl?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NavigationControl 导航控件配置
|
||||||
|
* @defaultValue { showCompass: true, showZoom: true, visualizePitch: false }
|
||||||
|
*/
|
||||||
|
navigationControlOptions?: NavigationControlOptions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否启用默认的中日韩本地字体渲染
|
||||||
|
* @defaultValue true
|
||||||
|
*/
|
||||||
|
enableLocalIdeograph?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户自定义的 map 'load' 事件处理器
|
||||||
|
*/
|
||||||
|
onLoad?: (map: Map) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 底图图层配置
|
||||||
|
*/
|
||||||
|
export interface BasemapLayerConfig {
|
||||||
|
/**
|
||||||
|
* 图层 id
|
||||||
|
*/
|
||||||
|
id: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图层名称(用于显示)
|
||||||
|
*/
|
||||||
|
name: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MapLibre 样式 URL 或 style 对象
|
||||||
|
*/
|
||||||
|
style: string | StyleSpecification
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 预置底图配置
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内置底图图层列表
|
||||||
|
*
|
||||||
|
* 可根据项目需要扩展更多底图样式
|
||||||
|
*/
|
||||||
|
export const PRESET_BASEMAPS: Record<string, BasemapLayerConfig> = {
|
||||||
|
/**
|
||||||
|
* MapLibre 官方 Demo 瓦片底图(开发/测试用)
|
||||||
|
*/
|
||||||
|
demotiles: {
|
||||||
|
id: 'basemap-demotiles',
|
||||||
|
name: 'Demo Tiles',
|
||||||
|
style: 'https://demotiles.maplibre.org/style.json',
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenStreetMap 栅格瓦片底图
|
||||||
|
*/
|
||||||
|
osm: {
|
||||||
|
id: 'basemap-osm',
|
||||||
|
name: 'OpenStreetMap',
|
||||||
|
style: {
|
||||||
|
version: 8,
|
||||||
|
sources: {
|
||||||
|
'osm-raster': {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||||
|
tileSize: 256,
|
||||||
|
attribution:
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
maxzoom: 19,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: 'osm-raster-layer',
|
||||||
|
type: 'raster',
|
||||||
|
source: 'osm-raster',
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 19,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// MapFactory
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地图工厂
|
||||||
|
*
|
||||||
|
* 提供一组静态方法,用于快速创建带有常用预配置的 maplibregl.Map 实例。
|
||||||
|
* 所有方法内部通过 MapManager 单例管理地图生命周期。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // 使用预置 OSM 底图快速创建地图
|
||||||
|
* const map = MapFactory.createWithBasemap('osm', {
|
||||||
|
* container: 'map-container',
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* // 使用自定义样式创建地图
|
||||||
|
* const map = MapFactory.create({
|
||||||
|
* container: 'map-container',
|
||||||
|
* style: 'https://my-style-server/style.json',
|
||||||
|
* zoom: 12,
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* // 创建带加载回调的地图
|
||||||
|
* const map = MapFactory.create({
|
||||||
|
* container: 'map-container',
|
||||||
|
* style: 'https://demotiles.maplibre.org/style.json',
|
||||||
|
* onLoad: (map) => {
|
||||||
|
* console.log('地图就绪', map.getCenter())
|
||||||
|
* },
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class MapFactory {
|
||||||
|
/**
|
||||||
|
* 获取 MapManager 单例
|
||||||
|
*
|
||||||
|
* @returns MapManager 实例
|
||||||
|
*/
|
||||||
|
private static getManager(): MapManager {
|
||||||
|
return MapManager.getInstance()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建预配置的地图实例
|
||||||
|
*
|
||||||
|
* 自动合并默认配置(南京中心点、默认缩放级别、本地字体等),
|
||||||
|
* 并添加 NavigationControl 导航控件。
|
||||||
|
*
|
||||||
|
* @param options - 地图工厂配置选项
|
||||||
|
* @returns 创建的 maplibregl.Map 实例
|
||||||
|
*/
|
||||||
|
static create(options: MapFactoryOptions): Map {
|
||||||
|
const manager = MapFactory.getManager()
|
||||||
|
|
||||||
|
// 提取 factory 特有参数
|
||||||
|
const {
|
||||||
|
container,
|
||||||
|
enableNavigationControl = true,
|
||||||
|
navigationControlOptions,
|
||||||
|
enableLocalIdeograph = true,
|
||||||
|
onLoad,
|
||||||
|
...mapOptions
|
||||||
|
} = options
|
||||||
|
|
||||||
|
// 构建 MapConfig
|
||||||
|
const config: Omit<MapConfig, 'container'> = {
|
||||||
|
// 默认配置
|
||||||
|
center: DEFAULT_CENTER,
|
||||||
|
zoom: DEFAULT_ZOOM,
|
||||||
|
|
||||||
|
// 如果启用本地字体,设置中文字体回退
|
||||||
|
localIdeographFontFamily: enableLocalIdeograph ? 'sans-serif' : false,
|
||||||
|
|
||||||
|
// 导航控件配置
|
||||||
|
navigationControl: enableNavigationControl
|
||||||
|
? (navigationControlOptions ?? DEFAULT_NAVIGATION_CONTROL_OPTIONS)
|
||||||
|
: false,
|
||||||
|
|
||||||
|
// 合并用户传入的其他配置
|
||||||
|
...mapOptions,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果用户提供了 onLoad 回调,先注册事件监听
|
||||||
|
if (onLoad) {
|
||||||
|
manager.on('map:loaded', (map) => {
|
||||||
|
onLoad(map)
|
||||||
|
// onLoad 只触发一次,用完即移除
|
||||||
|
manager.off('map:loaded', onLoad as () => void)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建地图
|
||||||
|
const map = manager.createMap(container, config)
|
||||||
|
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用预置底图创建地图
|
||||||
|
*
|
||||||
|
* 从 {@link PRESET_BASEMAPS} 中选择一个预定义底图样式。
|
||||||
|
*
|
||||||
|
* @param basemapKey - 底图键名,可选值:'demotiles' | 'osm'
|
||||||
|
* @param options - 地图工厂配置选项(可选,会覆盖底图默认样式中的 style)
|
||||||
|
* @returns 创建的 maplibregl.Map 实例
|
||||||
|
* @throws 如果传入的 basemapKey 不存在于预置底图列表中
|
||||||
|
*/
|
||||||
|
static createWithBasemap(
|
||||||
|
basemapKey: keyof typeof PRESET_BASEMAPS,
|
||||||
|
options: MapFactoryOptions
|
||||||
|
): Map {
|
||||||
|
const basemap = PRESET_BASEMAPS[basemapKey]
|
||||||
|
|
||||||
|
if (!basemap) {
|
||||||
|
throw new Error(
|
||||||
|
`[MapFactory] 未知的底图键名: "${basemapKey}",可用值: ${Object.keys(PRESET_BASEMAPS).join(', ')}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用预置底图的 style,但允许用户覆盖
|
||||||
|
return MapFactory.create({
|
||||||
|
style: basemap.style,
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册自定义底图到预置列表
|
||||||
|
*
|
||||||
|
* @param key - 底图唯一键名
|
||||||
|
* @param config - 底图图层配置
|
||||||
|
*/
|
||||||
|
static registerBasemap(key: string, config: BasemapLayerConfig): void {
|
||||||
|
PRESET_BASEMAPS[key] = config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除预置底图
|
||||||
|
*
|
||||||
|
* @param key - 底图键名
|
||||||
|
*/
|
||||||
|
static unregisterBasemap(key: string): void {
|
||||||
|
delete PRESET_BASEMAPS[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有已注册的底图列表
|
||||||
|
*
|
||||||
|
* @returns 底图配置记录
|
||||||
|
*/
|
||||||
|
static getRegisteredBasemaps(): Record<string, BasemapLayerConfig> {
|
||||||
|
return { ...PRESET_BASEMAPS }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前地图实例(便捷方法)
|
||||||
|
*
|
||||||
|
* @returns 当前 maplibregl.Map 实例,未创建时返回 null
|
||||||
|
*/
|
||||||
|
static getCurrentMap(): Map | null {
|
||||||
|
return MapFactory.getManager().getMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销毁当前地图实例(便捷方法)
|
||||||
|
*/
|
||||||
|
static destroy(): void {
|
||||||
|
MapFactory.getManager().destroyMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅地图事件(便捷方法)
|
||||||
|
*
|
||||||
|
* @param type - 事件类型
|
||||||
|
* @param handler - 事件处理函数
|
||||||
|
*/
|
||||||
|
static on(
|
||||||
|
type: 'map:loaded' | 'map:destroyed' | 'map:creating',
|
||||||
|
handler: (...args: any[]) => void
|
||||||
|
): void {
|
||||||
|
MapFactory.getManager().on(type as any, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消订阅地图事件(便捷方法)
|
||||||
|
*
|
||||||
|
* @param type - 事件类型
|
||||||
|
* @param handler - 要移除的事件处理函数
|
||||||
|
*/
|
||||||
|
static off(
|
||||||
|
type: 'map:loaded' | 'map:destroyed' | 'map:creating',
|
||||||
|
handler?: (...args: any[]) => void
|
||||||
|
): void {
|
||||||
|
MapFactory.getManager().off(type as any, handler as any)
|
||||||
|
}
|
||||||
|
}
|
||||||
417
src/map/MapManager.ts
Normal file
417
src/map/MapManager.ts
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
/**
|
||||||
|
* MapManager - maplibregl.Map 单例管理器
|
||||||
|
*
|
||||||
|
* 基于 mitt 事件总线的地图实例管理器,用于全局管理 maplibregl.Map 的生命周期。
|
||||||
|
* 替代原有 GeoScene 项目中的 ytmapUtil.js 单例模式。
|
||||||
|
*
|
||||||
|
* @module map/MapManager
|
||||||
|
*/
|
||||||
|
|
||||||
|
import maplibregl, {
|
||||||
|
Map,
|
||||||
|
MapOptions,
|
||||||
|
NavigationControl,
|
||||||
|
NavigationControlOptions,
|
||||||
|
} from 'maplibre-gl'
|
||||||
|
import mitt, { Emitter } from 'mitt'
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 类型定义
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地图配置接口,继承 maplibregl.MapOptions 并添加自定义配置项
|
||||||
|
*/
|
||||||
|
export interface MapConfig extends Omit<MapOptions, 'container'> {
|
||||||
|
/**
|
||||||
|
* 地图容器元素或其 id
|
||||||
|
*/
|
||||||
|
container: string | HTMLElement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导航控件配置,设为 false 则禁用导航控件
|
||||||
|
* @defaultValue { showCompass: true, showZoom: true, visualizePitch: false }
|
||||||
|
*/
|
||||||
|
navigationControl?: NavigationControlOptions | false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地图事件类型定义
|
||||||
|
*
|
||||||
|
* 通过 mitt 事件总线触发,供其他模块订阅
|
||||||
|
*/
|
||||||
|
export type MapEvents = {
|
||||||
|
/**
|
||||||
|
* 地图实例创建完毕并触发 'load' 事件后触发
|
||||||
|
*/
|
||||||
|
'map:loaded': Map
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地图实例被销毁后触发
|
||||||
|
*/
|
||||||
|
'map:destroyed': void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地图实例创建开始时触发
|
||||||
|
*/
|
||||||
|
'map:creating': MapConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 默认配置
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认地图中心点坐标(南京)
|
||||||
|
*/
|
||||||
|
export const DEFAULT_CENTER: [number, number] = [118.734575, 31.990939]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认缩放级别
|
||||||
|
*/
|
||||||
|
export const DEFAULT_ZOOM = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认导航控件配置
|
||||||
|
*/
|
||||||
|
export const DEFAULT_NAVIGATION_CONTROL_OPTIONS: NavigationControlOptions = {
|
||||||
|
showCompass: true,
|
||||||
|
showZoom: true,
|
||||||
|
visualizePitch: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// MapManager 单例类
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地图管理器单例
|
||||||
|
*
|
||||||
|
* 负责全局唯一 maplibregl.Map 实例的创建、销毁与访问,
|
||||||
|
* 并通过 mitt 事件总线广播地图生命周期事件。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // 获取单例
|
||||||
|
* const manager = MapManager.getInstance()
|
||||||
|
*
|
||||||
|
* // 订阅地图事件
|
||||||
|
* manager.on('map:loaded', (map) => {
|
||||||
|
* console.log('地图已加载', map.getCenter())
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* // 创建地图
|
||||||
|
* manager.createMap('map-container', {
|
||||||
|
* style: 'https://demotiles.maplibre.org/style.json',
|
||||||
|
* center: [118.734575, 31.990939],
|
||||||
|
* zoom: 10,
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* // 获取当前地图实例
|
||||||
|
* const map = manager.getMap()
|
||||||
|
*
|
||||||
|
* // 销毁地图
|
||||||
|
* manager.destroyMap()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class MapManager {
|
||||||
|
// ==========================================================
|
||||||
|
// 静态成员(单例模式)
|
||||||
|
// ==========================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单例实例持有者
|
||||||
|
*/
|
||||||
|
private static instance: MapManager | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 MapManager 单例实例
|
||||||
|
*
|
||||||
|
* @returns MapManager 单例
|
||||||
|
*/
|
||||||
|
static getInstance(): MapManager {
|
||||||
|
if (!MapManager.instance) {
|
||||||
|
MapManager.instance = new MapManager()
|
||||||
|
}
|
||||||
|
return MapManager.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置单例(主要用于测试环境)
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
static resetInstance(): void {
|
||||||
|
if (MapManager.instance) {
|
||||||
|
MapManager.instance.destroyMap()
|
||||||
|
MapManager.instance = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================
|
||||||
|
// 实例成员
|
||||||
|
// ==========================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前持有的 maplibregl.Map 实例
|
||||||
|
*/
|
||||||
|
private map: Map | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前持有的 NavigationControl 导航控件实例
|
||||||
|
*/
|
||||||
|
private navigationControl: NavigationControl | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mitt 事件总线实例
|
||||||
|
*/
|
||||||
|
private eventBus: Emitter<MapEvents>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前地图配置快照
|
||||||
|
*/
|
||||||
|
private currentConfig: MapConfig | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 私有构造函数,防止外部实例化
|
||||||
|
*/
|
||||||
|
private constructor() {
|
||||||
|
this.eventBus = mitt<MapEvents>()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================
|
||||||
|
// 事件总线 API
|
||||||
|
// ==========================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅地图事件
|
||||||
|
*
|
||||||
|
* @typeParam K - 事件类型
|
||||||
|
* @param type - 事件名称
|
||||||
|
* @param handler - 事件处理函数
|
||||||
|
*/
|
||||||
|
on<K extends keyof MapEvents>(type: K, handler: (event: MapEvents[K]) => void): void {
|
||||||
|
this.eventBus.on(type, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消订阅地图事件
|
||||||
|
*
|
||||||
|
* @typeParam K - 事件类型
|
||||||
|
* @param type - 事件名称
|
||||||
|
* @param handler - 要移除的事件处理函数(可选,不传则移除该事件的所有监听器)
|
||||||
|
*/
|
||||||
|
off<K extends keyof MapEvents>(type: K, handler?: (event: MapEvents[K]) => void): void {
|
||||||
|
this.eventBus.off(type, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发地图事件(内部使用)
|
||||||
|
*
|
||||||
|
* @typeParam K - 事件类型
|
||||||
|
* @param type - 事件名称
|
||||||
|
* @param event - 事件负载
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
private emit<K extends keyof MapEvents>(type: K, event: MapEvents[K]): void {
|
||||||
|
this.eventBus.emit(type, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取内部事件总线,用于高级场景
|
||||||
|
*
|
||||||
|
* @returns mitt 事件发射器
|
||||||
|
*/
|
||||||
|
getEventBus(): Emitter<MapEvents> {
|
||||||
|
return this.eventBus
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================
|
||||||
|
// 地图生命周期 API
|
||||||
|
// ==========================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建并返回 maplibregl.Map 实例
|
||||||
|
*
|
||||||
|
* 如果已存在地图实例,会先销毁旧实例再创建新实例。
|
||||||
|
* 创建完成后会监听地图 'load' 事件,触发后广播 'map:loaded'。
|
||||||
|
*
|
||||||
|
* @param container - 地图容器元素或其 id
|
||||||
|
* @param options - 地图配置选项(不包含 container 字段)
|
||||||
|
* @returns 创建的 maplibregl.Map 实例
|
||||||
|
* @throws 如果容器元素不存在
|
||||||
|
*/
|
||||||
|
createMap(
|
||||||
|
container: string | HTMLElement,
|
||||||
|
options: Omit<MapConfig, 'container'> = {}
|
||||||
|
): Map {
|
||||||
|
// 销毁已有地图实例
|
||||||
|
if (this.map) {
|
||||||
|
this.destroyMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并配置
|
||||||
|
const config: MapConfig = {
|
||||||
|
container,
|
||||||
|
center: options.center ?? DEFAULT_CENTER,
|
||||||
|
zoom: options.zoom ?? DEFAULT_ZOOM,
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 广播创建开始事件
|
||||||
|
this.emit('map:creating', config)
|
||||||
|
|
||||||
|
// 创建 MapLibre 地图实例
|
||||||
|
this.map = new Map({
|
||||||
|
container: config.container,
|
||||||
|
style: config.style,
|
||||||
|
center: config.center,
|
||||||
|
zoom: config.zoom,
|
||||||
|
bearing: config.bearing,
|
||||||
|
pitch: config.pitch,
|
||||||
|
minZoom: config.minZoom,
|
||||||
|
maxZoom: config.maxZoom,
|
||||||
|
maxBounds: config.maxBounds,
|
||||||
|
interactive: config.interactive,
|
||||||
|
hash: config.hash,
|
||||||
|
attributionControl: config.attributionControl,
|
||||||
|
renderWorldCopies: config.renderWorldCopies,
|
||||||
|
trackResize: config.trackResize,
|
||||||
|
localIdeographFontFamily: config.localIdeographFontFamily,
|
||||||
|
transformRequest: config.transformRequest,
|
||||||
|
scrollZoom: config.scrollZoom,
|
||||||
|
dragPan: config.dragPan,
|
||||||
|
dragRotate: config.dragRotate,
|
||||||
|
boxZoom: config.boxZoom,
|
||||||
|
doubleClickZoom: config.doubleClickZoom,
|
||||||
|
touchZoomRotate: config.touchZoomRotate,
|
||||||
|
touchPitch: config.touchPitch,
|
||||||
|
keyboard: config.keyboard,
|
||||||
|
cooperativeGestures: config.cooperativeGestures,
|
||||||
|
fadeDuration: config.fadeDuration,
|
||||||
|
crossSourceCollisions: config.crossSourceCollisions,
|
||||||
|
collectResourceTiming: config.collectResourceTiming,
|
||||||
|
clickTolerance: config.clickTolerance,
|
||||||
|
bounds: config.bounds,
|
||||||
|
fitBoundsOptions: config.fitBoundsOptions,
|
||||||
|
pixelRatio: config.pixelRatio,
|
||||||
|
validateStyle: config.validateStyle,
|
||||||
|
maxTileCacheSize: config.maxTileCacheSize,
|
||||||
|
maxTileCacheZoomLevels: config.maxTileCacheZoomLevels,
|
||||||
|
refreshExpiredTiles: config.refreshExpiredTiles,
|
||||||
|
logoPosition: config.logoPosition,
|
||||||
|
maplibreLogo: config.maplibreLogo,
|
||||||
|
bearingSnap: config.bearingSnap,
|
||||||
|
zoomSnap: config.zoomSnap,
|
||||||
|
pitchWithRotate: config.pitchWithRotate,
|
||||||
|
rollEnabled: config.rollEnabled,
|
||||||
|
reduceMotion: config.reduceMotion,
|
||||||
|
canvasContextAttributes: config.canvasContextAttributes,
|
||||||
|
maxCanvasSize: config.maxCanvasSize,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.currentConfig = config
|
||||||
|
|
||||||
|
// 添加导航控件
|
||||||
|
this.addNavigationControl(config.navigationControl)
|
||||||
|
|
||||||
|
// 监听地图加载完成事件,广播 'map:loaded'
|
||||||
|
this.map.once('load', () => {
|
||||||
|
if (this.map) {
|
||||||
|
this.emit('map:loaded', this.map)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.map
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加或更新导航控件
|
||||||
|
*
|
||||||
|
* @param options - 导航控件配置,false 表示不添加导航控件
|
||||||
|
*/
|
||||||
|
private addNavigationControl(options: NavigationControlOptions | false | undefined): void {
|
||||||
|
if (!this.map) return
|
||||||
|
|
||||||
|
// 移除旧控件
|
||||||
|
if (this.navigationControl) {
|
||||||
|
this.map.removeControl(this.navigationControl)
|
||||||
|
this.navigationControl = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如明确设为 false 则不添加
|
||||||
|
if (options === false) return
|
||||||
|
|
||||||
|
// 使用默认或用户配置创建 NavigationControl
|
||||||
|
const controlOptions = options ?? DEFAULT_NAVIGATION_CONTROL_OPTIONS
|
||||||
|
this.navigationControl = new NavigationControl(controlOptions)
|
||||||
|
this.map.addControl(this.navigationControl, 'top-left')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前地图实例
|
||||||
|
*
|
||||||
|
* @returns 当前 maplibregl.Map 实例,未创建时返回 null
|
||||||
|
*/
|
||||||
|
getMap(): Map | null {
|
||||||
|
return this.map
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前导航控件实例
|
||||||
|
*
|
||||||
|
* @returns 当前 NavigationControl 实例,未创建时返回 null
|
||||||
|
*/
|
||||||
|
getNavigationControl(): NavigationControl | null {
|
||||||
|
return this.navigationControl
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前地图配置
|
||||||
|
*
|
||||||
|
* @returns 当前地图配置快照,未创建时返回 null
|
||||||
|
*/
|
||||||
|
getCurrentConfig(): MapConfig | null {
|
||||||
|
return this.currentConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销毁当前地图实例
|
||||||
|
*
|
||||||
|
* 调用 map.remove() 清理 DOM 节点和事件监听器,
|
||||||
|
* 并广播 'map:destroyed' 事件。
|
||||||
|
* 销毁后可通过 createMap() 重新创建新实例。
|
||||||
|
*/
|
||||||
|
destroyMap(): void {
|
||||||
|
if (this.map) {
|
||||||
|
// 移除导航控件引用
|
||||||
|
this.navigationControl = null
|
||||||
|
|
||||||
|
// 销毁地图实例
|
||||||
|
this.map.remove()
|
||||||
|
this.map = null
|
||||||
|
this.currentConfig = null
|
||||||
|
|
||||||
|
// 广播销毁事件
|
||||||
|
this.emit('map:destroyed', undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查地图是否已创建
|
||||||
|
*
|
||||||
|
* @returns 地图实例是否存在
|
||||||
|
*/
|
||||||
|
hasMap(): boolean {
|
||||||
|
return this.map !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除事件总线上的所有监听器
|
||||||
|
*
|
||||||
|
* 通常在页面卸载或全局重置时调用。
|
||||||
|
*/
|
||||||
|
clearAllListeners(): void {
|
||||||
|
this.eventBus.all.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
480
src/map/composables/useLayer.ts
Normal file
480
src/map/composables/useLayer.ts
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
/**
|
||||||
|
* useLayer - 图层 CRUD 操作组合式函数
|
||||||
|
*
|
||||||
|
* 基于 MapManager 单例中的 Map 实例,提供图层的添加、移除、显隐切换、
|
||||||
|
* 可见性查询等响应式操作方法。
|
||||||
|
*
|
||||||
|
* @module map/composables/useLayer
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, computed, readonly, onUnmounted, type Ref, type DeepReadonly } from 'vue'
|
||||||
|
import type {
|
||||||
|
LayerSpecification,
|
||||||
|
CustomLayerInterface,
|
||||||
|
FilterSpecification,
|
||||||
|
} from 'maplibre-gl'
|
||||||
|
import { MapManager } from '../MapManager'
|
||||||
|
import { useMap } from './useMap'
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 类型定义
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图层操作结果
|
||||||
|
*/
|
||||||
|
export interface LayerOperationResult {
|
||||||
|
/** 是否成功 */
|
||||||
|
success: boolean
|
||||||
|
/** 错误信息(失败时) */
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图层信息
|
||||||
|
*/
|
||||||
|
export interface LayerInfo {
|
||||||
|
/** 图层 ID */
|
||||||
|
id: string
|
||||||
|
/** 图层类型 */
|
||||||
|
type: string
|
||||||
|
/** 当前可见性 */
|
||||||
|
visible: boolean
|
||||||
|
/** 图层元数据(如有) */
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图层可见性映射表
|
||||||
|
*
|
||||||
|
* key 为图层 ID,value 为 Ref<boolean> 表示该图层可见性
|
||||||
|
*/
|
||||||
|
export interface LayerVisibilityMap {
|
||||||
|
[layerId: string]: Ref<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useLayer 返回类型
|
||||||
|
*/
|
||||||
|
export interface UseLayerReturn {
|
||||||
|
/**
|
||||||
|
* 添加图层到地图
|
||||||
|
*
|
||||||
|
* @param layer - 图层配置(LayerSpecification 或 CustomLayerInterface)
|
||||||
|
* @param beforeId - 插入到指定图层之前(可选)
|
||||||
|
* @returns 操作结果
|
||||||
|
*/
|
||||||
|
addLayer: (
|
||||||
|
layer: LayerSpecification | CustomLayerInterface,
|
||||||
|
beforeId?: string
|
||||||
|
) => LayerOperationResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从地图移除图层
|
||||||
|
*
|
||||||
|
* 同时清理该图层的可见性追踪
|
||||||
|
*
|
||||||
|
* @param layerId - 图层 ID
|
||||||
|
* @returns 操作结果
|
||||||
|
*/
|
||||||
|
removeLayer: (layerId: string) => LayerOperationResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换图层可见性
|
||||||
|
*
|
||||||
|
* 如果图层当前可见则隐藏,反之则显示
|
||||||
|
*
|
||||||
|
* @param layerId - 图层 ID
|
||||||
|
* @returns 操作结果
|
||||||
|
*/
|
||||||
|
toggleLayer: (layerId: string) => LayerOperationResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置图层可见性
|
||||||
|
*
|
||||||
|
* @param layerId - 图层 ID
|
||||||
|
* @param visible - 是否可见
|
||||||
|
* @returns 操作结果
|
||||||
|
*/
|
||||||
|
setLayerVisibility: (layerId: string, visible: boolean) => LayerOperationResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取图层可见性的响应式引用
|
||||||
|
*
|
||||||
|
* 如果图层不存在则返回始终为 false 的 ref
|
||||||
|
*
|
||||||
|
* @param layerId - 图层 ID
|
||||||
|
* @returns 响应式可见性引用
|
||||||
|
*/
|
||||||
|
getLayerVisibility: (layerId: string) => DeepReadonly<Ref<boolean>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有已添加图层的 ID 列表
|
||||||
|
*
|
||||||
|
* @returns 图层 ID 数组
|
||||||
|
*/
|
||||||
|
getLayerIds: () => string[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取图层信息
|
||||||
|
*
|
||||||
|
* @param layerId - 图层 ID
|
||||||
|
* @returns 图层信息,不存在则返回 null
|
||||||
|
*/
|
||||||
|
getLayerInfo: (layerId: string) => LayerInfo | null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置图层滤镜
|
||||||
|
*
|
||||||
|
* @param layerId - 图层 ID
|
||||||
|
* @param filter - 滤镜表达式,传 null 清除滤镜
|
||||||
|
* @returns 操作结果
|
||||||
|
*/
|
||||||
|
setLayerFilter: (
|
||||||
|
layerId: string,
|
||||||
|
filter: FilterSpecification | null
|
||||||
|
) => LayerOperationResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所有已追踪图层的可见性映射表(只读)
|
||||||
|
*/
|
||||||
|
visibilityMap: DeepReadonly<Ref<LayerVisibilityMap>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前图层数量
|
||||||
|
*/
|
||||||
|
layerCount: import('vue').ComputedRef<number>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// useLayer
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图层 CRUD 操作组合式函数
|
||||||
|
*
|
||||||
|
* 提供对 MapLibre GL 地图图层的添加、移除、显隐控制等操作。
|
||||||
|
* 内部使用 useMap() 获取当前地图实例,确保操作始终针对当前地图。
|
||||||
|
*
|
||||||
|
* @returns 图层操作方法集合
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```vue
|
||||||
|
* <script setup lang="ts">
|
||||||
|
* import { useLayer } from '@/map/composables/useLayer'
|
||||||
|
*
|
||||||
|
* const { addLayer, removeLayer, toggleLayer, getLayerVisibility, layerCount } = useLayer()
|
||||||
|
*
|
||||||
|
* // 添加一个圆形图层
|
||||||
|
* addLayer({
|
||||||
|
* id: 'points-layer',
|
||||||
|
* type: 'circle',
|
||||||
|
* source: 'my-source',
|
||||||
|
* paint: { 'circle-radius': 6, 'circle-color': '#ff0000' }
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* // 切换可见性
|
||||||
|
* toggleLayer('points-layer')
|
||||||
|
*
|
||||||
|
* // 响应式获取可见性
|
||||||
|
* const isVisible = getLayerVisibility('points-layer')
|
||||||
|
* </script>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useLayer(): UseLayerReturn {
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 获取当前地图
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const { map } = useMap()
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 内部状态
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图层可见性映射表
|
||||||
|
*
|
||||||
|
* 追踪所有通过本 composable 操作的图层的可见性
|
||||||
|
*/
|
||||||
|
const visibilityMap = ref<LayerVisibilityMap>({})
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 内部辅助函数
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前 Map 实例,不存在则返回 null
|
||||||
|
*/
|
||||||
|
function getMap() {
|
||||||
|
return map.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查图层是否存在
|
||||||
|
*/
|
||||||
|
function layerExists(layerId: string): boolean {
|
||||||
|
const m = getMap()
|
||||||
|
if (!m) return false
|
||||||
|
try {
|
||||||
|
return !!m.getLayer(layerId)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步图层可见性到内部状态
|
||||||
|
*/
|
||||||
|
function syncLayerVisibility(layerId: string, visible: boolean): void {
|
||||||
|
if (!visibilityMap.value[layerId]) {
|
||||||
|
visibilityMap.value[layerId] = ref(visible)
|
||||||
|
} else {
|
||||||
|
visibilityMap.value[layerId].value = visible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 公开方法
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加图层到地图
|
||||||
|
*/
|
||||||
|
function addLayer(
|
||||||
|
layer: LayerSpecification | CustomLayerInterface,
|
||||||
|
beforeId?: string
|
||||||
|
): LayerOperationResult {
|
||||||
|
const m = getMap()
|
||||||
|
if (!m) {
|
||||||
|
return { success: false, error: '地图实例不存在,请先创建地图' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查图层是否已存在
|
||||||
|
if (m.getLayer(layer.id)) {
|
||||||
|
return { success: false, error: `图层 "${layer.id}" 已存在` }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 beforeId 是否存在
|
||||||
|
if (beforeId && !m.getLayer(beforeId)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `参考图层 "${beforeId}" 不存在`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.addLayer(layer, beforeId)
|
||||||
|
|
||||||
|
// 追踪可见性
|
||||||
|
const visibility = 'layout' in layer && layer.layout && 'visibility' in layer.layout
|
||||||
|
? (layer.layout as { visibility?: string }).visibility !== 'none'
|
||||||
|
: true
|
||||||
|
syncLayerVisibility(layer.id, visibility)
|
||||||
|
|
||||||
|
// 触发响应式更新
|
||||||
|
visibilityMap.value = { ...visibilityMap.value }
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : '添加图层失败',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从地图移除图层
|
||||||
|
*/
|
||||||
|
function removeLayer(layerId: string): LayerOperationResult {
|
||||||
|
const m = getMap()
|
||||||
|
if (!m) {
|
||||||
|
return { success: false, error: '地图实例不存在' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!m.getLayer(layerId)) {
|
||||||
|
return { success: false, error: `图层 "${layerId}" 不存在` }
|
||||||
|
}
|
||||||
|
|
||||||
|
m.removeLayer(layerId)
|
||||||
|
|
||||||
|
// 清理可见性追踪
|
||||||
|
if (visibilityMap.value[layerId]) {
|
||||||
|
const newMap = { ...visibilityMap.value }
|
||||||
|
delete newMap[layerId]
|
||||||
|
visibilityMap.value = newMap
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : '移除图层失败',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换图层可见性
|
||||||
|
*/
|
||||||
|
function toggleLayer(layerId: string): LayerOperationResult {
|
||||||
|
if (!layerExists(layerId)) {
|
||||||
|
return { success: false, error: `图层 "${layerId}" 不存在` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentVisibility = visibilityMap.value[layerId]?.value ?? true
|
||||||
|
return setLayerVisibility(layerId, !currentVisibility)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置图层可见性
|
||||||
|
*/
|
||||||
|
function setLayerVisibility(layerId: string, visible: boolean): LayerOperationResult {
|
||||||
|
const m = getMap()
|
||||||
|
if (!m) {
|
||||||
|
return { success: false, error: '地图实例不存在' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!m.getLayer(layerId)) {
|
||||||
|
return { success: false, error: `图层 "${layerId}" 不存在` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibility = visible ? 'visible' : 'none'
|
||||||
|
m.setLayoutProperty(layerId, 'visibility', visibility)
|
||||||
|
|
||||||
|
syncLayerVisibility(layerId, visible)
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : '设置图层可见性失败',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取图层可见性的响应式引用
|
||||||
|
*/
|
||||||
|
function getLayerVisibility(layerId: string): DeepReadonly<Ref<boolean>> {
|
||||||
|
if (!visibilityMap.value[layerId]) {
|
||||||
|
// 惰性初始化:直接从地图读取当前状态
|
||||||
|
const m = getMap()
|
||||||
|
let visible = false
|
||||||
|
if (m && m.getLayer(layerId)) {
|
||||||
|
try {
|
||||||
|
const v = m.getLayoutProperty(layerId, 'visibility')
|
||||||
|
visible = v !== 'none'
|
||||||
|
} catch {
|
||||||
|
visible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visibilityMap.value[layerId] = ref(visible)
|
||||||
|
visibilityMap.value = { ...visibilityMap.value }
|
||||||
|
}
|
||||||
|
|
||||||
|
return readonly(visibilityMap.value[layerId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有已添加图层的 ID 列表
|
||||||
|
*/
|
||||||
|
function getLayerIds(): string[] {
|
||||||
|
const m = getMap()
|
||||||
|
if (!m) return []
|
||||||
|
return m.getStyle()?.layers?.map((l) => l.id) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取图层信息
|
||||||
|
*/
|
||||||
|
function getLayerInfo(layerId: string): LayerInfo | null {
|
||||||
|
const m = getMap()
|
||||||
|
if (!m) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const layer = m.getLayer(layerId)
|
||||||
|
if (!layer) return null
|
||||||
|
|
||||||
|
const visibility = visibilityMap.value[layerId]?.value ?? true
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: layer.id,
|
||||||
|
type: layer.type,
|
||||||
|
visible: visibility,
|
||||||
|
metadata: (layer as LayerSpecification).metadata as Record<string, unknown> | undefined,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置图层滤镜
|
||||||
|
*/
|
||||||
|
function setLayerFilter(
|
||||||
|
layerId: string,
|
||||||
|
filter: FilterSpecification | null
|
||||||
|
): LayerOperationResult {
|
||||||
|
const m = getMap()
|
||||||
|
if (!m) {
|
||||||
|
return { success: false, error: '地图实例不存在' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!m.getLayer(layerId)) {
|
||||||
|
return { success: false, error: `图层 "${layerId}" 不存在` }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter === null) {
|
||||||
|
// 清除滤镜
|
||||||
|
m.setFilter(layerId, null as unknown as FilterSpecification)
|
||||||
|
} else {
|
||||||
|
m.setFilter(layerId, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : '设置图层滤镜失败',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 计算属性
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
/** 当前图层数量 */
|
||||||
|
const layerCount = computed(() => {
|
||||||
|
const m = getMap()
|
||||||
|
if (!m) return 0
|
||||||
|
return m.getStyle()?.layers?.length ?? 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 组件卸载时清理
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
onUnmounted(() => {
|
||||||
|
visibilityMap.value = {}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 返回
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
return {
|
||||||
|
addLayer,
|
||||||
|
removeLayer,
|
||||||
|
toggleLayer,
|
||||||
|
setLayerVisibility,
|
||||||
|
getLayerVisibility,
|
||||||
|
getLayerIds,
|
||||||
|
getLayerInfo,
|
||||||
|
setLayerFilter,
|
||||||
|
visibilityMap: readonly(visibilityMap),
|
||||||
|
layerCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
247
src/map/composables/useMap.ts
Normal file
247
src/map/composables/useMap.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* useMap - 响应式地图实例管理组合式函数
|
||||||
|
*
|
||||||
|
* 基于 MapManager 单例提供响应式地图实例、加载状态、中心点、缩放级别等。
|
||||||
|
* 自动订阅地图生命周期事件,组件卸载时自动清理。
|
||||||
|
*
|
||||||
|
* @module map/composables/useMap
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, readonly, onUnmounted, type Ref, type DeepReadonly } from 'vue'
|
||||||
|
import maplibregl from 'maplibre-gl'
|
||||||
|
import { MapManager } from '../MapManager'
|
||||||
|
|
||||||
|
// Local type alias: use maplibregl.Map to avoid potential conflicts
|
||||||
|
// with types re-exported via `export type *` in maplibre-gl's d.ts
|
||||||
|
type MapInstance = maplibregl.Map
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 类型定义
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useMap 返回类型
|
||||||
|
*/
|
||||||
|
export interface UseMapReturn {
|
||||||
|
/**
|
||||||
|
* 当前 maplibregl.Map 实例(只读)
|
||||||
|
*
|
||||||
|
* 地图未创建时为 null,加载完成后为 Map 实例
|
||||||
|
*/
|
||||||
|
map: Ref<MapInstance | null>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地图是否正在加载
|
||||||
|
*
|
||||||
|
* 初始为 true,收到 'map:loaded' 事件后变为 false;
|
||||||
|
* 地图销毁后重置为 true
|
||||||
|
*/
|
||||||
|
loading: DeepReadonly<Ref<boolean>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地图加载/运行时的错误信息
|
||||||
|
*
|
||||||
|
* 当 map 触发 'error' 事件时更新
|
||||||
|
*/
|
||||||
|
error: DeepReadonly<Ref<string | null>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地图当前中心点坐标 [lng, lat](只读)
|
||||||
|
*
|
||||||
|
* 每次 'moveend' 事件后自动更新
|
||||||
|
*/
|
||||||
|
center: DeepReadonly<Ref<[number, number] | null>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地图当前缩放级别(只读)
|
||||||
|
*
|
||||||
|
* 每次 'zoomend' 事件后自动更新
|
||||||
|
*/
|
||||||
|
zoom: DeepReadonly<Ref<number | null>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地图是否已加载完成
|
||||||
|
*
|
||||||
|
* 等同于 !loading && map !== null
|
||||||
|
*/
|
||||||
|
isReady: DeepReadonly<Ref<boolean>>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// useMap
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 响应式地图实例管理组合式函数
|
||||||
|
*
|
||||||
|
* 订阅 MapManager 单例的生命周期事件,提供 Vue 响应式的地图状态。
|
||||||
|
* 组件卸载时自动取消所有订阅。
|
||||||
|
*
|
||||||
|
* @returns 地图响应式状态与方法
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```vue
|
||||||
|
* <script setup lang="ts">
|
||||||
|
* import { useMap } from '@/map/composables/useMap'
|
||||||
|
*
|
||||||
|
* const { map, loading, center, zoom, isReady, error } = useMap()
|
||||||
|
*
|
||||||
|
* watch(isReady, (ready) => {
|
||||||
|
* if (ready) console.log('地图就绪,中心点:', center.value)
|
||||||
|
* })
|
||||||
|
* </script>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useMap(): UseMapReturn {
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 获取单例
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const manager = MapManager.getInstance()
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 响应式状态
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
/** 地图实例 */
|
||||||
|
const map = ref<MapInstance | null>(manager.getMap())
|
||||||
|
|
||||||
|
/** 加载状态:初始根据当前是否有已加载的地图判断 */
|
||||||
|
const loading = ref<boolean>(!manager.hasMap())
|
||||||
|
|
||||||
|
/** 错误信息 */
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
/** 中心点坐标 */
|
||||||
|
const center = ref<[number, number] | null>(null)
|
||||||
|
|
||||||
|
/** 缩放级别 */
|
||||||
|
const zoom = ref<number | null>(null)
|
||||||
|
|
||||||
|
/** 是否就绪 */
|
||||||
|
const isReady = ref<boolean>(manager.hasMap())
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 内部辅助函数
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步中心点和缩放级别
|
||||||
|
*/
|
||||||
|
function syncViewState(m: MapInstance): void {
|
||||||
|
const c = m.getCenter()
|
||||||
|
center.value = [c.lng, c.lat]
|
||||||
|
zoom.value = m.getZoom()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定 map 实例上的原生事件
|
||||||
|
*/
|
||||||
|
function bindMapEvents(m: MapInstance): void {
|
||||||
|
m.on('moveend', () => syncViewState(m))
|
||||||
|
m.on('zoomend', () => {
|
||||||
|
zoom.value = m.getZoom()
|
||||||
|
})
|
||||||
|
m.on('error', (e: { error?: Error }) => {
|
||||||
|
error.value = e.error?.message ?? '地图发生未知错误'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解绑 map 实例上的原生事件
|
||||||
|
*/
|
||||||
|
function unbindMapEvents(m: MapInstance): void {
|
||||||
|
m.off('moveend', () => syncViewState(m))
|
||||||
|
m.off('zoomend', () => {
|
||||||
|
zoom.value = m.getZoom()
|
||||||
|
})
|
||||||
|
m.off('error', () => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 订阅 MapManager 生命周期事件
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
/** 地图加载完成 */
|
||||||
|
function onMapLoaded(m: MapInstance): void {
|
||||||
|
map.value = m
|
||||||
|
loading.value = false
|
||||||
|
error.value = null
|
||||||
|
isReady.value = true
|
||||||
|
|
||||||
|
// 同步初始视图状态
|
||||||
|
syncViewState(m)
|
||||||
|
|
||||||
|
// 绑定后续的原生地图事件
|
||||||
|
bindMapEvents(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 地图即将创建 */
|
||||||
|
function onMapCreating(): void {
|
||||||
|
loading.value = true
|
||||||
|
isReady.value = false
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 地图已销毁 */
|
||||||
|
function onMapDestroyed(): void {
|
||||||
|
// 解绑旧实例的事件
|
||||||
|
const m = map.value as MapInstance | null
|
||||||
|
if (m) {
|
||||||
|
unbindMapEvents(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
map.value = null
|
||||||
|
loading.value = true
|
||||||
|
isReady.value = false
|
||||||
|
center.value = null
|
||||||
|
zoom.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册事件监听
|
||||||
|
manager.on('map:loaded', onMapLoaded)
|
||||||
|
manager.on('map:creating', onMapCreating)
|
||||||
|
manager.on('map:destroyed', onMapDestroyed)
|
||||||
|
|
||||||
|
// 如果当前已有已加载的地图实例,立即同步状态
|
||||||
|
const currentMap = manager.getMap()
|
||||||
|
if (currentMap && currentMap.loaded()) {
|
||||||
|
onMapLoaded(currentMap)
|
||||||
|
} else if (currentMap) {
|
||||||
|
// 地图存在但尚未加载完成
|
||||||
|
map.value = currentMap
|
||||||
|
loading.value = true
|
||||||
|
isReady.value = false
|
||||||
|
|
||||||
|
// 监听该实例的 load 事件
|
||||||
|
currentMap.once('load', () => {
|
||||||
|
onMapLoaded(currentMap)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 组件卸载时清理
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
onUnmounted(() => {
|
||||||
|
manager.off('map:loaded', onMapLoaded)
|
||||||
|
manager.off('map:creating', onMapCreating)
|
||||||
|
manager.off('map:destroyed', onMapDestroyed)
|
||||||
|
|
||||||
|
// 清理原生地图事件
|
||||||
|
const m2 = map.value as MapInstance | null
|
||||||
|
if (m2) {
|
||||||
|
unbindMapEvents(m2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 返回只读响应式状态
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
return {
|
||||||
|
map: map as Ref<MapInstance | null>,
|
||||||
|
loading: readonly(loading),
|
||||||
|
error: readonly(error),
|
||||||
|
center: readonly(center),
|
||||||
|
zoom: readonly(zoom),
|
||||||
|
isReady: readonly(isReady),
|
||||||
|
} as UseMapReturn
|
||||||
|
}
|
||||||
376
src/map/composables/usePopup.ts
Normal file
376
src/map/composables/usePopup.ts
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
/**
|
||||||
|
* usePopup - 地图弹窗管理组合式函数
|
||||||
|
*
|
||||||
|
* 基于 MapManager 单例中的 Map 实例,提供在地图上显示/隐藏 Popup 弹窗的
|
||||||
|
* 响应式操作方法。支持自定义 HTML 内容和完整的 Popup 配置选项。
|
||||||
|
*
|
||||||
|
* @module map/composables/usePopup
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, readonly, onUnmounted, type Ref, type DeepReadonly } from 'vue'
|
||||||
|
import { Popup, type PopupOptions, type LngLatLike, type Offset } from 'maplibre-gl'
|
||||||
|
import { useMap } from './useMap'
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 类型定义
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扩展的弹窗选项
|
||||||
|
*
|
||||||
|
* 继承 maplibregl.PopupOptions 并增加便利配置项
|
||||||
|
*/
|
||||||
|
export interface UsePopupOptions extends Omit<PopupOptions, 'className' | 'maxWidth'> {
|
||||||
|
/**
|
||||||
|
* Popup 的 CSS 类名
|
||||||
|
*/
|
||||||
|
className?: string | string[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Popup 最大宽度
|
||||||
|
* @defaultValue '240px'
|
||||||
|
*/
|
||||||
|
maxWidth?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否在弹窗关闭时自动销毁(移除 DOM)
|
||||||
|
* @defaultValue true
|
||||||
|
*/
|
||||||
|
autoRemove?: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 弹窗关闭回调
|
||||||
|
*/
|
||||||
|
onClose?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* usePopup 返回类型
|
||||||
|
*/
|
||||||
|
export interface UsePopupReturn {
|
||||||
|
/**
|
||||||
|
* 当前 Popup 实例(只读)
|
||||||
|
*
|
||||||
|
* 无弹窗时为 null
|
||||||
|
*/
|
||||||
|
popup: Ref<Popup | null>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 弹窗是否可见(只读)
|
||||||
|
*/
|
||||||
|
isVisible: DeepReadonly<Ref<boolean>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 弹窗当前位置(只读)
|
||||||
|
*
|
||||||
|
* 无弹窗时为 null
|
||||||
|
*/
|
||||||
|
position: DeepReadonly<Ref<LngLatLike | null>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在地图上显示弹窗
|
||||||
|
*
|
||||||
|
* 如果已有弹窗,会先关闭旧弹窗再显示新弹窗。
|
||||||
|
*
|
||||||
|
* @param lngLat - 弹窗位置(经纬度坐标)
|
||||||
|
* @param html - 弹窗 HTML 内容
|
||||||
|
* @param options - 弹窗配置选项(可选)
|
||||||
|
* @returns Popup 实例
|
||||||
|
*/
|
||||||
|
showPopup: (
|
||||||
|
lngLat: LngLatLike,
|
||||||
|
html: string,
|
||||||
|
options?: UsePopupOptions
|
||||||
|
) => Popup | null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 隐藏并销毁当前弹窗
|
||||||
|
*
|
||||||
|
* 调用后 isVisible 变为 false,popup 变为 null
|
||||||
|
*/
|
||||||
|
hidePopup: () => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新弹窗位置
|
||||||
|
*
|
||||||
|
* @param lngLat - 新的经纬度坐标
|
||||||
|
* @returns 是否更新成功
|
||||||
|
*/
|
||||||
|
setPosition: (lngLat: LngLatLike) => boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新弹窗 HTML 内容
|
||||||
|
*
|
||||||
|
* @param html - 新的 HTML 内容
|
||||||
|
* @returns 是否更新成功
|
||||||
|
*/
|
||||||
|
setHTML: (html: string) => boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置弹窗偏移量
|
||||||
|
*
|
||||||
|
* @param offset - 偏移量配置
|
||||||
|
* @returns 是否设置成功
|
||||||
|
*/
|
||||||
|
setOffset: (offset: Offset) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// usePopup
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 地图弹窗管理组合式函数
|
||||||
|
*
|
||||||
|
* 封装 maplibregl.Popup 的创建、显示、隐藏逻辑,提供 Vue 响应式状态。
|
||||||
|
* 同一时间仅管理一个弹窗,新弹窗会覆盖旧弹窗。
|
||||||
|
* 组件卸载时自动清理弹窗。
|
||||||
|
*
|
||||||
|
* @returns 弹窗管理方法与响应式状态
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```vue
|
||||||
|
* <script setup lang="ts">
|
||||||
|
* import { usePopup } from '@/map/composables/usePopup'
|
||||||
|
*
|
||||||
|
* const { showPopup, hidePopup, isVisible, popup } = usePopup()
|
||||||
|
*
|
||||||
|
* // 通过地图点击事件显示弹窗
|
||||||
|
* function onMapClick(e) {
|
||||||
|
* showPopup(e.lngLat, `
|
||||||
|
* <div class="custom-popup">
|
||||||
|
* <h3>坐标信息</h3>
|
||||||
|
* <p>经度: ${e.lngLat.lng.toFixed(6)}</p>
|
||||||
|
* <p>纬度: ${e.lngLat.lat.toFixed(6)}</p>
|
||||||
|
* </div>
|
||||||
|
* `, {
|
||||||
|
* closeButton: true,
|
||||||
|
* closeOnClick: false,
|
||||||
|
* maxWidth: '300px',
|
||||||
|
* onClose: () => console.log('弹窗已关闭')
|
||||||
|
* })
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // 手动隐藏弹窗
|
||||||
|
* // hidePopup()
|
||||||
|
* </script>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function usePopup(): UsePopupReturn {
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 获取当前地图
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const { map } = useMap()
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 响应式状态
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
/** 当前弹窗实例 */
|
||||||
|
const popup = ref<Popup | null>(null)
|
||||||
|
|
||||||
|
/** 弹窗可见性 */
|
||||||
|
const isVisible = ref<boolean>(false)
|
||||||
|
|
||||||
|
/** 弹窗当前位置 */
|
||||||
|
const position = ref<LngLatLike | null>(null)
|
||||||
|
|
||||||
|
/** 当前弹窗关闭回调 */
|
||||||
|
let currentOnClose: (() => void) | null = null
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 内部辅助函数
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前 Map 实例
|
||||||
|
*/
|
||||||
|
function getMap() {
|
||||||
|
return map.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销毁当前弹窗
|
||||||
|
*/
|
||||||
|
function destroyPopup(): void {
|
||||||
|
if (popup.value) {
|
||||||
|
try {
|
||||||
|
popup.value.remove()
|
||||||
|
} catch {
|
||||||
|
// 忽略已移除的弹窗错误
|
||||||
|
}
|
||||||
|
popup.value = null
|
||||||
|
}
|
||||||
|
isVisible.value = false
|
||||||
|
position.value = null
|
||||||
|
|
||||||
|
// 触发关闭回调
|
||||||
|
if (currentOnClose) {
|
||||||
|
const cb = currentOnClose
|
||||||
|
currentOnClose = null
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 公开方法
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在地图上显示弹窗
|
||||||
|
*/
|
||||||
|
function showPopup(
|
||||||
|
lngLat: LngLatLike,
|
||||||
|
html: string,
|
||||||
|
options: UsePopupOptions = {}
|
||||||
|
): Popup | null {
|
||||||
|
const m = getMap()
|
||||||
|
if (!m) {
|
||||||
|
console.warn('[usePopup] 地图实例不存在,无法显示弹窗')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已有弹窗,先销毁
|
||||||
|
destroyPopup()
|
||||||
|
|
||||||
|
// 提取自定义配置
|
||||||
|
const { autoRemove = true, onClose, ...popupOptions } = options
|
||||||
|
|
||||||
|
// 处理 className
|
||||||
|
let className: string | undefined
|
||||||
|
if (typeof popupOptions.className === 'string') {
|
||||||
|
className = popupOptions.className
|
||||||
|
} else if (Array.isArray(popupOptions.className)) {
|
||||||
|
className = popupOptions.className.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const p = new Popup({
|
||||||
|
closeButton: popupOptions.closeButton ?? true,
|
||||||
|
closeOnClick: popupOptions.closeOnClick ?? true,
|
||||||
|
closeOnMove: popupOptions.closeOnMove ?? false,
|
||||||
|
focusAfterOpen: popupOptions.focusAfterOpen ?? true,
|
||||||
|
anchor: popupOptions.anchor,
|
||||||
|
offset: popupOptions.offset,
|
||||||
|
className,
|
||||||
|
maxWidth: popupOptions.maxWidth ?? '240px',
|
||||||
|
subpixelPositioning: popupOptions.subpixelPositioning,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置 HTML 内容和位置
|
||||||
|
p.setLngLat(lngLat).setHTML(html).addTo(m)
|
||||||
|
|
||||||
|
// 更新状态
|
||||||
|
popup.value = p
|
||||||
|
isVisible.value = true
|
||||||
|
position.value = lngLat
|
||||||
|
currentOnClose = onClose ?? null
|
||||||
|
|
||||||
|
// 监听弹窗关闭事件
|
||||||
|
if (autoRemove || onClose) {
|
||||||
|
p.on('close', () => {
|
||||||
|
isVisible.value = false
|
||||||
|
position.value = null
|
||||||
|
// Use as unknown comparison to avoid TS deep type instantiation on Popup
|
||||||
|
if ((popup.value as unknown) === p) {
|
||||||
|
popup.value = null
|
||||||
|
}
|
||||||
|
if (currentOnClose) {
|
||||||
|
const cb = currentOnClose
|
||||||
|
currentOnClose = null
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return p
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[usePopup] 创建弹窗失败:', err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 隐藏并销毁当前弹窗
|
||||||
|
*/
|
||||||
|
function hidePopup(): void {
|
||||||
|
destroyPopup()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新弹窗位置
|
||||||
|
*/
|
||||||
|
function setPosition(lngLat: LngLatLike): boolean {
|
||||||
|
if (!popup.value) {
|
||||||
|
console.warn('[usePopup] 当前没有活动的弹窗')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
popup.value.setLngLat(lngLat)
|
||||||
|
position.value = lngLat
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[usePopup] 更新弹窗位置失败:', err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新弹窗 HTML 内容
|
||||||
|
*/
|
||||||
|
function setHTML(html: string): boolean {
|
||||||
|
if (!popup.value) {
|
||||||
|
console.warn('[usePopup] 当前没有活动的弹窗')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
popup.value.setHTML(html)
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[usePopup] 更新弹窗内容失败:', err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置弹窗偏移量
|
||||||
|
*/
|
||||||
|
function setOffset(offset: Offset): boolean {
|
||||||
|
if (!popup.value) {
|
||||||
|
console.warn('[usePopup] 当前没有活动的弹窗')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
popup.value.setOffset(offset)
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[usePopup] 设置弹窗偏移量失败:', err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 组件卸载时清理
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
onUnmounted(() => {
|
||||||
|
destroyPopup()
|
||||||
|
currentOnClose = null
|
||||||
|
})
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 返回
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
return {
|
||||||
|
popup: popup as Ref<Popup | null>,
|
||||||
|
isVisible: readonly(isVisible),
|
||||||
|
position: readonly(position),
|
||||||
|
showPopup,
|
||||||
|
hidePopup,
|
||||||
|
setPosition,
|
||||||
|
setHTML,
|
||||||
|
setOffset,
|
||||||
|
} as UsePopupReturn
|
||||||
|
}
|
||||||
354
src/map/sources/tdt-source.ts
Normal file
354
src/map/sources/tdt-source.ts
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
/**
|
||||||
|
* TdtSource - 天地图 WMTS 栅格瓦片源(MapLibre GL 适配)
|
||||||
|
*
|
||||||
|
* 提供将天地图 WMTS 服务转换为 maplibregl 栅格瓦片源配置的工具函数。
|
||||||
|
* 替代原有 GeoScene 项目中的 TdtLayer.js,通过自定义 tiles URL 模板
|
||||||
|
* 实现天地图六种底图/注记样式的地图加载。
|
||||||
|
*
|
||||||
|
* @module map/sources/tdt-source
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RasterSourceSpecification, RasterLayerSpecification } from 'maplibre-gl'
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 常量定义
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 天地图样式中文名称映射
|
||||||
|
*/
|
||||||
|
export const TDT_STYLE_NAMES: Record<string, string> = {
|
||||||
|
vec: '矢量底图',
|
||||||
|
img: '影像底图',
|
||||||
|
ter: '地形晕渲',
|
||||||
|
cva: '矢量注记',
|
||||||
|
cia: '影像注记',
|
||||||
|
cta: '地形注记',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 天地图子域列表(用于负载均衡)
|
||||||
|
*/
|
||||||
|
export const TDT_SUBDOMAINS: string[] = ['0', '1', '2', '3', '4', '5', '6', '7']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 天地图默认瓦片服务器地址
|
||||||
|
*/
|
||||||
|
export const TDT_DEFAULT_URL = 'https://t{s}.tianditu.gov.cn'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 天地图 WMTS 地址模板
|
||||||
|
*
|
||||||
|
* 占位符说明:
|
||||||
|
* - `{style}`: 图层样式(vec/img/ter/cva/cia/cta)
|
||||||
|
* - `{matrix}`: 瓦片矩阵集标识(c=经纬度,w=墨卡托)
|
||||||
|
* - `{z}`: 缩放级别(TILEMATRIX)
|
||||||
|
* - `{y}`: 瓦片行号(TILEROW)
|
||||||
|
* - `{x}`: 瓦片列号(TILECOL)
|
||||||
|
* - `{s}`: 子域编号(负载均衡)
|
||||||
|
*/
|
||||||
|
export const TDT_WMTS_TEMPLATE =
|
||||||
|
'/{style}_{matrix}/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER={style}&STYLE=default&TILEMATRIXSET={matrix}&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=tiles'
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 类型定义
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 天地图图层样式类型
|
||||||
|
*
|
||||||
|
* - `vec`: 矢量底图
|
||||||
|
* - `img`: 影像底图
|
||||||
|
* - `ter`: 地形晕渲
|
||||||
|
* - `cva`: 矢量注记(中文注记叠加层)
|
||||||
|
* - `cia`: 影像注记(中文注记叠加层)
|
||||||
|
* - `cta`: 地形注记(中文注记叠加层)
|
||||||
|
*/
|
||||||
|
export type TdtStyle = 'vec' | 'img' | 'ter' | 'cva' | 'cia' | 'cta'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 天地图瓦片矩阵集类型
|
||||||
|
*
|
||||||
|
* - `c`: 经纬度投影(EPSG:4490 / CGCS2000)
|
||||||
|
* - `w`: Web 墨卡托投影(EPSG:3857)
|
||||||
|
*/
|
||||||
|
export type TdtMatrix = 'c' | 'w'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 天地图暗色主题效果 CSS filter
|
||||||
|
*
|
||||||
|
* 当 theme 为 'dark' 时,通过 canvas 渲染后应用此 CSS 滤镜
|
||||||
|
*/
|
||||||
|
export const TDT_DARK_THEME_FILTER =
|
||||||
|
'invert(82%) sepia(80%) hue-rotate(180deg) saturate(340%)'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 天地图瓦片源配置选项
|
||||||
|
*/
|
||||||
|
export interface TdtSourceOptions {
|
||||||
|
/**
|
||||||
|
* 天地图图层样式
|
||||||
|
* @defaultValue 'vec'
|
||||||
|
*/
|
||||||
|
style?: TdtStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 瓦片矩阵集
|
||||||
|
*
|
||||||
|
* - `'c'`: 经纬度投影(CGCS2000),适合国内应用
|
||||||
|
* - `'w'`: Web 墨卡托投影,适合与 OSM 等国际底图叠加
|
||||||
|
*
|
||||||
|
* @defaultValue 'c'
|
||||||
|
*/
|
||||||
|
matrix?: TdtMatrix
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 天地图 API 密钥(tk 参数)
|
||||||
|
*
|
||||||
|
* 注册地址:https://console.tiangong.gov.cn/
|
||||||
|
*
|
||||||
|
* @defaultValue ''(不传则不追加 tk 参数)
|
||||||
|
*/
|
||||||
|
tk?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 天地图瓦片服务器地址
|
||||||
|
* @defaultValue 'https://t{s}.tianditu.gov.cn'
|
||||||
|
*/
|
||||||
|
url?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子域列表(负载均衡用)
|
||||||
|
* @defaultValue ['0', '1', '2', '3', '4', '5', '6', '7']
|
||||||
|
*/
|
||||||
|
subdomains?: string[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最小缩放级别
|
||||||
|
* @defaultValue 0
|
||||||
|
*/
|
||||||
|
minzoom?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最大缩放级别(天地图在线瓦片最高 18 级)
|
||||||
|
* @defaultValue 18
|
||||||
|
*/
|
||||||
|
maxzoom?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 瓦片大小(像素)
|
||||||
|
* @defaultValue 256
|
||||||
|
*/
|
||||||
|
tileSize?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图层属性(visible、opacity 等)
|
||||||
|
*/
|
||||||
|
layer?: Partial<Omit<RasterLayerSpecification, 'id' | 'type' | 'source'>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 天地图源创建结果
|
||||||
|
*/
|
||||||
|
export interface TdtSourceResult {
|
||||||
|
/**
|
||||||
|
* 源 ID(由 style 和 matrix 拼接生成)
|
||||||
|
*/
|
||||||
|
sourceId: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MapLibre 源配置(可直接传入 map.addSource)
|
||||||
|
*/
|
||||||
|
source: RasterSourceSpecification
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MapLibre 图层配置(可直接传入 map.addLayer)
|
||||||
|
*/
|
||||||
|
layer: RasterLayerSpecification
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 内部工具函数
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建天地图 WMTS 瓦片 URL 模板
|
||||||
|
*
|
||||||
|
* 将 {style}/{matrix}/{tk}/{s} 等参数注入 URL 模板,
|
||||||
|
* 保留 {z}/{x}/{y} 占位符供 MapLibre 运行时替换。
|
||||||
|
*
|
||||||
|
* @param options - 源配置选项
|
||||||
|
* @returns 完整的瓦片 URL 模板字符串
|
||||||
|
*/
|
||||||
|
function buildTileUrlTemplate(options: TdtSourceOptions): string {
|
||||||
|
const {
|
||||||
|
url = TDT_DEFAULT_URL,
|
||||||
|
style = 'vec',
|
||||||
|
matrix = 'c',
|
||||||
|
tk = '',
|
||||||
|
} = options
|
||||||
|
|
||||||
|
let template = TDT_WMTS_TEMPLATE
|
||||||
|
.replace(/\{style\}/g, style)
|
||||||
|
.replace(/\{matrix\}/g, matrix)
|
||||||
|
|
||||||
|
// 拼接基础 URL
|
||||||
|
let fullUrl = url + template
|
||||||
|
|
||||||
|
// 追加 tk 参数(需编码)
|
||||||
|
if (tk) {
|
||||||
|
fullUrl += '&tk=' + encodeURIComponent(tk)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 公开 API
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成天地图源 ID
|
||||||
|
*
|
||||||
|
* 格式为 `tdt-{style}-{matrix}`,例如 `tdt-vec-c`。
|
||||||
|
*
|
||||||
|
* @param style - 图层样式
|
||||||
|
* @param matrix - 瓦片矩阵集
|
||||||
|
* @returns 源 ID 字符串
|
||||||
|
*/
|
||||||
|
export function getTdtSourceId(style: TdtStyle = 'vec', matrix: TdtMatrix = 'c'): string {
|
||||||
|
return `tdt-${style}-${matrix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成天地图图层 ID
|
||||||
|
*
|
||||||
|
* @param style - 图层样式
|
||||||
|
* @param matrix - 瓦片矩阵集
|
||||||
|
* @returns 图层 ID 字符串
|
||||||
|
*/
|
||||||
|
export function getTdtLayerId(style: TdtStyle = 'vec', matrix: TdtMatrix = 'c'): string {
|
||||||
|
return `tdt-layer-${style}-${matrix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建天地图栅格瓦片源与图层配置
|
||||||
|
*
|
||||||
|
* 返回 MapLibre GL 兼容的 source 和 layer 配置对象,
|
||||||
|
* 可用于 `map.addSource()` 和 `map.addLayer()`。
|
||||||
|
*
|
||||||
|
* @param options - 天地图源配置选项
|
||||||
|
* @returns 包含 source、layer 及 ID 的配置对象
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* import { createTdtSource } from '@/map/sources/tdt-source'
|
||||||
|
*
|
||||||
|
* // 创建矢量底图源
|
||||||
|
* const { sourceId, source, layer } = createTdtSource({
|
||||||
|
* style: 'vec',
|
||||||
|
* matrix: 'c',
|
||||||
|
* tk: 'your-tianditu-key',
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* map.addSource(sourceId, source)
|
||||||
|
* map.addLayer(layer)
|
||||||
|
*
|
||||||
|
* // 叠加矢量注记层
|
||||||
|
* const annotation = createTdtSource({
|
||||||
|
* style: 'cva',
|
||||||
|
* matrix: 'c',
|
||||||
|
* tk: 'your-tianditu-key',
|
||||||
|
* })
|
||||||
|
* map.addSource(annotation.sourceId, annotation.source)
|
||||||
|
* map.addLayer(annotation.layer)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createTdtSource(options: TdtSourceOptions = {}): TdtSourceResult {
|
||||||
|
const {
|
||||||
|
style = 'vec',
|
||||||
|
matrix = 'c',
|
||||||
|
subdomains = TDT_SUBDOMAINS,
|
||||||
|
minzoom = 0,
|
||||||
|
maxzoom = 18,
|
||||||
|
tileSize = 256,
|
||||||
|
layer: layerOptions = {},
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const sourceId = getTdtSourceId(style, matrix)
|
||||||
|
const layerId = getTdtLayerId(style, matrix)
|
||||||
|
const tileUrl = buildTileUrlTemplate(options)
|
||||||
|
const title = TDT_STYLE_NAMES[style] ?? style
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 构建源配置
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const source: RasterSourceSpecification = {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: [tileUrl],
|
||||||
|
tileSize,
|
||||||
|
minzoom,
|
||||||
|
maxzoom,
|
||||||
|
attribution:
|
||||||
|
'© <a href="https://www.tianditu.gov.cn" target="_blank">天地图</a>',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 构建图层配置
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const layer: RasterLayerSpecification = {
|
||||||
|
id: layerId,
|
||||||
|
type: 'raster',
|
||||||
|
source: sourceId,
|
||||||
|
minzoom,
|
||||||
|
maxzoom,
|
||||||
|
metadata: {
|
||||||
|
title,
|
||||||
|
style,
|
||||||
|
matrix,
|
||||||
|
type: 'TdtLayer',
|
||||||
|
},
|
||||||
|
...layerOptions,
|
||||||
|
} as RasterLayerSpecification
|
||||||
|
|
||||||
|
return { sourceId, source, layer }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建天地图底图+注记的完整图层组
|
||||||
|
*
|
||||||
|
* 同时创建底图图层(vec/img/ter)和对应的中文注记图层(cva/cia/cta),
|
||||||
|
* 注记图层会自动放置在底图图层之上。
|
||||||
|
*
|
||||||
|
* @param style - 底图样式(vec/img/ter),注记自动匹配
|
||||||
|
* @param options - 共享的源配置(tk、matrix 等)
|
||||||
|
* @returns 包含底图和注记两个 TdtSourceResult 的数组
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const [basemap, annotation] = createTdtLayerGroup('vec', { tk: 'your-key' })
|
||||||
|
* // 先加底图,再加注记(注记在上层)
|
||||||
|
* map.addSource(basemap.sourceId, basemap.source)
|
||||||
|
* map.addLayer(basemap.layer)
|
||||||
|
* map.addSource(annotation.sourceId, annotation.source)
|
||||||
|
* map.addLayer(annotation.layer)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createTdtLayerGroup(
|
||||||
|
style: 'vec' | 'img' | 'ter',
|
||||||
|
options: Omit<TdtSourceOptions, 'style'> = {}
|
||||||
|
): [TdtSourceResult, TdtSourceResult] {
|
||||||
|
const annotationStyleMap: Record<'vec' | 'img' | 'ter', TdtStyle> = {
|
||||||
|
vec: 'cva',
|
||||||
|
img: 'cia',
|
||||||
|
ter: 'cta',
|
||||||
|
}
|
||||||
|
|
||||||
|
const basemap = createTdtSource({ ...options, style })
|
||||||
|
const annotation = createTdtSource({
|
||||||
|
...options,
|
||||||
|
style: annotationStyleMap[style],
|
||||||
|
})
|
||||||
|
|
||||||
|
return [basemap, annotation]
|
||||||
|
}
|
||||||
296
src/style.css
Normal file
296
src/style.css
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
:root {
|
||||||
|
--text: #6b6375;
|
||||||
|
--text-h: #08060d;
|
||||||
|
--bg: #fff;
|
||||||
|
--border: #e5e4e7;
|
||||||
|
--code-bg: #f4f3ec;
|
||||||
|
--accent: #aa3bff;
|
||||||
|
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||||
|
--accent-border: rgba(170, 59, 255, 0.5);
|
||||||
|
--social-bg: rgba(244, 243, 236, 0.5);
|
||||||
|
--shadow:
|
||||||
|
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||||
|
|
||||||
|
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--mono: ui-monospace, Consolas, monospace;
|
||||||
|
|
||||||
|
font: 18px/145% var(--sans);
|
||||||
|
letter-spacing: 0.18px;
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--text: #9ca3af;
|
||||||
|
--text-h: #f3f4f6;
|
||||||
|
--bg: #16171d;
|
||||||
|
--border: #2e303a;
|
||||||
|
--code-bg: #1f2028;
|
||||||
|
--accent: #c084fc;
|
||||||
|
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||||
|
--accent-border: rgba(192, 132, 252, 0.5);
|
||||||
|
--social-bg: rgba(47, 48, 58, 0.5);
|
||||||
|
--shadow:
|
||||||
|
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#social .button-icon {
|
||||||
|
filter: invert(1) brightness(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2 {
|
||||||
|
font-family: var(--heading);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 56px;
|
||||||
|
letter-spacing: -1.68px;
|
||||||
|
margin: 32px 0;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 36px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 118%;
|
||||||
|
letter-spacing: -0.24px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
.counter {
|
||||||
|
font-family: var(--mono);
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 135%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.base,
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
inset-inline: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
width: 170px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework {
|
||||||
|
z-index: 1;
|
||||||
|
top: 34px;
|
||||||
|
height: 28px;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||||
|
scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vite {
|
||||||
|
z-index: 0;
|
||||||
|
top: 107px;
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||||
|
scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 1126px;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
border-inline: 1px solid var(--border);
|
||||||
|
min-height: 100svh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 32px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#docs {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 32px 0 0;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--social-bg);
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.button-icon {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1 1 calc(50% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spacer {
|
||||||
|
height: 88px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4.5px;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 0;
|
||||||
|
border-left-color: var(--border);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
right: 0;
|
||||||
|
border-right-color: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
291
src/styles/index.scss
Normal file
291
src/styles/index.scss
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
// ============================================================
|
||||||
|
// Yuto Water H5 — Global Styles
|
||||||
|
// Mobile-first · No Tailwind · Dark mode aware
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
@use './variables' as *;
|
||||||
|
|
||||||
|
// ── CSS Reset (minimal, mobile-focused) ────────────────────
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTML & Body ─────────────────────────────────────────────
|
||||||
|
html {
|
||||||
|
// Smooth scrolling
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
// Prevent text size adjustment on orientation change
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
// Font smoothing
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
// Prevent pull-to-refresh / overscroll bounce on iOS
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: $font-family-base;
|
||||||
|
font-size: $font-size-base;
|
||||||
|
font-weight: $font-weight-regular;
|
||||||
|
line-height: $line-height-normal;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: var(--color-bg-page);
|
||||||
|
// Mobile-first: full viewport width
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
// Safe area insets for notched devices
|
||||||
|
padding-top: $safe-area-inset-top;
|
||||||
|
padding-bottom: $safe-area-inset-bottom;
|
||||||
|
// Prevent horizontal overflow
|
||||||
|
overflow-x: hidden;
|
||||||
|
// Mobile tap highlight removal
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
// Improve touch responsiveness
|
||||||
|
touch-action: manipulation;
|
||||||
|
// Enable momentum scrolling on iOS
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Typography Defaults ─────────────────────────────────────
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-weight: $font-weight-semibold;
|
||||||
|
line-height: $line-height-tight;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: $font-size-xxxl; }
|
||||||
|
h2 { font-size: $font-size-xxl; }
|
||||||
|
h3 { font-size: $font-size-xl; }
|
||||||
|
h4 { font-size: $font-size-lg; }
|
||||||
|
h5 { font-size: $font-size-md; }
|
||||||
|
h6 { font-size: $font-size-base; }
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: $spacing-sm;
|
||||||
|
color: var(--color-text-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Form Elements ───────────────────────────────────────────
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select,
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
// iOS default styling reset
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
// Remove inner shadow on iOS inputs
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix iOS input zoom on focus (prevents zoom for font-size >= 16px on iOS)
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font-size: $font-size-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
touch-action: manipulation;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lists ───────────────────────────────────────────────────
|
||||||
|
ul, ol {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Images & Media ──────────────────────────────────────────
|
||||||
|
img,
|
||||||
|
video,
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
// Prevent image dragging
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Table defaults ──────────────────────────────────────────
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Focus Styles (accessibility) ────────────────────────────
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove focus outline for mouse/touch users (browser handles :focus-visible)
|
||||||
|
:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scrollbar Styling (WebKit) ──────────────────────────────
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border-dark);
|
||||||
|
border-radius: $radius-round;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-text-placeholder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Selection ───────────────────────────────────────────────
|
||||||
|
::selection {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utility: Safe Area Padding ──────────────────────────────
|
||||||
|
.safe-area-top {
|
||||||
|
padding-top: $safe-area-inset-top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.safe-area-bottom {
|
||||||
|
padding-bottom: $safe-area-inset-bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utility: Screen Reader Only ─────────────────────────────
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utility: Custom Scroll Container ────────────────────────
|
||||||
|
.scroll-container {
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page Wrapper (centered, max-width constrained) ──────────
|
||||||
|
.page-wrapper {
|
||||||
|
max-width: $max-width-mobile;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--color-bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Card Component Base ─────────────────────────────────────
|
||||||
|
.card {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: $spacing-md;
|
||||||
|
margin: $spacing-sm $spacing-md;
|
||||||
|
|
||||||
|
&--elevated {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--flat {
|
||||||
|
box-shadow: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Flex Helpers ────────────────────────────────────────────
|
||||||
|
.flex-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-between {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Text Helpers ────────────────────────────────────────────
|
||||||
|
.text-primary { color: var(--color-primary); }
|
||||||
|
.text-success { color: var(--color-success); }
|
||||||
|
.text-warning { color: var(--color-warning); }
|
||||||
|
.text-danger { color: var(--color-danger); }
|
||||||
|
.text-secondary { color: var(--color-text-secondary); }
|
||||||
|
.text-ellipsis {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Spacing Utility Classes ──────────────────────────────────
|
||||||
|
@each $size in (xxs, xs, sm, md, lg, xl, xxl, xxxl) {
|
||||||
|
.m-#{$size} { margin: var(--spacing-#{$size}); }
|
||||||
|
.mt-#{$size} { margin-top: var(--spacing-#{$size}); }
|
||||||
|
.mb-#{$size} { margin-bottom: var(--spacing-#{$size}); }
|
||||||
|
.ml-#{$size} { margin-left: var(--spacing-#{$size}); }
|
||||||
|
.mr-#{$size} { margin-right: var(--spacing-#{$size}); }
|
||||||
|
.mx-#{$size} { margin-left: var(--spacing-#{$size}); margin-right: var(--spacing-#{$size}); }
|
||||||
|
.my-#{$size} { margin-top: var(--spacing-#{$size}); margin-bottom: var(--spacing-#{$size}); }
|
||||||
|
|
||||||
|
.p-#{$size} { padding: var(--spacing-#{$size}); }
|
||||||
|
.pt-#{$size} { padding-top: var(--spacing-#{$size}); }
|
||||||
|
.pb-#{$size} { padding-bottom: var(--spacing-#{$size}); }
|
||||||
|
.pl-#{$size} { padding-left: var(--spacing-#{$size}); }
|
||||||
|
.pr-#{$size} { padding-right: var(--spacing-#{$size}); }
|
||||||
|
.px-#{$size} { padding-left: var(--spacing-#{$size}); padding-right: var(--spacing-#{$size}); }
|
||||||
|
.py-#{$size} { padding-top: var(--spacing-#{$size}); padding-bottom: var(--spacing-#{$size}); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dark Mode Transition (smooth theme switch) ──────────────
|
||||||
|
body,
|
||||||
|
body *,
|
||||||
|
body *::before,
|
||||||
|
body *::after {
|
||||||
|
transition:
|
||||||
|
background-color $transition-base,
|
||||||
|
border-color $transition-base,
|
||||||
|
color $transition-fast;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable transitions on page load (prevents flash)
|
||||||
|
.preload * {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
274
src/styles/variables.scss
Normal file
274
src/styles/variables.scss
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
// ============================================================
|
||||||
|
// Yuto Water H5 — Design Tokens
|
||||||
|
// Water-blue theme · Mobile-first · Dark mode ready
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// ── Brand Colors ────────────────────────────────────────────
|
||||||
|
$color-primary: #2196F3;
|
||||||
|
$color-primary-light: #64B5F6;
|
||||||
|
$color-primary-dark: #1976D2;
|
||||||
|
$color-primary-bg: #E3F2FD;
|
||||||
|
|
||||||
|
$color-success: #07C160;
|
||||||
|
$color-success-light: #69E09E;
|
||||||
|
$color-success-dark: #06AD56;
|
||||||
|
$color-success-bg: #E8F8EF;
|
||||||
|
|
||||||
|
$color-warning: #FF976A;
|
||||||
|
$color-warning-light: #FFB899;
|
||||||
|
$color-warning-dark: #ED7A4C;
|
||||||
|
$color-warning-bg: #FFF3ED;
|
||||||
|
|
||||||
|
$color-danger: #EE0A24;
|
||||||
|
$color-danger-light: #F56C6C;
|
||||||
|
$color-danger-dark: #D0091F;
|
||||||
|
$color-danger-bg: #FDECEC;
|
||||||
|
|
||||||
|
// ── Neutral Colors ──────────────────────────────────────────
|
||||||
|
$color-white: #FFFFFF;
|
||||||
|
$color-gray-1: #F7F8FA;
|
||||||
|
$color-gray-2: #F2F3F5;
|
||||||
|
$color-gray-3: #EBEDF0;
|
||||||
|
$color-gray-4: #DCDFE6;
|
||||||
|
$color-gray-5: #C8C9CC;
|
||||||
|
$color-gray-6: #969799;
|
||||||
|
$color-gray-7: #646566;
|
||||||
|
$color-gray-8: #323233;
|
||||||
|
|
||||||
|
$color-black: #1A1A1A;
|
||||||
|
|
||||||
|
// ── Text Colors ─────────────────────────────────────────────
|
||||||
|
$color-text-primary: $color-gray-8;
|
||||||
|
$color-text-regular: $color-gray-7;
|
||||||
|
$color-text-secondary: $color-gray-6;
|
||||||
|
$color-text-placeholder:$color-gray-5;
|
||||||
|
$color-text-inverse: $color-white;
|
||||||
|
|
||||||
|
// ── Background Colors ───────────────────────────────────────
|
||||||
|
$color-bg-page: $color-gray-1;
|
||||||
|
$color-bg-card: $color-white;
|
||||||
|
$color-bg-elevated: $color-white;
|
||||||
|
$color-bg-mask: rgba(0, 0, 0, 0.6);
|
||||||
|
|
||||||
|
// ── Border Colors ───────────────────────────────────────────
|
||||||
|
$color-border: $color-gray-3;
|
||||||
|
$color-border-light: $color-gray-2;
|
||||||
|
$color-border-dark: $color-gray-4;
|
||||||
|
|
||||||
|
// ── Font Family ─────────────────────────────────────────────
|
||||||
|
$font-family-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||||
|
'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue',
|
||||||
|
Helvetica, Arial, sans-serif;
|
||||||
|
$font-family-number: 'SF Mono', 'Menlo', 'Consolas', 'Courier New', monospace;
|
||||||
|
|
||||||
|
// ── Font Sizes (mobile-first modular scale) ────────────────
|
||||||
|
$font-size-xs: 10px;
|
||||||
|
$font-size-sm: 12px;
|
||||||
|
$font-size-base: 14px;
|
||||||
|
$font-size-md: 16px;
|
||||||
|
$font-size-lg: 18px;
|
||||||
|
$font-size-xl: 20px;
|
||||||
|
$font-size-xxl: 24px;
|
||||||
|
$font-size-xxxl: 30px;
|
||||||
|
|
||||||
|
// ── Font Weights ────────────────────────────────────────────
|
||||||
|
$font-weight-light: 300;
|
||||||
|
$font-weight-regular: 400;
|
||||||
|
$font-weight-medium: 500;
|
||||||
|
$font-weight-semibold: 600;
|
||||||
|
$font-weight-bold: 700;
|
||||||
|
|
||||||
|
// ── Line Heights ────────────────────────────────────────────
|
||||||
|
$line-height-tight: 1.2;
|
||||||
|
$line-height-normal: 1.4;
|
||||||
|
$line-height-relaxed: 1.6;
|
||||||
|
$line-height-loose: 1.8;
|
||||||
|
|
||||||
|
// ── Spacing Scale (4px base) ───────────────────────────────
|
||||||
|
$spacing-xxs: 4px;
|
||||||
|
$spacing-xs: 8px;
|
||||||
|
$spacing-sm: 12px;
|
||||||
|
$spacing-md: 16px;
|
||||||
|
$spacing-lg: 20px;
|
||||||
|
$spacing-xl: 24px;
|
||||||
|
$spacing-xxl: 32px;
|
||||||
|
$spacing-xxxl: 48px;
|
||||||
|
|
||||||
|
// ── Border Radius ───────────────────────────────────────────
|
||||||
|
$radius-sm: 4px;
|
||||||
|
$radius-md: 8px;
|
||||||
|
$radius-lg: 12px;
|
||||||
|
$radius-xl: 16px;
|
||||||
|
$radius-round: 999px;
|
||||||
|
$radius-circle: 50%;
|
||||||
|
|
||||||
|
// ── Shadows ─────────────────────────────────────────────────
|
||||||
|
$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
|
$shadow-md: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
$shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
$shadow-xl: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
|
||||||
|
// ── Z-Index Scale ───────────────────────────────────────────
|
||||||
|
$z-index-dropdown: 1000;
|
||||||
|
$z-index-sticky: 1020;
|
||||||
|
$z-index-fixed: 1030;
|
||||||
|
$z-index-modal-backdrop:1040;
|
||||||
|
$z-index-modal: 1050;
|
||||||
|
$z-index-popover: 1060;
|
||||||
|
$z-index-tooltip: 1070;
|
||||||
|
$z-index-toast: 1080;
|
||||||
|
|
||||||
|
// ── Transitions ─────────────────────────────────────────────
|
||||||
|
$transition-fast: 0.15s ease;
|
||||||
|
$transition-base: 0.3s ease;
|
||||||
|
$transition-slow: 0.5s ease;
|
||||||
|
|
||||||
|
// ── Layout ──────────────────────────────────────────────────
|
||||||
|
$max-width-mobile: 750px;
|
||||||
|
$safe-area-inset-top: env(safe-area-inset-top, 0px);
|
||||||
|
$safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// CSS Custom Properties (for runtime theme switching)
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
:root {
|
||||||
|
// Brand
|
||||||
|
--color-primary: #{$color-primary};
|
||||||
|
--color-primary-light: #{$color-primary-light};
|
||||||
|
--color-primary-dark: #{$color-primary-dark};
|
||||||
|
--color-primary-bg: #{$color-primary-bg};
|
||||||
|
|
||||||
|
--color-success: #{$color-success};
|
||||||
|
--color-success-light: #{$color-success-light};
|
||||||
|
--color-success-dark: #{$color-success-dark};
|
||||||
|
--color-success-bg: #{$color-success-bg};
|
||||||
|
|
||||||
|
--color-warning: #{$color-warning};
|
||||||
|
--color-warning-light: #{$color-warning-light};
|
||||||
|
--color-warning-dark: #{$color-warning-dark};
|
||||||
|
--color-warning-bg: #{$color-warning-bg};
|
||||||
|
|
||||||
|
--color-danger: #{$color-danger};
|
||||||
|
--color-danger-light: #{$color-danger-light};
|
||||||
|
--color-danger-dark: #{$color-danger-dark};
|
||||||
|
--color-danger-bg: #{$color-danger-bg};
|
||||||
|
|
||||||
|
// Text
|
||||||
|
--color-text-primary: #{$color-text-primary};
|
||||||
|
--color-text-regular: #{$color-text-regular};
|
||||||
|
--color-text-secondary: #{$color-text-secondary};
|
||||||
|
--color-text-placeholder: #{$color-text-placeholder};
|
||||||
|
--color-text-inverse: #{$color-text-inverse};
|
||||||
|
|
||||||
|
// Backgrounds
|
||||||
|
--color-bg-page: #{$color-bg-page};
|
||||||
|
--color-bg-card: #{$color-bg-card};
|
||||||
|
--color-bg-elevated: #{$color-bg-elevated};
|
||||||
|
--color-bg-mask: #{$color-bg-mask};
|
||||||
|
|
||||||
|
// Borders
|
||||||
|
--color-border: #{$color-border};
|
||||||
|
--color-border-light: #{$color-border-light};
|
||||||
|
--color-border-dark: #{$color-border-dark};
|
||||||
|
|
||||||
|
// Shadows
|
||||||
|
--shadow-sm: #{$shadow-sm};
|
||||||
|
--shadow-md: #{$shadow-md};
|
||||||
|
--shadow-lg: #{$shadow-lg};
|
||||||
|
--shadow-xl: #{$shadow-xl};
|
||||||
|
|
||||||
|
// Radii
|
||||||
|
--radius-sm: #{$radius-sm};
|
||||||
|
--radius-md: #{$radius-md};
|
||||||
|
--radius-lg: #{$radius-lg};
|
||||||
|
--radius-xl: #{$radius-xl};
|
||||||
|
--radius-round: #{$radius-round};
|
||||||
|
|
||||||
|
// Spacing
|
||||||
|
--spacing-xxs: #{$spacing-xxs};
|
||||||
|
--spacing-xs: #{$spacing-xs};
|
||||||
|
--spacing-sm: #{$spacing-sm};
|
||||||
|
--spacing-md: #{$spacing-md};
|
||||||
|
--spacing-lg: #{$spacing-lg};
|
||||||
|
--spacing-xl: #{$spacing-xl};
|
||||||
|
--spacing-xxl: #{$spacing-xxl};
|
||||||
|
--spacing-xxxl: #{$spacing-xxxl};
|
||||||
|
|
||||||
|
// Font
|
||||||
|
--font-size-xs: #{$font-size-xs};
|
||||||
|
--font-size-sm: #{$font-size-sm};
|
||||||
|
--font-size-base: #{$font-size-base};
|
||||||
|
--font-size-md: #{$font-size-md};
|
||||||
|
--font-size-lg: #{$font-size-lg};
|
||||||
|
--font-size-xl: #{$font-size-xl};
|
||||||
|
--font-size-xxl: #{$font-size-xxl};
|
||||||
|
--font-size-xxxl: #{$font-size-xxxl};
|
||||||
|
|
||||||
|
// Transitions
|
||||||
|
--transition-fast: #{$transition-fast};
|
||||||
|
--transition-base: #{$transition-base};
|
||||||
|
--transition-slow: #{$transition-slow};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dark Mode ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Dark theme variable mapping (defined first so it can be @included below)
|
||||||
|
@mixin dark-theme-vars {
|
||||||
|
// Brand — slightly softened for dark backgrounds
|
||||||
|
--color-primary: #42A5F5;
|
||||||
|
--color-primary-light: #90CAF9;
|
||||||
|
--color-primary-dark: #1E88E5;
|
||||||
|
--color-primary-bg: #0D2137;
|
||||||
|
|
||||||
|
--color-success: #07C160;
|
||||||
|
--color-success-light: #69E09E;
|
||||||
|
--color-success-dark: #06AD56;
|
||||||
|
--color-success-bg: #0D2818;
|
||||||
|
|
||||||
|
--color-warning: #FF976A;
|
||||||
|
--color-warning-light: #FFB899;
|
||||||
|
--color-warning-dark: #ED7A4C;
|
||||||
|
--color-warning-bg: #2B1A10;
|
||||||
|
|
||||||
|
--color-danger: #EE0A24;
|
||||||
|
--color-danger-light: #F56C6C;
|
||||||
|
--color-danger-dark: #D0091F;
|
||||||
|
--color-danger-bg: #2B0D10;
|
||||||
|
|
||||||
|
// Text — inverted hierarchy
|
||||||
|
--color-text-primary: #E5E5E5;
|
||||||
|
--color-text-regular: #B3B3B3;
|
||||||
|
--color-text-secondary: #8C8C8C;
|
||||||
|
--color-text-placeholder: #666666;
|
||||||
|
--color-text-inverse: #1A1A1A;
|
||||||
|
|
||||||
|
// Backgrounds
|
||||||
|
--color-bg-page: #0F0F0F;
|
||||||
|
--color-bg-card: #1A1A1A;
|
||||||
|
--color-bg-elevated: #242424;
|
||||||
|
--color-bg-mask: rgba(0, 0, 0, 0.7);
|
||||||
|
|
||||||
|
// Borders
|
||||||
|
--color-border: #2E2E2E;
|
||||||
|
--color-border-light: #242424;
|
||||||
|
--color-border-dark: #3A3A3A;
|
||||||
|
|
||||||
|
// Shadows — darker for dark mode
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||||
|
--shadow-xl: 0 8px 24px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) System preference (auto)
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme='light']) {
|
||||||
|
@include dark-theme-vars;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Explicit [data-theme='dark'] override
|
||||||
|
[data-theme='dark'] {
|
||||||
|
@include dark-theme-vars;
|
||||||
|
}
|
||||||
12
src/vite-env.d.ts
vendored
Normal file
12
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.scss' {
|
||||||
|
const content: string
|
||||||
|
export default content
|
||||||
|
}
|
||||||
14
tsconfig.app.json
Normal file
14
tsconfig.app.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"baseUrl": ".",
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
24
tsconfig.node.json
Normal file
24
tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
41
vite.config.ts
Normal file
41
vite.config.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [vue()],
|
||||||
|
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
additionalData: `@use "@/styles/variables.scss" as *;`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: Number(env.VITE_PORT) || 5173,
|
||||||
|
hmr: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
'/dev-api': {
|
||||||
|
target: 'http://10.10.10.189:81/icp-api',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/dev-api/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user