From 7ef40947880e0d9fa2fd62b9f114649cb47c77aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=91=9E=E5=93=B2?= Date: Mon, 3 Aug 2026 11:08:39 +0800 Subject: [PATCH] =?UTF-8?q?fix(map):=20=E6=94=B6=E6=95=9B=20regionchange?= =?UTF-8?q?=20=E5=9B=9E=E5=86=99=E5=9B=9E=E8=B7=AF=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E9=80=89=E4=B8=AD=E6=A0=87=E7=82=B9=E5=90=8E=E5=9C=B0?= =?UTF-8?q?=E5=9B=BE=E6=8A=BD=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 选中标点后地图在左右/上下反复抽动,根因是视口回写形成了自激回路: 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。 --- scripts/verify-weixin-output.mjs | 6 ++- src/pages-poi/{detail.vue => poi-detail.vue} | 0 src/pages.json | 2 +- src/pages/assistant/index.vue | 2 +- src/pages/check-in/records.vue | 2 +- src/pages/itinerary/index.vue | 2 +- src/pages/map/index.vue | 42 +++++++++++++++----- src/pages/poi/detail.vue | 2 +- src/services/map/index.ts | 1 + src/services/map/viewport.ts | 25 ++++++++++++ test/map-viewport.test.ts | 33 +++++++++++++++ uni-pages.d.ts | 2 +- 12 files changed, 101 insertions(+), 18 deletions(-) rename src/pages-poi/{detail.vue => poi-detail.vue} (100%) create mode 100644 src/services/map/viewport.ts create mode 100644 test/map-viewport.test.ts diff --git a/scripts/verify-weixin-output.mjs b/scripts/verify-weixin-output.mjs index 06b771b..02ed818 100644 --- a/scripts/verify-weixin-output.mjs +++ b/scripts/verify-weixin-output.mjs @@ -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', diff --git a/src/pages-poi/detail.vue b/src/pages-poi/poi-detail.vue similarity index 100% rename from src/pages-poi/detail.vue rename to src/pages-poi/poi-detail.vue diff --git a/src/pages.json b/src/pages.json index c0b1eb6..b426b7f 100644 --- a/src/pages.json +++ b/src/pages.json @@ -136,7 +136,7 @@ "root": "pages-poi", "pages": [ { - "path": "detail", + "path": "poi-detail", "type": "page", "layout": "map", "style": { diff --git a/src/pages/assistant/index.vue b/src/pages/assistant/index.vue index 80c3ff1..893837c 100644 --- a/src/pages/assistant/index.vue +++ b/src/pages/assistant/index.vue @@ -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) { diff --git a/src/pages/check-in/records.vue b/src/pages/check-in/records.vue index 79fa596..d1f6b66 100644 --- a/src/pages/check-in/records.vue +++ b/src/pages/check-in/records.vue @@ -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() { diff --git a/src/pages/itinerary/index.vue b/src/pages/itinerary/index.vue index 1047f85..ff0ae2b 100644 --- a/src/pages/itinerary/index.vue +++ b/src/pages/itinerary/index.vue @@ -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) { diff --git a/src/pages/map/index.vue b/src/pages/map/index.vue index 77271ff..dbee7fa 100644 --- a/src/pages/map/index.vue +++ b/src/pages/map/index.vue @@ -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): GeoPoint | null { return null } -function updateViewport(event: { type: string, detail: Record }) { +function updateViewport(event: { + type: string + causedBy?: unknown + detail: Record +}) { const changeType = String(event.detail.type ?? event.type ?? '') if (changeType !== 'end') return @@ -300,6 +304,15 @@ function updateViewport(event: { type: string, detail: Record } 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 } }) } +/** + * 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 & { 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 diff --git a/src/pages/poi/detail.vue b/src/pages/poi/detail.vue index 4763b37..6766e74 100644 --- a/src/pages/poi/detail.vue +++ b/src/pages/poi/detail.vue @@ -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() diff --git a/src/services/map/index.ts b/src/services/map/index.ts index 3bc057b..8b4442e 100644 --- a/src/services/map/index.ts +++ b/src/services/map/index.ts @@ -1 +1,2 @@ export * from './marker' +export * from './viewport' diff --git a/src/services/map/viewport.ts b/src/services/map/viewport.ts new file mode 100644 index 0000000..1818759 --- /dev/null +++ b/src/services/map/viewport.ts @@ -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 +} diff --git a/test/map-viewport.test.ts b/test/map-viewport.test.ts new file mode 100644 index 0000000..b66df26 --- /dev/null +++ b/test/map-viewport.test.ts @@ -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 { + 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) + }) +}) diff --git a/uni-pages.d.ts b/uni-pages.d.ts index 2c0f814..2e8e3bc 100644 --- a/uni-pages.d.ts +++ b/uni-pages.d.ts @@ -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 {}