归属判定升级:DataV 官方行政区划表 + 同名县消歧
用阿里 DataV GeoAtlas(免密钥)替换 OSM Overpass 离线县表: - 民政部 adcode 体系,县级前 4 位即所属地级码,2832 县 100% 可定位地级 (OSM 表只有 1182/2362,且 admin_level 层级不可靠) - 同名县(全国 30 组)保留全部候选,运行时按坐标消歧, 修掉「柳州的城中区被归到西宁市」 归属判定改为「按名字精确查官方表」优先于「按中心点猜最近的县」: 窄长县按中心点会跨省错判(环江喀斯特实际在环江县,按中心点判到荔波县) 查表带距离校验,精确全称命中放宽到 150km(狭长县中心离边界可达 85km+) 修掉三个真实缺陷: - Overpass 兜底把地级塞进 osmCounty、县名塞进 osmCity, 与 attachPlaces「先找县级」的顺序相反,导致兜底路径永远丢下层县名 - Overpass 请求无硬超时,公共实例「连着不返回」能把一次分析拖成十几分钟; 补三层限(单次超时 / 阶段总时限 / is_in 反查次数上限),13 分钟 → 48 秒 - Photon 的 osm.place.city 类目混着景点 POI,kind==='城市' 时无条件把名字 当地名,导致「社更穿洞北门」「柳州之门」等景点自己成一组; 改为必须同时是行政区名且能在官方表查到 新增离线回归测试(都不打外部网络,Photon/Overpass 挂掉也能回归): - test-admin-lookup:同名县消歧、已知边界点、狭长县远端点(19 项) - test-overpass-fallback:本地假 Overpass,验兜底路径、简称归一、200+HTML 错误页 - test-grouping-regression:本地假 Photon,20 候选完整分组 - verify-ui-full:端到端 UI 验收(真实无头 Chrome 读真实 DOM)
This commit is contained in:
@@ -279,8 +279,10 @@ async function analyze(input, cfg, progress = () => {}) {
|
||||
const dataNotes = [
|
||||
`周边目的地:Photon / OpenStreetMap(成功 ${stats.categoryHits}/${stats.requested} 类)` +
|
||||
`${stats.amapCount ? ` + 高德周边 ${stats.amapCount} 条` : ''}` +
|
||||
`${stats.overpassCount ? ` + Overpass 兜底 ${stats.overpassCount} 条` : ''}` +
|
||||
` + 内置知名景区 ${stats.curatedCount} 个 + 内置城市 ${stats.cityCount} 个 + 内置小众地点 ${stats.nicheCount} 个` +
|
||||
`${stats.fallbackOnly ? ';⚠️ 实时 POI 服务不可用,本次清单完全来自内置地名库(坐标为近似值)' : ''}` +
|
||||
`${stats.overpassError ? `;Overpass 兜底也失败(${stats.overpassError})` : ''}` +
|
||||
`${c.excludeLocal && stats.localExcluded ? `;已排除出发地所在城区的同城点位 ${stats.localExcluded} 个(同市下辖的其它县保留为周边目的地)` : ''}`,
|
||||
`天气:${weatherRes.note}`,
|
||||
`空气质量:${aqiRes.note}`,
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+700
-2
@@ -12,6 +12,8 @@
|
||||
const U = require('./util');
|
||||
const C = require('./cache');
|
||||
const { JUNK_NAME, POOR_NAME, LOW_VALUE_NAME, CURATED, CN_CITIES, NICHE_SPOTS, wmo } = require('./data');
|
||||
const ADMIN = require('./places-admin');
|
||||
const DV = require('./places-admin-dv');
|
||||
|
||||
// 数据源层缓存 TTL(秒级语义):
|
||||
// 地理编码 7 天(行政区划不会变)/高德 POI 6 小时(商家会变)
|
||||
@@ -167,6 +169,9 @@ function photonCandidate(feature, cat, origin) {
|
||||
if (JUNK_NAME.test(name) || POOR_NAME.test(name) || LOW_VALUE_NAME.test(name)) return null;
|
||||
if (/^\d+$/.test(name)) return null;
|
||||
if (name.length < 2 || name.length > 30) return null;
|
||||
// 纯外文的条目(如「Rice Terraces」)对中文用户没有辨识度,
|
||||
// 而且 OSM 上外国景点会混进来占名额。名称里至少要有一个汉字。
|
||||
if (!/[\u4e00-\u9fa5]/.test(name)) return null;
|
||||
const distanceKm = U.haversine(origin.lat, origin.lon, lat, lon);
|
||||
return {
|
||||
id: `osm-${p.osm_type || 'N'}-${p.osm_id || Math.round(lat * 1e5)}`,
|
||||
@@ -175,7 +180,12 @@ function photonCandidate(feature, cat, origin) {
|
||||
tags: [...cat.tags],
|
||||
lat: U.round(lat, 5),
|
||||
lon: U.round(lon, 5),
|
||||
region: [p.city, p.district, p.locality].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).slice(0, 2).join(' · '),
|
||||
// OSM 中国的层级命名与直觉相反:county 是地级市/州,city 是县/区,
|
||||
// district 是街道,locality 是村。分组必须用 county,取 district 会分出「玉屏街道」这种组。
|
||||
osmCounty: String(p.county || '').trim(),
|
||||
osmCity: String(p.city || '').trim(),
|
||||
osmDistrict: String(p.district || '').trim(),
|
||||
region: [p.county, p.city, p.district].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).slice(0, 2).join(' · '),
|
||||
distanceKm,
|
||||
bearingDeg: U.bearing(origin.lat, origin.lon, lat, lon),
|
||||
source: 'OpenStreetMap / Photon',
|
||||
@@ -716,10 +726,623 @@ async function amapAround(origin, radiusKm, cfg) {
|
||||
}, TTL.poi);
|
||||
}
|
||||
|
||||
/**
|
||||
* Overpass 兜底检索(免密钥)。
|
||||
*
|
||||
* 为什么需要:Photon 公共实例限流或临时挂掉时,候选池会掉到只剩内置库——
|
||||
* 而内置库在广西、云南这类省的覆盖很薄(150km 内常常只有 1~3 个点),
|
||||
* 第二步就几乎没内容可分。Overpass 直接查 OSM 的原始标签,是同源数据但不同入口。
|
||||
*
|
||||
* 代价:单次查询要 20~120 秒,所以**只在 Photon 全部失败时启用**,且走独立缓存。
|
||||
*/
|
||||
const OVERPASS_TAGS = [
|
||||
{ sel: 'node["tourism"="attraction"]', kind: '景点', tags: ['人文历史', '拍照', '观光'] },
|
||||
{ sel: 'node["tourism"="viewpoint"]', kind: '观景地', tags: ['自然风光', '拍照', '徒步登山'] },
|
||||
{ sel: 'node["tourism"="museum"]', kind: '博物馆', tags: ['人文历史', '亲子', '室内避雨'] },
|
||||
{ sel: 'way["tourism"="attraction"]', kind: '景点', tags: ['人文历史', '拍照', '观光'] },
|
||||
{ sel: 'node["natural"="cave_entrance"]', kind: '溶洞', tags: ['自然风光', '探险', '避暑'] },
|
||||
{ sel: 'node["historic"="memorial"]', kind: '纪念地', tags: ['人文历史', '室内避雨'] },
|
||||
{ sel: 'node["leisure"="nature_reserve"]', kind: '自然保护地', tags: ['自然风光', '徒步登山'] },
|
||||
{ sel: 'node["tourism"="theme_park"]', kind: '主题乐园', tags: ['亲子', '主题乐园'] },
|
||||
];
|
||||
|
||||
async function overpassQuery(origin, radiusKm, cfg) {
|
||||
// bboxFromRadius 返回 [west, south, east, north](Photon 的顺序),
|
||||
// 而 Overpass 要 [south, west, north, east] —— 顺序错了直接 400。
|
||||
const [w, s, e, n] = U.bboxFromRadius(origin.lat, origin.lon, radiusKm);
|
||||
const body = [s, w, n, e].map((x) => x.toFixed(4)).join(',');
|
||||
const sel = OVERPASS_TAGS.map((t) => `${t.sel}(${body});`).join('');
|
||||
const q = `[out:json][timeout:50];(${sel});out center 120;`;
|
||||
// Overpass 公共实例对大 bbox + 多标签容易 502/504,换几个镜像重试
|
||||
const hosts = Array.isArray(cfg.ENDPOINTS.overpass) ? cfg.ENDPOINTS.overpass : [cfg.ENDPOINTS.overpass];
|
||||
const timeoutMs = Number(cfg.SEARCH?.overpassTimeoutMs) || 20000;
|
||||
// 单次查询的总时限:镜像可能有 5 个,逐个等超时最坏要 5×timeout。
|
||||
// 给一份总预算,超了就别再试下一个镜像了。
|
||||
const deadline = Date.now() + timeoutMs * 2;
|
||||
let lastErr = '';
|
||||
for (const host of hosts) {
|
||||
if (Date.now() > deadline) {
|
||||
lastErr = lastErr || 'Overpass 超出总时限';
|
||||
break;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(host, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'TripSense/1.0' },
|
||||
body: 'data=' + encodeURIComponent(q),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
const text = await res.text();
|
||||
// 实例可能返回 200 + HTML 错误页(如 osm.ch 的 duplicate_query),
|
||||
// 只看 status 会把错误页当成功,判据必须是响应体是否以 '{' 开头。
|
||||
if (!text.startsWith('{')) throw new Error(`${host} 返回 ${res.status}`);
|
||||
return JSON.parse(text).elements || [];
|
||||
} catch (e) {
|
||||
lastErr = e.message || String(e);
|
||||
console.warn('[overpass]', lastErr);
|
||||
}
|
||||
}
|
||||
throw new Error(lastErr || 'Overpass 全部镜像均失败');
|
||||
}
|
||||
/**
|
||||
* Overpass 的兴趣点大多**没有** `addr:*` 标签,直接拿归属会得到空组名。
|
||||
*
|
||||
* 解法:用 Overpass 的 `is_in` 反查**点落在哪些行政区里**。
|
||||
* 一次请求就能拿到完整层级(实测 25.1,107.98 → L6 环江毛南族自治县 / L5 河池市 / L4 广西)。
|
||||
*
|
||||
* 为什么不用「拉边界多边形再本地做点面判定」:
|
||||
* relation 的几何在 `members` 里,`out tags geom` / `out geom` 都拿不到
|
||||
* relation 级的 geometry 数组;实测 `out tags geom` 返回的 1064 条元素
|
||||
* 只有 type/id/bounds/tags,geometry 为 undefined。绕这一圈代价太高。
|
||||
*
|
||||
* 层级:OSM 中国的 `admin_level` 是 **4=省 / 5=地级市 / 6=县**,
|
||||
* 不是直觉上的 4/6/8(实测荔波县是 6)。
|
||||
*/
|
||||
async function overpassAdminAreas(origin, radiusKm, cfg) {
|
||||
// 保留旧签名:这里改为「一次拉 bbox 内所有行政区 + 打点标记」,不再逐点查
|
||||
const [w, s, e, n] = U.bboxFromRadius(origin.lat, origin.lon, radiusKm);
|
||||
const body = [s, w, n, e].map((x) => x.toFixed(4)).join(',');
|
||||
const q = `[out:json][timeout:60];rel["admin_level"~"^[56]$"](${body});out center tags;`;
|
||||
const hosts = Array.isArray(cfg.ENDPOINTS.overpass) ? cfg.ENDPOINTS.overpass : [cfg.ENDPOINTS.overpass];
|
||||
const timeoutMs = Number(cfg.SEARCH?.overpassTimeoutMs) || 20000;
|
||||
const deadline = Date.now() + timeoutMs * 2;
|
||||
let lastErr = '';
|
||||
for (const host of hosts) {
|
||||
if (Date.now() > deadline) { lastErr = lastErr || 'Overpass 超出总时限'; break; }
|
||||
try {
|
||||
const res = await fetch(host, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'TripSense/1.0' },
|
||||
body: 'data=' + encodeURIComponent(q),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!text.startsWith('{')) throw new Error(`${host} 返回 ${res.status}`);
|
||||
const els = JSON.parse(text).elements || [];
|
||||
const areas = [];
|
||||
for (const el of els) {
|
||||
const name = String(el.tags?.['name:zh'] || el.tags?.name || '').trim();
|
||||
if (!name) continue;
|
||||
const c = el.center || {};
|
||||
const lat = Number(c.lat);
|
||||
const lon = Number(c.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
areas.push({ name, level: String(el.tags.admin_level), lat, lon });
|
||||
}
|
||||
return areas;
|
||||
} catch (e) {
|
||||
lastErr = e.message || String(e);
|
||||
console.warn('[overpass-admin]', lastErr);
|
||||
}
|
||||
}
|
||||
throw new Error(lastErr || 'Overpass 行政区查询失败');
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐点反查所在行政区(Overpass `is_in`)。
|
||||
* 只在 Overpass 兜底路径上、且候选缺少 addr:* 标签时用(一次约 1~3 秒)。
|
||||
* @returns {{ area: string, detail: string } | null}
|
||||
*/
|
||||
async function overpassAdminAtPoint(lat, lon, cfg) {
|
||||
const q = `[out:json][timeout:60];is_in(${lat.toFixed(5)},${lon.toFixed(5)})->.a;.a out tags;`;
|
||||
const hosts = Array.isArray(cfg.ENDPOINTS.overpass) ? cfg.ENDPOINTS.overpass : [cfg.ENDPOINTS.overpass];
|
||||
const timeoutMs = Number(cfg.SEARCH?.overpassTimeoutMs) || 20000;
|
||||
for (const host of hosts) {
|
||||
try {
|
||||
const res = await fetch(host, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'TripSense/1.0' },
|
||||
body: 'data=' + encodeURIComponent(q),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!text.startsWith('{')) continue;
|
||||
const els = JSON.parse(text).elements || [];
|
||||
let prefecture = '';
|
||||
let county = '';
|
||||
for (const el of els) {
|
||||
const level = String(el.tags?.admin_level || '');
|
||||
const name = String(el.tags?.['name:zh'] || el.tags?.name || '').trim();
|
||||
if (!name) continue;
|
||||
if (level === '5') prefecture = name;
|
||||
else if (level === '6') county = name;
|
||||
}
|
||||
if (!prefecture && !county) continue;
|
||||
// 走统一查表:OSM 的名字可能是简称(「环江」)或全称(「环江毛南族自治县」),
|
||||
// 查表能补齐标准全称并带上所属地级;同名县还要按坐标消歧。
|
||||
const cHit = county ? lookupAdminByName(county, lat, lon) : null;
|
||||
if (cHit && cHit.detail) return { area: cHit.area, detail: cHit.detail };
|
||||
const pHit = prefecture ? lookupAdminByName(prefecture, lat, lon) : null;
|
||||
if (pHit) return { area: pHit.area, detail: pHit.detail || '' };
|
||||
// 查表查不到就原样返回 OSM 名字,至少还有个能显示的地名
|
||||
return { area: prefecture || county, detail: county && county !== prefecture ? county : '' };
|
||||
} catch (e) {
|
||||
console.warn('[overpass-isin]', e.message || e);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPrefectureSuffix = (n) => /(市|州|盟|地区)$/.test(String(n || '').trim());
|
||||
const isCountySuffix = (n) => /(县|区|旗)$/.test(String(n || '').trim());
|
||||
|
||||
/**
|
||||
* 按坐标找所属行政区(Overpass 兜底路径专用)。
|
||||
*
|
||||
* 输入是 bbox 内所有行政区 relation 的 **center 点**(不是多边形——
|
||||
* relation 级 geometry 在 Overpass 里拿不到,见 overpassAdminAreas 注释)。
|
||||
* 所以判定退化��「找最近的同层级行政区」,并用 admin_level 区分地级/县级:
|
||||
* L5=地级市、L6=县。
|
||||
*
|
||||
* @returns {{ area: string, detail: string } | null}
|
||||
*/
|
||||
function areaByPoint(areas, lat, lon) {
|
||||
let prefecture = null;
|
||||
let county = null;
|
||||
for (const a of areas) {
|
||||
const d = U.haversine(lat, lon, a.lat, a.lon);
|
||||
if (a.level === '5' || isPrefectureSuffix(a.name)) {
|
||||
if (!prefecture || d < prefecture.d) prefecture = { name: a.name, d };
|
||||
} else if (a.level === '6' || isCountySuffix(a.name)) {
|
||||
if (!county || d < county.d) county = { name: a.name, d };
|
||||
}
|
||||
}
|
||||
// 近邻判定给的是 OSM 的名字(可能是简称),过一遍统一查表补全标准全称
|
||||
if (county) {
|
||||
const hit = lookupAdminByName(county.name, lat, lon);
|
||||
if (hit && hit.detail) return { area: hit.area, detail: hit.detail };
|
||||
}
|
||||
if (prefecture) {
|
||||
const hit = lookupAdminByName(prefecture.name, lat, lon);
|
||||
if (hit) return { area: hit.area, detail: hit.detail || (county ? county.name : '') };
|
||||
}
|
||||
if (prefecture) return { area: prefecture.name, detail: county ? county.name : '' };
|
||||
if (county) return { area: county.name, detail: '' };
|
||||
return null;
|
||||
}
|
||||
|
||||
function overpassCandidate(el, origin, cfg) {
|
||||
const t = el.tags || {};
|
||||
const name = String(t['name:zh'] || t.name || '').trim();
|
||||
if (!name) return null;
|
||||
const lat = Number(el.lat ?? el.center?.lat);
|
||||
const lon = Number(el.lon ?? el.center?.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
// 按标签找类别
|
||||
const tag = OVERPASS_TAGS.find((x) => x.sel.includes(`"${t.tourism || t.natural || t.historic || t.leisure}"`));
|
||||
const kind = tag?.kind || '景点';
|
||||
const tags = tag?.tags || ['自然风光', '拍照'];
|
||||
const distanceKm = U.haversine(origin.lat, origin.lon, lat, lon);
|
||||
return {
|
||||
id: `overpass-${el.type}-${el.id}`,
|
||||
name,
|
||||
kind,
|
||||
tags: [...tags],
|
||||
lat: U.round(lat, 5),
|
||||
lon: U.round(lon, 5),
|
||||
osmCounty: String(t['addr:county'] || t['addr:city'] || t['addr:province'] || '').trim(),
|
||||
osmCity: String(t['addr:district'] || t['addr:city'] || '').trim(),
|
||||
region: [t['addr:province'], t['addr:city'], t['addr:district']].filter(Boolean).join(' · '),
|
||||
distanceKm,
|
||||
bearingDeg: U.bearing(origin.lat, origin.lon, lat, lon),
|
||||
source: 'OpenStreetMap / Overpass',
|
||||
hasExtent: false,
|
||||
featured: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检索周边目的地(多层来源,任一不可用都不影响出结果):
|
||||
* Photon 分类检索 → 高德周边 POI(可选) → 内置知名景区 → 内置城市库 → 内置小众地点库
|
||||
*/
|
||||
/**
|
||||
* 候选的归属地名(第二步分组用)。
|
||||
*
|
||||
* 问题:候选来自四个来源,归属信息粒度完全不一致——
|
||||
* 高德 place/text → 有 cityname(地级市)与 adname(区县)
|
||||
* 内置小众库 → region 是「省 · 市」
|
||||
* 内置知名库 → 没有任何归属信息
|
||||
* Photon(OSM) → region 是街道名(「金城江街道」)
|
||||
* 直接把这些 region 铺在卡片上,用户看到的就是一堆平铺的名字,
|
||||
* 分不清「柳州」和「柳州古城」是两级还是并列。
|
||||
*
|
||||
* 做法:统一反查成「地级市 + 区县」,作为分组键。
|
||||
* 高德 regeo 的批量参数(batch=true)实测返回 INVALID_PARAMS,
|
||||
* 所以逐点反查 + 缓存(TTL.geo,坐标取整 3 位做指纹)。
|
||||
*/
|
||||
async function amapPlaceOf(lat, lon, cfg) {
|
||||
if (!cfg.AMAP_KEY) return null;
|
||||
const [glng, glat] = U.wgs84ToGcj02(lon, lat);
|
||||
return C.memo(sk(`placeOf:${U.round(lat, 3)},${U.round(lon, 3)}`, cfg), async () => {
|
||||
const u = new URL(cfg.ENDPOINTS.amapRegeo);
|
||||
u.searchParams.set('location', `${U.round(glng, 6)},${U.round(glat, 6)}`);
|
||||
u.searchParams.set('extensions', 'base');
|
||||
u.searchParams.set('key', cfg.AMAP_KEY);
|
||||
const d = await U.fetchJSON(u.toString(), {}, 8000, 1);
|
||||
if (d.status !== '1' || !d.regeocode) return null;
|
||||
const ac = d.regeocode.addressComponent || {};
|
||||
const province = String(ac.province || '').trim();
|
||||
const city = String(ac.city || '').trim();
|
||||
const district = String(ac.district || '').trim();
|
||||
if (!city && !district) return null;
|
||||
return { province, city: city || district, district: city && district ? district : '', label: district && district !== city ? `${city} · ${district}` : city };
|
||||
}, TTL.geo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组键归一:去掉末尾的「市 / 地区 / 盟 / 自治州」等行政后缀。
|
||||
* 「柳州」(内置小众库写法)与「柳州市」(高德 cityname)必须落到同一组,
|
||||
* 否则同一个地方会被拆成两堆。
|
||||
*
|
||||
* 民族自治州要连民族名一起去掉:「黔南布依族苗族自治州」→「黔南」,
|
||||
* 「黔东南苗族侗族自治州」→「黔东南」。但**不能用贪婪正则**——
|
||||
* `[一-龥]{1,3}族` 会把「黔南」的「南」也当成民族名吃掉(实测变成「黔」)。
|
||||
* 所以这里用显式的民族名清单。
|
||||
*/
|
||||
const ETHNIC_AUTONOMY = /(?:壮族|满族|回族|苗族|侗族|土家族|羌族|彝族|蒙古族|藏族|维吾尔族|哈萨克族|柯尔克孜族|布依族|朝鲜族|白族|傣族|黎族|傈僳族|佤族|畲族|高山族|拉祜族|水族|东乡族|纳西族|景颇族|土族|达斡尔族|仫佬族|羌族|布朗族|撒拉族|毛南族|仡佬族|锡伯族|阿昌族|普米族|塔吉克族|怒族|乌孜别克族|俄罗斯族|鄂温克族|德昂族|保安族|裕固族|京族|塔塔尔族|独龙族|鄂伦春族|赫哲族|门巴族|珞巴族|基诺族)+自治(?:州|县)/g;
|
||||
|
||||
function placeKey(city) {
|
||||
return String(city || '').trim()
|
||||
.replace(ETHNIC_AUTONOMY, '')
|
||||
.replace(/(自治区|自治州|自治县|地区|盟|市)$/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 给候选补 place / placeKey / placeDetail:
|
||||
* place 分组显示名(地级市/州,如「黔南州」「柳州市」)
|
||||
* placeKey 分组键(归一后的地名,用于合并不同写法)
|
||||
* placeDetail 更细一级的区县说明
|
||||
*
|
||||
* 分组名必须落在**地级**这一层,不能是街道或村名。
|
||||
* 用户看到「玉屏街道」「乐依村」这种组名等于没分组——它是 OSM 的最细一级,
|
||||
* 同一个县下面的几十个点会被拆成几十组。
|
||||
*/
|
||||
/**
|
||||
* 查内置地点库(知名景区 / 小众地点)的离线归属地名。
|
||||
* 数据由 tools/build-admin*.cjs 生成:Photon 按名检索 + 坐标距离消歧 + 归一到地级。
|
||||
*
|
||||
* detail 只保留**县级**(以「县/区/市/州/旗」结尾):
|
||||
* Photon 有时把村名(「摆别村」)塞在这一级,展示出来反而更乱。
|
||||
*/
|
||||
function adminAreaOf(c) {
|
||||
const table = ADMIN.SPOT_AREA;
|
||||
if (!table) return null;
|
||||
const key = c.id && c.id.startsWith('curated-')
|
||||
? `scenic:${c.id.slice('curated-'.length)}`
|
||||
: (c.id && c.id.startsWith('niche-') ? `niche:${c.id.slice('niche-'.length)}` : null);
|
||||
if (!key) return null;
|
||||
const hit = table[key];
|
||||
if (!hit || !hit.area) return null;
|
||||
const detail = /(县|区|市|州|旗|盟)$/.test(String(hit.detail || '').trim()) ? hit.detail : '';
|
||||
return { area: hit.area, detail };
|
||||
}
|
||||
|
||||
/**
|
||||
* 就近最近的地级市/州:全国地级坐标表 + 内置城市库 + 离线归属表里的地名坐标。
|
||||
* 用 haversine 在本地算,不发请求(几十个点 × 几百个地名的规模完全够用)。
|
||||
*
|
||||
* 只认**地级**名字(以「市/州/盟/地区」结尾)。
|
||||
* 县级名(从江县、吉县)当组名会让同一个地级市被拆成好几组,
|
||||
* 也会出现「荔波小七孔归到隔壁的从江县」这种误导。
|
||||
*
|
||||
* 全国表(lib/places-admin-dv.js,343 个地级)是主力;CN_CITIES 只有 146 个,
|
||||
* 桂黔边境(河池市、黔南州、黔东南州)根本不在里面,缺了它候选点会落进空组名。
|
||||
*/
|
||||
function isPrefectureName(name) {
|
||||
return /(市|州|盟|地区)$/.test(String(name || '').trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 「下层地名」是否合格:必须是县级(县/区/旗/市)或地级,不能是村/街道/屯。
|
||||
* Photon 的 city/district 字段层级不稳,会给出「乐依村」「双福」「陈家甫」这种
|
||||
* 村名甚至门牌名——挂在分组标题下当区县完全没法看。
|
||||
*/
|
||||
function isDistrictName(name) {
|
||||
return /(县|区|旗|盟|州|市)$/.test(String(name || '').trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 这个名字是不是**已知的地级行政区**。
|
||||
*
|
||||
* 用于过滤 Photon `osm.place.city` 检索里的噪声:OSM 的 place=city 里
|
||||
* 混着大量景点 POI(牌坊、寺庙、老街),它们的名字也带「市/州」后缀
|
||||
* 会被误当行政区。查一下官方表就知道是不是真地名。
|
||||
*/
|
||||
function isKnownPrefecture(name) {
|
||||
const raw = String(name || '').trim();
|
||||
if (!raw) return false;
|
||||
if (DV.PREF_DV[raw]) return true;
|
||||
const alias = DV.PREF_ALIAS[raw];
|
||||
return !!(alias && DV.PREF_DV[alias]);
|
||||
}
|
||||
|
||||
function nearestArea(lat, lon) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
let best = null;
|
||||
const scan = (name, coord) => {
|
||||
if (!coord || !isPrefectureName(name)) return;
|
||||
const d = U.haversine(lat, lon, coord[0], coord[1]);
|
||||
if (!best || d < best.d) best = { area: name, d };
|
||||
};
|
||||
// DataV 官方地级表(343 个,含各族自治州简称)优先
|
||||
for (const [name, coord] of Object.entries(DV.PREF_DV || {})) scan(name, coord);
|
||||
for (const [name, la, lo] of CN_CITIES) scan(name, [la, lo]);
|
||||
for (const [name, coord] of Object.entries(ADMIN.AREA_COORD || {})) scan(name, coord);
|
||||
// 最近的超过 120km 基本就不是「这一片」,宁可留空也不要给出误导性的地名
|
||||
return best && best.d <= 120 ? { area: best.area } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从离线表里取一个县的条目,同名县按距离选最近的。
|
||||
*
|
||||
* DataV 官方表里同名县保留为**数组**(全国 30 组:「城中区」柳州/西宁都有、
|
||||
* 「鼓楼区」南京/徐州/福州/开封都有、「铁西区」沈阳/鞍山/四平都有)。
|
||||
* 只按名字取会把柳州的点归到西宁去,所以必须带坐标做消歧。
|
||||
*
|
||||
* @param {string} name 县全称(表里的 key)
|
||||
* @param {number} lat 候选点纬度
|
||||
* @param {number} lon 候选点经度
|
||||
* @returns {[number, number, string, string] | null} [纬度, 经度, 所属地级, adcode]
|
||||
*/
|
||||
function pickCounty(name, lat, lon) {
|
||||
const v = DV.COUNTY_DV[name];
|
||||
if (!v) return null;
|
||||
const list = (Array.isArray(v) && Array.isArray(v[0])) ? v : [v];
|
||||
if (list.length === 1) return list[0];
|
||||
let best = null;
|
||||
for (const c of list) {
|
||||
const d = U.haversine(lat, lon, c[0], c[1]);
|
||||
if (!best || d < best.d) best = { c, d };
|
||||
}
|
||||
return best ? best.c : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 就近最近的县级行政区(DataV 官方县级表)。
|
||||
* 县比地级小得多,所以「最近县」比「最近地级」精确得多——桂黔边境
|
||||
* 荔波县与河池市相距不到 40km,按地级中心点会分错,按县不会。
|
||||
*
|
||||
* 注意:这是**兜底**路径,主力是按名字精确查(lookupAdminByName)。
|
||||
* 按中心点距离在狭长县上仍会判错(环江县中心点远在贵州方向)。
|
||||
*/
|
||||
function nearestCounty(lat, lon) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
let best = null;
|
||||
for (const [name, coord] of Object.entries(DV.COUNTY_DV || {})) {
|
||||
if (!coord) continue;
|
||||
// 同名县在表里是数组(每个候选各自有中心点),要展平后逐个比距离
|
||||
const list = (Array.isArray(coord) && Array.isArray(coord[0])) ? coord : [coord];
|
||||
for (const c of list) {
|
||||
if (!c) continue;
|
||||
const d = U.haversine(lat, lon, c[0], c[1]);
|
||||
if (!best || d < best.d) best = { county: name, d, prefecture: c[2] || '' };
|
||||
}
|
||||
}
|
||||
// 60km 外基本就不是「这一片」了,县级行政区一般也就横跨 30~60km
|
||||
return best && best.d <= 60 ? best : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 由「最近县」推导所属地级。
|
||||
* 优先用 GB/T 2260 编码前缀(县级表第三项,已离线算好),
|
||||
* 没有编码时退回「按坐标找最近地级」。
|
||||
*/
|
||||
function prefectureOfCounty(countyHit, lat, lon) {
|
||||
if (countyHit && countyHit.prefecture) return countyHit.prefecture;
|
||||
const near = nearestArea(lat, lon);
|
||||
return near ? near.area : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 按**名称**精确查行政区(不靠距离)。
|
||||
*
|
||||
* 这是归属判定的主力路径:OSM 的 `addr:county` / Photon 的 `county`
|
||||
* 有时直接就是标准县名(「环江毛南族自治县」「大化瑶族自治县」),
|
||||
* 此时按名字查官方表是 100% 准确的,比「按中心点找最近的县」可靠得多
|
||||
* (桂黔边境的狭长县,中心点距离会判到隔壁省去)。
|
||||
*
|
||||
* **必须带距离校验**:全国有同名县(「城中区」柳州西宁都有、
|
||||
* 「鼓楼区」福州南京都有),只看名字会把柳州的点归到西宁去。
|
||||
* 查到的县离候选点太远(>80km)就判定为不可信,退回按坐标近邻。
|
||||
*
|
||||
* @param {number} lat 候选点纬度
|
||||
* @param {number} lon 候选点经度
|
||||
* @returns {{ area: string, detail: string } | null}
|
||||
*/
|
||||
function lookupAdminByName(name, lat, lon) {
|
||||
const raw = String(name || '').trim();
|
||||
if (!raw) return null;
|
||||
// 县级:全称 → 别名
|
||||
const isExact = !!DV.COUNTY_DV[raw];
|
||||
const full = isExact ? raw : (DV.COUNTY_ALIAS[raw] || null);
|
||||
if (full && DV.COUNTY_DV[full]) {
|
||||
// 同名县在表里是数组,按坐标取最近的那个(否则柳州的「城中区」会落到西宁)
|
||||
const c = pickCounty(full, lat, lon);
|
||||
if (!c) return null;
|
||||
// 距离阈值分两档:
|
||||
// 精确全称命中(OSM 明确写了这个县名)→ 可信度高,放宽到 150km。
|
||||
// 狭长县的中心点离边界可达 80km 以上(大化县中心在西北角,
|
||||
// 东南角的响水关离中心 85km),按 80km 一刀切会把正确的县名丢掉。
|
||||
// 别名/简称命中 → 只说明大致是这片地方,严格一点(80km)。
|
||||
const limitKm = (full === raw) ? 150 : 80;
|
||||
if (tooFar(lat, lon, c, limitKm)) return null; // 同名县但离得远 → 不可信
|
||||
// 同名县组(如「城中区」有 2 个候选)必须选**距离最近**的那个:
|
||||
// pickCounty 已经按距离挑了,但还要确认它明显比同组其他候选更近,
|
||||
// 否则说明 OSM 这个名字可能是别名/误填。
|
||||
const list = DV.COUNTY_DV[full];
|
||||
if (Array.isArray(list) && Array.isArray(list[0])) {
|
||||
const sorted = list
|
||||
.map((x) => ({ x, d: U.haversine(lat, lon, x[0], x[1]) }))
|
||||
.sort((a, b) => a.d - b.d);
|
||||
// 最近的这个比第二名远 1.5 倍以上 → 判为误匹配
|
||||
if (sorted[1] && sorted[0].d > sorted[1].d * 1.5 && sorted[0].d > 30) return null;
|
||||
}
|
||||
return { area: c[2], detail: full };
|
||||
}
|
||||
// 名字本身是地级(OSM 常常把地级填进 county 字段)
|
||||
const pref = DV.PREF_DV[raw] ? raw : (DV.PREF_ALIAS[raw] || null);
|
||||
if (pref && DV.PREF_DV[pref]) {
|
||||
const p = DV.PREF_DV[pref];
|
||||
// 地级可以很大(新疆的市跨几百公里),阈值放宽
|
||||
if (!tooFar(lat, lon, p, 200)) return { area: pref, detail: '' };
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查到的行政区离候选点是否太远(超过 limitKm 判为同名误匹配)。
|
||||
* 县一般横跨 30~60km,80km 是上限;地级市可以很大,用 200km。
|
||||
*/
|
||||
function tooFar(lat, lon, coord, limitKm) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||
if (!coord) return false;
|
||||
return U.haversine(lat, lon, coord[0], coord[1]) > limitKm;
|
||||
}
|
||||
|
||||
async function attachPlaces(candidates, cfg) {
|
||||
const need = candidates.filter((c) => !c.city && c.kind !== '城市');
|
||||
if (need.length && cfg.AMAP_KEY) {
|
||||
// 串行 + 间隔:高德 regeo 并发 3 就会限流,失败的候选会退回街道名当分组名
|
||||
// (出现过「坡进屯」「内塘屯」这种没法看的一组)。实测串行 20 个约 730ms。
|
||||
for (const c of need) {
|
||||
try {
|
||||
const p = await amapPlaceOf(c.lat, c.lon, cfg);
|
||||
if (p) {
|
||||
c.city = p.city;
|
||||
c.district = p.district || c.district || '';
|
||||
c.province = p.province || '';
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[attachPlaces]', c.name, err.message);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
}
|
||||
for (const c of candidates) {
|
||||
// 内置城市库:名字本身就是地名(柳州市),它是这一组的地名节点。
|
||||
//
|
||||
// 但「城市」这个 kind 也可能来自 Photon 的 `osm.place.city` 检索,
|
||||
// 而 OSM 的 place=city 里混着大量**景点 POI**(牌坊、寺庙、老街)。
|
||||
// 那些名字不是行政区,不能拿来当组名——否则会出现
|
||||
// 「社更穿洞北门」自己成一组、「柳州之门」自己成一组这种荒唐结果。
|
||||
// 所以必须先校验名字确实是行政区名,否则走下面的正常归属流程。
|
||||
if (c.kind === '城市' && isPrefectureName(c.name) && isKnownPrefecture(c.name)) {
|
||||
c.place = c.name;
|
||||
c.placeKey = placeKey(c.name) || c.name;
|
||||
c.placeDetail = '';
|
||||
continue;
|
||||
}
|
||||
const city = String(c.city || '').trim();
|
||||
const district = String(c.district || '').trim();
|
||||
if (city) {
|
||||
c.place = city;
|
||||
c.placeKey = placeKey(city) || city;
|
||||
c.placeDetail = district && district !== city && isDistrictName(district) ? district : '';
|
||||
continue;
|
||||
}
|
||||
// 内置库(知名景区 / 小众地点):查离线归属表,最准,优先用
|
||||
const hit = adminAreaOf(c);
|
||||
if (hit) {
|
||||
c.place = hit.area;
|
||||
c.placeKey = placeKey(hit.area) || hit.area;
|
||||
c.placeDetail = hit.detail || '';
|
||||
continue;
|
||||
}
|
||||
|
||||
// OSM 系(Photon / Overpass):优先用 OSM 自带的归属字段**按名字精确查**。
|
||||
//
|
||||
// 为什么不能一律「按坐标找最近的县」:桂黔边境的县界狭长,
|
||||
// 「环江喀斯特世界自然遗产」实际在环江县(河池市),但按中心点距离
|
||||
// 会判到隔壁的荔波县(黔南州)——差 45km 完全够跨省。
|
||||
// OSM 的 addr:county 在多数条目上直接写了标准县名,按名字查是准的。
|
||||
//
|
||||
// OSM 的层级字段本身不可靠(county 有时填县名、有时填地级,
|
||||
// city 有时填村名),所以三个字段都试一遍,取第一个查得到的。
|
||||
if (c.osmCounty || c.osmCity || c.osmDistrict) {
|
||||
// 先在所有字段里找**县级**(下层地名),找不到再退到地级。
|
||||
// OSM 常常把地级填进 county、把县名填进 city(「黔南州」+「荔波县」),
|
||||
// 若只看第一个字段就定案,下层地名会丢。
|
||||
let countyHitName = null;
|
||||
let areaHit = null;
|
||||
for (const raw of [c.osmCounty, c.osmCity, c.osmDistrict]) {
|
||||
if (!raw) continue;
|
||||
const hit = lookupAdminByName(raw, c.lat, c.lon);
|
||||
if (!hit) continue;
|
||||
if (hit.detail) { countyHitName = hit.detail; areaHit = hit.area; break; }
|
||||
if (!areaHit) areaHit = hit.area;
|
||||
}
|
||||
if (areaHit) {
|
||||
c.place = areaHit;
|
||||
c.placeKey = placeKey(areaHit) || areaHit;
|
||||
c.placeDetail = countyHitName && countyHitName !== areaHit ? countyHitName : '';
|
||||
continue;
|
||||
}
|
||||
// 名字查不到:退回按坐标找最近的县
|
||||
const countyHit = nearestCounty(c.lat, c.lon);
|
||||
const area = countyHit ? prefectureOfCounty(countyHit, c.lat, c.lon) : '';
|
||||
if (area) {
|
||||
c.place = area;
|
||||
c.placeKey = placeKey(area) || area;
|
||||
const detail = countyHit ? countyHit.county : (isDistrictName(c.osmCity) ? c.osmCity : '');
|
||||
c.placeDetail = detail && detail !== area ? detail : '';
|
||||
continue;
|
||||
}
|
||||
// 县级表没覆盖到:退回 OSM 的 county 字段,但必须校验它是地级
|
||||
const area2 = isPrefectureName(c.osmCounty) ? c.osmCounty : '';
|
||||
if (area2) {
|
||||
c.place = area2;
|
||||
c.placeKey = placeKey(area2) || area2;
|
||||
c.placeDetail = c.osmCity && c.osmCity !== area2 && isDistrictName(c.osmCity) ? c.osmCity : '';
|
||||
continue;
|
||||
}
|
||||
if (c.osmCity) {
|
||||
const lift = nearestArea(c.lat, c.lon);
|
||||
c.place = lift ? lift.area : c.osmCity;
|
||||
c.placeKey = placeKey(c.place) || c.place;
|
||||
c.placeDetail = lift && isDistrictName(c.osmCity) ? c.osmCity : '';
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 最后兜底:按坐标找最近的地级市(全国地级表 + 内置城市库)
|
||||
const near = nearestArea(c.lat, c.lon);
|
||||
if (near) {
|
||||
c.place = near.area;
|
||||
c.placeKey = placeKey(near.area) || near.area;
|
||||
c.placeDetail = '';
|
||||
continue;
|
||||
}
|
||||
// 实在查不到:退回内置库的 region,至少不丢信息
|
||||
const seg = String(c.region || '').split('·').map((s) => s.trim()).filter(Boolean);
|
||||
c.place = seg.length ? seg[seg.length - 1] : '';
|
||||
c.placeKey = placeKey(c.place) || c.place;
|
||||
c.placeDetail = seg.length > 1 ? seg[0] : '';
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function discoverCandidates(origin, radiusKm, cfg, opts = {}) {
|
||||
// 候选发现缓存键:起点 + 半径 + 排除同城设置。Photon 限流严重,重复分析时必须复用。
|
||||
const dkey = `disc:${U.round(origin.lat, 3)},${U.round(origin.lon, 3)}:${Math.round(radiusKm)}:${opts.excludeLocal !== false}:${opts.localKm || ''}:${opts.homeDistrict || ''}:${opts.homeCity || ''}`;
|
||||
@@ -767,6 +1390,76 @@ async function discoverCandidates(origin, radiusKm, cfg, opts = {}) {
|
||||
}
|
||||
for (const c of amapPois || []) pool.push(c);
|
||||
|
||||
// Overpass 兜底:只在 Photon 全部失败时才启用(单次查询要 20~120 秒)
|
||||
let overpassCount = 0;
|
||||
let overpassError = '';
|
||||
if (photonOk === 0 && !(amapPois || []).length) {
|
||||
// 兜底路径的总时限预算。公共实例经常「连着不返回」,
|
||||
// 没有总预算的话一次分析能被拖成十几分钟(实测踩过)。
|
||||
// 超时后直接放弃兜底,清单退回内置地名库(+ dataNotes 里说明实时检索不可用)。
|
||||
const budgetMs = Math.max(10000, (Number(cfg.SEARCH?.overpassTimeoutMs) || 20000) * 4);
|
||||
const deadline = Date.now() + budgetMs;
|
||||
try {
|
||||
const els = await overpassQuery(origin, radiusKm, cfg);
|
||||
// 兴趣点大多没有 addr:* 标签,要靠行政区反查归属
|
||||
let areas = null;
|
||||
try {
|
||||
areas = await overpassAdminAreas(origin, radiusKm, cfg);
|
||||
} catch (e2) {
|
||||
console.warn('[overpass] 行政区边界拿不到,归属将退化', e2.message);
|
||||
}
|
||||
// 逐点 is_in 反查是**最慢**的一环(每个点每个镜像都要等一次超时),
|
||||
// 必须限量:超过上限后不再精确反查,剩下的点靠 bbox 行政区中心点近邻判定
|
||||
// (近似,但一次请求覆盖全部候选,慢不到一个数量级)。
|
||||
const isInMax = Number(cfg.SEARCH?.overpassIsInMax) || 8;
|
||||
let isInUsed = 0;
|
||||
for (const el of els) {
|
||||
const c = overpassCandidate(el, origin, cfg);
|
||||
if (!c) continue;
|
||||
if (JUNK_NAME.test(c.name) || POOR_NAME.test(c.name) || LOW_VALUE_NAME.test(c.name)) continue;
|
||||
if (c.distanceKm > radiusKm * 1.05) continue;
|
||||
if (!c.osmCounty) {
|
||||
// 先用 bbox 内的行政区中心点做近邻判定(一次请求覆盖全部)
|
||||
let hit = areas ? areaByPoint(areas, c.lat, c.lon) : null;
|
||||
// 近邻判定拿不到(中心点离边界太远)时,逐点用 is_in 精确反查
|
||||
if (!hit && isInUsed < isInMax) {
|
||||
isInUsed += 1;
|
||||
// 超预算就停止精确反查,但**候选照常入池**:
|
||||
// 归属还有离线官方表兜底(attachPlaces 会按坐标找最近的县),
|
||||
// 不能因为反查超时就丢掉整个候选。
|
||||
if (Date.now() > deadline) {
|
||||
console.warn('[overpass] 兜底路径超出时限预算,停止精确反查');
|
||||
isInUsed = isInMax;
|
||||
} else {
|
||||
try {
|
||||
hit = await overpassAdminAtPoint(c.lat, c.lon, cfg);
|
||||
} catch (e3) {
|
||||
console.warn('[overpass-isin]', c.name, e3.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hit) {
|
||||
// 字段摆位要和 attachPlaces 的读取顺序对齐:
|
||||
// attachPlaces 是「先在 county/city/district 里找**县级**,找不到再退地级」,
|
||||
// 所以县级名必须放 osmCounty、地级名放 osmCity。
|
||||
// 反过来(地级放 county)会让兜底路径永远丢掉下层县名。
|
||||
if (hit.detail) {
|
||||
c.osmCounty = hit.detail;
|
||||
c.osmCity = hit.area || '';
|
||||
} else {
|
||||
c.osmCity = hit.area || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
pool.push(c);
|
||||
overpassCount += 1;
|
||||
}
|
||||
} catch (e) {
|
||||
overpassError = e.message || String(e);
|
||||
console.warn('[overpass]', overpassError);
|
||||
}
|
||||
}
|
||||
|
||||
// 内置知名目的地:既做兜底,也常年补入若干高辨识度地点
|
||||
let curatedCount = 0;
|
||||
for (const [name, lat, lon, kind, tagStr] of CURATED) {
|
||||
@@ -905,11 +1598,16 @@ async function discoverCandidates(origin, radiusKm, cfg, opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// 分组:给每个候选补上「归属地名」,第二步按地名分组展示
|
||||
await attachPlaces(selected, cfg);
|
||||
|
||||
return {
|
||||
candidates: selected.map((c, i) => ({ ...c, index: i })),
|
||||
stats: {
|
||||
photonOk, categoryHits,
|
||||
curatedCount, cityCount, nicheCount,
|
||||
overpassCount,
|
||||
overpassError: overpassError || '',
|
||||
amapCount: (amapPois || []).length,
|
||||
amapError: amapError || '',
|
||||
poolSize: unique.length,
|
||||
@@ -1292,5 +1990,5 @@ module.exports = {
|
||||
reverseGeocode, amapReverse, discoverCandidates, bestWeatherWindow,
|
||||
fetchWeather, fetchAQI, aqiLabel,
|
||||
roadMatrix, railMatrix, estimateRoad, estimateRail,
|
||||
amapResolvePlace,
|
||||
amapResolvePlace, attachPlaces, placeKey,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user