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:
2026-06-15 20:46:11 +08:00
commit 74cc0df2b8
33 changed files with 9720 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
.env.local
*.log
.DS_Store
tsconfig.tsbuildinfo

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>yuto-water-h5</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

5069
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "yuto-water-h5",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@turf/turf": "^7.3.5",
"axios": "^1.18.0",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.21",
"echarts": "^6.1.0",
"encryptlong": "^3.1.4",
"maplibre-gl": "^5.24.0",
"mitt": "^3.0.1",
"pinia": "^3.0.4",
"supercluster": "^8.0.1",
"uuid": "^14.0.0",
"vant": "^4.9.24",
"vue": "^3.5.34"
},
"devDependencies": {
"@types/node": "^24.12.3",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1",
"sass-embedded": "^1.100.0",
"typescript": "~6.0.2",
"vite": "^8.0.12",
"vue-tsc": "^3.2.8"
}
}

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

7
src/App.vue Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

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
View 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
View 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
View 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
}
}

View 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
View 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>
}

View 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
View 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
View File

@@ -0,0 +1,691 @@
/**
* LayerFactory - 旧版 GeoScene 图层配置 → MapLibre 样式规范转换器
*
* 将原 GeoScene 项目中 jsonToLayerFactory.js 的图层配置对象
* 转换为 maplibregl 兼容的 style specsource + 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
View 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:
'&copy; <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
View 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()
}
}

View 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 为图层 IDvalue 为 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,
}
}

View 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
}

View 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 变为 falsepopup 变为 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
}

View 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:
'&copy; <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
View 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
View 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
View 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
View File

@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '*.scss' {
const content: string
export default content
}

14
tsconfig.app.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"erasableSyntaxOnly": true,
"skipLibCheck": true,
"paths": {
"@/*": ["./src/*"]
},
"baseUrl": ".",
"ignoreDeprecations": "6.0",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"resolveJsonModule": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["node_modules", "dist"]
}

24
tsconfig.node.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

41
vite.config.ts Normal file
View File

@@ -0,0 +1,41 @@
import { resolve } from 'node:path'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/styles/variables.scss" as *;`,
},
},
},
server: {
host: '0.0.0.0',
port: Number(env.VITE_PORT) || 5173,
hmr: {
host: '0.0.0.0',
},
proxy: {
'/dev-api': {
target: 'http://10.10.10.189:81/icp-api',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/dev-api/, ''),
},
},
},
}
})