forked from zhouruizhe/gmTouringMiniApp
初始视野 - 新增 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。
This commit is contained in:
@@ -493,10 +493,13 @@ export const poiDataset: PoiDataset = {
|
|||||||
authoritativeBaselineProvided: false,
|
authoritativeBaselineProvided: false,
|
||||||
canClaimFullCoverage: false,
|
canClaimFullCoverage: false,
|
||||||
disclaimer: `本地数据来自 ${SNAPSHOT_CAPTURED_AT.slice(0, 10)} 高德候选快照并经 POC 筛选;简介、开放时间、推荐指数与图片仍需项目方逐点复核,不代表光明区全域权威清单。`,
|
disclaimer: `本地数据来自 ${SNAPSHOT_CAPTURED_AT.slice(0, 10)} 高德候选快照并经 POC 筛选;简介、开放时间、推荐指数与图片仍需项目方逐点复核,不代表光明区全域权威清单。`,
|
||||||
|
// 仅在运行时算不出聚类视野(点位为空)时兜底。
|
||||||
|
// 值取自 30 个点位的算术中心,与 computeClusterViewport 的结果一致;
|
||||||
|
// 真正生效的初始视野由 computeClusterViewport 按当前点位实时算出。
|
||||||
defaultViewport: {
|
defaultViewport: {
|
||||||
longitude: 113.9275,
|
longitude: 113.9275,
|
||||||
latitude: 22.76,
|
latitude: 22.7611,
|
||||||
scale: 11,
|
scale: 12,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
categories: [
|
categories: [
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ import type {
|
|||||||
|
|
||||||
// A conservative POC envelope used to catch obviously misplaced coordinates.
|
// A conservative POC envelope used to catch obviously misplaced coordinates.
|
||||||
// Content review remains the authority for whether a point belongs to Guangming District.
|
// Content review remains the authority for whether a point belongs to Guangming District.
|
||||||
const GUANGMING_POC_BOUNDS = {
|
/**
|
||||||
|
* 光明区 POC 的告警范围,用于提示「这个点位可能不在光明区」。
|
||||||
|
*
|
||||||
|
* 注意:这是内容审核的告警包络,不是运行时的坐标限制。真实用户可能站在范围之外
|
||||||
|
* (比如在市区规划行程),不能拿它去拒绝定位结果 —— 那是 isTrustworthyCoordinate 的活。
|
||||||
|
*/
|
||||||
|
export const GUANGMING_POC_BOUNDS = {
|
||||||
minLongitude: 113.78,
|
minLongitude: 113.78,
|
||||||
maxLongitude: 114.02,
|
maxLongitude: 114.02,
|
||||||
minLatitude: 22.70,
|
minLatitude: 22.70,
|
||||||
@@ -223,12 +229,23 @@ function validateCoordinates(poi: Poi, issues: PoiValidationIssue[]): void {
|
|||||||
if (poi.coordinateSystem !== 'GCJ02')
|
if (poi.coordinateSystem !== 'GCJ02')
|
||||||
addIssue(issues, 'COORDINATE_SYSTEM_INVALID', `pois.${id}.coordinateSystem`, '本期坐标系必须为 GCJ02', id)
|
addIssue(issues, 'COORDINATE_SYSTEM_INVALID', `pois.${id}.coordinateSystem`, '本期坐标系必须为 GCJ02', id)
|
||||||
|
|
||||||
if (longitude < GUANGMING_POC_BOUNDS.minLongitude
|
if (!isWithinGuangmingArea(longitude, latitude))
|
||||||
|| longitude > GUANGMING_POC_BOUNDS.maxLongitude
|
|
||||||
|| latitude < GUANGMING_POC_BOUNDS.minLatitude
|
|
||||||
|| latitude > GUANGMING_POC_BOUNDS.maxLatitude) {
|
|
||||||
addIssue(issues, 'OUTSIDE_TARGET_AREA', `pois.${id}.coordinates`, '坐标超出光明区 POC 粗校验包络,需内容负责人复核', id)
|
addIssue(issues, 'OUTSIDE_TARGET_AREA', `pois.${id}.coordinates`, '坐标超出光明区 POC 粗校验包络,需内容负责人复核', id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 坐标是否落在光明区 POC 粗校验包络内。
|
||||||
|
*
|
||||||
|
* 这是一个宽松的矩形包络,不是行政边界,也**不该**用来拒绝真实定位 ——
|
||||||
|
* 用户站在包络外面是完全正常的。它只回答一个问题:把地图移到这个坐标,
|
||||||
|
* 画面里还看得见本期点位吗?用途有两个:数据入库时的内容审核告警,
|
||||||
|
* 以及开屏定位时判断该不该把视野从聚类中心移到用户身上。
|
||||||
|
*/
|
||||||
|
export function isWithinGuangmingArea(longitude: number, latitude: number): boolean {
|
||||||
|
return longitude >= GUANGMING_POC_BOUNDS.minLongitude
|
||||||
|
&& longitude <= GUANGMING_POC_BOUNDS.maxLongitude
|
||||||
|
&& latitude >= GUANGMING_POC_BOUNDS.minLatitude
|
||||||
|
&& latitude <= GUANGMING_POC_BOUNDS.maxLatitude
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateDistanceMeters(left: Poi, right: Poi): number {
|
function calculateDistanceMeters(left: Poi, right: Poi): number {
|
||||||
|
|||||||
+72
-16
@@ -6,10 +6,10 @@ import MapFilterHeader from '@/components/map/MapFilterHeader.vue'
|
|||||||
import PoiSummaryCard from '@/components/map/PoiSummaryCard.vue'
|
import PoiSummaryCard from '@/components/map/PoiSummaryCard.vue'
|
||||||
import PageState from '@/components/poi/PageState.vue'
|
import PageState from '@/components/poi/PageState.vue'
|
||||||
import { getPoiRepository } from '@/data/poi'
|
import { getPoiRepository } from '@/data/poi'
|
||||||
import { isTrustworthyCoordinate } from '@/domain/poi'
|
import { isTrustworthyCoordinate, isWithinGuangmingArea } from '@/domain/poi'
|
||||||
import { buildMarkerIdMap, createPoiMarkers, isSignificantViewportChange } from '@/services/map'
|
import { buildMarkerIdMap, computeClusterViewport, createPoiMarkers, isSignificantViewportChange } from '@/services/map'
|
||||||
import { getSessionPlanningOrigin, loadPlan } from '@/services/travel-assistant'
|
import { getSessionPlanningOrigin, loadPlan } from '@/services/travel-assistant'
|
||||||
import { useLocationStore, useMapStore } from '@/stores'
|
import { DEFAULT_GUANGMING_VIEWPORT, useLocationStore, useMapStore } from '@/stores'
|
||||||
|
|
||||||
const MAP_ID = 'guangming-cultural-map'
|
const MAP_ID = 'guangming-cultural-map'
|
||||||
const mapStore = useMapStore()
|
const mapStore = useMapStore()
|
||||||
@@ -27,6 +27,8 @@ const markerIdToPoiId = shallowRef(new Map<number, string>())
|
|||||||
const lastMarkerTapAt = ref(0)
|
const lastMarkerTapAt = ref(0)
|
||||||
const navigating = ref(false)
|
const navigating = ref(false)
|
||||||
let includePointsRequestVersion = 0
|
let includePointsRequestVersion = 0
|
||||||
|
/** 开屏静默定位是否已经把视野挪到了用户位置。只影响首帧要不要 includePoints。 */
|
||||||
|
let launchCenteredOnUser = false
|
||||||
let locationListenerAttached = false
|
let locationListenerAttached = false
|
||||||
let locationUpdatesStarted = false
|
let locationUpdatesStarted = false
|
||||||
let locationUpdatesStarting = false
|
let locationUpdatesStarting = false
|
||||||
@@ -150,11 +152,17 @@ function loadDataset() {
|
|||||||
allPoiSummaries.value = nextPois
|
allPoiSummaries.value = nextPois
|
||||||
markerIdToPoiId.value = buildMarkerIdMap(nextPois.map(poi => poi.id))
|
markerIdToPoiId.value = buildMarkerIdMap(nextPois.map(poi => poi.id))
|
||||||
|
|
||||||
const untouchedDefaultViewport = mapStore.viewport.longitude === 113.935
|
// 用户还没动过视野时,把初始视野对到「所有点位聚类中心 + 一眼看全的缩放」。
|
||||||
&& mapStore.viewport.latitude === 22.748
|
// 动过就别抢:这时候 store 里的是用户自己拖出来的位置。
|
||||||
&& mapStore.viewport.scale === 11
|
//
|
||||||
|
// 聚类视野按当前点位实时算,不用数据集里手写的 defaultViewport —— 增删点位之后
|
||||||
|
// 手写值会过期,算出来的不会。数据集为空时才回落到手写值。
|
||||||
|
const untouchedDefaultViewport = !isSignificantViewportChange(
|
||||||
|
mapStore.viewport,
|
||||||
|
DEFAULT_GUANGMING_VIEWPORT,
|
||||||
|
)
|
||||||
if (untouchedDefaultViewport)
|
if (untouchedDefaultViewport)
|
||||||
mapStore.resetViewport(nextMeta.defaultViewport)
|
mapStore.resetViewport(computeClusterViewport(nextPois) ?? nextMeta.defaultViewport)
|
||||||
if (mapStore.categoryCode && !nextCategories.some(category => category.code === mapStore.categoryCode))
|
if (mapStore.categoryCode && !nextCategories.some(category => category.code === mapStore.categoryCode))
|
||||||
mapStore.setCategory(null)
|
mapStore.setCategory(null)
|
||||||
if (mapStore.selectedPoiId && !nextPois.some(poi => poi.id === mapStore.selectedPoiId))
|
if (mapStore.selectedPoiId && !nextPois.some(poi => poi.id === mapStore.selectedPoiId))
|
||||||
@@ -180,6 +188,10 @@ function includeFilteredPoints() {
|
|||||||
if (!mapReady.value || !context || points.length === 0)
|
if (!mapReady.value || !context || points.length === 0)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
// 走到这里说明确实要按点位取景(切分类、回到全域、地图重试),
|
||||||
|
// 开屏定位的视野不再是当前视野,标记随之失效。
|
||||||
|
launchCenteredOnUser = false
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (requestVersion !== includePointsRequestVersion
|
if (requestVersion !== includePointsRequestVersion
|
||||||
|| !mapReady.value
|
|| !mapReady.value
|
||||||
@@ -374,7 +386,8 @@ function resetToAll() {
|
|||||||
mapStore.selectPoi(null)
|
mapStore.selectPoi(null)
|
||||||
mapStore.hidePlannedRoute()
|
mapStore.hidePlannedRoute()
|
||||||
activePlan.value = null
|
activePlan.value = null
|
||||||
const target = datasetMeta.value?.defaultViewport
|
// 「回到全域」跟开屏用同一个聚类视野,别用数据集里可能过期的手写值。
|
||||||
|
const target = computeClusterViewport(allPoiSummaries.value) ?? datasetMeta.value?.defaultViewport
|
||||||
if (target)
|
if (target)
|
||||||
mapStore.resetViewport(target)
|
mapStore.resetViewport(target)
|
||||||
includeFilteredPoints()
|
includeFilteredPoints()
|
||||||
@@ -443,7 +456,7 @@ function stopForegroundLocationUpdates() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function centerOnUserLocation() {
|
function centerOnUserLocation(scale = 15) {
|
||||||
const snapshot = locationStore.snapshot
|
const snapshot = locationStore.snapshot
|
||||||
if (!snapshot)
|
if (!snapshot)
|
||||||
return
|
return
|
||||||
@@ -452,7 +465,7 @@ function centerOnUserLocation() {
|
|||||||
mapStore.updateViewport({
|
mapStore.updateViewport({
|
||||||
longitude: snapshot.longitude,
|
longitude: snapshot.longitude,
|
||||||
latitude: snapshot.latitude,
|
latitude: snapshot.latitude,
|
||||||
scale: 15,
|
scale,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,7 +496,14 @@ function requestUserLocation() {
|
|||||||
requestCurrentLocation()
|
requestCurrentLocation()
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestCurrentLocation() {
|
/**
|
||||||
|
* 取一次当前位置。
|
||||||
|
*
|
||||||
|
* `silent` 是开屏静默定位:失败不弹任何提示(用户没主动求过定位,不该被打扰),
|
||||||
|
* 成功也只在用户确实在光明区内才居中 —— 否则把地图甩到一个没有任何点位的地方,
|
||||||
|
* 比停在聚类视野更糟。
|
||||||
|
*/
|
||||||
|
function requestCurrentLocation({ silent = false }: { silent?: boolean } = {}) {
|
||||||
const requestVersion = ++locationRequestVersion
|
const requestVersion = ++locationRequestVersion
|
||||||
uni.getLocation({
|
uni.getLocation({
|
||||||
type: 'gcj02',
|
type: 'gcj02',
|
||||||
@@ -492,16 +512,25 @@ function requestCurrentLocation() {
|
|||||||
success: (result) => {
|
success: (result) => {
|
||||||
if (requestVersion !== locationRequestVersion)
|
if (requestVersion !== locationRequestVersion)
|
||||||
return
|
return
|
||||||
|
const longitude = Number(result.longitude)
|
||||||
|
const latitude = Number(result.latitude)
|
||||||
const updated = locationStore.updateLocation(
|
const updated = locationStore.updateLocation(
|
||||||
Number(result.longitude),
|
longitude,
|
||||||
Number(result.latitude),
|
latitude,
|
||||||
Number(result.accuracy ?? result.horizontalAccuracy),
|
Number(result.accuracy ?? result.horizontalAccuracy),
|
||||||
)
|
)
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
|
// 无论静默与否都要落一个终态,否则 status 卡在 locating,定位按钮永远显示「定位中…」。
|
||||||
locationStore.failLocation('error', '定位结果无效,请稍后重试')
|
locationStore.failLocation('error', '定位结果无效,请稍后重试')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
centerOnUserLocation()
|
if (!silent) {
|
||||||
|
centerOnUserLocation()
|
||||||
|
}
|
||||||
|
else if (isWithinGuangmingArea(longitude, latitude)) {
|
||||||
|
launchCenteredOnUser = true
|
||||||
|
centerOnUserLocation(14)
|
||||||
|
}
|
||||||
startForegroundLocationUpdates()
|
startForegroundLocationUpdates()
|
||||||
},
|
},
|
||||||
fail: (error) => {
|
fail: (error) => {
|
||||||
@@ -509,6 +538,8 @@ function requestCurrentLocation() {
|
|||||||
return
|
return
|
||||||
if (isPermissionDenied(error)) {
|
if (isPermissionDenied(error)) {
|
||||||
locationStore.failLocation('denied', '位置权限未开启')
|
locationStore.failLocation('denied', '位置权限未开启')
|
||||||
|
if (silent)
|
||||||
|
return
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '需要位置权限',
|
title: '需要位置权限',
|
||||||
content: '开启位置权限后,可在地图显示实时方位,并按当前位置优化推荐路线。',
|
content: '开启位置权限后,可在地图显示实时方位,并按当前位置优化推荐路线。',
|
||||||
@@ -518,7 +549,27 @@ function requestCurrentLocation() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
locationStore.failLocation('unavailable', '暂时无法获取位置')
|
locationStore.failLocation('unavailable', '暂时无法获取位置')
|
||||||
uni.showToast({ title: '定位失败,请检查系统定位服务', icon: 'none' })
|
if (!silent)
|
||||||
|
uni.showToast({ title: '定位失败,请检查系统定位服务', icon: 'none' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开屏定位:已授权过就静默定位并居中,蓝点直接出现在自己位置上。
|
||||||
|
*
|
||||||
|
* 只在 `scope.userLocation` 已授权时才走 —— 未决定或已拒绝一律不碰,
|
||||||
|
* 否则小程序第一帧就弹微信的授权框,拒绝之后还会连着弹我们自己的引导框。
|
||||||
|
* 那两种状态留给右下角的定位按钮,用户主动点了再走完整链路。
|
||||||
|
*/
|
||||||
|
function restoreLocationOnLaunch() {
|
||||||
|
uni.getSetting({
|
||||||
|
success: (settings) => {
|
||||||
|
if (settings.authSetting['scope.userLocation'] !== true)
|
||||||
|
return
|
||||||
|
locationUpdatesRequested = true
|
||||||
|
locationStore.startLocating()
|
||||||
|
requestCurrentLocation({ silent: true })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -562,7 +613,11 @@ function initializeMap() {
|
|||||||
mapReady.value = true
|
mapReady.value = true
|
||||||
if (activePlan.value)
|
if (activePlan.value)
|
||||||
includePlannedRoute()
|
includePlannedRoute()
|
||||||
else
|
// 开屏静默定位已经把视野挪到用户位置了,别再 includePoints 把它顶回全域视野。
|
||||||
|
// centerOnUserLocation 会 bump includePointsRequestVersion,能取消已经排队的
|
||||||
|
// includeFilteredPoints;但它也可能在 initializeMap 之前就跑完(定位命中缓存),
|
||||||
|
// 那种顺序下只有这个标记拦得住。
|
||||||
|
else if (!launchCenteredOnUser)
|
||||||
includeFilteredPoints()
|
includeFilteredPoints()
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -583,6 +638,7 @@ function handleMapError(event: unknown) {
|
|||||||
|
|
||||||
onLoad(() => {
|
onLoad(() => {
|
||||||
loadDataset()
|
loadDataset()
|
||||||
|
restoreLocationOnLaunch()
|
||||||
})
|
})
|
||||||
|
|
||||||
onReady(() => {
|
onReady(() => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { MapViewport } from '@/domain/poi'
|
import type { GeoPoint, MapViewport } from '@/domain/poi'
|
||||||
|
import { isTrustworthyCoordinate } from '@/domain/poi'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 约 1e-4 度 ≈ 11 米。原生地图把中心点量化到像素,回传的中心点和我们写进去的
|
* 约 1e-4 度 ≈ 11 米。原生地图把中心点量化到像素,回传的中心点和我们写进去的
|
||||||
@@ -23,3 +24,126 @@ export function isSignificantViewportChange(current: MapViewport, next: MapViewp
|
|||||||
|| Math.abs(next.latitude - current.latitude) >= COORDINATE_EPSILON
|
|| Math.abs(next.latitude - current.latitude) >= COORDINATE_EPSILON
|
||||||
|| Math.abs(next.scale - current.scale) >= SCALE_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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,17 @@ import type { MapViewport } from '@/domain/poi'
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { isTrustworthyCoordinate } from '@/domain/poi'
|
import { isTrustworthyCoordinate } from '@/domain/poi'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 冷启动第一帧的视野,只在数据集读出来之前生效。
|
||||||
|
*
|
||||||
|
* 取值是当前数据集全部点位的聚类中心与「一眼看全」的缩放(见 computeClusterViewport)。
|
||||||
|
* 数据集一加载完,地图页会用实时算出来的聚类视野覆盖它,所以这里稍微过期也不影响,
|
||||||
|
* 但对齐能让第一帧就落在光明区,不会出现「先在别处、再跳过来」的位移。
|
||||||
|
*/
|
||||||
export const DEFAULT_GUANGMING_VIEWPORT: MapViewport = {
|
export const DEFAULT_GUANGMING_VIEWPORT: MapViewport = {
|
||||||
longitude: 113.935,
|
longitude: 113.9275,
|
||||||
latitude: 22.748,
|
latitude: 22.761,
|
||||||
scale: 11,
|
scale: 12,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MapSessionState {
|
interface MapSessionState {
|
||||||
|
|||||||
+103
-2
@@ -1,6 +1,8 @@
|
|||||||
import type { MapViewport } from '@/domain/poi'
|
import type { GeoPoint, MapViewport } from '@/domain/poi'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { isSignificantViewportChange } from '@/services/map'
|
import { getPoiRepository } from '@/data/poi'
|
||||||
|
import { GUANGMING_POC_BOUNDS, isWithinGuangmingArea } from '@/domain/poi'
|
||||||
|
import { computeClusterViewport, isSignificantViewportChange } from '@/services/map'
|
||||||
|
|
||||||
const current: MapViewport = { longitude: 113.935, latitude: 22.748, scale: 13 }
|
const current: MapViewport = { longitude: 113.935, latitude: 22.748, scale: 13 }
|
||||||
|
|
||||||
@@ -31,3 +33,102 @@ describe('isSignificantViewportChange', () => {
|
|||||||
expect(isSignificantViewportChange(current, next)).toBe(true)
|
expect(isSignificantViewportChange(current, next)).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const VIEWPORT_SIZE = { width: 375, height: 560 }
|
||||||
|
|
||||||
|
describe('computeClusterViewport', () => {
|
||||||
|
it('没有可用点位时返回 null', () => {
|
||||||
|
expect(computeClusterViewport([])).toBeNull()
|
||||||
|
// (0, 0) 是定位没拿到 fix 时的产物,不该参与聚类。
|
||||||
|
expect(computeClusterViewport([{ longitude: 0, latitude: 0 }])).toBeNull()
|
||||||
|
expect(computeClusterViewport([{ longitude: Number.NaN, latitude: 22.7 }])).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('中心点取算术中心,密度大的一侧更靠中间', () => {
|
||||||
|
// 三个点挤在西侧,一个点在东侧远处:算术中心应偏西,而不是落在东西正中。
|
||||||
|
const points: GeoPoint[] = [
|
||||||
|
{ longitude: 113.90, latitude: 22.75 },
|
||||||
|
{ longitude: 113.91, latitude: 22.75 },
|
||||||
|
{ longitude: 113.92, latitude: 22.75 },
|
||||||
|
{ longitude: 114.00, latitude: 22.75 },
|
||||||
|
]
|
||||||
|
const viewport = computeClusterViewport(points, VIEWPORT_SIZE)!
|
||||||
|
|
||||||
|
expect(viewport.longitude).toBeCloseTo(113.9325, 4)
|
||||||
|
// 外接矩形中心是 113.95,算术中心必须比它更靠西。
|
||||||
|
expect(viewport.longitude).toBeLessThan(113.95)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('跨度越大 scale 越小,且始终落在微信允许的 3~20', () => {
|
||||||
|
const tight = computeClusterViewport([
|
||||||
|
{ longitude: 113.93, latitude: 22.75 },
|
||||||
|
{ longitude: 113.94, latitude: 22.76 },
|
||||||
|
], VIEWPORT_SIZE)!
|
||||||
|
const wide = computeClusterViewport([
|
||||||
|
{ longitude: 113.50, latitude: 22.40 },
|
||||||
|
{ longitude: 114.40, latitude: 23.10 },
|
||||||
|
], VIEWPORT_SIZE)!
|
||||||
|
|
||||||
|
expect(tight.scale).toBeGreaterThan(wide.scale)
|
||||||
|
for (const scale of [tight.scale, wide.scale]) {
|
||||||
|
expect(scale).toBeGreaterThanOrEqual(3)
|
||||||
|
expect(scale).toBeLessThanOrEqual(20)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('所有点位重合时给街区级视野', () => {
|
||||||
|
const viewport = computeClusterViewport([
|
||||||
|
{ longitude: 113.93, latitude: 22.75 },
|
||||||
|
{ longitude: 113.93, latitude: 22.75 },
|
||||||
|
], VIEWPORT_SIZE)!
|
||||||
|
|
||||||
|
expect(viewport).toEqual({ longitude: 113.93, latitude: 22.75, scale: 15 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('屏幕越窄 scale 越小', () => {
|
||||||
|
const points: GeoPoint[] = [
|
||||||
|
{ longitude: 113.88, latitude: 22.70 },
|
||||||
|
{ longitude: 113.97, latitude: 22.81 },
|
||||||
|
]
|
||||||
|
const narrow = computeClusterViewport(points, { width: 320, height: 480 })!
|
||||||
|
const wide = computeClusterViewport(points, { width: 768, height: 900 })!
|
||||||
|
|
||||||
|
expect(narrow.scale).toBeLessThanOrEqual(wide.scale)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('真实数据集的聚类视野落在光明区范围内,且装得下全部点位', () => {
|
||||||
|
const pois = getPoiRepository().getPoiSummaries()
|
||||||
|
const viewport = computeClusterViewport(pois, VIEWPORT_SIZE)!
|
||||||
|
|
||||||
|
expect(viewport.longitude).toBeGreaterThanOrEqual(GUANGMING_POC_BOUNDS.minLongitude)
|
||||||
|
expect(viewport.longitude).toBeLessThanOrEqual(GUANGMING_POC_BOUNDS.maxLongitude)
|
||||||
|
expect(viewport.latitude).toBeGreaterThanOrEqual(GUANGMING_POC_BOUNDS.minLatitude)
|
||||||
|
expect(viewport.latitude).toBeLessThanOrEqual(GUANGMING_POC_BOUNDS.maxLatitude)
|
||||||
|
// 区级取景:11 级会把光明区缩成一小块,14 级又装不下南北 12.6 km 的跨度。
|
||||||
|
expect(viewport.scale).toBeGreaterThanOrEqual(12)
|
||||||
|
expect(viewport.scale).toBeLessThan(14)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isWithinGuangmingArea', () => {
|
||||||
|
it('本期全部点位都落在包络内', () => {
|
||||||
|
for (const poi of getPoiRepository().getPoiSummaries())
|
||||||
|
expect(isWithinGuangmingArea(poi.longitude, poi.latitude)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('区外坐标返回 false,用于决定开屏是否把视野挪到用户身上', () => {
|
||||||
|
// 深圳市民中心、广州塔:都是真实可达的位置,但画面里看不到本期点位。
|
||||||
|
expect(isWithinGuangmingArea(114.0655, 22.5477)).toBe(false)
|
||||||
|
expect(isWithinGuangmingArea(113.3245, 23.1066)).toBe(false)
|
||||||
|
// (0, 0) 落在几内亚湾,同样在包络外。
|
||||||
|
expect(isWithinGuangmingArea(0, 0)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('包络边界闭区间', () => {
|
||||||
|
const { minLongitude, maxLongitude, minLatitude, maxLatitude } = GUANGMING_POC_BOUNDS
|
||||||
|
expect(isWithinGuangmingArea(minLongitude, minLatitude)).toBe(true)
|
||||||
|
expect(isWithinGuangmingArea(maxLongitude, maxLatitude)).toBe(true)
|
||||||
|
expect(isWithinGuangmingArea(minLongitude - 0.001, minLatitude)).toBe(false)
|
||||||
|
expect(isWithinGuangmingArea(maxLongitude, maxLatitude + 0.001)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user