You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

614 lines
18 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

declare global {
interface Window {
T?: {
Map: new (
container: HTMLElement | string,
options?: { layers?: TiandituTileLayer[] },
) => TiandituMap
LngLat: new (lng: number, lat: number) => TiandituLngLat
Marker: new (lnglat: TiandituLngLat, options?: { icon?: TiandituIcon }) => TiandituMarker
Icon: new (options: {
iconUrl: string
iconSize: TiandituPoint
iconAnchor?: TiandituPoint
}) => TiandituIcon
Point: new (x: number, y: number) => TiandituPoint
TileLayer?: new (
url: string,
options?: { minZoom?: number; maxZoom?: number },
) => TiandituTileLayer
Label: new (options: {
text: string
position: TiandituLngLat
offset?: TiandituPoint
}) => TiandituLabel
Overlay: {
extend: (proto: Record<string, unknown>) => new (
lnglat: TiandituLngLat,
options: SchoolMapOverlayOptions,
) => TiandituSchoolOverlay
}
LocalSearch: new (
map: TiandituMap,
config: TiandituLocalSearchConfig,
) => TiandituLocalSearch
}
}
}
export interface TiandituSearchPoi {
name: string
address?: string
lonlat?: string
lon?: number | string
lng?: number | string
lat?: number | string
}
export interface TiandituLocalSearchResult {
getResultType(): number | string
getPois(): TiandituSearchPoi[] | Record<string, TiandituSearchPoi> | false | null
}
export interface TiandituLocalSearchConfig {
pageCapacity?: number
onSearchComplete?: (result: TiandituLocalSearchResult) => void
}
export interface TiandituLocalSearch {
search(keyword: string): void
}
export interface SchoolMapOverlayOptions {
name: string
active?: boolean
}
export interface TiandituLngLat {
lng: number
lat: number
}
export interface TiandituMarker {
addEventListener(type: string, handler: () => void): void
}
export interface TiandituLabel {
addEventListener?(type: string, handler: () => void): void
setStyle?(style: Record<string, string>): void
getElement?(): HTMLElement | null
}
export interface TiandituSchoolOverlay {
addEventListener(type: string, handler: () => void): void
setActive?(active: boolean): void
}
export interface TiandituIcon {
// marker icon handle
}
export interface TiandituTileLayer {
// tile layer handle
}
export interface TiandituPoint {
x: number
y: number
}
export interface TiandituMapClickEvent {
lnglat?: TiandituLngLat
containerPoint?: TiandituPoint
}
export interface TiandituMap {
centerAndZoom(lnglat: TiandituLngLat, zoom: number): void
enableScrollWheelZoom(): void
enableDrag?(): void
disableDrag?(): void
enableAutoResize?(): void
panBy?(position: TiandituPoint): void
addOverLay(overlay: TiandituMarker | TiandituLabel | TiandituSchoolOverlay): void
removeOverLay(overlay: TiandituMarker | TiandituLabel | TiandituSchoolOverlay): void
setViewport?(points: TiandituLngLat[]): void
clearOverLays?(): void
addLayer?(layer: TiandituTileLayer): void
addEventListener?(type: string, handler: (e?: TiandituMapClickEvent) => void): void
removeEventListener?(type: string, handler: (e?: TiandituMapClickEvent) => void): void
lngLatToLayerPoint?(lnglat: TiandituLngLat): TiandituPoint
lngLatToContainerPoint?(lnglat: TiandituLngLat): TiandituPoint
getPanes?(): Record<string, HTMLElement>
getContainer?(): HTMLElement
checkResize?(): void
destroy?(): void
}
const MAP_INTERACTIVE_PANE_KEYS = new Set(['mapPane', 'tilePane', 'floatPane'])
const MAP_PASSTHROUGH_PANE_CLASS_RE =
/tdt-overlay-pane|tdt-marker-pane|tdt-tooltip-pane|tdt-popup-pane|tdt-shadow-pane/i
function invokeMapMethod(map: TiandituMap, method: string) {
const fn = (map as unknown as Record<string, unknown>)[method]
if (typeof fn === 'function') {
;(fn as () => void).call(map)
}
}
/** 创建天地图实例。使用 SDK 默认底图,避免自定义瓦片图层影响拖拽/初始化。 */
export function createTiandituMap(
T: NonNullable<typeof window.T>,
container: HTMLElement | string,
): TiandituMap {
return new T.Map(container)
}
/** 弹窗内地图在容器尺寸变化后需重算布局,避免瓦片错位到页面顶部 */
export function invalidateMapSize(map: TiandituMap) {
map.checkResize?.()
}
/** 等待地图容器具备有效宽高(弹窗动画期间常为 0 */
export async function waitForMapContainerReady(
container: HTMLElement,
timeoutMs = 5000,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const rect = container.getBoundingClientRect()
if (rect.width > 0 && rect.height > 0) return true
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
return false
}
/** 重新计算尺寸并刷新视野,修复弹窗内瓦片不渲染 */
export function refreshMapView(
map: TiandituMap,
T: NonNullable<typeof window.T>,
center: { lng: number; lat: number },
zoom: number,
) {
invalidateMapSize(map)
map.centerAndZoom(new T.LngLat(center.lng, center.lat), zoom)
scheduleMapResize(map, [50, 200, 500])
}
/** 容器尺寸变化时自动触发 checkResize弹窗、侧栏展开等场景 */
export function bindMapResizeObserver(
container: HTMLElement,
getMap: () => TiandituMap | null,
): () => void {
if (typeof ResizeObserver === 'undefined') {
return () => {}
}
const observer = new ResizeObserver(() => {
const map = getMap()
if (map) invalidateMapSize(map)
})
observer.observe(container)
return () => observer.disconnect()
}
/** 多次延迟触发 checkResize覆盖弹窗动画结束后的布局 */
export function scheduleMapResize(map: TiandituMap, delays = [0, 80, 240, 480, 800]) {
for (const delay of delays) {
window.setTimeout(() => invalidateMapSize(map), delay)
}
}
/**
* 覆盖物层不拦截鼠标,仅可交互标记响应点击。
* SDK 的 getPanes 与 DOM class 双路径设置,避免类名不一致导致拖拽失效。
*/
export function applyOverlayPassthrough(map: TiandituMap) {
const panes = map.getPanes?.()
if (panes) {
for (const [key, pane] of Object.entries(panes)) {
if (!pane || MAP_INTERACTIVE_PANE_KEYS.has(key)) continue
pane.style.pointerEvents = 'none'
}
}
const root = map.getContainer?.()
if (root) {
root.querySelectorAll<HTMLElement>('[class*="tdt-"][class*="pane"]').forEach((pane) => {
if (MAP_PASSTHROUGH_PANE_CLASS_RE.test(pane.className)) {
pane.style.pointerEvents = 'none'
}
})
root.querySelectorAll<HTMLElement>('.slake-map-school-marker').forEach((marker) => {
marker.style.pointerEvents = 'auto'
})
}
}
/** 启用滚轮缩放与拖拽,并让覆盖物层不挡住地图 */
export function configureMapInteraction(map: TiandituMap) {
invokeMapMethod(map, 'enableScrollWheelZoom')
invokeMapMethod(map, 'enableDrag')
invokeMapMethod(map, 'enableInertia')
invokeMapMethod(map, 'enableAutoResize')
const setOptions = (map as TiandituMap & { setOptions?: (opts: { drag?: boolean }) => void })
.setOptions
setOptions?.({ drag: true })
}
/**
* 自定义 HTML 覆盖物会挡住 SDK 内置拖拽时,改用手动 panBy 平移。
* 会关闭 SDK 拖拽,避免与手动平移冲突。
*/
export function bindMapDragPan(
map: TiandituMap,
T: NonNullable<typeof window.T>,
options: { ignoreSelector?: string } = {},
): () => void {
const container = map.getContainer?.()
if (!container || !map.panBy) return () => {}
const root: HTMLElement = container
invokeMapMethod(map, 'disableDrag')
const ignoreSelector =
options.ignoreSelector ?? '.slake-map-school-marker, .slake-map-school-marker *'
let panning = false
let lastX = 0
let lastY = 0
let pendingDx = 0
let pendingDy = 0
let panRaf = 0
function shouldIgnore(target: EventTarget | null) {
return target instanceof Element && Boolean(target.closest(ignoreSelector))
}
function startPan(clientX: number, clientY: number) {
panning = true
lastX = clientX
lastY = clientY
root.style.cursor = 'grabbing'
}
function movePan(clientX: number, clientY: number) {
if (!panning) return
const dx = clientX - lastX
const dy = clientY - lastY
if (!dx && !dy) return
lastX = clientX
lastY = clientY
pendingDx += dx
pendingDy += dy
if (panRaf) return
panRaf = requestAnimationFrame(() => {
panRaf = 0
if (!pendingDx && !pendingDy) return
map.panBy?.(new T.Point(-pendingDx, -pendingDy))
pendingDx = 0
pendingDy = 0
})
}
function endPan() {
if (!panning) return
panning = false
root.style.cursor = 'grab'
}
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0 || shouldIgnore(e.target)) return
startPan(e.clientX, e.clientY)
e.preventDefault()
}
const onMouseMove = (e: MouseEvent) => {
movePan(e.clientX, e.clientY)
}
const onMouseUp = () => endPan()
const onTouchStart = (e: TouchEvent) => {
if (shouldIgnore(e.target) || e.touches.length !== 1) return
startPan(e.touches[0].clientX, e.touches[0].clientY)
}
const onTouchMove = (e: TouchEvent) => {
if (!panning || e.touches.length !== 1) return
movePan(e.touches[0].clientX, e.touches[0].clientY)
e.preventDefault()
}
const onTouchEnd = () => endPan()
root.style.cursor = 'grab'
root.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
root.addEventListener('touchstart', onTouchStart, { passive: false })
root.addEventListener('touchmove', onTouchMove, { passive: false })
root.addEventListener('touchend', onTouchEnd)
root.addEventListener('touchcancel', onTouchEnd)
return () => {
if (panRaf) {
cancelAnimationFrame(panRaf)
panRaf = 0
}
root.removeEventListener('mousedown', onMouseDown)
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
root.removeEventListener('touchstart', onTouchStart)
root.removeEventListener('touchmove', onTouchMove)
root.removeEventListener('touchend', onTouchEnd)
root.removeEventListener('touchcancel', onTouchEnd)
root.style.cursor = ''
}
}
interface SchoolMapOverlayInstance {
lnglat: TiandituLngLat
options: SchoolMapOverlayOptions
map: TiandituMap | null
_div: HTMLDivElement | null
_onMapChange: (() => void) | null
_listeners: { type: string; handler: () => void }[]
initialize(lnglat: TiandituLngLat, options: SchoolMapOverlayOptions): void
onAdd(map: TiandituMap): void
onRemove(): void
update(): void
setActive(active: boolean): void
addEventListener(type: string, handler: () => void): void
}
let loadPromise: Promise<NonNullable<typeof window.T>> | null = null
let schoolMapOverlayClass: (new (
lnglat: TiandituLngLat,
options: SchoolMapOverlayOptions,
) => TiandituSchoolOverlay) | null = null
/** 天地图浏览器端 Key国家测绘 */
const DEFAULT_TIANDITU_TK = 'c0e64d52ae7934dfd448c59953559c40'
export function getTiandituKey(): string {
return import.meta.env.VITE_TIANDITU_TK?.trim() || DEFAULT_TIANDITU_TK
}
export function loadTianditu(): Promise<NonNullable<typeof window.T>> {
if (window.T) {
return Promise.resolve(window.T)
}
if (!loadPromise) {
loadPromise = new Promise((resolve, reject) => {
const tk = getTiandituKey()
if (!tk) {
reject(new Error('未配置 VITE_TIANDITU_TK'))
return
}
const script = document.createElement('script')
script.src = `https://api.tianditu.gov.cn/api?v=4.0&tk=${encodeURIComponent(tk)}`
script.async = true
script.onload = () => {
if (window.T) {
resolve(window.T)
} else {
reject(new Error('天地图 SDK 加载失败'))
}
}
script.onerror = () => reject(new Error('天地图脚本加载失败'))
document.head.appendChild(script)
})
}
return loadPromise
}
/** 地图默认中心:苏州市 */
export const SUZHOU_MAP_CENTER = { lng: 120.585316, lat: 31.298886 }
/** 默认缩放:以苏州为中心,首屏仅展示苏州市域 */
export const SUZHOU_MAP_ZOOM = 11
function escapeMapLabelHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function getSchoolMapOverlayClass(T: NonNullable<typeof window.T>) {
if (schoolMapOverlayClass) return schoolMapOverlayClass
const proto: SchoolMapOverlayInstance = {
lnglat: { lng: 0, lat: 0 },
options: { name: '' },
map: null,
_div: null,
_onMapChange: null,
_listeners: [],
initialize(lnglat, options) {
this.lnglat = lnglat
this.options = options || { name: '' }
this._listeners = []
},
onAdd(map) {
this.map = map
const div = document.createElement('div')
div.className = 'slake-map-school-marker'
if (this.options.active) div.classList.add('is-active')
div.setAttribute('role', 'button')
div.setAttribute('tabindex', '0')
div.style.pointerEvents = 'auto'
div.innerHTML =
'<span class="slake-map-school-dot" aria-hidden="true"></span>' +
`<span class="slake-map-school-label">${escapeMapLabelHtml(this.options.name.trim() || '—')}</span>`
this._div = div
const panes = map.getPanes?.()
const overlayPane = panes?.overlayPane ?? panes?.markerPane
if (overlayPane) overlayPane.appendChild(div)
let moveRaf = 0
this._onMapChange = () => {
if (moveRaf) return
moveRaf = requestAnimationFrame(() => {
moveRaf = 0
this.update()
})
}
map.addEventListener?.('move', this._onMapChange)
map.addEventListener?.('zoomend', this._onMapChange)
this.update()
for (const { type, handler } of this._listeners) {
this._div?.addEventListener(type, handler)
}
},
onRemove() {
const map = this.map
if (map && this._onMapChange) {
map.removeEventListener?.('move', this._onMapChange)
map.removeEventListener?.('zoomend', this._onMapChange)
}
if (this._div?.parentNode) {
this._div.parentNode.removeChild(this._div)
}
this.map = null
this._div = null
},
update() {
const map = this.map
if (!map?.lngLatToLayerPoint || !this._div) return
const pos = map.lngLatToLayerPoint(this.lnglat)
this._div.style.left = `${pos.x}px`
this._div.style.top = `${pos.y}px`
},
setActive(active) {
this.options.active = active
this._div?.classList.toggle('is-active', active)
},
addEventListener(type, handler) {
if (this._div) {
this._div.addEventListener(type, handler)
return
}
this._listeners.push({ type, handler })
},
}
schoolMapOverlayClass = T.Overlay.extend(proto as unknown as Record<string, unknown>) as new (
lnglat: TiandituLngLat,
options: SchoolMapOverlayOptions,
) => TiandituSchoolOverlay
return schoolMapOverlayClass
}
/** 圆点 + 校名一体覆盖物flex 横排,与原型一致) */
export function createSchoolMapOverlay(
T: NonNullable<typeof window.T>,
school: { name: string; longitude: number; latitude: number },
active = false,
): TiandituSchoolOverlay {
const OverlayClass = getSchoolMapOverlayClass(T)
return new OverlayClass(new T.LngLat(school.longitude, school.latitude), {
name: school.name,
active,
})
}
export function shortenSchoolMapLabel(name: string, max = 12): string {
const t = name.trim()
if (!t) return '—'
return t.length > max ? `${t.slice(0, max)}` : t
}
function parseLonlatPair(first: unknown, second: unknown): { lng: number; lat: number } | null {
let lng = Number(first)
let lat = Number(second)
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null
// 部分结果可能为 "纬度,经度",按中国范围自动纠正
if (lng <= 60 && lat >= 70) {
;[lng, lat] = [lat, lng]
}
return { lng, lat }
}
/** 解析天地图 POI 坐标(支持 "经度 纬度"、"经度,纬度" 及 lon/lat 字段) */
export function parseTiandituPoiLonlat(
poi: TiandituSearchPoi | string,
): { lng: number; lat: number } | null {
if (typeof poi === 'string') {
const text = poi.trim()
if (!text) return null
const parts = text.split(/[\s,;]+/).filter(Boolean)
if (parts.length < 2) return null
return parseLonlatPair(parts[0], parts[1])
}
const extra = poi as unknown as Record<string, unknown>
const rawLonlat = poi.lonlat ?? extra.LonLat ?? extra.lonLat
if (typeof rawLonlat === 'string' && rawLonlat.trim()) {
const fromLonlat = parseTiandituPoiLonlat(rawLonlat)
if (fromLonlat) return fromLonlat
}
const lng = poi.lng ?? poi.lon ?? extra.Lon
const lat = poi.lat ?? extra.Lat
if (lng != null && lat != null) {
return parseLonlatPair(lng, lat)
}
return null
}
/** 规范化 LocalSearch 返回的 POI 列表 */
export function normalizeTiandituPois(pois: unknown): TiandituSearchPoi[] {
if (!pois) return []
if (Array.isArray(pois)) return pois
if (typeof pois === 'object') return Object.values(pois)
return []
}
/** 高校雷达网地图:固定以苏州市为中心展示 */
export function centerMapOnSuzhou(
map: TiandituMap,
T: NonNullable<typeof window.T>,
zoom = SUZHOU_MAP_ZOOM,
) {
map.centerAndZoom(new T.LngLat(SUZHOU_MAP_CENTER.lng, SUZHOU_MAP_CENTER.lat), zoom)
}
export function fitMapToSchools(
map: TiandituMap,
T: NonNullable<typeof window.T>,
schools: { latitude: number; longitude: number }[],
) {
if (!schools.length) return
const points = schools.map((s) => new T.LngLat(s.longitude, s.latitude))
if (schools.length === 1) {
map.centerAndZoom(points[0], SUZHOU_MAP_ZOOM)
return
}
if (typeof map.setViewport === 'function') {
map.setViewport(points)
return
}
const lngs = schools.map((s) => s.longitude)
const lats = schools.map((s) => s.latitude)
const center = new T.LngLat(
(Math.min(...lngs) + Math.max(...lngs)) / 2,
(Math.min(...lats) + Math.max(...lats)) / 2,
)
map.centerAndZoom(center, SUZHOU_MAP_ZOOM)
}
export {}