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.

367 lines
11 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) => 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
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 TiandituPoint {
x: number
y: number
}
export interface TiandituMapClickEvent {
lnglat?: TiandituLngLat
}
export interface TiandituMap {
centerAndZoom(lnglat: TiandituLngLat, zoom: number): void
enableScrollWheelZoom(): void
addOverLay(overlay: TiandituMarker | TiandituSchoolOverlay): void
removeOverLay(overlay: TiandituMarker | TiandituSchoolOverlay): void
setViewport?(points: TiandituLngLat[]): void
clearOverLays?(): void
addEventListener?(type: string, handler: (e?: TiandituMapClickEvent) => void): void
removeEventListener?(type: string, handler: (e?: TiandituMapClickEvent) => void): void
lngLatToLayerPoint?(lnglat: TiandituLngLat): TiandituPoint
getPanes?(): { overlayPane: HTMLElement }
checkResize?(): void
destroy?(): void
}
/** 弹窗内地图在容器尺寸变化后需重算布局,避免瓦片错位到页面顶部 */
export function invalidateMapSize(map: TiandituMap) {
map.checkResize?.()
}
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 = 'cc3f61fa6cafe2f0ad440018d0f07b13'
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.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
map.getPanes?.().overlayPane.appendChild(div)
this._onMapChange = () => 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 {}