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) => 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 | 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): 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 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)[method] if (typeof fn === 'function') { ;(fn as () => void).call(map) } } /** 创建天地图实例。使用 SDK 默认底图,避免自定义瓦片图层影响拖拽/初始化。 */ export function createTiandituMap( T: NonNullable, 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 { 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((resolve) => requestAnimationFrame(() => resolve())) } return false } /** 重新计算尺寸并刷新视野,修复弹窗内瓦片不渲染 */ export function refreshMapView( map: TiandituMap, T: NonNullable, 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('[class*="tdt-"][class*="pane"]').forEach((pane) => { if (MAP_PASSTHROUGH_PANE_CLASS_RE.test(pane.className)) { pane.style.pointerEvents = 'none' } }) root.querySelectorAll('.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, 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> | 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> { 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, '&') .replace(//g, '>') .replace(/"/g, '"') } function getSchoolMapOverlayClass(T: NonNullable) { 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 = '' + `${escapeMapLabelHtml(this.options.name.trim() || '—')}` 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) as new ( lnglat: TiandituLngLat, options: SchoolMapOverlayOptions, ) => TiandituSchoolOverlay return schoolMapOverlayClass } /** 圆点 + 校名一体覆盖物(flex 横排,与原型一致) */ export function createSchoolMapOverlay( T: NonNullable, 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 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, 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, 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 {}