forked from zhouruizhe/gmTouringMiniApp
AI Code Review / review (pull_request) Successful in 7m5s
初始视野 - 新增 computeClusterViewport:把全部点位当一个聚类,中心取算术中心 (跟点位密度走,不像外接矩形中心那样被个别远点拽偏),scale 由聚类跨度 反推出「一眼看全」的档位。当前 30 个点位算出 (113.92754, 22.76105) scale 12 —— 原来的 scale 11 会把光明区缩成一小块,留一圈空白。 - 视野改成运行时实时算,不再读数据集里手写的 defaultViewport:增删点位之后 手写值会过期,算出来的不会。手写值降级为点位为空时的兜底。 - 「回到全域」用同一个聚类视野。 - loadDataset 里判断「视野还没被用户动过」原来是硬编码 113.935/22.748/11 三个字面量,跟 DEFAULT_GUANGMING_VIEWPORT 重复;改成直接跟常量比。 开屏定位 - 新增 restoreLocationOnLaunch:仅当 scope.userLocation 已授权时静默定位并 居中,蓝点直接出现在用户位置上。未决定/已拒绝一律不碰,避免小程序第一帧 就弹微信授权框、拒绝后再连弹一个引导框;那两种状态留给定位按钮。 - 静默定位只在用户确实在光明区包络内才居中,否则把地图甩到没有任何点位的 地方比停在聚类视野更糟。 - 静默失败不弹提示,但仍写入终态,否则 status 卡在 locating、定位按钮 永远显示「定位中…」。 - 新增 isWithinGuangmingArea,与数据校验共用同一个包络定义。 需要说明:地图漂到几内亚湾不是「开屏没请求定位」造成的,而是没有 fix 时 (0, 0) 被写进 viewport。堵住它的是 isTrustworthyCoordinate(7ef4094), 开屏定位是叠在守卫之上的体验改进,不是替代 —— 用户拒权、模拟器没设位置、 室内超时都还是拿不到 fix。
150 lines
6.1 KiB
TypeScript
150 lines
6.1 KiB
TypeScript
import type { GeoPoint, MapViewport } from '@/domain/poi'
|
|
import { isTrustworthyCoordinate } from '@/domain/poi'
|
|
|
|
/**
|
|
* 约 1e-4 度 ≈ 11 米。原生地图把中心点量化到像素,回传的中心点和我们写进去的
|
|
* 值总会差一点点;这个阈值用来吸收那点量化噪声,同时远小于任何一次真实拖动。
|
|
*/
|
|
const COORDINATE_EPSILON = 1e-4
|
|
const SCALE_EPSILON = 0.01
|
|
|
|
/**
|
|
* 判断地图回传的视口是否真的变了,值得写回 store。
|
|
*
|
|
* 地图的 longitude/latitude/scale 是绑定到 store 的,而 `regionchange` 又会把
|
|
* 地图当前中心点回写 store —— 这是一个闭环。选中标点后我们把视口设到 POI 坐标,
|
|
* 地图动画结束回传一个「差一点点」的中心点,无条件写回就会再次改动绑定值、
|
|
* 触发地图移动、再回传……表现出来就是地图在左右或上下反复抽动。
|
|
*
|
|
* 回写本身是需要的(用户拖完地图,store 要记住新位置,返回页面时才能恢复),
|
|
* 所以这里不是禁止回写,而是把小于阈值的变化当作噪声丢掉,让闭环收敛。
|
|
*/
|
|
export function isSignificantViewportChange(current: MapViewport, next: MapViewport): boolean {
|
|
return Math.abs(next.longitude - current.longitude) >= COORDINATE_EPSILON
|
|
|| Math.abs(next.latitude - current.latitude) >= COORDINATE_EPSILON
|
|
|| Math.abs(next.scale - current.scale) >= SCALE_EPSILON
|
|
}
|
|
|
|
/** 微信 `<map>` 的 scale 取值范围是 3~20。 */
|
|
const MIN_MAP_SCALE = 3
|
|
const MAX_MAP_SCALE = 20
|
|
|
|
/** Web 墨卡托瓦片边长:z 级时全球 360° 宽 256 × 2^z 像素。 */
|
|
const TILE_SIZE = 256
|
|
|
|
/** 聚类外接矩形与视野边缘之间的余量,避免最外侧点位贴边或被顶部筛选栏压住。 */
|
|
const FIT_PADDING_RATIO = 1.25
|
|
|
|
/** 只有一个点位(或所有点位重合)时没有跨度可依据,直接给街区级视野。 */
|
|
const SINGLE_POINT_SCALE = 15
|
|
|
|
/** 拿不到屏幕尺寸时按最窄的在售机型(iPhone SE)估算,宁可保守一点。 */
|
|
const FALLBACK_VIEWPORT_SIZE = { width: 375, height: 560 }
|
|
|
|
/**
|
|
* 地图之外还有导航栏、顶部筛选栏和底部 tabBar,扣掉它们才是 `<map>` 的实际高度。
|
|
* 地图是 `flex: 1`,拿不到精确值也不影响 —— FIT_PADDING_RATIO 已经留了余量。
|
|
*/
|
|
const MAP_CHROME_HEIGHT_PX = 180
|
|
|
|
export interface ClusterViewportOptions {
|
|
/** 地图可视区域宽度(px)。默认读设备屏幕宽度。 */
|
|
width?: number
|
|
/** 地图可视区域高度(px)。默认读设备屏幕高度并扣掉导航栏与 tabBar。 */
|
|
height?: number
|
|
}
|
|
|
|
/**
|
|
* 读设备可视区域尺寸。测试环境里没有 `uni`,回落到 iPhone SE 的估算值。
|
|
* 同 marker.ts 的 rpxToPx,这里也用 typeof 守卫而不是假设宿主一定存在。
|
|
*/
|
|
function readViewportSize(): { width: number, height: number } {
|
|
if (typeof uni === 'undefined' || typeof uni.getWindowInfo !== 'function')
|
|
return FALLBACK_VIEWPORT_SIZE
|
|
|
|
try {
|
|
const info = uni.getWindowInfo()
|
|
const width = Number(info?.windowWidth)
|
|
const height = Number(info?.windowHeight)
|
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0)
|
|
return FALLBACK_VIEWPORT_SIZE
|
|
|
|
return { width, height: Math.max(height - MAP_CHROME_HEIGHT_PX, height / 2) }
|
|
}
|
|
catch {
|
|
return FALLBACK_VIEWPORT_SIZE
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 把所有点位当成一个聚类,算出「一眼看全」的初始视野。
|
|
*
|
|
* 中心点取算术中心(centroid)而不是外接矩形中心:前者跟着点位密度走,点多的那片
|
|
* 会更靠画面中间;后者只由最外侧四个点决定,个别远点就能把中心拽偏。当前数据集里
|
|
* 两者只差 0.36 km,但数据集是会增删的,跟着密度走更稳。
|
|
*
|
|
* scale 由聚类跨度反推而不是写死:点位范围一变,写死的值要么留一圈空白、
|
|
* 要么把最外侧的点切在屏幕外。
|
|
*/
|
|
export function computeClusterViewport(
|
|
points: readonly GeoPoint[],
|
|
options: ClusterViewportOptions = {},
|
|
): MapViewport | null {
|
|
const usable = points.filter(point => isTrustworthyCoordinate(point.longitude, point.latitude))
|
|
if (usable.length === 0)
|
|
return null
|
|
|
|
let longitudeSum = 0
|
|
let latitudeSum = 0
|
|
let minLongitude = Number.POSITIVE_INFINITY
|
|
let maxLongitude = Number.NEGATIVE_INFINITY
|
|
let minLatitude = Number.POSITIVE_INFINITY
|
|
let maxLatitude = Number.NEGATIVE_INFINITY
|
|
|
|
for (const { longitude, latitude } of usable) {
|
|
longitudeSum += longitude
|
|
latitudeSum += latitude
|
|
minLongitude = Math.min(minLongitude, longitude)
|
|
maxLongitude = Math.max(maxLongitude, longitude)
|
|
minLatitude = Math.min(minLatitude, latitude)
|
|
maxLatitude = Math.max(maxLatitude, latitude)
|
|
}
|
|
|
|
const longitude = longitudeSum / usable.length
|
|
const latitude = latitudeSum / usable.length
|
|
|
|
return {
|
|
longitude,
|
|
latitude,
|
|
scale: fitScale(maxLongitude - minLongitude, maxLatitude - minLatitude, latitude, options),
|
|
}
|
|
}
|
|
|
|
function fitScale(
|
|
spanLongitude: number,
|
|
spanLatitude: number,
|
|
centerLatitude: number,
|
|
options: ClusterViewportOptions,
|
|
): number {
|
|
const measured = readViewportSize()
|
|
const width = options.width ?? measured.width
|
|
const height = options.height ?? measured.height
|
|
|
|
if (spanLongitude <= 0 && spanLatitude <= 0)
|
|
return SINGLE_POINT_SCALE
|
|
|
|
const candidates: number[] = []
|
|
if (spanLongitude > 0)
|
|
candidates.push(Math.log2(width * 360 / (TILE_SIZE * spanLongitude * FIT_PADDING_RATIO)))
|
|
if (spanLatitude > 0) {
|
|
// 墨卡托投影下,同样度数的纬度跨度比经度跨度占更多像素,按中心纬度折算回等效经度。
|
|
const projected = spanLatitude / Math.cos(centerLatitude * Math.PI / 180)
|
|
candidates.push(Math.log2(height * 360 / (TILE_SIZE * projected * FIT_PADDING_RATIO)))
|
|
}
|
|
|
|
// 取两个方向里更小的,保证短边也装得下;向下取到半级,微信 scale 是连续值,
|
|
// 但半级以下的差别肉眼分辨不出来,取整能让不同机型落在同一档、便于断言。
|
|
const fitted = Math.floor(Math.min(...candidates) * 2) / 2
|
|
return Math.min(MAX_MAP_SCALE, Math.max(MIN_MAP_SCALE, fitted))
|
|
}
|