diff --git a/src/data/poi/dataset.ts b/src/data/poi/dataset.ts index 8dac96d..c72c274 100644 --- a/src/data/poi/dataset.ts +++ b/src/data/poi/dataset.ts @@ -493,10 +493,13 @@ export const poiDataset: PoiDataset = { authoritativeBaselineProvided: false, canClaimFullCoverage: false, disclaimer: `本地数据来自 ${SNAPSHOT_CAPTURED_AT.slice(0, 10)} 高德候选快照并经 POC 筛选;简介、开放时间、推荐指数与图片仍需项目方逐点复核,不代表光明区全域权威清单。`, + // 仅在运行时算不出聚类视野(点位为空)时兜底。 + // 值取自 30 个点位的算术中心,与 computeClusterViewport 的结果一致; + // 真正生效的初始视野由 computeClusterViewport 按当前点位实时算出。 defaultViewport: { longitude: 113.9275, - latitude: 22.76, - scale: 11, + latitude: 22.7611, + scale: 12, }, }, categories: [ diff --git a/src/domain/poi/validation.ts b/src/domain/poi/validation.ts index 4c50093..cd6bba0 100644 --- a/src/domain/poi/validation.ts +++ b/src/domain/poi/validation.ts @@ -12,7 +12,13 @@ import type { // A conservative POC envelope used to catch obviously misplaced coordinates. // 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, maxLongitude: 114.02, minLatitude: 22.70, @@ -223,12 +229,23 @@ function validateCoordinates(poi: Poi, issues: PoiValidationIssue[]): void { if (poi.coordinateSystem !== 'GCJ02') addIssue(issues, 'COORDINATE_SYSTEM_INVALID', `pois.${id}.coordinateSystem`, '本期坐标系必须为 GCJ02', id) - if (longitude < GUANGMING_POC_BOUNDS.minLongitude - || longitude > GUANGMING_POC_BOUNDS.maxLongitude - || latitude < GUANGMING_POC_BOUNDS.minLatitude - || latitude > GUANGMING_POC_BOUNDS.maxLatitude) { + if (!isWithinGuangmingArea(longitude, latitude)) 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 { diff --git a/src/pages/map/index.vue b/src/pages/map/index.vue index dbee7fa..38e43fb 100644 --- a/src/pages/map/index.vue +++ b/src/pages/map/index.vue @@ -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()) 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(() => { diff --git a/src/services/map/viewport.ts b/src/services/map/viewport.ts index 1818759..daba75e 100644 --- a/src/services/map/viewport.ts +++ b/src/services/map/viewport.ts @@ -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 米。原生地图把中心点量化到像素,回传的中心点和我们写进去的 @@ -23,3 +24,126 @@ export function isSignificantViewportChange(current: MapViewport, next: MapViewp || Math.abs(next.latitude - current.latitude) >= COORDINATE_EPSILON || Math.abs(next.scale - current.scale) >= SCALE_EPSILON } + +/** 微信 `` 的 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,扣掉它们才是 `` 的实际高度。 + * 地图是 `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)) +} diff --git a/src/stores/modules/map.ts b/src/stores/modules/map.ts index 3f02b07..a53ed90 100644 --- a/src/stores/modules/map.ts +++ b/src/stores/modules/map.ts @@ -2,10 +2,17 @@ import type { MapViewport } from '@/domain/poi' import { defineStore } from 'pinia' import { isTrustworthyCoordinate } from '@/domain/poi' +/** + * 冷启动第一帧的视野,只在数据集读出来之前生效。 + * + * 取值是当前数据集全部点位的聚类中心与「一眼看全」的缩放(见 computeClusterViewport)。 + * 数据集一加载完,地图页会用实时算出来的聚类视野覆盖它,所以这里稍微过期也不影响, + * 但对齐能让第一帧就落在光明区,不会出现「先在别处、再跳过来」的位移。 + */ export const DEFAULT_GUANGMING_VIEWPORT: MapViewport = { - longitude: 113.935, - latitude: 22.748, - scale: 11, + longitude: 113.9275, + latitude: 22.761, + scale: 12, } interface MapSessionState { diff --git a/test/map-viewport.test.ts b/test/map-viewport.test.ts index b66df26..b0f56f2 100644 --- a/test/map-viewport.test.ts +++ b/test/map-viewport.test.ts @@ -1,6 +1,8 @@ -import type { MapViewport } from '@/domain/poi' +import type { GeoPoint, MapViewport } from '@/domain/poi' 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 } @@ -31,3 +33,102 @@ describe('isSignificantViewportChange', () => { 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) + }) +})