归属判定升级: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:
+133
-7
@@ -68,6 +68,12 @@ const state = {
|
||||
sort: 'score',
|
||||
onlyRec: false,
|
||||
onlyNiche: false,
|
||||
// 候选搜索关键词(按地名/景点名/标签过滤)
|
||||
search: '',
|
||||
// 第二步是否按「归属地名」分组展示
|
||||
groupByPlace: true,
|
||||
// 已折叠的地名分组
|
||||
collapsedPlaces: new Set(),
|
||||
compareOpen: false,
|
||||
autoPreselected: false,
|
||||
datesTouched: false,
|
||||
@@ -490,7 +496,7 @@ function cardHtml(c) {
|
||||
</div>
|
||||
<div class="card-title">
|
||||
<h4>${esc(c.name)}</h4>
|
||||
<div class="sub">${esc(c.kind || '目的地')}${c.region ? ' · ' + esc(c.region) : ''} · ${compassCN(c.bearingDeg)}方向 ${fmtKm(c.distanceKm)}</div>
|
||||
<div class="sub">${esc(c.kind || '目的地')}${c.placeDetail ? ' · ' + esc(c.placeDetail) : (c.region && !state.groupByPlace ? ' · ' + esc(c.region) : '')} · ${compassCN(c.bearingDeg)}方向 ${fmtKm(c.distanceKm)}</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="score-ring">${scoreRing(c.score)}</div>
|
||||
@@ -523,24 +529,121 @@ function sortedCandidates() {
|
||||
});
|
||||
if (state.onlyRec) list = list.filter((c) => c.score >= 60);
|
||||
if (state.onlyNiche) list = list.filter((c) => c.niche);
|
||||
const q = (state.search || '').trim().toLowerCase();
|
||||
if (q) {
|
||||
// 搜索范围要覆盖分组名:搜「柳州」应该能命中「柳州古城」「融水双龙沟」
|
||||
// 这类分属柳州/融水两个地名的条目,否则用户会以为搜不到
|
||||
list = list.filter((c) => {
|
||||
const hay = [
|
||||
c.name, c.place, c.placeDetail, c.kind, c.region,
|
||||
...(c.tags || []),
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按「归属地名」分组。
|
||||
*
|
||||
* 为什么要分组:候选里既有地名(柳州市、桂林市这类城市级目的地),
|
||||
* 又有具体景点(柳州古城、融水双龙沟),平铺在一起看不出层级,
|
||||
* 也不知道某个景点属于哪片地方。分组后是「地名 → 景点」两级。
|
||||
*/
|
||||
function groupByPlace(list) {
|
||||
const map = new Map();
|
||||
for (const c of list) {
|
||||
const key = c.placeKey || c.place || '其他';
|
||||
if (!map.has(key)) map.set(key, { key, place: c.place || '其他', detail: '', items: [] });
|
||||
const g = map.get(key);
|
||||
g.items.push(c);
|
||||
// 组副标题取出现最多的区县,作为该组的地理说明
|
||||
if (c.placeDetail && !g.detail.includes(c.placeDetail)) {
|
||||
g.detail = g.detail ? `${g.detail}、${c.placeDetail}` : c.placeDetail;
|
||||
}
|
||||
}
|
||||
const groups = [...map.values()];
|
||||
// 组内最高分高的排前面;同分按最近距离
|
||||
groups.forEach((g) => {
|
||||
g.best = g.items.reduce((m, c) => Math.max(m, c.score || 0), 0);
|
||||
g.nearest = g.items.reduce((m, c) => Math.min(m, c.distanceKm ?? 1e9), 1e9);
|
||||
});
|
||||
groups.sort((a, b) => b.best - a.best || a.nearest - b.nearest);
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** 组标题:地名 + 景点数 + 该组最佳分与距离 */
|
||||
function placeGroupHtml(g) {
|
||||
const collapsed = state.collapsedPlaces.has(g.key);
|
||||
const sample = g.items[0];
|
||||
const dirs = [...new Set(g.items.map((c) => compassCN(c.bearingDeg)))];
|
||||
const maxKm = Math.max(...g.items.map((c) => c.distanceKm || 0));
|
||||
const minKm = Math.min(...g.items.map((c) => c.distanceKm || 0));
|
||||
const distTxt = maxKm - minKm < 1
|
||||
? fmtKm(minKm)
|
||||
: `${fmtKm(minKm)} ~ ${fmtKm(maxKm)}`;
|
||||
return `<section class="place-group" data-place="${esc(g.key)}">
|
||||
<button type="button" class="place-head" data-place-toggle="${esc(g.key)}" aria-expanded="${collapsed ? 'false' : 'true'}">
|
||||
<span class="place-caret">${collapsed ? '▸' : '▾'}</span>
|
||||
<span class="place-name">${esc(g.place)}</span>
|
||||
${g.detail ? `<span class="place-detail">${esc(g.detail)}</span>` : ''}
|
||||
<span class="place-meta">${g.items.length} 个目的地 · 最佳 ${g.best} 分 · ${distTxt} · ${dirs.slice(0, 2).join('/')}方向</span>
|
||||
</button>
|
||||
<div class="place-items ${collapsed ? 'collapsed' : ''}">${g.items.map(cardHtml).join('')}</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderCards() {
|
||||
const list = sortedCandidates();
|
||||
// 重渲染会重建 DOM:先记住哪些逐日天气是展开的,避免勾选/排序后莫名收起
|
||||
const openDays = new Set($$('#cards .daily[open]').map((d) => d.dataset.day));
|
||||
const noBase = !(state.analysis?.candidates || []).length;
|
||||
$('#cards').innerHTML = list.length
|
||||
? list.map(cardHtml).join('')
|
||||
: `<div class="empty-mini">${noBase
|
||||
const total = (state.analysis?.candidates || []).length;
|
||||
const cardsBox = $('#cards');
|
||||
cardsBox.classList.toggle('has-groups', state.groupByPlace && list.length > 0);
|
||||
if (list.length) {
|
||||
cardsBox.innerHTML = state.groupByPlace
|
||||
? groupByPlace(list).map(placeGroupHtml).join('')
|
||||
: list.map(cardHtml).join('');
|
||||
} else {
|
||||
cardsBox.innerHTML = `<div class="empty-mini">${noBase
|
||||
? `当前半径内没有可去的地点${state.analysis?.constraints?.excludeLocal ? '(已排除出发地附近的同城点位)' : ''},试试取消「排除同城」或增大搜索半径。`
|
||||
: state.onlyNiche
|
||||
? '当前半径内没有小众地点,试试取消「只看小众」或增大搜索半径。'
|
||||
: '没有符合筛选条件的目的地,试试取消「仅显示推荐」或「只看小众」。'}</div>`;
|
||||
: state.search
|
||||
? `没有匹配「${esc(state.search)}」的地点,换个关键词,或取消筛选条件。`
|
||||
: state.onlyNiche
|
||||
? '当前半径内没有小众地点,试试取消「只看小众」或增大搜索半径。'
|
||||
: '没有符合筛选条件的目的地,试试取消「仅显示推荐」或「只看小众」。'}</div>`;
|
||||
}
|
||||
$$('#cards .daily').forEach((d) => { if (openDays.has(d.dataset.day)) d.open = true; });
|
||||
$$('#cards .pick-btn').forEach((b) => b.addEventListener('click', () => togglePick(b.dataset.id)));
|
||||
// 组标题折叠:只动 class,不重渲染(重渲染会丢失滚动位置与展开态)
|
||||
$$('#cards [data-place-toggle]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const key = btn.dataset.placeToggle;
|
||||
if (state.collapsedPlaces.has(key)) state.collapsedPlaces.delete(key);
|
||||
else state.collapsedPlaces.add(key);
|
||||
const sec = btn.closest('.place-group');
|
||||
const items = sec?.querySelector('.place-items');
|
||||
const collapsed = state.collapsedPlaces.has(key);
|
||||
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
btn.querySelector('.place-caret').textContent = collapsed ? '▸' : '▾';
|
||||
if (items) items.classList.toggle('collapsed', collapsed);
|
||||
});
|
||||
});
|
||||
$('#selectedCount').textContent = `已选 ${state.selected.size} 个`;
|
||||
$('#preselectHint').classList.toggle('hidden', !state.autoPreselected);
|
||||
// 筛选提示条:告诉用户「当前显示多少 / 共多少」,否则筛完不知道漏了什么
|
||||
const fn = $('#filterNote');
|
||||
const filtered = list.length !== total || state.onlyRec || state.onlyNiche || state.search;
|
||||
fn.classList.toggle('hidden', !filtered);
|
||||
if (filtered) {
|
||||
fn.innerHTML = `当前显示 <b>${list.length}</b> / ${total} 个目的地`
|
||||
+ (state.search ? ` · 搜索「${esc(state.search)}」` : '')
|
||||
+ (state.onlyRec ? ' · 仅推荐及以上' : '')
|
||||
+ (state.onlyNiche ? ' · 仅小众' : '')
|
||||
+ (state.groupByPlace ? ` · 已按 ${groupByPlace(list).length} 个地名分组` : '');
|
||||
}
|
||||
renderCompass(list, state.analysis.constraints.radiusKm);
|
||||
renderCompare();
|
||||
}
|
||||
@@ -1673,6 +1776,29 @@ function init() {
|
||||
}));
|
||||
$('#onlyRec').addEventListener('change', (e) => { state.onlyRec = e.target.checked; renderCards(); });
|
||||
$('#onlyNiche').addEventListener('change', (e) => { state.onlyNiche = e.target.checked; renderCards(); });
|
||||
$('#groupByPlace').addEventListener('change', (e) => { state.groupByPlace = e.target.checked; renderCards(); });
|
||||
|
||||
// 候选搜索:输入即过滤,不用等回车。回车要阻止默认行为,
|
||||
// 否则在输入框里按回车会触发表单提交刷新页面
|
||||
const searchInput = $('#candSearch');
|
||||
let searchTimer = null;
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
const v = e.target.value;
|
||||
$('#candSearchClear').classList.toggle('hidden', !v);
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => { state.search = v; renderCards(); }, 160);
|
||||
});
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); clearTimeout(searchTimer); state.search = e.target.value; renderCards(); }
|
||||
if (e.key === 'Escape') { e.target.value = ''; state.search = ''; $('#candSearchClear').classList.add('hidden'); renderCards(); }
|
||||
});
|
||||
$('#candSearchClear').addEventListener('click', () => {
|
||||
searchInput.value = '';
|
||||
state.search = '';
|
||||
$('#candSearchClear').classList.add('hidden');
|
||||
renderCards();
|
||||
searchInput.focus();
|
||||
});
|
||||
$('#compareBtn').addEventListener('click', toggleCompare);
|
||||
|
||||
$('#planBtn').addEventListener('click', () => runPlan());
|
||||
|
||||
Reference in New Issue
Block a user