fix(map): 收敛 regionchange 回写回路,修复选中标点后地图抽动
AI Code Review / review (pull_request) Successful in 2m11s

选中标点后地图在左右/上下反复抽动,根因是视口回写形成了自激回路:
map 的 longitude/latitude/scale 绑定到 store,而 regionchange 又无条件把
地图当前中心点写回 store —— 写 store 触发地图移动,移动结束回传一个
「差一点点」的中心点,再写回,再移动。上一个提交把中心点改成从
detail.centerLocation 同步读取后,回路里原本靠 getCenterLocation 异步跳变
掩盖住的这一点噪声就直接闭合了。

两道闸:
- 只接受手势造成的变化。causedBy 为 update(我们自己改绑定值或调
  includePoints 触发)时 store 已是权威值,不回写。老基础库拿不到
  causedBy 时退化到下一道闸。
- 所有回写走 commitViewport,经 isSignificantViewportChange 过滤掉小于
  1e-4 度 / 0.01 级的变化。远小于任何一次真实拖动,足以吸收量化噪声。

顺带修一个 H5 构建回归(1bcdd40 引入):uni 的 H5 路由生成器按路径推导
组件标识,pages/poi/detail 与 pages-poi/detail 都归一成 PagesPoiDetail,
重复声明导致 build:h5 失败。兼容跳板必须留在旧路径上,所以改名分包路由
pages-poi/detail -> pages-poi/poi-detail。
This commit is contained in:
周瑞哲
2026-08-03 11:08:39 +08:00
parent 1bcdd40729
commit 7ef4094788
12 changed files with 101 additions and 18 deletions
+4 -2
View File
@@ -19,8 +19,10 @@ const expectedPages = [
// 点位详情页放在 pages-poi 分包里,连带 800×600 大图一起按需下载,
// 否则 30 张大图会把主包顶过 2 MB 上限。
const expectedSubPackageRoot = 'pages-poi'
const expectedSubPackagePages = ['detail']
const poiDetailPath = `${expectedSubPackageRoot}/detail`
// 分包页文件名不能是 detail:uni 的 H5 路由表按路径生成组件标识,`pages-poi/detail`
// 和上面兼容跳板的 `pages/poi/detail` 会同时归一化成 PagesPoiDetail,重复声明直接编译失败。
const expectedSubPackagePages = ['poi-detail']
const poiDetailPath = `${expectedSubPackageRoot}/poi-detail`
const expectedTabPages = [
'pages/map/index',
'pages/assistant/index',
+1 -1
View File
@@ -136,7 +136,7 @@
"root": "pages-poi",
"pages": [
{
"path": "detail",
"path": "poi-detail",
"type": "page",
"layout": "map",
"style": {
+1 -1
View File
@@ -66,7 +66,7 @@ function openItinerary() {
}
function openDetail(poiId: string) {
uni.navigateTo({ url: `/pages-poi/detail?poiId=${encodeURIComponent(poiId)}` })
uni.navigateTo({ url: `/pages-poi/poi-detail?poiId=${encodeURIComponent(poiId)}` })
}
function focusOnMap(poiId: string) {
+1 -1
View File
@@ -53,7 +53,7 @@ function openPoi(poiId: string) {
})
return
}
uni.navigateTo({ url: `/pages-poi/detail?poiId=${encodeURIComponent(poiId)}` })
uni.navigateTo({ url: `/pages-poi/poi-detail?poiId=${encodeURIComponent(poiId)}` })
}
function loadProfile() {
+1 -1
View File
@@ -284,7 +284,7 @@ function openPlanner() {
}
function openDetail(poiId: string) {
uni.navigateTo({ url: `/pages-poi/detail?poiId=${encodeURIComponent(poiId)}` })
uni.navigateTo({ url: `/pages-poi/poi-detail?poiId=${encodeURIComponent(poiId)}` })
}
function focusOnMap(poiId: string) {
+32 -10
View File
@@ -7,7 +7,7 @@ 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 } from '@/services/map'
import { buildMarkerIdMap, createPoiMarkers, isSignificantViewportChange } from '@/services/map'
import { getSessionPlanningOrigin, loadPlan } from '@/services/travel-assistant'
import { useLocationStore, useMapStore } from '@/stores'
@@ -292,7 +292,11 @@ function readEventCenter(detail: Record<string, unknown>): GeoPoint | null {
return null
}
function updateViewport(event: { type: string, detail: Record<string, unknown> }) {
function updateViewport(event: {
type: string
causedBy?: unknown
detail: Record<string, unknown>
}) {
const changeType = String(event.detail.type ?? event.type ?? '')
if (changeType !== 'end')
return
@@ -300,6 +304,15 @@ function updateViewport(event: { type: string, detail: Record<string, unknown> }
if (!mapReady.value || navigating.value)
return
// 只接受用户手势造成的视野变化。
//
// `causedBy: 'update'` 是我们自己改 longitude/latitude/scale 或调 includePoints
// 触发的回调 —— 此时 store 已经是权威值,再把地图回传的落点写回去会形成
// 「写 store → 地图动 → regionchange → 写 store」的自激回路,表现为地图反复抽动。
const causedBy = String(event.causedBy ?? event.detail.causedBy ?? '')
if (causedBy && causedBy !== 'drag' && causedBy !== 'scale' && causedBy !== 'gesture')
return
const eventScale = Number(event.detail.scale)
const fallbackScale = Number.isFinite(eventScale) ? eventScale : viewport.value.scale
@@ -319,6 +332,19 @@ function updateViewport(event: { type: string, detail: Record<string, unknown> }
})
}
/**
* regionchange 回写 store 的唯一入口。
*
* 只有超过阈值的变化才写:地图的中心点是绑定到 store 的,写回去会再次触发地图移动,
* 把量化噪声原样回灌就会自激。见 isSignificantViewportChange 的注释。
*/
function commitViewport(longitude: number, latitude: number, scale: number) {
const next = { longitude, latitude, scale }
if (!isSignificantViewportChange(viewport.value, next))
return
mapStore.updateViewport(next)
}
function updateViewportWithScale(longitude: number, latitude: number, fallbackScale: number) {
const context = mapContext.value as (ReturnType<typeof uni.createMapContext> & {
getScale?: (options: {
@@ -328,21 +354,17 @@ function updateViewportWithScale(longitude: number, latitude: number, fallbackSc
}) | null
if (!context?.getScale) {
mapStore.updateViewport({ longitude, latitude, scale: fallbackScale })
commitViewport(longitude, latitude, fallbackScale)
return
}
context.getScale({
success: (result) => {
const scale = Number(result.scale)
mapStore.updateViewport({
longitude,
latitude,
scale: Number.isFinite(scale) ? scale : fallbackScale,
})
commitViewport(longitude, latitude, Number.isFinite(scale) ? scale : fallbackScale)
},
fail: () => {
mapStore.updateViewport({ longitude, latitude, scale: fallbackScale })
commitViewport(longitude, latitude, fallbackScale)
},
})
}
@@ -506,7 +528,7 @@ function openDetail(poiId: string) {
return
navigating.value = true
uni.navigateTo({
url: `/pages-poi/detail?poiId=${encodeURIComponent(poiId)}`,
url: `/pages-poi/poi-detail?poiId=${encodeURIComponent(poiId)}`,
complete: () => {
setTimeout(() => {
navigating.value = false
+1 -1
View File
@@ -26,7 +26,7 @@ onLoad((query) => {
// redirectTo 而非 navigateTo:跳板本身不该留在页面栈里。
uni.redirectTo({
url: `/pages-poi/detail?poiId=${encodeURIComponent(rawPoiId)}`,
url: `/pages-poi/poi-detail?poiId=${encodeURIComponent(rawPoiId)}`,
fail: () => {
uni.showToast({ title: REDIRECT_FAILURE_TOAST, icon: 'none' })
fallbackToMap()
+1
View File
@@ -1 +1,2 @@
export * from './marker'
export * from './viewport'
+25
View File
@@ -0,0 +1,25 @@
import type { MapViewport } 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
}
+33
View File
@@ -0,0 +1,33 @@
import type { MapViewport } from '@/domain/poi'
import { describe, expect, it } from 'vitest'
import { isSignificantViewportChange } from '@/services/map'
const current: MapViewport = { longitude: 113.935, latitude: 22.748, scale: 13 }
function shifted(delta: Partial<MapViewport>): MapViewport {
return {
longitude: current.longitude + (delta.longitude ?? 0),
latitude: current.latitude + (delta.latitude ?? 0),
scale: current.scale + (delta.scale ?? 0),
}
}
describe('isSignificantViewportChange', () => {
it('丢弃地图回传中心点的量化噪声', () => {
expect(isSignificantViewportChange(current, shifted({}))).toBe(false)
expect(isSignificantViewportChange(current, shifted({ longitude: 1e-6 }))).toBe(false)
expect(isSignificantViewportChange(current, shifted({ latitude: -1e-6 }))).toBe(false)
expect(isSignificantViewportChange(current, shifted({ scale: 0.001 }))).toBe(false)
})
it('接受真实拖动与缩放', () => {
expect(isSignificantViewportChange(current, shifted({ longitude: 0.01 }))).toBe(true)
expect(isSignificantViewportChange(current, shifted({ latitude: -0.01 }))).toBe(true)
expect(isSignificantViewportChange(current, shifted({ scale: 1 }))).toBe(true)
})
it('单个维度超阈值就算变化', () => {
const next = shifted({ longitude: 1e-6, latitude: 1e-6, scale: 2 })
expect(isSignificantViewportChange(current, next)).toBe(true)
})
})
+1 -1
View File
@@ -11,7 +11,7 @@ interface NavigateToOptions {
"/pages/itinerary/index" |
"/pages/planner/index" |
"/pages/poi/detail" |
"/pages-poi/detail";
"/pages-poi/poi-detail";
}
interface RedirectToOptions extends NavigateToOptions {}