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

3 months ago
declare global {
interface Window {
T?: {
2 months ago
Map: new (
container: HTMLElement | string,
options?: { layers?: TiandituTileLayer[] },
) => TiandituMap
3 months ago
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
2 months ago
TileLayer?: new (
url: string,
options?: { minZoom?: number; maxZoom?: number },
) => TiandituTileLayer
3 months ago
Label: new (options: {
text: string
position: TiandituLngLat
offset?: TiandituPoint
}) => TiandituLabel
Overlay: {
extend: (proto: Record<string, unknown>) => new (
lnglat: TiandituLngLat,
options: SchoolMapOverlayOptions,
) => TiandituSchoolOverlay
}
2 months ago
LocalSearch: new (
map: TiandituMap,
config: TiandituLocalSearchConfig,
) => TiandituLocalSearch
3 months ago
}
}
}
2 months ago
export interface TiandituSearchPoi {
name: string
address?: string
2 months ago
lonlat?: string
lon?: number | string
lng?: number | string
lat?: number | string
2 months ago
}
export interface TiandituLocalSearchResult {
2 months ago
getResultType(): number | string
getPois(): TiandituSearchPoi[] | Record<string, TiandituSearchPoi> | false | null
2 months ago
}
export interface TiandituLocalSearchConfig {
pageCapacity?: number
onSearchComplete?: (result: TiandituLocalSearchResult) => void
}
export interface TiandituLocalSearch {
search(keyword: string): void
}
3 months ago
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
}
2 months ago
export interface TiandituTileLayer {
// tile layer handle
}
3 months ago
export interface TiandituPoint {
x: number
y: number
}
2 months ago
export interface TiandituMapClickEvent {
lnglat?: TiandituLngLat
2 months ago
containerPoint?: TiandituPoint
2 months ago
}
3 months ago
export interface TiandituMap {
centerAndZoom(lnglat: TiandituLngLat, zoom: number): void
enableScrollWheelZoom(): void
2 months ago
enableDrag?(): void
disableDrag?(): void
enableAutoResize?(): void
panBy?(position: TiandituPoint): void
addOverLay(overlay: TiandituMarker | TiandituLabel | TiandituSchoolOverlay): void
removeOverLay(overlay: TiandituMarker | TiandituLabel | TiandituSchoolOverlay): void
3 months ago
setViewport?(points: TiandituLngLat[]): void
clearOverLays?(): void
2 months ago
addLayer?(layer: TiandituTileLayer): void
2 months ago
addEventListener?(type: string, handler: (e?: TiandituMapClickEvent) => void): void
removeEventListener?(type: string, handler: (e?: TiandituMapClickEvent) => void): void
3 months ago
lngLatToLayerPoint?(lnglat: TiandituLngLat): TiandituPoint
2 months ago
lngLatToContainerPoint?(lnglat: TiandituLngLat): TiandituPoint
getPanes?(): Record<string, HTMLElement>
getContainer?(): HTMLElement
2 months ago
checkResize?(): void
destroy?(): void
}
2 months ago
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)
}
2 months ago
/** 弹窗内地图在容器尺寸变化后需重算布局,避免瓦片错位到页面顶部 */
export function invalidateMapSize(map: TiandituMap) {
map.checkResize?.()
3 months ago
}
2 months ago
/** 等待地图容器具备有效宽高(弹窗动画期间常为 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 = ''
}
}
2 months ago
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
}
3 months ago
let loadPromise: Promise<NonNullable<typeof window.T>> | null = null
let schoolMapOverlayClass: (new (
lnglat: TiandituLngLat,
options: SchoolMapOverlayOptions,
) => TiandituSchoolOverlay) | null = null
/** 天地图浏览器端 Key国家测绘 */
7 days ago
const DEFAULT_TIANDITU_TK = 'c0e64d52ae7934dfd448c59953559c40'
3 months ago
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
2 months ago
const proto: SchoolMapOverlayInstance = {
lnglat: { lng: 0, lat: 0 },
options: { name: '' },
map: null,
_div: null,
_onMapChange: null,
_listeners: [],
initialize(lnglat, options) {
3 months ago
this.lnglat = lnglat
this.options = options || { name: '' }
2 months ago
this._listeners = []
3 months ago
},
2 months ago
onAdd(map) {
3 months ago
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')
2 months ago
div.style.pointerEvents = 'auto'
3 months ago
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
2 months ago
const panes = map.getPanes?.()
const overlayPane = panes?.overlayPane ?? panes?.markerPane
if (overlayPane) overlayPane.appendChild(div)
3 months ago
2 months ago
let moveRaf = 0
this._onMapChange = () => {
if (moveRaf) return
moveRaf = requestAnimationFrame(() => {
moveRaf = 0
this.update()
})
}
3 months ago
map.addEventListener?.('move', this._onMapChange)
map.addEventListener?.('zoomend', this._onMapChange)
this.update()
2 months ago
for (const { type, handler } of this._listeners) {
this._div?.addEventListener(type, handler)
}
3 months ago
},
onRemove() {
2 months ago
const map = this.map
3 months ago
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() {
2 months ago
const map = this.map
3 months ago
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`
},
2 months ago
setActive(active) {
3 months ago
this.options.active = active
2 months ago
this._div?.classList.toggle('is-active', active)
3 months ago
},
2 months ago
addEventListener(type, handler) {
3 months ago
if (this._div) {
this._div.addEventListener(type, handler)
2 months ago
return
3 months ago
}
2 months ago
this._listeners.push({ type, handler })
3 months ago
},
2 months ago
}
schoolMapOverlayClass = T.Overlay.extend(proto as unknown as Record<string, unknown>) as new (
lnglat: TiandituLngLat,
options: SchoolMapOverlayOptions,
) => TiandituSchoolOverlay
3 months ago
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
}
2 months ago
function parseLonlatPair(first: unknown, second: unknown): { lng: number; lat: number } | null {
let lng = Number(first)
let lat = Number(second)
2 months ago
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null
2 months ago
// 部分结果可能为 "纬度,经度",按中国范围自动纠正
if (lng <= 60 && lat >= 70) {
;[lng, lat] = [lat, lng]
}
2 months ago
return { lng, lat }
}
2 months ago
/** 解析天地图 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 []
}
2 months ago
/** 高校雷达网地图:固定以苏州市为中心展示 */
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)
}
3 months ago
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 {}