feat(map): 初始视野对到点位聚类中心,并在已授权时开屏静默定位

初始视野
- 新增 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:
周瑞哲
2026-08-03 12:14:46 +08:00
parent 57ba418630
commit 377efbffa8
6 changed files with 338 additions and 30 deletions
+72 -16
View File
@@ -6,10 +6,10 @@ import MapFilterHeader from '@/components/map/MapFilterHeader.vue'
import PoiSummaryCard from '@/components/map/PoiSummaryCard.vue'
import PageState from '@/components/poi/PageState.vue'
import { getPoiRepository } from '@/data/poi'
import { isTrustworthyCoordinate } from '@/domain/poi'
import { buildMarkerIdMap, createPoiMarkers, isSignificantViewportChange } from '@/services/map'
import { isTrustworthyCoordinate, isWithinGuangmingArea } from '@/domain/poi'
import { buildMarkerIdMap, computeClusterViewport, createPoiMarkers, isSignificantViewportChange } from '@/services/map'
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 mapStore = useMapStore()
@@ -27,6 +27,8 @@ const markerIdToPoiId = shallowRef(new Map<number, string>())
const lastMarkerTapAt = ref(0)
const navigating = ref(false)
let includePointsRequestVersion = 0
/** 开屏静默定位是否已经把视野挪到了用户位置。只影响首帧要不要 includePoints。 */
let launchCenteredOnUser = false
let locationListenerAttached = false
let locationUpdatesStarted = false
let locationUpdatesStarting = false
@@ -150,11 +152,17 @@ function loadDataset() {
allPoiSummaries.value = nextPois
markerIdToPoiId.value = buildMarkerIdMap(nextPois.map(poi => poi.id))
const untouchedDefaultViewport = mapStore.viewport.longitude === 113.935
&& mapStore.viewport.latitude === 22.748
&& mapStore.viewport.scale === 11
// 用户还没动过视野时,把初始视野对到「所有点位聚类中心 + 一眼看全的缩放」。
// 动过就别抢:这时候 store 里的是用户自己拖出来的位置。
//
// 聚类视野按当前点位实时算,不用数据集里手写的 defaultViewport —— 增删点位之后
// 手写值会过期,算出来的不会。数据集为空时才回落到手写值。
const untouchedDefaultViewport = !isSignificantViewportChange(
mapStore.viewport,
DEFAULT_GUANGMING_VIEWPORT,
)
if (untouchedDefaultViewport)
mapStore.resetViewport(nextMeta.defaultViewport)
mapStore.resetViewport(computeClusterViewport(nextPois) ?? nextMeta.defaultViewport)
if (mapStore.categoryCode && !nextCategories.some(category => category.code === mapStore.categoryCode))
mapStore.setCategory(null)
if (mapStore.selectedPoiId && !nextPois.some(poi => poi.id === mapStore.selectedPoiId))
@@ -180,6 +188,10 @@ function includeFilteredPoints() {
if (!mapReady.value || !context || points.length === 0)
return
// 走到这里说明确实要按点位取景(切分类、回到全域、地图重试),
// 开屏定位的视野不再是当前视野,标记随之失效。
launchCenteredOnUser = false
nextTick(() => {
if (requestVersion !== includePointsRequestVersion
|| !mapReady.value
@@ -374,7 +386,8 @@ function resetToAll() {
mapStore.selectPoi(null)
mapStore.hidePlannedRoute()
activePlan.value = null
const target = datasetMeta.value?.defaultViewport
// 「回到全域」跟开屏用同一个聚类视野,别用数据集里可能过期的手写值。
const target = computeClusterViewport(allPoiSummaries.value) ?? datasetMeta.value?.defaultViewport
if (target)
mapStore.resetViewport(target)
includeFilteredPoints()
@@ -443,7 +456,7 @@ function stopForegroundLocationUpdates() {
}
}
function centerOnUserLocation() {
function centerOnUserLocation(scale = 15) {
const snapshot = locationStore.snapshot
if (!snapshot)
return
@@ -452,7 +465,7 @@ function centerOnUserLocation() {
mapStore.updateViewport({
longitude: snapshot.longitude,
latitude: snapshot.latitude,
scale: 15,
scale,
})
}
@@ -483,7 +496,14 @@ function requestUserLocation() {
requestCurrentLocation()
}
function requestCurrentLocation() {
/**
* 取一次当前位置。
*
* `silent` 是开屏静默定位:失败不弹任何提示(用户没主动求过定位,不该被打扰),
* 成功也只在用户确实在光明区内才居中 —— 否则把地图甩到一个没有任何点位的地方,
* 比停在聚类视野更糟。
*/
function requestCurrentLocation({ silent = false }: { silent?: boolean } = {}) {
const requestVersion = ++locationRequestVersion
uni.getLocation({
type: 'gcj02',
@@ -492,16 +512,25 @@ function requestCurrentLocation() {
success: (result) => {
if (requestVersion !== locationRequestVersion)
return
const longitude = Number(result.longitude)
const latitude = Number(result.latitude)
const updated = locationStore.updateLocation(
Number(result.longitude),
Number(result.latitude),
longitude,
latitude,
Number(result.accuracy ?? result.horizontalAccuracy),
)
if (!updated) {
// 无论静默与否都要落一个终态,否则 status 卡在 locating,定位按钮永远显示「定位中…」。
locationStore.failLocation('error', '定位结果无效,请稍后重试')
return
}
centerOnUserLocation()
if (!silent) {
centerOnUserLocation()
}
else if (isWithinGuangmingArea(longitude, latitude)) {
launchCenteredOnUser = true
centerOnUserLocation(14)
}
startForegroundLocationUpdates()
},
fail: (error) => {
@@ -509,6 +538,8 @@ function requestCurrentLocation() {
return
if (isPermissionDenied(error)) {
locationStore.failLocation('denied', '位置权限未开启')
if (silent)
return
uni.showModal({
title: '需要位置权限',
content: '开启位置权限后,可在地图显示实时方位,并按当前位置优化推荐路线。',
@@ -518,7 +549,27 @@ function requestCurrentLocation() {
return
}
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
if (activePlan.value)
includePlannedRoute()
else
// 开屏静默定位已经把视野挪到用户位置了,别再 includePoints 把它顶回全域视野。
// centerOnUserLocation 会 bump includePointsRequestVersion,能取消已经排队的
// includeFilteredPoints;但它也可能在 initializeMap 之前就跑完(定位命中缓存),
// 那种顺序下只有这个标记拦得住。
else if (!launchCenteredOnUser)
includeFilteredPoints()
}
catch (error) {
@@ -583,6 +638,7 @@ function handleMapError(event: unknown) {
onLoad(() => {
loadDataset()
restoreLocationOnLaunch()
})
onReady(() => {