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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user