|
|
|
|
|
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, '&')
|
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 {}
|