归属判定升级: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());
|
||||
|
||||
+8
-2
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user