forked from zhouruizhe/gmTouringMiniApp
fix(map): 收敛 regionchange 回写回路,修复选中标点后地图抽动
AI Code Review / review (pull_request) Successful in 2m11s
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:
@@ -19,8 +19,10 @@ const expectedPages = [
|
|||||||
// 点位详情页放在 pages-poi 分包里,连带 800×600 大图一起按需下载,
|
// 点位详情页放在 pages-poi 分包里,连带 800×600 大图一起按需下载,
|
||||||
// 否则 30 张大图会把主包顶过 2 MB 上限。
|
// 否则 30 张大图会把主包顶过 2 MB 上限。
|
||||||
const expectedSubPackageRoot = 'pages-poi'
|
const expectedSubPackageRoot = 'pages-poi'
|
||||||
const expectedSubPackagePages = ['detail']
|
// 分包页文件名不能是 detail:uni 的 H5 路由表按路径生成组件标识,`pages-poi/detail`
|
||||||
const poiDetailPath = `${expectedSubPackageRoot}/detail`
|
// 和上面兼容跳板的 `pages/poi/detail` 会同时归一化成 PagesPoiDetail,重复声明直接编译失败。
|
||||||
|
const expectedSubPackagePages = ['poi-detail']
|
||||||
|
const poiDetailPath = `${expectedSubPackageRoot}/poi-detail`
|
||||||
const expectedTabPages = [
|
const expectedTabPages = [
|
||||||
'pages/map/index',
|
'pages/map/index',
|
||||||
'pages/assistant/index',
|
'pages/assistant/index',
|
||||||
|
|||||||
+1
-1
@@ -136,7 +136,7 @@
|
|||||||
"root": "pages-poi",
|
"root": "pages-poi",
|
||||||
"pages": [
|
"pages": [
|
||||||
{
|
{
|
||||||
"path": "detail",
|
"path": "poi-detail",
|
||||||
"type": "page",
|
"type": "page",
|
||||||
"layout": "map",
|
"layout": "map",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function openItinerary() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openDetail(poiId: string) {
|
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) {
|
function focusOnMap(poiId: string) {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ function openPoi(poiId: string) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uni.navigateTo({ url: `/pages-poi/detail?poiId=${encodeURIComponent(poiId)}` })
|
uni.navigateTo({ url: `/pages-poi/poi-detail?poiId=${encodeURIComponent(poiId)}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadProfile() {
|
function loadProfile() {
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ function openPlanner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openDetail(poiId: string) {
|
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) {
|
function focusOnMap(poiId: string) {
|
||||||
|
|||||||
+32
-10
@@ -7,7 +7,7 @@ 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 } from '@/domain/poi'
|
||||||
import { buildMarkerIdMap, createPoiMarkers } from '@/services/map'
|
import { buildMarkerIdMap, 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 { useLocationStore, useMapStore } from '@/stores'
|
||||||
|
|
||||||
@@ -292,7 +292,11 @@ function readEventCenter(detail: Record<string, unknown>): GeoPoint | null {
|
|||||||
return 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 ?? '')
|
const changeType = String(event.detail.type ?? event.type ?? '')
|
||||||
if (changeType !== 'end')
|
if (changeType !== 'end')
|
||||||
return
|
return
|
||||||
@@ -300,6 +304,15 @@ function updateViewport(event: { type: string, detail: Record<string, unknown> }
|
|||||||
if (!mapReady.value || navigating.value)
|
if (!mapReady.value || navigating.value)
|
||||||
return
|
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 eventScale = Number(event.detail.scale)
|
||||||
const fallbackScale = Number.isFinite(eventScale) ? eventScale : viewport.value.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) {
|
function updateViewportWithScale(longitude: number, latitude: number, fallbackScale: number) {
|
||||||
const context = mapContext.value as (ReturnType<typeof uni.createMapContext> & {
|
const context = mapContext.value as (ReturnType<typeof uni.createMapContext> & {
|
||||||
getScale?: (options: {
|
getScale?: (options: {
|
||||||
@@ -328,21 +354,17 @@ function updateViewportWithScale(longitude: number, latitude: number, fallbackSc
|
|||||||
}) | null
|
}) | null
|
||||||
|
|
||||||
if (!context?.getScale) {
|
if (!context?.getScale) {
|
||||||
mapStore.updateViewport({ longitude, latitude, scale: fallbackScale })
|
commitViewport(longitude, latitude, fallbackScale)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
context.getScale({
|
context.getScale({
|
||||||
success: (result) => {
|
success: (result) => {
|
||||||
const scale = Number(result.scale)
|
const scale = Number(result.scale)
|
||||||
mapStore.updateViewport({
|
commitViewport(longitude, latitude, Number.isFinite(scale) ? scale : fallbackScale)
|
||||||
longitude,
|
|
||||||
latitude,
|
|
||||||
scale: Number.isFinite(scale) ? scale : fallbackScale,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
fail: () => {
|
fail: () => {
|
||||||
mapStore.updateViewport({ longitude, latitude, scale: fallbackScale })
|
commitViewport(longitude, latitude, fallbackScale)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -506,7 +528,7 @@ function openDetail(poiId: string) {
|
|||||||
return
|
return
|
||||||
navigating.value = true
|
navigating.value = true
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages-poi/detail?poiId=${encodeURIComponent(poiId)}`,
|
url: `/pages-poi/poi-detail?poiId=${encodeURIComponent(poiId)}`,
|
||||||
complete: () => {
|
complete: () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
navigating.value = false
|
navigating.value = false
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ onLoad((query) => {
|
|||||||
|
|
||||||
// redirectTo 而非 navigateTo:跳板本身不该留在页面栈里。
|
// redirectTo 而非 navigateTo:跳板本身不该留在页面栈里。
|
||||||
uni.redirectTo({
|
uni.redirectTo({
|
||||||
url: `/pages-poi/detail?poiId=${encodeURIComponent(rawPoiId)}`,
|
url: `/pages-poi/poi-detail?poiId=${encodeURIComponent(rawPoiId)}`,
|
||||||
fail: () => {
|
fail: () => {
|
||||||
uni.showToast({ title: REDIRECT_FAILURE_TOAST, icon: 'none' })
|
uni.showToast({ title: REDIRECT_FAILURE_TOAST, icon: 'none' })
|
||||||
fallbackToMap()
|
fallbackToMap()
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './marker'
|
export * from './marker'
|
||||||
|
export * from './viewport'
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Vendored
+1
-1
@@ -11,7 +11,7 @@ interface NavigateToOptions {
|
|||||||
"/pages/itinerary/index" |
|
"/pages/itinerary/index" |
|
||||||
"/pages/planner/index" |
|
"/pages/planner/index" |
|
||||||
"/pages/poi/detail" |
|
"/pages/poi/detail" |
|
||||||
"/pages-poi/detail";
|
"/pages-poi/poi-detail";
|
||||||
}
|
}
|
||||||
interface RedirectToOptions extends NavigateToOptions {}
|
interface RedirectToOptions extends NavigateToOptions {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user