归属判定升级: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:
2026-11-06 02:05:24 +08:00
parent c5c2ffae68
commit 7ad86c6619
25 changed files with 2766 additions and 13 deletions
+133 -7
View File
@@ -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());
+8 -2
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="行知 TripSense:按时间与所在地分析周边适合出行的目的地,叠加天气与交通,并由 AI 生成逐日行程规划。">
<title>行知 · 周边出行顾问 | TripSense</title>
<link rel="stylesheet" href="styles.css?v=18">
<link rel="stylesheet" href="styles.css?v=19">
</head>
<body>
<header class="topbar">
@@ -149,13 +149,19 @@
<button data-sort="distance">距离最近</button>
<button data-sort="time">单程最短</button>
</div>
<div class="search-box">
<input type="search" id="candSearch" placeholder="搜索地名或景点名" autocomplete="off" aria-label="搜索候选目的地">
<button type="button" class="search-clear hidden" id="candSearchClear" title="清空">✕</button>
</div>
<label class="check"><input type="checkbox" id="onlyRec"> 仅显示「推荐」及以上</label>
<label class="check"><input type="checkbox" id="onlyNiche"> 只看小众</label>
<label class="check"><input type="checkbox" id="groupByPlace" checked> 按地名分组</label>
<button class="btn-outline sm" id="compareBtn" type="button">对比已选</button>
<span class="selected-count" id="selectedCount">已选 0 个</span>
</div>
<div class="preselect-hint hidden" id="preselectHint">已自动预选评分最高的 3 个目的地,可点击卡片按钮取消或改选</div>
<div class="filter-note hidden" id="filterNote"></div>
<div class="compare-panel hidden" id="comparePanel"></div>
<div class="cards" id="cards"></div>
<div class="notes" id="dataNotes"></div>
@@ -319,6 +325,6 @@
</div>
<div class="toast hidden" id="toast"></div>
<script src="app.js?v=18"></script>
<script src="app.js?v=19"></script>
</body>
</html>
+61
View File
@@ -691,6 +691,67 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
/* 两张卡内容长度差很多,按 start 对齐,别互相拉伸 */
.plan-extra { align-items: start; }
/* ------------------------------------------------- 第二步:搜索 + 地名分组 */
.search-box { position: relative; display: flex; align-items: center; }
.search-box input[type="search"] {
width: 230px; padding: 7px 30px 7px 11px;
border: 1px solid var(--rule-strong); border-radius: 8px;
background: var(--sheet); color: var(--ink); font-size: 12.5px;
transition: border-color .15s, box-shadow .15s;
}
.search-box input[type="search"]::-webkit-search-cancel-button { display: none; }
.search-box input[type="search"]::placeholder { color: var(--muted); }
.search-box input[type="search"]:focus {
outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(47, 111, 237, .12);
}
.search-clear {
position: absolute; right: 6px; border: 0; background: transparent;
color: var(--muted); font-size: 13px; line-height: 1; padding: 3px 5px; border-radius: 5px;
}
.search-clear:hover { background: var(--paper-2); color: var(--ink); }
/* 筛选提示条:告诉用户「当前显示多少 / 共多少」以及生效了哪些条件 */
.filter-note {
font-family: var(--mono); font-size: 11.5px; color: var(--ink-soft);
background: var(--sheet-2); border: 1px solid var(--rule); border-left: 3px solid var(--accent);
border-radius: 8px; padding: 8px 12px; margin-bottom: 14px; line-height: 1.7;
}
.filter-note b { color: var(--accent); font-weight: 700; }
/* 地名分组:组标题是按钮,下面缩进放景点卡片 */
.cards.has-groups { display: block; }
.place-group { margin-bottom: 22px; }
.place-head {
display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap;
width: 100%; text-align: left; padding: 9px 12px; margin-bottom: 12px;
background: var(--sheet-2); border: 1px solid var(--rule-strong); border-left: 4px solid var(--accent);
border-radius: 8px; transition: .15s ease;
}
.place-head:hover { background: var(--accent-soft); border-color: var(--accent); }
.place-caret { color: var(--accent); font-size: 11px; flex: 0 0 auto; }
.place-name { font-family: var(--serif); font-size: 16px; font-weight: 700; color: var(--ink); }
.place-detail {
font-size: 11.5px; color: var(--ink-soft); background: var(--sheet);
border: 1px solid var(--rule); border-radius: 999px; padding: 2px 9px;
}
.place-meta {
margin-left: auto; font-family: var(--mono); font-size: 11px; color: var(--muted);
white-space: nowrap;
}
.place-items {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px;
align-items: start; padding-left: 16px; border-left: 2px dashed var(--rule-strong);
margin-left: 6px;
}
.place-items.collapsed { display: none; }
@media (max-width: 720px) {
.search-box input[type="search"] { width: 100%; }
.search-box { flex: 1 1 100%; }
.place-items { grid-template-columns: 1fr; padding-left: 10px; }
.place-meta { margin-left: 0; width: 100%; }
}
@media (max-width: 900px) {
.plan-extra { grid-template-columns: 1fr; }
}