clxx 3.0.1 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/AGENTS.md +49 -3
  2. package/README.md +6 -3
  3. package/build/Alert/style.js +15 -14
  4. package/build/CarouselNotice/style.js +2 -1
  5. package/build/CitySelect/data.d.ts +1 -1
  6. package/build/CitySelect/data.js +2277 -1674
  7. package/build/CitySelect/index.d.ts +2 -1
  8. package/build/CitySelect/index.js +35 -9
  9. package/build/CitySelect/style.js +52 -48
  10. package/build/Container/index.js +21 -1
  11. package/build/DatePicker/style.d.ts +1 -1
  12. package/build/DatePicker/style.js +39 -35
  13. package/build/Indicator/index.js +2 -1
  14. package/build/Loading/Wrapper.js +3 -2
  15. package/build/Loading/style.js +7 -6
  16. package/build/MapLocationSelection/buildSelectedLocation.d.ts +16 -0
  17. package/build/MapLocationSelection/buildSelectedLocation.js +123 -0
  18. package/build/MapLocationSelection/createProvider.d.ts +8 -0
  19. package/build/MapLocationSelection/createProvider.js +33 -0
  20. package/build/MapLocationSelection/getLocation.d.ts +8 -0
  21. package/build/MapLocationSelection/getLocation.js +112 -0
  22. package/build/MapLocationSelection/index.d.ts +16 -0
  23. package/build/MapLocationSelection/index.js +985 -0
  24. package/build/MapLocationSelection/loader.amap.d.ts +48 -0
  25. package/build/MapLocationSelection/loader.amap.js +125 -0
  26. package/build/MapLocationSelection/loader.bmap.d.ts +8 -0
  27. package/build/MapLocationSelection/loader.bmap.js +60 -0
  28. package/build/MapLocationSelection/provider.amap.d.ts +38 -0
  29. package/build/MapLocationSelection/provider.amap.js +659 -0
  30. package/build/MapLocationSelection/provider.bmap.d.ts +36 -0
  31. package/build/MapLocationSelection/provider.bmap.js +837 -0
  32. package/build/MapLocationSelection/provider.d.ts +45 -0
  33. package/build/MapLocationSelection/provider.js +10 -0
  34. package/build/MapLocationSelection/style.d.ts +4 -0
  35. package/build/MapLocationSelection/style.js +442 -0
  36. package/build/MapLocationSelection/types.d.ts +29 -0
  37. package/build/MapLocationSelection/types.js +22 -0
  38. package/build/MapLocationSelection/userMarker.d.ts +2 -0
  39. package/build/MapLocationSelection/userMarker.js +95 -0
  40. package/build/RegionPicker/data.js +974 -992
  41. package/build/RegionPicker/index.d.ts +3 -2
  42. package/build/RegionPicker/index.js +29 -10
  43. package/build/RegionPicker/style.js +54 -49
  44. package/build/ScrollView/style.js +5 -4
  45. package/build/Toast/style.js +6 -5
  46. package/build/index.d.ts +3 -0
  47. package/build/index.js +8 -2
  48. package/build/utils/rem.d.ts +1 -0
  49. package/build/utils/rem.js +48 -0
  50. package/package.json +2 -2
  51. package/test/src/city-select/index.jsx +28 -15
  52. package/test/src/index/index.jsx +5 -0
  53. package/test/src/index.jsx +1 -0
  54. package/test/src/map-location-selection/index.jsx +192 -0
  55. package/test/src/region-picker/index.jsx +29 -21
@@ -0,0 +1,985 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.getLocation = void 0;
13
+ exports.MapLocationSelection = MapLocationSelection;
14
+ exports.showMapLocationSelection = showMapLocationSelection;
15
+ const jsx_runtime_1 = require("@emotion/react/jsx-runtime");
16
+ const react_1 = require("react");
17
+ const Dialog_1 = require("../Dialog");
18
+ const Clickable_1 = require("../Clickable");
19
+ const ScrollView_1 = require("../ScrollView");
20
+ const style_1 = require("./style");
21
+ const types_1 = require("./types");
22
+ const createProvider_1 = require("./createProvider");
23
+ const buildSelectedLocation_1 = require("./buildSelectedLocation");
24
+ var getLocation_1 = require("./getLocation");
25
+ Object.defineProperty(exports, "getLocation", { enumerable: true, get: function () { return getLocation_1.getLocation; } });
26
+ // 周边搜索结果排序:距离从近到远(distance 缺失时排到最后)。
27
+ function sortByDistance(items) {
28
+ return [...items].sort((a, b) => {
29
+ var _a, _b;
30
+ return ((_a = a.distance) !== null && _a !== void 0 ? _a : Number.POSITIVE_INFINITY) -
31
+ ((_b = b.distance) !== null && _b !== void 0 ? _b : Number.POSITIVE_INFINITY);
32
+ });
33
+ }
34
+ // 关键字搜索结果排序:「名称命中度分级 + 同档距离升序」。
35
+ //
36
+ // 为什么 keyword 模式不能用 sortByDistance:
37
+ // * 高德 SDK 对"虹桥火车站"会拆 token 做模糊匹配,"浦东虹桥花园"等仅含"虹桥"
38
+ // 的近距离 POI 会大量挤入 4-9km 这一档,把真实"虹桥火车站"(28km 外的精确
39
+ // 命中)一路压到很后面,用户在首屏完全看不到想搜的目标。
40
+ // * 百度数据库里站点出入口/商铺等以独立 POI 注册,按距离排虽然能进列表但顺序
41
+ // 混乱("上海近虹桥火车站民宿"5km 在前、"虹桥火车站东出口"17.8km 在后),
42
+ // 用户得自行甄别。
43
+ // * 跨城市搜索(在上海搜"天安门")时,本地若有任何"天安门XX 分店"等同名子串
44
+ // POI,单纯按距离排会把它顶到首位,把 1075km 外**真正的**北京天安门挤到末尾。
45
+ // 微信"发送位置"的体感是远端唯一精确命中应该出现在顶部——靠 tier 0(精确等于)
46
+ // 兜住这个语义。
47
+ //
48
+ // 命中等级:
49
+ // tier 0:name 完全等于 keyword("天安门"=="天安门"、"虹桥火车站"=="虹桥火车站")。
50
+ // 即使 1000km 外也强制顶到列表首位——这是"用户搜的就是这个"的最强信号。
51
+ // tier 1:name 含完整 keyword 且非 tier 0("天安门广场"、"虹桥火车站东出口"、
52
+ // "上海虹桥火车站民宿"等)。同档按距离升序,本地命中天然在远端命中之上。
53
+ // tier 2:name 不含但 address 含完整 keyword(街道地址里出现关键字的)。
54
+ // tier 3:其余仅 SDK 拆 token 命中的模糊匹配("浦东虹桥花园"等)。
55
+ function sortByKeywordRelevance(items, keyword) {
56
+ const kw = keyword.trim();
57
+ if (!kw)
58
+ return sortByDistance(items);
59
+ const tier = (item) => {
60
+ var _a, _b;
61
+ const name = (_a = item.name) !== null && _a !== void 0 ? _a : "";
62
+ if (name === kw)
63
+ return 0;
64
+ if (name.includes(kw))
65
+ return 1;
66
+ if (((_b = item.address) !== null && _b !== void 0 ? _b : "").includes(kw))
67
+ return 2;
68
+ return 3;
69
+ };
70
+ return [...items].sort((a, b) => {
71
+ var _a, _b;
72
+ const ta = tier(a);
73
+ const tb = tier(b);
74
+ if (ta !== tb)
75
+ return ta - tb;
76
+ return (((_a = a.distance) !== null && _a !== void 0 ? _a : Number.POSITIVE_INFINITY) -
77
+ ((_b = b.distance) !== null && _b !== void 0 ? _b : Number.POSITIVE_INFINITY));
78
+ });
79
+ }
80
+ // 把 POI 列表的 distance 字段重写成「POI ↔ 地图中心 (centerPin)」。
81
+ //
82
+ // 打车 / 网约车「上车点」选择场景的关键不变量:用户拖到哪儿,列表就展示
83
+ // "离那里最近的 POI",distance 直接告诉用户「我刚好选在这家酒店门口 5m」。
84
+ // 跟"POI ↔ 我当前位置"完全不同——后者在用户拖图选别处上车点(如帮家人接机时
85
+ // 选远处机场出口)时数值动辄上千米,毫无意义。
86
+ //
87
+ // 高德 / 百度 SDK 自带的 distance 字段口径不一(searchNearBy 给的是
88
+ // "POI ↔ 搜索中心",但 child_pois 展开后会丢失),统一在 UI 层用 haversine
89
+ // 重算,保证排序口径绝对一致。
90
+ function rewriteDistanceFromCenter(items, center) {
91
+ return items.map((item) => (Object.assign(Object.assign({}, item), { distance: (0, types_1.haversineMeters)(center[0], center[1], item.location.lng, item.location.lat) })));
92
+ }
93
+ // 列表头部「当前位置」虚拟项的固定 id。
94
+ //
95
+ // 与真实 POI 的 id 解耦:真实 POI id 来自 SDK(高德 amap_poi_xxx、百度 b0_xxx
96
+ // 等格式),不会撞这个保留前缀,可放心做 === 比对。用途:
97
+ // * list 渲染:识别"这条是当前位置项"→ 强制 active=true;
98
+ // * handlePickItem:识别"用户点了当前位置项"→ no-op(已经选中,无需操作);
99
+ // * onSelect 路径不需要识别它——handleConfirm 走 centerRef + reverseCache,
100
+ // 从不读列表项 id,CURRENT_LOCATION_ID 永远不会进入回调输出。
101
+ const CURRENT_LOCATION_ID = "__current_location__";
102
+ function MapLocationSelection(props) {
103
+ const { primary = style_1.DEFAULT_PRIMARY, initialCenter, initialCity, searchRadius = 200, pageSize = 20, allowIpFallback = false, onClose, onSelect, } = props;
104
+ const style = (0, react_1.useMemo)(() => (0, style_1.createStyle)(primary), [primary]);
105
+ const safePageSize = Math.min(50, Math.max(1, pageSize));
106
+ // ===== 状态 =====
107
+ // 周边 POI 列表:以地图当前中心为基准、按距离从近到远的精确 POI(楼宇 / 酒店 /
108
+ // 学校 / 小区楼栋 等大类全开,详见 provider.amap.ts NEARBY_POI_TYPE)。
109
+ // 拖图 / 点图 / 回到当前位置 / 列表翻页都通过 commitMapCenter / searchAroundMore
110
+ // 维护这一份。**搜索框为空时**列表展示的就是它。
111
+ const [poiList, setPoiList] = (0, react_1.useState)([]);
112
+ // 用户在列表里点选的项 id;**只在用户主动点击时才有值**——拖图后默认 null
113
+ // (列表全部不勾选),避免"系统默认选中 list[0]"被误以为是用户选择,从而把
114
+ // 离 centerPin 几米~几十米的 POI 当成上车点。仅作为列表行高亮的视觉反馈,
115
+ // 不参与「确定」回调(那一步永远以 centerRef + reverseGeocode 为真值)。
116
+ const [selectedId, setSelectedId] = (0, react_1.useState)(null);
117
+ const [errorMsg, setErrorMsg] = (0, react_1.useState)(null);
118
+ const [hasMore, setHasMore] = (0, react_1.useState)(false);
119
+ // 搜索关键字:清空时 tips 也回到 null,列表自动 fallback 到 poiList(centerPin 周边)。
120
+ const [keyword, setKeyword] = (0, react_1.useState)("");
121
+ // 中文 / 韩文 / 日文输入法 composing 状态:composition 期间不发起搜索,
122
+ // 避免每次按键都打一次接口(也避免拼音/候选词阶段的"半字符"被当成关键字)。
123
+ const [composing, setComposing] = (0, react_1.useState)(false);
124
+ // 搜索结果列表。
125
+ // - null:当前是"周边模式",UI 渲染 poiList;
126
+ // - [] :搜索完成但 0 命中("未找到相关地点"提示);
127
+ // - [..]:搜索命中,UI 渲染搜索结果。
128
+ // 需要把"周边模式"和"搜索模式"严格区分,因为:1) 翻页只在周边模式生效;
129
+ // 2) 选中项点击后搜索模式要清搜索框、周边模式要保持列表稳定。
130
+ const [tips, setTips] = (0, react_1.useState)(null);
131
+ // "定位中":加载地图 SDK 或首次定位期间展示遮罩,阻断交互
132
+ // (仅在地图尚未就绪 / 首次定位拿到结果之前出现;点击 locateBtn 重定位不再走这套)
133
+ const [locating, setLocating] = (0, react_1.useState)(true);
134
+ // 「回到当前位置」按钮自身的轻量 loading:仅按钮显示 spinner,地图与列表保持可交互
135
+ const [btnLocating, setBtnLocating] = (0, react_1.useState)(false);
136
+ // 列表加载中:commitMapCenter 拉周边 / keyword effect 拉搜索结果**进入飞行中**时
137
+ // 为 true,结果回写完成切回 false。**仅控制列表区半透明遮罩**,不阻断搜索框输入
138
+ // (keyword 搜索期间用户可继续编辑关键字,新一次 effect 会接管 loading)。
139
+ // 翻页(searchAroundMore)不动这个状态——翻页用 ScrollView 自身底部 spinner。
140
+ const [listLoading, setListLoading] = (0, react_1.useState)(false);
141
+ // 地图中心 pin 动画状态:'idle' | 'lifted' | 'drop'
142
+ const [pinPhase, setPinPhase] = (0, react_1.useState)("idle");
143
+ const dropTimerRef = (0, react_1.useRef)(null);
144
+ // 「列表头部当前位置项」重算触发器。
145
+ //
146
+ // 当前位置项的数据源(centerRef / commitSeqRef / reverseCacheRef)都是 ref,
147
+ // ref 变化不会触发 React 重渲染——bumpCurrentItem 推这个 state 让派生的
148
+ // currentItem useMemo 重算。poiList(用于 nearestPoi 兜底)本身是 state,
149
+ // 自动触发依赖它的 useMemo,不需要 bump。
150
+ //
151
+ // 触发时机:commitMapCenter 入口(centerRef 变更)+ commitMapCenter 内
152
+ // rgPromise.then(reverseCacheRef 写入)。其他路径(拖图触发的 moveend、
153
+ // 程序化定位 moveTo 等)最终都会走到 commitMapCenter,无需单独 bump。
154
+ const [currentItemVersion, setCurrentItemVersion] = (0, react_1.useState)(0);
155
+ const bumpCurrentItem = (0, react_1.useCallback)(() => {
156
+ setCurrentItemVersion((v) => v + 1);
157
+ }, []);
158
+ // 用户真实 GPS 定位坐标([lng, lat]),仅在两个时机更新:
159
+ // 1) 组件初始化时的自动 geolocate 成功;
160
+ // 2) 用户点"回到当前位置"按钮 handleLocate 成功。
161
+ // 用途:列表项的距离展示("距离我多远"),以及当前位置项的 distance
162
+ // (centerPin ↔ 用户真实位置)。
163
+ // 为什么是 state 不是 ref:列表渲染需要这个值参与 displayDistance 计算,
164
+ // ref 变化不会触发重渲染;state 在用户回到当前位置后能立即让所有距离值刷新。
165
+ // null 表示 GPS 不可用 / 被拒 / 未授权——此时 displayDistance 退化到
166
+ // item.distance(即 POI ↔ centerPin),保持原行为。
167
+ const [userLocation, setUserLocation] = (0, react_1.useState)(null);
168
+ // ===== refs =====
169
+ const mapElRef = (0, react_1.useRef)(null);
170
+ // 列表 ScrollView 命令式句柄:每次列表"全量刷新"(commit / 搜索)后调
171
+ // scrollToTop 把滚动位置回到顶部,与微信 / 高德 App 拉新列表时的体感对齐。
172
+ // 翻页(searchAroundMore)追加场景**不**调,避免把用户拉到顶。
173
+ const scrollViewRef = (0, react_1.useRef)(null);
174
+ // provider 是稳定引用:在 useEffect 内一次性创建并 init,组件卸载时销毁
175
+ const providerRef = (0, react_1.useRef)(null);
176
+ // 标记"当前的 setCenter 是程序触发",避免 moveend 形成回环(程序化移动期间
177
+ // moveend 来临时不再二次 commitMapCenter)。
178
+ const programmaticMoveRef = (0, react_1.useRef)(false);
179
+ // 最新的中心点(用于「确定」回调输出 / 翻页起算坐标)。
180
+ // 打车场景核心契约:经纬度永远来自这个 ref(centerPin 真值,100% 精准),
181
+ // **不**从列表挑 POI——POI 与 centerPin 天然有几米~几十米误差不能用作真值。
182
+ const centerRef = (0, react_1.useRef)(initialCenter !== null && initialCenter !== void 0 ? initialCenter : null);
183
+ // centerPin 当前坐标对应的逆地理结果缓存。**严格只存 provider.reverseGeocode
184
+ // 的官方反查结果**——保证 province/city/district + 详细 address 都齐全。
185
+ // commitMapCenter 后台触发 reverseGeocode 写入这里;handleConfirm 命中 cache 时
186
+ // 即可省掉一次网络请求。
187
+ // seq 用 commitSeqRef.current 同步:旧 seq 的回调到达时会被丢弃,避免读到过期数据。
188
+ const reverseCacheRef = (0, react_1.useRef)(null);
189
+ // commitMapCenter / handlePickItem 中 reverseGeocode 的「在飞 promise」。
190
+ // handleConfirm 缓存未命中时优先 await 这条 promise(已发起的,等它回来即可),
191
+ // 避免在原已发起的请求外重复多发一次。
192
+ const reversePendingRef = (0, react_1.useRef)(null);
193
+ // 用户**主动**点过的列表项(不是默认勾选)。仅用于「确定」回调的 name 优先级——
194
+ // POI 的 name 通常是"楼栋 / 商铺 / 出入口"级别,比反查 API 的 "街道+门牌"
195
+ // 更精准、对司机更友好。但 POI 经常缺 province/city/district,所以
196
+ // address / 行政区划仍以 reverseCacheRef 的官方反查结果为准。
197
+ // seq 跟 commitSeqRef.current 同步——拖图后 seq 推进,老 POI 选择失效。
198
+ const pickedPoiRef = (0, react_1.useRef)(null);
199
+ // poiList 的 ref 镜像:handleConfirm 在「地图选址、未点列表」时要从最近 POI
200
+ // 取 name/address 兜底(解决 reverseGeocode fallback 到"陆家嘴街道""高桥镇"等
201
+ // 粗粒度 township 的体验问题),但 useCallback 不想把 poiList state 加到依赖
202
+ // 里(避免每次列表更新都重建 handleConfirm)。用 ref 镜像保证读到最新值且
203
+ // 闭包稳定。
204
+ const poiListRef = (0, react_1.useRef)([]);
205
+ // 翻页:当前 page、当前正在查询的 page、当前中心、加载锁
206
+ const pageIndexRef = (0, react_1.useRef)(1);
207
+ const searchCenterRef = (0, react_1.useRef)(null);
208
+ const loadingMoreRef = (0, react_1.useRef)(false);
209
+ // 错误提示的最新值(避免 setState 闭包过期)
210
+ const errorMsgRef = (0, react_1.useRef)(null);
211
+ errorMsgRef.current = errorMsg;
212
+ // 「地图中心已变」的最新回调引用,让一次性注册的 map 事件闭包能调到最新状态
213
+ const commitMapCenterRef = (0, react_1.useRef)(() => { });
214
+ // 手指/鼠标是否正在地图上(pointerdown 后、pointerup 前)
215
+ const interactingRef = (0, react_1.useRef)(false);
216
+ // 地图是否正在运动(movestart 后、moveend 前)
217
+ const mapMovingRef = (0, react_1.useRef)(false);
218
+ // pin 动画状态的最新值,以供事件回调读取
219
+ const pinPhaseRef = (0, react_1.useRef)("idle");
220
+ pinPhaseRef.current = pinPhase;
221
+ // poiList ref 镜像:每次渲染同步一次,保证 handleConfirm 能读到最新列表。
222
+ // 配合上方 poiListRef 的声明使用。直接用赋值而非 useEffect——render 期赋值
223
+ // ref 在 React 里是被允许的(且立即生效,无需等 commit 阶段)。
224
+ poiListRef.current = poiList;
225
+ // 拆除绑在 mapEl 上的 pointer 事件监听
226
+ const cleanupPointerRef = (0, react_1.useRef)(null);
227
+ // commitMapCenter 抢占 seq:拖图 / 点击 / 回到当前位置连续触发时,旧的 fetchAround
228
+ // 回调抵达后若不是最新一次直接丢弃,避免老结果回来覆盖新中心的 poiList。
229
+ const commitSeqRef = (0, react_1.useRef)(0);
230
+ // handleConfirm 的互斥锁:用户在 reverseGeocode 网络等待中再次点击确定时,
231
+ // 防止 onSelect 被多次回调(业务侧重复处理订单)。一次进入即"成立",不解锁——
232
+ // onClose 后组件销毁,互斥锁随组件 unmount 一起被回收。
233
+ const confirmingRef = (0, react_1.useRef)(false);
234
+ // 关键字搜索的抢占 seq。useEffect 中 debounce 后发起的请求若回来时 seq 已过期
235
+ // (用户继续输入 / 清空了搜索框)直接丢弃。与 provider 内部的 keywordSeq 配合
236
+ // 形成双层保护:组件层管 UI 应不应该接收,provider 层管 SDK 回调应不应该 resolve。
237
+ const keywordSeqRef = (0, react_1.useRef)(0);
238
+ const fetchAround = (0, react_1.useCallback)((lng, lat, page) => __awaiter(this, void 0, void 0, function* () {
239
+ const provider = providerRef.current;
240
+ if (!provider)
241
+ return null;
242
+ let result;
243
+ try {
244
+ result = yield provider.searchAround([lng, lat], {
245
+ page,
246
+ pageSize: safePageSize,
247
+ radius: searchRadius,
248
+ });
249
+ }
250
+ catch (err) {
251
+ console.warn("[MapLocationSelection] searchAround 失败:", err);
252
+ return { ok: false, error: (err === null || err === void 0 ? void 0 : err.message) || "地点服务不可用" };
253
+ }
254
+ const rewritten = rewriteDistanceFromCenter(result.pois, [lng, lat]);
255
+ const list = sortByDistance(rewritten);
256
+ return { ok: true, list, hasMore: result.hasMore };
257
+ }), [safePageSize, searchRadius]);
258
+ // ===== 周边搜索:翻页(追加到 poiList 尾)=====
259
+ // 仅 onReachBottom 调用。本轮 page+1,原列表保留并 append。
260
+ // 翻页**不重置 selectedId**——用户在新追加的项里看到更远的目标时仍可保留之前的选择。
261
+ const searchAroundMore = (0, react_1.useCallback)(() => __awaiter(this, void 0, void 0, function* () {
262
+ const center = searchCenterRef.current;
263
+ if (!center)
264
+ return;
265
+ if (!hasMore)
266
+ return; // provider 已表态后续无更多
267
+ const targetPage = pageIndexRef.current + 1;
268
+ loadingMoreRef.current = true;
269
+ const res = yield fetchAround(center[0], center[1], targetPage);
270
+ loadingMoreRef.current = false;
271
+ if (!res || !res.ok)
272
+ return;
273
+ if (res.list.length === 0) {
274
+ setHasMore(false);
275
+ return;
276
+ }
277
+ pageIndexRef.current = targetPage;
278
+ setPoiList((prev) => {
279
+ const existed = new Set(prev.map((p) => p.id));
280
+ const merged = [...prev];
281
+ for (const item of res.list) {
282
+ if (!existed.has(item.id))
283
+ merged.push(item);
284
+ }
285
+ return merged;
286
+ });
287
+ setHasMore(res.hasMore);
288
+ }), [fetchAround, hasMore]);
289
+ // 翻页(onReachBottom)
290
+ // 搜索模式(tips !== null)禁用翻页:provider.searchByKeyword 不返回分页元数据,
291
+ // 翻页接口语义不一致,强行翻页会拿到错乱顺序的结果。
292
+ const handleReachBottom = (0, react_1.useCallback)(() => {
293
+ if (loadingMoreRef.current)
294
+ return;
295
+ if (!hasMore)
296
+ return;
297
+ if (tips !== null)
298
+ return;
299
+ searchAroundMore();
300
+ }, [hasMore, searchAroundMore, tips]);
301
+ // ===== 「地图中心已变」的统一回调 =====
302
+ // 任何让中心发生变化的入口(拖图静止后、点击地图、回到当前位置、init)都收敛到这里:
303
+ // 1. 推进 commitSeqRef 抢占式 seq;
304
+ // 2. **并发**触发两件事:
305
+ // a) fetchAround 拉新一轮周边 POI(page=1)—— 给列表渲染用;
306
+ // b) provider.reverseGeocode 反查 centerPin 真实地名 —— 给「确定」回调兜底。
307
+ // 这个反查与列表的 POI 完全独立——列表只是辅助参考,最终回调用的地名以
308
+ // centerPin 经纬度逆地理为准(打车场景的"100% 精准"契约)。
309
+ // 3. 拿到新 list 后 setPoiList,**清空 selectedId**——列表项默认不勾选,
310
+ // list[0] 与 centerPin 之间天然有 5~80m 误差,默认勾选会让用户误以为
311
+ // "选了这个 POI"导致司机被导航到错误位置。仅当用户主动点列表项时才有 selectedId。
312
+ // 4. reverseGeocode 回来后写入 reverseCacheRef,handleConfirm 优先读它。
313
+ // 抢占式 seq:连续触发只保留最后一次的结果,避免「拖到 A → 拖到 B → A 的回调
314
+ // 才回来覆盖 B 的列表 / 缓存」的竞态。
315
+ const commitMapCenter = (0, react_1.useCallback)((lng, lat) => __awaiter(this, void 0, void 0, function* () {
316
+ const provider = providerRef.current;
317
+ if (!provider)
318
+ return;
319
+ const seq = ++commitSeqRef.current;
320
+ centerRef.current = [lng, lat];
321
+ pageIndexRef.current = 1;
322
+ searchCenterRef.current = [lng, lat];
323
+ // 触发列表头部当前位置项重算:center / commitSeqRef 已新——旧 reverseCache
324
+ // 因 seq 不匹配会被识别为 stale,currentItem useMemo 立刻给到「占位形态」
325
+ // (仅有经纬度兜底);rgPromise 回来后再 bump 一次覆写真值。
326
+ bumpCurrentItem();
327
+ // **进入加载态**:列表区半透明遮罩 + spinner 即刻显示。
328
+ //
329
+ // 抢占式 seq 下的 loading 复位策略:
330
+ // - 早退分支(seq 已被新 seq 覆盖)**不复位** loading,因为新 seq 进入时
331
+ // 已经又 setListLoading(true) 了一次,最终最后一次成功的会复位;
332
+ // - 当前 seq 走完正常 setPoiList 分支后才 setListLoading(false);
333
+ // - fetchAround 内部已 try/catch 异常并返回 { ok: false },外层不会抛——
334
+ // 所以 loading 一定会到一次复位(不会卡死)。
335
+ setListLoading(true);
336
+ // 后台并发发起反向地理编码,把 promise 存起来供 handleConfirm 复用。
337
+ // .catch 收住异常避免「未处理的 rejection」warning(reverseGeocode 失败时返回 null)。
338
+ const rgPromise = provider.reverseGeocode([lng, lat]).catch(() => null);
339
+ reversePendingRef.current = { seq, promise: rgPromise };
340
+ rgPromise.then((rg) => {
341
+ if (seq !== commitSeqRef.current)
342
+ return; // 已被新 seq 作废
343
+ if (!rg)
344
+ return;
345
+ reverseCacheRef.current = Object.assign(Object.assign({}, rg), { seq });
346
+ // 反查回来后再 bump:currentItem useMemo 用真值(含真实地名 / 详细
347
+ // address)覆写之前的占位形态。
348
+ bumpCurrentItem();
349
+ });
350
+ const aroundRes = yield fetchAround(lng, lat, 1);
351
+ if (seq !== commitSeqRef.current)
352
+ return;
353
+ // **不默认勾选 list[0]**:list[0] 是"离 centerPin 最近的 POI",但**不等于**centerPin
354
+ // 真值——通常有 5~80m 误差。打车场景下默认勾选会让用户误以为"选了这个 POI",
355
+ // 导致司机被导航到 POI 而非用户实际拖动到的精准位置。
356
+ // 用户主动点击列表项时才设置 selectedId(见 handlePickItem),那一刻才是
357
+ // "用户主动认领某个 POI 当作上车点"的真实信号。
358
+ if (aroundRes && aroundRes.ok) {
359
+ setPoiList(aroundRes.list);
360
+ setHasMore(aroundRes.hasMore);
361
+ setSelectedId(null);
362
+ if (errorMsgRef.current)
363
+ setErrorMsg(null);
364
+ }
365
+ else {
366
+ setPoiList([]);
367
+ setHasMore(false);
368
+ setSelectedId(null);
369
+ if (aroundRes && !aroundRes.ok) {
370
+ setErrorMsg(aroundRes.error);
371
+ }
372
+ }
373
+ setListLoading(false);
374
+ // 列表全量刷新后回到顶部:与微信 / 高德 App 拉新列表的体感对齐。
375
+ // requestAnimationFrame 确保在 React commit 之后执行——这一帧 ScrollView
376
+ // 的 DOM 已经渲染了新内容,scrollTop=0 恰好让用户看到 list[0]。
377
+ requestAnimationFrame(() => { var _a; return (_a = scrollViewRef.current) === null || _a === void 0 ? void 0 : _a.scrollToTop(); });
378
+ }), [fetchAround, bumpCurrentItem]);
379
+ commitMapCenterRef.current = commitMapCenter;
380
+ // ===== 程序化定位到某个坐标(屏蔽 moveend 自动搜索回环)=====
381
+ // 仅 handleLocate / init 用——把 centerPin 飞到目标点,然后主动调一次
382
+ // commitMapCenter 刷新列表,并通过 programmaticMoveRef 让 moveend 不重复跑一遍。
383
+ const moveTo = (0, react_1.useCallback)((lng, lat, zoom) => {
384
+ const provider = providerRef.current;
385
+ if (!provider)
386
+ return;
387
+ programmaticMoveRef.current = true;
388
+ provider.setCenter([lng, lat], zoom);
389
+ commitMapCenter(lng, lat);
390
+ }, [commitMapCenter]);
391
+ // ===== 地图视角飞行:仅同步地图中心,**不刷新列表 / 不重置 selectedId** =====
392
+ // 专给 handlePickItem 用:用户在列表里点选某 POI 后,让地图飞过去给视觉确认,
393
+ // 但列表内容、当前选中态都保持稳定。programmaticMoveRef 让 moveend 来临时
394
+ // 直接 return,不触发 commitMapCenter——避免"飞过去 → 拉新列表 → selectedId 被
395
+ // 重置回 list[0] → 用户的选择丢失"的回环(这是旧版闪烁问题的根因)。
396
+ //
397
+ // 注意 setCenter 不传 zoom:保留用户当前的缩放层级,避免视觉跳变。
398
+ //
399
+ // **同步推进 commitSeqRef**:作废任何进行中的 reverseGeocode 异步回调
400
+ //(例如旧位置的反查回来时不再写入缓存),保证 reverseCacheRef 跟最新 centerRef 一致。
401
+ const flyMapTo = (0, react_1.useCallback)((lng, lat) => {
402
+ const provider = providerRef.current;
403
+ if (!provider)
404
+ return;
405
+ programmaticMoveRef.current = true;
406
+ provider.setCenter([lng, lat]);
407
+ centerRef.current = [lng, lat];
408
+ commitSeqRef.current += 1;
409
+ reversePendingRef.current = null;
410
+ }, []);
411
+ // ===== 「回到当前位置」按钮 =====
412
+ // 用 silent 定位 + 按钮自身 spinner,避免每次都掀起整屏遮罩 → 体感更快、地图与列表保持可交互。
413
+ // 连续点击由 provider.geolocate 内部去抖(共享 in-flight Promise),不会触发多次 GPS。
414
+ const handleLocate = (0, react_1.useCallback)(() => __awaiter(this, void 0, void 0, function* () {
415
+ if (btnLocating)
416
+ return;
417
+ const provider = providerRef.current;
418
+ if (!provider)
419
+ return;
420
+ setBtnLocating(true);
421
+ try {
422
+ const pos = yield provider.geolocate({ allowIpFallback });
423
+ if (!pos)
424
+ return;
425
+ provider.upsertUserMarker(pos);
426
+ // 更新真实 GPS 坐标 → 让列表所有项 + 当前位置项的距离展示按"距离我"刷新。
427
+ // 此时 centerPin 也会通过 moveTo → commitMapCenter 同步到 pos,所以
428
+ // 当前位置项的距离会变为 0m(真正"重合时为 0")。
429
+ setUserLocation(pos);
430
+ moveTo(pos[0], pos[1], 16);
431
+ }
432
+ finally {
433
+ setBtnLocating(false);
434
+ }
435
+ }), [btnLocating, moveTo, allowIpFallback]);
436
+ // ===== 初始化地图(仅一次)=====
437
+ (0, react_1.useEffect)(() => {
438
+ let cancelled = false;
439
+ let provider = null;
440
+ (() => __awaiter(this, void 0, void 0, function* () {
441
+ var _a, _b, _c;
442
+ try {
443
+ provider = (0, createProvider_1.createProvider)(props);
444
+ providerRef.current = provider;
445
+ if (!mapElRef.current)
446
+ return;
447
+ yield provider.init({
448
+ container: mapElRef.current,
449
+ initialCenter,
450
+ initialZoom: 16,
451
+ initialCity,
452
+ primary,
453
+ });
454
+ if (cancelled)
455
+ return;
456
+ // 同步 centerRef:以 provider 的实际中心为准(fallback 等情况由 provider 决定)
457
+ const c0 = provider.getCenter();
458
+ centerRef.current = c0;
459
+ // 触发一次"落下 + 弹跳"动画(时长 = drop keyframes 0.5s + 缓冲)
460
+ const triggerDrop = () => {
461
+ setPinPhase("drop");
462
+ if (dropTimerRef.current) {
463
+ window.clearTimeout(dropTimerRef.current);
464
+ }
465
+ dropTimerRef.current = window.setTimeout(() => {
466
+ setPinPhase("idle");
467
+ dropTimerRef.current = null;
468
+ }, 540);
469
+ };
470
+ // 地图开始移动 → 抬起中心图钉
471
+ provider.on("movestart", () => {
472
+ mapMovingRef.current = true;
473
+ if (dropTimerRef.current) {
474
+ window.clearTimeout(dropTimerRef.current);
475
+ dropTimerRef.current = null;
476
+ }
477
+ setPinPhase("lifted");
478
+ });
479
+ // 地图移动结束 → 更新中心点 + 刷新周边 POI
480
+ // 注意:手指仍按在地图上时(user 未抬起),不触发搜索、不触发落下动画
481
+ provider.on("moveend", () => {
482
+ mapMovingRef.current = false;
483
+ if (!providerRef.current)
484
+ return;
485
+ const c = providerRef.current.getCenter();
486
+ centerRef.current = c;
487
+ if (programmaticMoveRef.current) {
488
+ programmaticMoveRef.current = false;
489
+ triggerDrop();
490
+ return;
491
+ }
492
+ if (interactingRef.current) {
493
+ // 用户手指仍按在地图上:保持抬起,等到 pointerup 再处理
494
+ return;
495
+ }
496
+ triggerDrop();
497
+ commitMapCenterRef.current(c[0], c[1]);
498
+ });
499
+ // 地图点击:跟拖图同语义——把中心切到点击点,列表重新以新中心为基础刷新。
500
+ // 不通过 moveTo(避免 init 一次性闭包持有旧引用),直接走 commitMapCenterRef 转发。
501
+ provider.on("click", (lng, lat) => {
502
+ var _a;
503
+ programmaticMoveRef.current = true;
504
+ (_a = providerRef.current) === null || _a === void 0 ? void 0 : _a.setCenter([lng, lat]);
505
+ commitMapCenterRef.current(lng, lat);
506
+ });
507
+ // 接管手势:手指按下/离开地图时控制 pin 的抬起与落下
508
+ const mapEl = mapElRef.current;
509
+ const onPointerDown = () => {
510
+ interactingRef.current = true;
511
+ if (dropTimerRef.current) {
512
+ window.clearTimeout(dropTimerRef.current);
513
+ dropTimerRef.current = null;
514
+ }
515
+ };
516
+ const onPointerUp = () => {
517
+ if (!interactingRef.current)
518
+ return;
519
+ interactingRef.current = false;
520
+ // 若此时地图已停止(用户拖动后停顿再松手),手动触发落下 + 同步列表
521
+ if (!mapMovingRef.current && pinPhaseRef.current === "lifted") {
522
+ const center = centerRef.current;
523
+ if (center) {
524
+ triggerDrop();
525
+ commitMapCenterRef.current(center[0], center[1]);
526
+ }
527
+ }
528
+ // 否则仍在惯性运动中,等 moveend 来处理
529
+ };
530
+ mapEl === null || mapEl === void 0 ? void 0 : mapEl.addEventListener("pointerdown", onPointerDown);
531
+ mapEl === null || mapEl === void 0 ? void 0 : mapEl.addEventListener("pointerup", onPointerUp);
532
+ mapEl === null || mapEl === void 0 ? void 0 : mapEl.addEventListener("pointercancel", onPointerUp);
533
+ cleanupPointerRef.current = () => {
534
+ mapEl === null || mapEl === void 0 ? void 0 : mapEl.removeEventListener("pointerdown", onPointerDown);
535
+ mapEl === null || mapEl === void 0 ? void 0 : mapEl.removeEventListener("pointerup", onPointerUp);
536
+ mapEl === null || mapEl === void 0 ? void 0 : mapEl.removeEventListener("pointercancel", onPointerUp);
537
+ };
538
+ // 首次 commit 策略(v2,修复"百度首次进入列表跑到 1000+ km 之外"):
539
+ //
540
+ // 老方案:无脑先用 c0(initialCenter 或 provider 内置 fallback = 北京)
541
+ // commit 一次,再 await geolocate → setCenter → commit 一次。问题在于:
542
+ // - 用户没传 initialCenter 时 c0 = 北京 fallback,第一次 fan-out 立刻
543
+ // 用北京中心拉周边;
544
+ // - 如果 geolocate 失败 / 被拒 / 极慢(典型场景:移动端无 HTTPS、用户拒绝
545
+ // 授权、移动数据定位 5-10s 才回),第二次 commit 不发生或迟到——
546
+ // 用户视觉上看到列表是北京 POI(与实际位置可能 1000+ km 之差);
547
+ // - 即便 geolocate 成功,那 5-10s 中间态也会先闪出错误 POI 列表。
548
+ //
549
+ // 新方案:
550
+ // * 提供了 initialCenter:c0 就是用户期望的中心,立刻 commit;
551
+ // * 没提供 initialCenter:**不立刻 commit fallback**,而是先 await
552
+ // geolocate 拿真值再 commit;geolocate 失败才用 fallback 兜底 commit。
553
+ // locating 遮罩本来就盖到 setLocating(false) 之前,列表区被挡住,
554
+ // 用户视觉上不会看到"先北京后上海"的中间错误态。
555
+ //
556
+ // 这条策略下,初次进入百度地图(无 initialCenter)的体验是:
557
+ // 遮罩盖住 → geolocate (1-3s) → setCenter 到真实位置 → commit 一次 → 关遮罩
558
+ // 全过程只有一次 fan-out,列表初始即为正确中心的周边。
559
+ if (initialCenter) {
560
+ commitMapCenterRef.current(c0[0], c0[1]);
561
+ setLocating(false);
562
+ }
563
+ else {
564
+ const pos = yield ((_a = providerRef.current) === null || _a === void 0 ? void 0 : _a.geolocate({
565
+ allowIpFallback,
566
+ }));
567
+ if (cancelled)
568
+ return;
569
+ if (pos) {
570
+ (_b = providerRef.current) === null || _b === void 0 ? void 0 : _b.upsertUserMarker(pos);
571
+ // 记下真实 GPS 坐标——列表项的距离展示口径需要它(POI ↔ 用户真实位置)。
572
+ // 此时 centerPin 也会被 setCenter 到 pos,所以当前位置项 distance = 0。
573
+ setUserLocation(pos);
574
+ programmaticMoveRef.current = true;
575
+ (_c = providerRef.current) === null || _c === void 0 ? void 0 : _c.setCenter(pos, 16);
576
+ commitMapCenterRef.current(pos[0], pos[1]);
577
+ }
578
+ else {
579
+ // geolocate 失败 / 被拒:用 c0(fallback 中心)兜底 commit 一次,
580
+ // 让组件至少能渲染出列表与 reverseGeocode 结果,业务方拿到的是
581
+ // fallback 经纬度——比"列表永远空 + 确定按钮无效"体验好。
582
+ commitMapCenterRef.current(c0[0], c0[1]);
583
+ }
584
+ if (cancelled)
585
+ return;
586
+ setLocating(false);
587
+ }
588
+ }
589
+ catch (err) {
590
+ if (cancelled)
591
+ return;
592
+ console.warn("[MapLocationSelection] init 失败:", err);
593
+ setErrorMsg((err === null || err === void 0 ? void 0 : err.message) || "地图加载失败");
594
+ setLocating(false);
595
+ }
596
+ }))();
597
+ return () => {
598
+ cancelled = true;
599
+ if (dropTimerRef.current) {
600
+ window.clearTimeout(dropTimerRef.current);
601
+ dropTimerRef.current = null;
602
+ }
603
+ if (cleanupPointerRef.current) {
604
+ cleanupPointerRef.current();
605
+ cleanupPointerRef.current = null;
606
+ }
607
+ try {
608
+ provider === null || provider === void 0 ? void 0 : provider.destroy();
609
+ }
610
+ catch (_a) {
611
+ // ignore
612
+ }
613
+ providerRef.current = null;
614
+ };
615
+ // 仅初始化一次
616
+ // eslint-disable-next-line react-hooks/exhaustive-deps
617
+ }, []);
618
+ // ===== 关键字搜索(debounced)=====
619
+ // 用户在搜索框输入:以 centerRef 为参考点调 provider.searchByKeyword,
620
+ // 返回的 POI 经过距离重写 + **名称命中度优先排序**后写入 tips。
621
+ //
622
+ // 距离与排序口径(与 nearby 列表故意不同):
623
+ // * 距离统一改写成「POI ↔ centerPin」(与 nearby 列表一致),让搜索结果里
624
+ // "30m 海底捞"和"180m 海底捞"的相对距离对用户更直观;
625
+ // * 排序用 sortByKeywordRelevance(名称包含完整 keyword 优先 + 同档距离升序)
626
+ // 而非纯 sortByDistance——纯距离排会让"虹桥火车站"等远距离精确命中被
627
+ // "浦东虹桥花园"等近距离 token 模糊命中挤出首屏,用户感觉"列表里没有
628
+ // 想搜的"。详见 sortByKeywordRelevance 头部注释。
629
+ //
630
+ // 防抖:250ms。每次按键产生新 seq,旧请求回来时 seq 不匹配直接丢弃;
631
+ // 清空 keyword(trim 后为空)会立刻把 tips 设回 null,无需等防抖到时。
632
+ // composing 期间(中文 / 日文输入法候选阶段)跳过,避免拼音字符被当成关键字。
633
+ (0, react_1.useEffect)(() => {
634
+ if (composing)
635
+ return;
636
+ const kw = keyword.trim();
637
+ if (!kw) {
638
+ // keyword 清空回到周边模式:tips 设 null 让 UI 立即切回 poiList(中间无
639
+ // 中间态闪烁);listLoading 也复位——keyword 清空不需要 loading 反馈。
640
+ keywordSeqRef.current += 1;
641
+ setTips(null);
642
+ setListLoading(false);
643
+ return;
644
+ }
645
+ const provider = providerRef.current;
646
+ const center = centerRef.current;
647
+ if (!provider || !center)
648
+ return;
649
+ const seq = ++keywordSeqRef.current;
650
+ const timer = window.setTimeout(() => __awaiter(this, void 0, void 0, function* () {
651
+ var _a;
652
+ // **进入加载态**:在防抖到时**之后**才 set loading,避免每打一个字都闪
653
+ // 一次 spinner(用户拼字阶段不应有 loading 反馈)。
654
+ setListLoading(true);
655
+ let list = [];
656
+ try {
657
+ list = yield provider.searchByKeyword(center, kw, {
658
+ page: 1,
659
+ pageSize: safePageSize,
660
+ radius: searchRadius,
661
+ });
662
+ }
663
+ catch (err) {
664
+ console.warn("[MapLocationSelection] searchByKeyword 失败:", err);
665
+ list = [];
666
+ }
667
+ if (seq !== keywordSeqRef.current)
668
+ return;
669
+ const baseCenter = (_a = centerRef.current) !== null && _a !== void 0 ? _a : center;
670
+ const rewritten = rewriteDistanceFromCenter(list, baseCenter);
671
+ setTips(sortByKeywordRelevance(rewritten, kw));
672
+ setListLoading(false);
673
+ // 搜索结果刷新后回到顶部:与周边模式刷新口径对齐。
674
+ requestAnimationFrame(() => { var _a; return (_a = scrollViewRef.current) === null || _a === void 0 ? void 0 : _a.scrollToTop(); });
675
+ }), 250);
676
+ return () => {
677
+ window.clearTimeout(timer);
678
+ };
679
+ }, [keyword, composing, safePageSize, searchRadius]);
680
+ // ===== 选择列表项(双向联动)=====
681
+ //
682
+ // 两种模式行为不同(**用户需求显式约定**):
683
+ //
684
+ // 1) 周边模式(tips === null)——保留 selectedId 视觉态、不刷新列表
685
+ // * 切换选中态(selectedId):UI 视觉反馈,标记"用户主动认领了这条 POI";
686
+ // * flyMapTo:地图 centerPin 飞到该 POI 上("用户选哪个,地图就指哪个"),
687
+ // 但**不**调 commitMapCenter——列表保持稳定,避免"点 A → 飞过去 →
688
+ // moveend → 拉新列表 → selectedId 被重置"的回环;
689
+ // * 写 pickedPoiRef:作为 handleConfirm 的 name 优先级来源(POI 名通常是
690
+ // 楼栋/商铺级,比反查的"街道+门牌"对司机更友好);
691
+ // * 后台 reverseGeocode:flyMapTo 已推进 commitSeqRef,老反查作废;
692
+ // 这里发起新一次反查,让 handleConfirm 能拿到该 POI 经纬度对应的
693
+ // 官方省市区 / 详细 address。
694
+ //
695
+ // 2) 搜索模式(tips !== null)——清搜索 + 刷新到周边列表
696
+ // * 用 moveTo(= setCenter + commitMapCenter):centerPin 飞到 POI、列表
697
+ // 重新拉成"以新中心为基准的周边列表";
698
+ // * setKeyword("") + setTips(null):搜索 effect 清理(keywordSeqRef 抢占
699
+ // 作废任何在飞的搜索请求),UI 回到周边模式;
700
+ // * **不主动 setSelectedId**:commitMapCenter 会把 selectedId 清掉
701
+ // (周边列表初始无选中态,符合"默认地址列表没有选中"的需求)。被点的 POI
702
+ // 此时已是 centerPin,会出现在新周边列表的最前面;
703
+ // * 仍写 pickedPoiRef(用 moveTo 后最新的 seq):让 handleConfirm 可优先
704
+ // 使用 POI 名("海底捞 浦东店"等楼栋/商铺级),比反查的"中山路 100 号"
705
+ // 对司机更友好。
706
+ const handlePickItem = (0, react_1.useCallback)((item) => {
707
+ // 用户点了表头的「当前位置」虚拟项——它已经永远处于 active 态,且就是
708
+ // centerPin 自己,flyMapTo 没有意义;setSelectedId(CURRENT_LOCATION_ID)
709
+ // 反而会把"用户没点过列表"的语义打破,导致下一次重渲染时该项消失。
710
+ // 直接 no-op 是最干净的处理。
711
+ if (item.id === CURRENT_LOCATION_ID)
712
+ return;
713
+ if (tips !== null) {
714
+ setKeyword("");
715
+ setTips(null);
716
+ moveTo(item.location.lng, item.location.lat);
717
+ const seq = commitSeqRef.current;
718
+ pickedPoiRef.current = {
719
+ seq,
720
+ name: item.name,
721
+ address: item.address,
722
+ };
723
+ return;
724
+ }
725
+ setSelectedId(item.id);
726
+ flyMapTo(item.location.lng, item.location.lat);
727
+ const seq = commitSeqRef.current;
728
+ pickedPoiRef.current = {
729
+ seq,
730
+ name: item.name,
731
+ address: item.address,
732
+ };
733
+ const provider = providerRef.current;
734
+ if (!provider)
735
+ return;
736
+ const promise = provider
737
+ .reverseGeocode([item.location.lng, item.location.lat])
738
+ .catch(() => null);
739
+ reversePendingRef.current = { seq, promise };
740
+ promise.then((rg) => {
741
+ if (seq !== commitSeqRef.current)
742
+ return;
743
+ if (!rg)
744
+ return;
745
+ reverseCacheRef.current = Object.assign(Object.assign({}, rg), { seq });
746
+ });
747
+ }, [tips, moveTo, flyMapTo]);
748
+ // ===== 「确定」按钮 =====
749
+ // 打车场景的核心契约:
750
+ // * **经纬度永远来自 centerRef.current(centerPin 针尖位置,100% 精准)**——
751
+ // 不依赖任何 POI 项的坐标(与 centerPin 天然有几米~几十米误差);
752
+ // * **永远基于 centerPin 经纬度调一次 reverseGeocode**——拿到该坐标对应的
753
+ // 官方省市区 + 详细 address。这一步是"返回的是用户在地图上选中的那个地址"
754
+ // 的核心保障:不论用户之前是拖图还是点列表项,这次反查的结果就是 centerPin
755
+ // 当前位置的真实地址。
756
+ //
757
+ // 反查复用策略(避免重复请求,但保证一定有反查结果):
758
+ // 1) reverseCacheRef 命中(commitMapCenter / handlePickItem 后台反查已回来)→
759
+ // 直接用,零延迟;
760
+ // 2) 缓存未到 + reversePendingRef 还在飞 → await 它(已发起的请求别浪费);
761
+ // 3) 都没有(极端:刚拖完图立刻点确定,pending 已被新 seq 清掉)→ 现发起一次。
762
+ //
763
+ // 字段合并完整规则参见 buildSelectedLocation 注释——本函数只负责「拿到
764
+ // geo + 候选 POI」这两份输入,拼装逻辑由 helper 统一承接,避免与列表头
765
+ // 「当前位置」虚拟项 / 独立的 getLocation() 函数式 API 出现实现差异。
766
+ const handleConfirm = (0, react_1.useCallback)(() => __awaiter(this, void 0, void 0, function* () {
767
+ var _a;
768
+ // 重复点击保护:网络慢时 reverseGeocode 可能 await 几百毫秒,期间用户多次
769
+ // 点确定按钮,会进入多次 handleConfirm 并多次回调 onSelect——业务侧可能
770
+ // 因此重复创建订单。一次进入即锁住,进入第二次直接 return。
771
+ if (confirmingRef.current)
772
+ return;
773
+ confirmingRef.current = true;
774
+ const center = centerRef.current;
775
+ if (!center) {
776
+ onClose === null || onClose === void 0 ? void 0 : onClose();
777
+ return;
778
+ }
779
+ const provider = providerRef.current;
780
+ const currentSeq = commitSeqRef.current;
781
+ let geo = null;
782
+ const cache = reverseCacheRef.current;
783
+ if (cache && cache.seq === currentSeq) {
784
+ geo = cache;
785
+ }
786
+ if (!geo) {
787
+ const pending = reversePendingRef.current;
788
+ if (pending && pending.seq === currentSeq) {
789
+ try {
790
+ geo = yield pending.promise;
791
+ }
792
+ catch (_b) {
793
+ geo = null;
794
+ }
795
+ }
796
+ }
797
+ if (!geo && provider) {
798
+ try {
799
+ geo = yield provider.reverseGeocode(center);
800
+ }
801
+ catch (_c) {
802
+ geo = null;
803
+ }
804
+ }
805
+ const picked = pickedPoiRef.current;
806
+ const userPickedPoi = picked && picked.seq === currentSeq ? picked : null;
807
+ // 拼装 SelectedLocation:与 currentItem useMemo / getLocation() 共用
808
+ // buildSelectedLocation。candidatePoi = poiListRef[0](仅在 userPickedPoi
809
+ // 为空时被 helper 内部按 80m 阈值采纳),让「未点列表 → 直接确定」也能拿
810
+ // 到楼栋/出入口级 name,规避 reverseGeocode fallback 到"街道镇"粗粒度。
811
+ //
812
+ // **经纬度依旧来自 centerRef,不切到 POI 经纬度**——保持"地图选什么
813
+ // 就是什么"的契约,POI 仅供 name/address 文本兜底。
814
+ const sel = (0, buildSelectedLocation_1.buildSelectedLocation)(center, geo, {
815
+ pickedPoi: userPickedPoi
816
+ ? { name: userPickedPoi.name, address: userPickedPoi.address }
817
+ : undefined,
818
+ candidatePoi: (_a = poiListRef.current[0]) !== null && _a !== void 0 ? _a : null,
819
+ });
820
+ // onSelect 是业务方传入的回调,**有可能抛错**(业务代码 bug、错误的 props 等)。
821
+ // 用 try/catch 兜住保证 onClose 一定会被调到,让组件能正常关闭——否则
822
+ // 出错时弹窗会卡在打开状态,互斥锁也卡住。
823
+ try {
824
+ onSelect === null || onSelect === void 0 ? void 0 : onSelect(sel);
825
+ }
826
+ catch (err) {
827
+ console.warn("[MapLocationSelection] onSelect 抛错:", err);
828
+ }
829
+ onClose === null || onClose === void 0 ? void 0 : onClose();
830
+ }), [onSelect, onClose]);
831
+ // ===== 列表头部「当前位置」虚拟项 =====
832
+ //
833
+ // 与 handleConfirm 的「未主动点 POI」分支同源——都通过 buildSelectedLocation
834
+ // 拼装,让用户在列表里看到的"当前位置"就等于此刻按确定会提交的内容。
835
+ // 共享 helper 后**不再需要 STAY-IN-SYNC 双源同步**——任何字段优先级 / 兜底
836
+ // 调整只需改 buildSelectedLocation 一处。
837
+ //
838
+ // 与 handleConfirm 的差异(有意为之):
839
+ // * picked 不参与:当前位置项的语义是"centerPin 自己",与"用户主动选 POI"
840
+ // 的 picked 维度互斥——picked !== null 时 selectedId !== null,currentItem
841
+ // 不会被插入列表(见下方 list 合成),picked.name 自然不会影响 currentItem;
842
+ // * useMemo 读 poiList 用 React state;handleConfirm 读 poiListRef——两者
843
+ // 在 React render 一致性下值相同,只是读写口径不同。
844
+ const currentItem = (0, react_1.useMemo)(() => {
845
+ var _a;
846
+ void currentItemVersion;
847
+ const center = centerRef.current;
848
+ if (!center)
849
+ return null;
850
+ const cache = reverseCacheRef.current;
851
+ const currentSeq = commitSeqRef.current;
852
+ const geo = cache && cache.seq === currentSeq ? cache : null;
853
+ const sel = (0, buildSelectedLocation_1.buildSelectedLocation)(center, geo, {
854
+ candidatePoi: (_a = poiList[0]) !== null && _a !== void 0 ? _a : null,
855
+ });
856
+ // distance 字段语义:
857
+ // * 列表所有项的 distance 展示口径 = "POI ↔ 用户真实 GPS 位置",重合时
858
+ // 才为 0m(详见 list 渲染处的 displayDistance 注释)。
859
+ // * 当前位置项的 location 就是 centerPin,所以这里 distance =
860
+ // "centerPin ↔ userLocation"——userLocation 未知(GPS 拒绝)时退化为 0。
861
+ // * 列表渲染时不直接读这个 distance 字段,而是用 displayDistance 现场算
862
+ // (读 currentItem.location 与 userLocation)。这里写值是为了语义一致 +
863
+ // 兜底(万一 displayDistance 路径有改动,retest 时数据仍合理)。
864
+ const distance = userLocation
865
+ ? (0, types_1.haversineMeters)(center[0], center[1], userLocation[0], userLocation[1])
866
+ : 0;
867
+ return {
868
+ id: CURRENT_LOCATION_ID,
869
+ name: sel.name,
870
+ address: sel.address,
871
+ location: { lng: center[0], lat: center[1] },
872
+ distance,
873
+ raw: geo,
874
+ };
875
+ }, [currentItemVersion, poiList, userLocation]);
876
+ // 列表渲染数据源:
877
+ // * 搜索模式(tips !== null):直接用 tips(搜索结果),不插入 currentItem——
878
+ // 用户搜索的意图是"找别处的地名",此刻 centerPin 处的"当前位置"对结果
879
+ // 筛选无意义;
880
+ // * 周边模式 + 用户没点过列表(selectedId === null):把 currentItem 插到
881
+ // 表头——这是"默认状态下的当前选择";
882
+ // * 周边模式 + 用户点过列表(selectedId !== null):**不插入** currentItem,
883
+ // 避免 currentItem 与用户已选的 POI 在视觉上同时存在两条很相近的项。
884
+ // 当前位置由用户已选的 POI 实际承载(active 高亮在那条上)。
885
+ // 同名去重:currentItem 的 name 走 nearestPoi 兜底(80m 阈值内借用
886
+ // poiList[0] 的名字,规避 reverseGeocode 在没楼宇位置 fallback 到 township
887
+ // 粗粒度的"南码头路街道"问题)——这必然导致 currentItem.name 与 poiList[0].name
888
+ // 一致。这种情况下 poiList[0] 从展示列表里去掉,避免「当前位置 0m + 同名 36m」
889
+ // 两条几乎一样的项;currentItem 既已替它显示,且 distance=0m 是 centerPin
890
+ // 真值,比 poiList[0] 的几十米距离更精确。
891
+ // hasMore / 翻页行为不受影响——slice 只动展示数据,poiList 本身不变。
892
+ const list = (0, react_1.useMemo)(() => {
893
+ var _a, _b, _c, _d, _e;
894
+ if (tips !== null)
895
+ return tips;
896
+ if (selectedId === null && currentItem) {
897
+ const firstName = (_c = (_b = (_a = poiList[0]) === null || _a === void 0 ? void 0 : _a.name) === null || _b === void 0 ? void 0 : _b.trim()) !== null && _c !== void 0 ? _c : "";
898
+ const currentName = (_e = (_d = currentItem.name) === null || _d === void 0 ? void 0 : _d.trim()) !== null && _e !== void 0 ? _e : "";
899
+ if (firstName && firstName === currentName) {
900
+ return [currentItem, ...poiList.slice(1)];
901
+ }
902
+ return [currentItem, ...poiList];
903
+ }
904
+ return poiList;
905
+ }, [tips, currentItem, poiList, selectedId]);
906
+ const isTipsMode = tips !== null;
907
+ const renderListContent = () => {
908
+ if (errorMsg)
909
+ return (0, jsx_runtime_1.jsx)("div", { css: style.empty, children: errorMsg });
910
+ if (list.length === 0) {
911
+ return ((0, jsx_runtime_1.jsx)("div", { css: style.empty, children: isTipsMode ? "未找到相关地点" : "暂无附近地点" }));
912
+ }
913
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [list.map((item) => {
914
+ // 当前位置项**始终** active(用户需求:"唯一区别是当前位置一定是
915
+ // 选中状态");其他项保持原有 selectedId === item.id 判定。
916
+ const active = item.id === CURRENT_LOCATION_ID || selectedId === item.id;
917
+ return ((0, jsx_runtime_1.jsxs)(Clickable_1.Clickable, { css: style.item, onClick: () => handlePickItem(item), children: [(0, jsx_runtime_1.jsxs)("div", { css: style.itemBody, children: [(0, jsx_runtime_1.jsx)("div", { css: [style.itemTitle, active && style.itemTitleActive], children: item.name || "未命名地点" }), (() => {
918
+ var _a, _b;
919
+ // 距离展示口径:「POI ↔ 用户真实 GPS 位置」("距离我多远")。
920
+ // 仅当 userLocation 已知时按这个口径展示——重合时为 0m,符合
921
+ // 用户对"距离"二字的直觉认知(与微信发送位置 / 美团 / 滴滴等
922
+ // 主流产品对齐)。userLocation 未知(GPS 被拒 / 不可用)时退
923
+ // 化到 item.distance(即 POI ↔ centerPin),保持原有行为。
924
+ // 注意:列表内部排序仍按 item.distance(POI ↔ centerPin)升
925
+ // 序,所以拖图到远处选址时可能出现「显示 50km 排在 30km 前」
926
+ // 的视觉错位——这是排序口径(按 centerPin 找最近的 POI 给用
927
+ // 户参考)与展示口径(让用户读懂数字)有意分离的代价,已与
928
+ // 用户确认接受。
929
+ const displayDistance = userLocation && item.location
930
+ ? (0, types_1.haversineMeters)(item.location.lng, item.location.lat, userLocation[0], userLocation[1])
931
+ : item.distance;
932
+ const distText = typeof displayDistance === "number"
933
+ ? displayDistance < 1000
934
+ ? `${Math.round(displayDistance)}m`
935
+ : `${(displayDistance / 1000).toFixed(1)}km`
936
+ : "";
937
+ const addrText = (_b = (_a = item.address) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : "";
938
+ if (!distText && !addrText)
939
+ return null;
940
+ return ((0, jsx_runtime_1.jsx)("div", { css: style.itemDesc, children: [distText, addrText].filter(Boolean).join(" | ") }));
941
+ })()] }), active && ((0, jsx_runtime_1.jsx)("svg", { viewBox: "0 0 1024 1024", css: style.itemCheck, children: (0, jsx_runtime_1.jsx)("path", { d: "M433.5 696.6l-176-176c-12.5-12.5-12.5-32.8 0-45.3 12.5-12.5 32.8-12.5 45.3 0L456.1 628.7l278.2-278.2c12.5-12.5 32.8-12.5 45.3 0 12.5 12.5 12.5 32.8 0 45.3l-301 300.8c-12.5 12.5-32.8 12.5-45.1 0z" }) }))] }, item.id));
942
+ }), !isTipsMode && !hasMore && list.length > 0 && ((0, jsx_runtime_1.jsx)("div", { css: style.listEnd, children: "\u6CA1\u6709\u66F4\u591A\u4E86" }))] }));
943
+ };
944
+ return ((0, jsx_runtime_1.jsxs)("div", { css: style.inner, children: [(0, jsx_runtime_1.jsxs)("div", { css: style.mapWrap, children: [(0, jsx_runtime_1.jsx)("div", { ref: mapElRef, css: style.mapContainer }), (0, jsx_runtime_1.jsxs)("div", { css: style.centerPin, children: [(0, jsx_runtime_1.jsx)("div", { css: [
945
+ style.centerPinStem,
946
+ pinPhase === "lifted" && style.centerPinStemLifted,
947
+ pinPhase === "drop" && style.centerPinStemDrop,
948
+ ] }, `stem-${pinPhase}`), (0, jsx_runtime_1.jsx)("div", { css: [
949
+ style.centerPinHead,
950
+ pinPhase === "lifted" && style.centerPinHeadLifted,
951
+ pinPhase === "drop" && style.centerPinHeadDrop,
952
+ ] }, `head-${pinPhase}`)] }), (0, jsx_runtime_1.jsxs)("div", { css: style.topBar, children: [(0, jsx_runtime_1.jsx)(Clickable_1.Clickable, { css: style.cancelBtn, "aria-label": "\u8FD4\u56DE", onClick: () => onClose === null || onClose === void 0 ? void 0 : onClose(), children: (0, jsx_runtime_1.jsx)("span", { css: style.cancelBtnIcon }) }), (0, jsx_runtime_1.jsx)(Clickable_1.Clickable, { css: style.confirmBtn, onClick: handleConfirm, children: "\u786E\u5B9A" })] }), (0, jsx_runtime_1.jsx)(Clickable_1.Clickable, { css: style.locateBtn, onClick: handleLocate, "aria-busy": btnLocating || undefined, children: btnLocating ? ((0, jsx_runtime_1.jsx)("span", { css: style.locateBtnSpinner })) : ((0, jsx_runtime_1.jsx)("svg", { viewBox: "0 0 1024 1024", css: style.locateBtnIcon, children: (0, jsx_runtime_1.jsx)("path", { d: "M511.963002 316.994807c-107.263506 0-195.034191 87.767686-195.034191 195.034191 0 107.270505 87.770686 195.039191 195.034191 195.039191 107.270505 0 195.039191-87.767686 195.039191-195.039191 0-107.265505-87.769686-195.034191-195.039191-195.034191z m416.563779 148.490009C907.584049 272.331511 751.662489 116.414951 558.514184 95.474219V0.062996H465.42182V95.474219C272.270515 116.416951 116.352955 272.333511 95.412223 465.485816H0v93.084364h95.412223c20.940732 193.152305 176.860292 349.069865 370.009597 370.017597v95.412223h93.092364v-95.412223C751.660489 907.645045 907.584049 751.723485 928.526781 558.56918H1023.938004v-93.084364h-95.411223zM511.963002 853.345333c-187.718634 0-341.311335-153.589701-341.311334-341.316335 0-187.718634 153.5927-341.311335 341.311334-341.311334 187.725634 0 341.316334 153.5927 341.316335 341.311334 0 187.725634-153.5927 341.316334-341.316335 341.316335z" }) })) }), locating && ((0, jsx_runtime_1.jsxs)("div", { css: style.locatingMask, children: [(0, jsx_runtime_1.jsx)("span", { css: style.spinner }), (0, jsx_runtime_1.jsx)("span", { children: "\u5B9A\u4F4D\u4E2D" })] }))] }), (0, jsx_runtime_1.jsxs)("div", { css: [style.bottom, style.bottomRelative], children: [(0, jsx_runtime_1.jsx)("div", { css: [style.searchBox, locating && style.searchDisabled], children: (0, jsx_runtime_1.jsxs)("div", { css: style.searchInner, children: [(0, jsx_runtime_1.jsx)("svg", { viewBox: "0 0 1024 1024", css: style.searchIcon, children: (0, jsx_runtime_1.jsx)("path", { d: "M896 870.4l-128-128c55.467-68.267 89.6-149.333 89.6-238.933 0-98.134-38.4-192-110.933-264.534-149.334-149.333-384-149.333-533.334-4.266-145.066 145.066-145.066 384 0 529.066 72.534 72.534 166.4 110.934 264.534 110.934 89.6 0 174.933-29.867 238.933-89.6l128 128c4.267 4.266 12.8 8.533 21.333 8.533s17.067-4.267 21.334-8.533c17.066-8.534 17.066-29.867 8.533-42.667zM260.267 721.067c-119.467-123.734-119.467-320 0-439.467 59.733-59.733 140.8-89.6 217.6-89.6 81.066 0 157.866 29.867 217.6 89.6 59.733 59.733 89.6 136.533 89.6 217.6 0 81.067-34.134 162.133-89.6 217.6-55.467 59.733-132.267 93.867-217.6 93.867-81.067 0-157.867-34.134-217.6-89.6z" }) }), (0, jsx_runtime_1.jsx)("input", { css: style.searchInput, placeholder: locating ? "定位中,请稍候" : "搜索地点", value: keyword, disabled: locating, onChange: (e) => setKeyword(e.target.value), onCompositionStart: () => setComposing(true), onCompositionEnd: (e) => {
953
+ setComposing(false);
954
+ // composition 结束时把最终字符同步到 state,避免被 setComposing 后的
955
+ // 一次 batch 漏掉(onCompositionEnd 与 onChange 触发顺序在不同浏览器
956
+ // 下不一致,显式 set 可保证最终态一定写入)。
957
+ setKeyword(e.currentTarget.value);
958
+ } }), keyword && !locating && ((0, jsx_runtime_1.jsx)("span", { css: style.searchClear, onClick: () => {
959
+ setKeyword("");
960
+ setTips(null);
961
+ }, children: (0, jsx_runtime_1.jsx)("svg", { viewBox: "0 0 1024 1024", css: style.searchClearIcon, children: (0, jsx_runtime_1.jsx)("path", { d: "M520.533333 464.008533l-155.409066-155.477333c-18.8416-18.773333-42.427733-14.1312-56.558934 0-18.8416 18.8416-14.097067 42.427733 0 56.558933l150.766934 150.7328-155.511467 155.4432c-18.8416 18.8416-14.097067 42.427733 0 56.558934 18.875733 18.773333 42.461867 14.097067 56.593067 0l150.7328-150.766934 150.698666 150.766934c18.8416 18.773333 42.427733 14.097067 56.5248 0 18.875733-18.875733 14.1312-42.461867 0-56.558934l-150.7328-150.766933 150.7328-150.698667c18.875733-18.8416 14.1312-42.427733 0-56.5248-18.8416-18.875733-42.427733-14.1312-56.5248 0l-146.0224 146.0224 4.7104 4.7104z m353.28 409.838934c-202.513067 202.513067-527.598933 197.8368-725.435733 0-197.870933-197.870933-197.802667-527.598933 0-725.469867 197.7344-197.8368 527.5648-197.8368 725.435733 0 197.8368 197.870933 202.513067 522.9568 0 725.469867z" }) }) }))] }) }), (0, jsx_runtime_1.jsxs)("div", { css: style.listArea, children: [(0, jsx_runtime_1.jsx)(ScrollView_1.ScrollView, { ref: scrollViewRef, height: "100%", onReachBottom: handleReachBottom, reachBottomThreshold: 80,
962
+ // 错误 / 空 / 已加载完毕 / 搜索模式:不展示底部 loading
963
+ // 搜索模式不分页(searchByKeyword 单页返回),底部 loading 没有意义
964
+ showLoading: !isTipsMode && !errorMsg && hasMore && poiList.length > 0, children: renderListContent() }), listLoading && ((0, jsx_runtime_1.jsx)("div", { css: style.listLoadingMask, children: (0, jsx_runtime_1.jsx)("div", { css: style.spinner }) }))] }), locating && (0, jsx_runtime_1.jsx)("div", { css: style.bottomLockedMask })] })] }));
965
+ }
966
+ function showMapLocationSelection(props) {
967
+ let closing = false;
968
+ let close;
969
+ const requestClose = () => {
970
+ if (closing)
971
+ return;
972
+ closing = true;
973
+ close === null || close === void 0 ? void 0 : close();
974
+ };
975
+ const userOnClose = props.onClose;
976
+ close = (0, Dialog_1.showDialog)({
977
+ type: "pullLeft",
978
+ showMask: false,
979
+ boxStyle: { left: 0, width: "100%" },
980
+ content: ((0, jsx_runtime_1.jsx)(MapLocationSelection, Object.assign({}, props, { onClose: () => {
981
+ userOnClose === null || userOnClose === void 0 ? void 0 : userOnClose();
982
+ requestClose();
983
+ } }))),
984
+ });
985
+ }