首页改成「旅行地图 / 活动列表」两个标签页(批 16)
原来首页是一屏卡片网格,一次旅行一张卡,看不出「去过哪儿」。改成: - **默认「🗺️ 旅行地图」**:一张大地图(`clamp(520px, 78vh, 920px)`), **一次旅行一枚图标**(有封面放封面、没有放 📍,下面挂一行活动名), 点图标直接进那次旅行的详情页。位置 = 那次旅行所有坐标点的**重心** (执行节点优先、计划节点兜底,取消的不算)。 - **另一个标签「📋 活动列表」**:每行一条 —— 左边封面,右边 标题 + 简述 + **主要参与人**(头像/名字,超过 5 位显示「等 N 人」)+ 去了几个地方(地名去重)+ 经历了几天(自然日)+ 几个节点。 - 只有一次旅行(或几次都在同城)时 `setFitView` 会一路放大到街道级 —— 那是导航视角不是"旅行足迹"视角,用 `MAX_ZOOM = 11` 压住。 - 一次旅行都没落坐标的家庭:不出标签条,退化成原来的单页列表。 - 图标下面挂了活动名,整块是 56×64 **矩形**,判重叠就按矩形; 详情页那种纯圆标才按中心距 —— 形状不同判定不同,混用会误判。 数据由 `ActivityController::collectStats()` 用几条 IN 查询**一次聚合** (不做逐活动的 N+1):节点明细 / 参与人(按参与节点数降序)/ 时间跨度 / 地点去重。 验证:新增真浏览器用例 `_page16.php` + `_map16.js`(首页 **20 项**), 夹具覆盖「两个重心只差几公里的同城旅行」(防重合回归)+「完全没坐标的活动」 (不上图但列表里要有)。首轮抓到两件事:① 断言拿了被截断的标题做匹配; ② 夹具里只有两个点,`fitView` 把它们拉得很开、根本测不到重合 → 补一个远在三亚的活动把视野拉回"看全国"。 e2e 新增 6b 节(**注意必须放在「删除活动」之前**,否则活动连带节点、 参与人一起被删,首页变空态、断言全程空转 —— 首轮就是这样红的)。 本地全链 13 步全绿(渲染检查 370 项、首页浏览器 20 项);远端 e2e 365 项 0 失败;部署静态断言 204 条 0 FAIL、双 PHP 渲染逐字节一致、内容断言全过。
This commit is contained in:
@@ -1322,3 +1322,70 @@ video.video-thumb.video-thumb-mini {
|
|||||||
}
|
}
|
||||||
/* 联想选中后回填的详细地址(让人看见记下来的是什么) */
|
/* 联想选中后回填的详细地址(让人看见记下来的是什么) */
|
||||||
.loc-detail { margin-top: 6px; font-size: 12.5px; word-break: break-all; }
|
.loc-detail { margin-top: 6px; font-size: 12.5px; word-break: break-all; }
|
||||||
|
|
||||||
|
/* ── 22. 首页:旅行地图 + 活动列表两个标签页(批 16)───────────── */
|
||||||
|
/* 首页的地图是主视图,比详情页那张再高一点 */
|
||||||
|
.home-map { height: clamp(520px, 78vh, 920px); }
|
||||||
|
|
||||||
|
/* 一次旅行一枚图标:圆形(有封面放封面,没有放 📍)+ 下面一行活动名 */
|
||||||
|
.home-marker-wrap { display: flex; flex-direction: column; align-items: center; gap: 3px; cursor: pointer; }
|
||||||
|
.home-marker {
|
||||||
|
width: 44px; height: 44px; border-radius: 50%;
|
||||||
|
border: 3px solid #fff; box-shadow: 0 2px 8px rgba(0, 0, 0, .35);
|
||||||
|
background: var(--surface); overflow: hidden;
|
||||||
|
display: flex; align-items: center; justify-content: center; font-size: 22px;
|
||||||
|
}
|
||||||
|
.home-marker img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.home-marker-emoji { line-height: 1; }
|
||||||
|
.home-marker-label {
|
||||||
|
max-width: 92px; padding: 1px 7px; border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, .94); color: var(--text);
|
||||||
|
font-size: 11.5px; line-height: 17px; white-space: nowrap;
|
||||||
|
overflow: hidden; text-overflow: ellipsis;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, .22);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 列表:每行一条 —— 左边封面、右边标题/简述/参与人/摘要 */
|
||||||
|
.act-list { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.act-row {
|
||||||
|
display: grid; grid-template-columns: 176px 1fr; gap: 16px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 12px;
|
||||||
|
transition: box-shadow .15s, border-color .15s;
|
||||||
|
}
|
||||||
|
.act-row:hover { border-color: var(--border-strong); box-shadow: var(--shadow-sm); }
|
||||||
|
.act-row-cover {
|
||||||
|
position: relative; aspect-ratio: 4 / 3; border-radius: var(--radius-sm, 8px);
|
||||||
|
overflow: hidden; background: var(--bg-soft);
|
||||||
|
}
|
||||||
|
.act-row-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.act-row-cover-ph {
|
||||||
|
width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 26px; color: var(--text-mute);
|
||||||
|
}
|
||||||
|
.act-row-body { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||||
|
.act-row-body h3 { margin: 0; font-size: 16px; }
|
||||||
|
.act-row-desc {
|
||||||
|
margin: 0; font-size: 13.5px; line-height: 1.5;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.act-row-members { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.act-member {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
padding: 2px 9px 2px 2px; border-radius: 999px; background: var(--bg-soft);
|
||||||
|
font-size: 12.5px; color: var(--text-soft); max-width: 150px;
|
||||||
|
}
|
||||||
|
.act-member img { width: 20px; height: 20px; border-radius: 50%; object-fit: cover; display: block; }
|
||||||
|
.act-member-initial {
|
||||||
|
width: 20px; height: 20px; border-radius: 50%; background: var(--accent); color: #fff;
|
||||||
|
font-size: 11px; display: flex; align-items: center; justify-content: center; flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.act-member-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.act-member-more { font-size: 12.5px; color: var(--text-mute); align-self: center; }
|
||||||
|
.act-row-meta { display: flex; flex-wrap: wrap; gap: 4px 14px; }
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.act-row { grid-template-columns: 104px 1fr; gap: 12px; padding: 10px; }
|
||||||
|
.home-map { height: clamp(420px, 62vh, 620px); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,29 +22,152 @@ class ActivityController
|
|||||||
$stmt->execute([$familyId]);
|
$stmt->execute([$familyId]);
|
||||||
$activities = $stmt->fetchAll();
|
$activities = $stmt->fetchAll();
|
||||||
|
|
||||||
// 每个活动的节点数:一次 GROUP BY 查完,避免逐个 COUNT 的 N+1
|
$stats = [];
|
||||||
if (!empty($activities)) {
|
if (!empty($activities)) {
|
||||||
$ids = array_column($activities, 'id');
|
$stats = $this->collectStats(array_map('intval', array_column($activities, 'id')));
|
||||||
$ph = implode(',', array_fill(0, count($ids), '?'));
|
|
||||||
$s = $db->prepare("SELECT activity_id, COUNT(*) AS cnt
|
|
||||||
FROM trip_nodes WHERE activity_id IN ($ph) GROUP BY activity_id");
|
|
||||||
$s->execute($ids);
|
|
||||||
$counts = [];
|
|
||||||
foreach ($s->fetchAll() as $row) {
|
|
||||||
$counts[(int)$row['activity_id']] = (int)$row['cnt'];
|
|
||||||
}
|
|
||||||
foreach ($activities as &$a) {
|
|
||||||
$a['node_count'] = $counts[(int)$a['id']] ?? 0;
|
|
||||||
}
|
|
||||||
unset($a);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
view('activities/index', [
|
view('activities/index', [
|
||||||
'activities' => $activities,
|
'activities' => $activities,
|
||||||
|
'stats' => $stats,
|
||||||
'pageTitle' => '活动列表',
|
'pageTitle' => '活动列表',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页要的聚合数据,**全部一次查完**(几条 IN 查询,不做逐活动的 N+1):
|
||||||
|
* - 主要位置(地图图标落哪儿):所有坐标点的重心,执行节点优先、计划节点兜底
|
||||||
|
* - 去过的几个地方:起终点里的地名去重计数
|
||||||
|
* - 时间跨度:最早开始 ~ 最晚结束,跨了几个自然日
|
||||||
|
* - 主要参与人:按参与节点数降序(参与得越多越靠前)
|
||||||
|
*
|
||||||
|
* @param array<int, int> $ids
|
||||||
|
* @return array<int, array>
|
||||||
|
*/
|
||||||
|
private function collectStats(array $ids): array
|
||||||
|
{
|
||||||
|
if (empty($ids)) return [];
|
||||||
|
$db = Database::get();
|
||||||
|
$ph = implode(',', array_fill(0, count($ids), '?'));
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$out[$id] = [
|
||||||
|
'node_count' => 0,
|
||||||
|
'place_count' => 0,
|
||||||
|
'place_main' => '',
|
||||||
|
'lng' => null,
|
||||||
|
'lat' => null,
|
||||||
|
'start' => null,
|
||||||
|
'end' => null,
|
||||||
|
'days' => 0,
|
||||||
|
'span_text' => '',
|
||||||
|
'members' => [],
|
||||||
|
'member_count' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ① 节点:位置 / 时间 / 地点名。取消的节点不计入(它没真的发生)。 */
|
||||||
|
$s = $db->prepare("SELECT activity_id, node_category, status, start_time, end_time,
|
||||||
|
start_location, end_location
|
||||||
|
FROM trip_nodes WHERE activity_id IN ($ph) ORDER BY start_time");
|
||||||
|
$s->execute($ids);
|
||||||
|
$coords = []; // activity => ['reality' => [[lng,lat,name],…], 'plan' => …]
|
||||||
|
$names = []; // activity => [地名 => 1]
|
||||||
|
foreach ($s->fetchAll() as $n) {
|
||||||
|
$aid = (int)$n['activity_id'];
|
||||||
|
if (!isset($out[$aid])) continue;
|
||||||
|
$st = &$out[$aid];
|
||||||
|
$st['node_count']++;
|
||||||
|
if ($n['status'] === 'cancelled') continue;
|
||||||
|
|
||||||
|
foreach (['start_location', 'end_location'] as $key) {
|
||||||
|
$loc = json_decode((string)$n[$key], true);
|
||||||
|
if (!is_array($loc)) continue;
|
||||||
|
$name = trim((string)($loc['name'] ?? ''));
|
||||||
|
if ($name !== '') $names[$aid][$name] = 1;
|
||||||
|
$lng = isset($loc['lng']) ? (float)$loc['lng'] : 0.0;
|
||||||
|
$lat = isset($loc['lat']) ? (float)$loc['lat'] : 0.0;
|
||||||
|
if ($lng == 0.0 && $lat == 0.0) continue; // 只有地名、没坐标
|
||||||
|
$bucket = ($n['node_category'] === 'reality') ? 'reality' : 'plan';
|
||||||
|
$coords[$aid][$bucket][] = [$lng, $lat, $name];
|
||||||
|
}
|
||||||
|
if (!empty($n['start_time'])
|
||||||
|
&& ($st['start'] === null || $n['start_time'] < $st['start'])) {
|
||||||
|
$st['start'] = $n['start_time'];
|
||||||
|
}
|
||||||
|
if (!empty($n['end_time'])
|
||||||
|
&& ($st['end'] === null || $n['end_time'] > $st['end'])) {
|
||||||
|
$st['end'] = $n['end_time'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($st);
|
||||||
|
|
||||||
|
/* ② 重心 = 主要位置;离重心最近那个点的地名当它的名字。
|
||||||
|
执行节点优先:计划改了、实际没去的地方不该把图标拽过去。 */
|
||||||
|
foreach ($coords as $aid => $set) {
|
||||||
|
$pts = !empty($set['reality']) ? $set['reality'] : ($set['plan'] ?? []);
|
||||||
|
if (!$pts) continue;
|
||||||
|
$sl = 0.0; $sb = 0.0;
|
||||||
|
foreach ($pts as $p) { $sl += $p[0]; $sb += $p[1]; }
|
||||||
|
$cl = $sl / count($pts);
|
||||||
|
$cb = $sb / count($pts);
|
||||||
|
$out[$aid]['lng'] = $cl;
|
||||||
|
$out[$aid]['lat'] = $cb;
|
||||||
|
$best = ''; $bestD = null;
|
||||||
|
foreach ($pts as $p) {
|
||||||
|
if ($p[2] === '') continue;
|
||||||
|
$d = ($p[0] - $cl) * ($p[0] - $cl) + ($p[1] - $cb) * ($p[1] - $cb);
|
||||||
|
if ($bestD === null || $d < $bestD) { $bestD = $d; $best = $p[2]; }
|
||||||
|
}
|
||||||
|
$out[$aid]['place_main'] = $best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ③ 去过的地方数:地名去重(同一个城市写十次也只算一个) */
|
||||||
|
foreach ($names as $aid => $set) {
|
||||||
|
$out[$aid]['place_count'] = count($set);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ④ 时间跨度:按自然日算(同一天出发并返回算 1 天) */
|
||||||
|
foreach ($out as $aid => $st) {
|
||||||
|
if ($st['start'] === null) continue;
|
||||||
|
$from = strtotime($st['start']);
|
||||||
|
$to = $st['end'] !== null ? strtotime($st['end']) : $from;
|
||||||
|
if (!$from) continue;
|
||||||
|
$fromD = strtotime(date('Y-m-d', $from));
|
||||||
|
$toD = strtotime(date('Y-m-d', $to));
|
||||||
|
$days = (int)floor(($toD - $fromD) / 86400) + 1;
|
||||||
|
$out[$aid]['days'] = max(1, $days);
|
||||||
|
$out[$aid]['span_text'] = ($toD === $fromD)
|
||||||
|
? date('Y-m-d', $fromD)
|
||||||
|
: date('Y-m-d', $fromD) . ' ~ ' . date('m-d', $toD);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ⑤ 参与人:参与节点越多越靠前 —— 「主要参与人」就是排在前面的那几位 */
|
||||||
|
$s = $db->prepare("SELECT tn.activity_id, nm.user_id, u.name, u.avatar, COUNT(*) AS hits
|
||||||
|
FROM node_members nm
|
||||||
|
JOIN trip_nodes tn ON tn.id = nm.trip_node_id
|
||||||
|
LEFT JOIN users u ON u.id = nm.user_id
|
||||||
|
WHERE tn.activity_id IN ($ph)
|
||||||
|
GROUP BY tn.activity_id, nm.user_id, u.name, u.avatar
|
||||||
|
ORDER BY tn.activity_id, hits DESC, nm.user_id ASC");
|
||||||
|
$s->execute($ids);
|
||||||
|
foreach ($s->fetchAll() as $m) {
|
||||||
|
$aid = (int)$m['activity_id'];
|
||||||
|
if (!isset($out[$aid])) continue;
|
||||||
|
$out[$aid]['members'][] = [
|
||||||
|
'id' => (int)$m['user_id'],
|
||||||
|
'name' => (string)($m['name'] ?? ''),
|
||||||
|
'avatar' => media_avatar($m['avatar'] ?? null),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
foreach ($out as $aid => $st) {
|
||||||
|
$out[$aid]['member_count'] = count($st['members']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
public function create(): void
|
public function create(): void
|
||||||
{
|
{
|
||||||
view('activities/form', [
|
view('activities/form', [
|
||||||
|
|||||||
+233
-12
@@ -1,3 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
/* 首页两个视图:默认「旅行地图」(一次旅行一枚图标,点进去就是那次旅行),
|
||||||
|
另一个标签是「活动列表」(每行一条:封面 + 标题简述 / 主要参与人 / 去了几个地方 /
|
||||||
|
经历了多长时间)。数据由 ActivityController::collectStats() 一次聚合好。 */
|
||||||
|
$stats = $stats ?? [];
|
||||||
|
$spots = [];
|
||||||
|
foreach ($activities as $a) {
|
||||||
|
$st = $stats[(int)$a['id']] ?? null;
|
||||||
|
if (!$st || $st['lat'] === null || $st['lng'] === null) continue;
|
||||||
|
$spots[] = [
|
||||||
|
'id' => (int)$a['id'],
|
||||||
|
'title' => (string)$a['title'],
|
||||||
|
'place' => (string)($st['place_main'] ?? ''),
|
||||||
|
'lat' => round((float)$st['lat'], 6),
|
||||||
|
'lng' => round((float)$st['lng'], 6),
|
||||||
|
'img' => !empty($a['cover_image']) ? media_cover((int)$a['id']) : '',
|
||||||
|
'url' => url('activities/show', ['id' => $a['id']]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
?>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>活动列表</h1>
|
<h1>活动列表</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -8,23 +28,224 @@
|
|||||||
<a href="<?= url('activities/create') ?>" class="btn-primary">创建第一个活动</a>
|
<a href="<?= url('activities/create') ?>" class="btn-primary">创建第一个活动</a>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="card-grid">
|
|
||||||
|
<?php if ($spots): ?>
|
||||||
|
<div class="route-tabs" role="tablist">
|
||||||
|
<button type="button" class="route-tab is-active" data-panel="home-panel-map"
|
||||||
|
role="tab" aria-selected="true">🗺️ 旅行地图</button>
|
||||||
|
<button type="button" class="route-tab" data-panel="home-panel-list"
|
||||||
|
role="tab" aria-selected="false">📋 活动列表</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="route-panel" id="home-panel-map">
|
||||||
|
<div class="route-map home-map" id="home-map-canvas"
|
||||||
|
data-spots="<?= e(json_encode($spots, JSON_UNESCAPED_UNICODE)) ?>"></div>
|
||||||
|
<p class="route-map-hint">每一枚图标是一次旅行(落在那次旅行去过的地方的重心),点图标就进那次旅行的详情。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="route-panel" id="home-panel-list" hidden>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="act-list">
|
||||||
<?php foreach ($activities as $a): ?>
|
<?php foreach ($activities as $a): ?>
|
||||||
<a href="<?= url('activities/show', ['id' => $a['id']]) ?>" class="card">
|
<?php
|
||||||
<?php if (!empty($a['cover_image'])): ?>
|
$st = $stats[(int)$a['id']] ?? null;
|
||||||
<img src="<?= e(media_cover((int)$a['id'])) ?>" alt="" class="card-cover">
|
$placeCount = (int)($st['place_count'] ?? 0);
|
||||||
<?php else: ?>
|
$placeMain = (string)($st['place_main'] ?? '');
|
||||||
<div class="card-cover card-cover-placeholder">📸</div>
|
$nodeCount = (int)($st['node_count'] ?? 0);
|
||||||
<?php endif; ?>
|
$days = (int)($st['days'] ?? 0);
|
||||||
<div class="card-body">
|
$spanText = (string)($st['span_text'] ?? '');
|
||||||
|
$memberCount = (int)($st['member_count'] ?? 0);
|
||||||
|
$members = array_slice($st['members'] ?? [], 0, 5);
|
||||||
|
?>
|
||||||
|
<a href="<?= url('activities/show', ['id' => $a['id']]) ?>" class="act-row">
|
||||||
|
<div class="act-row-cover">
|
||||||
|
<?php if (!empty($a['cover_image'])): ?>
|
||||||
|
<img src="<?= e(media_cover((int)$a['id'])) ?>" alt="">
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="act-row-cover-ph">📸</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="act-row-body">
|
||||||
<h3><?= e($a['title']) ?></h3>
|
<h3><?= e($a['title']) ?></h3>
|
||||||
<p class="muted"><?= e($a['description'] ?: '暂无简介') ?></p>
|
<p class="muted act-row-desc"><?= e($a['description'] ?: '暂无简介') ?></p>
|
||||||
<div class="meta">
|
|
||||||
<span><?= (int)$a['node_count'] ?> 个节点</span>
|
<?php if ($members): ?>
|
||||||
<span><?= date('Y-m-d', strtotime($a['created_at'])) ?></span>
|
<div class="act-row-members">
|
||||||
|
<?php foreach ($members as $m): ?>
|
||||||
|
<span class="act-member">
|
||||||
|
<?php if (!empty($m['avatar'])): ?>
|
||||||
|
<img src="<?= e($m['avatar']) ?>" alt="">
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="act-member-initial"><?= e(mb_substr((string)$m['name'], 0, 1)) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<span class="act-member-name"><?= e($m['name']) ?></span>
|
||||||
|
</span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if ($memberCount > count($members)): ?>
|
||||||
|
<span class="act-member-more">等 <?= $memberCount ?> 人</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="meta act-row-meta">
|
||||||
|
<?php if ($placeCount > 0): ?>
|
||||||
|
<span>📍 <?= $placeCount ?> 个地方<?= $placeMain !== '' ? '(' . e($placeMain) . ')' : '' ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($days > 0): ?>
|
||||||
|
<span>🕒 <?= $days ?> 天<?= $spanText !== '' ? '(' . e($spanText) . ')' : '' ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<span>🧳 <?= $nodeCount ?> 个节点</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if ($spots): ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
/* ── 标签页:地图默认打开;切回地图时重新适配视野并重排图标 ── */
|
||||||
|
var map = null;
|
||||||
|
var tabs = Array.prototype.slice.call(document.querySelectorAll('.route-tab'));
|
||||||
|
var panels = {
|
||||||
|
'home-panel-map': document.getElementById('home-panel-map'),
|
||||||
|
'home-panel-list': document.getElementById('home-panel-list'),
|
||||||
|
};
|
||||||
|
tabs.forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var id = btn.getAttribute('data-panel');
|
||||||
|
if (!panels[id]) return;
|
||||||
|
tabs.forEach(function (b) {
|
||||||
|
var on = (b === btn);
|
||||||
|
b.classList.toggle('is-active', on);
|
||||||
|
b.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
Object.keys(panels).forEach(function (k) {
|
||||||
|
if (panels[k]) panels[k].hidden = (k !== id);
|
||||||
|
});
|
||||||
|
if (id === 'home-panel-map' && map) {
|
||||||
|
fitView();
|
||||||
|
relayout();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var el = document.getElementById('home-map-canvas');
|
||||||
|
if (!el) return;
|
||||||
|
if (typeof AMap === 'undefined' || window.__AMAP_ERROR) {
|
||||||
|
el.innerHTML = '<p class="route-map-fallback">地图脚本没有加载成功,暂时无法显示旅行地图。</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var spots;
|
||||||
|
try {
|
||||||
|
spots = JSON.parse(el.getAttribute('data-spots') || '[]');
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!spots.length) return;
|
||||||
|
|
||||||
|
/* 图标下面还挂了一行活动名,整块是 56×64 的**矩形**,所以这里按矩形判重叠。
|
||||||
|
(详情页那两处是纯圆标,才按中心距判 —— 形状不同判定就不同,别混用。) */
|
||||||
|
var MARK_W = 56, MARK_H = 64, STEP = MARK_W + 12;
|
||||||
|
|
||||||
|
function shortTitle(t) {
|
||||||
|
return (t && t.length > 8) ? t.slice(0, 8) + '…' : (t || '');
|
||||||
|
}
|
||||||
|
function esc(s) {
|
||||||
|
return String(s === null || s === undefined ? '' : s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
map = new AMap.Map(el, { viewMode: '2D', zoom: 5 });
|
||||||
|
window.__homeMap = map;
|
||||||
|
|
||||||
|
var markers = [];
|
||||||
|
spots.forEach(function (s) {
|
||||||
|
var inner = s.img
|
||||||
|
? '<img src="' + esc(s.img) + '" alt="">'
|
||||||
|
: '<span class="home-marker-emoji">📍</span>';
|
||||||
|
var tip = s.place ? s.title + '(' + s.place + ')' : s.title;
|
||||||
|
var html = '<div class="home-marker-wrap" title="' + esc(tip) + '">'
|
||||||
|
+ '<div class="home-marker">' + inner + '</div>'
|
||||||
|
+ '<div class="home-marker-label">' + esc(shortTitle(s.title)) + '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
/* ⚠️ position 必须是 [lng, lat] 数组:传 {lng,lat} 裸对象会让 AMap 抛错,
|
||||||
|
整个绘制循环当场中断(批 11 的「光秃秃地图」就是它)。 */
|
||||||
|
var mk = new AMap.Marker({
|
||||||
|
position: [s.lng, s.lat],
|
||||||
|
content: html,
|
||||||
|
anchor: 'center',
|
||||||
|
map: map,
|
||||||
|
});
|
||||||
|
mk.on('click', function () { window.location.href = s.url; });
|
||||||
|
markers.push(mk);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 适配视野,但**别放到街道级**:只有一次旅行(或几次都在同城)时
|
||||||
|
setFitView 会一路放大到 18 级,那是导航视角不是"旅行足迹"视角。
|
||||||
|
上限定在 11 级(能看清一座城市及其周边)。setZoom 会触发 zoomend → 自动重排。 */
|
||||||
|
var MAX_ZOOM = 11;
|
||||||
|
function fitView() {
|
||||||
|
if (!markers.length) return;
|
||||||
|
map.setFitView(null, false, [40, 40, 40, 40]);
|
||||||
|
if (map.getZoom() > MAX_ZOOM) map.setZoom(MAX_ZOOM);
|
||||||
|
}
|
||||||
|
fitView();
|
||||||
|
|
||||||
|
/* 图标重合(同一个城市去过好几次)时让开:没有线段可沿,就先左右、
|
||||||
|
再上下,按**屏幕像素**判定(缩放到"看全国"时两个城市会叠在一起)。 */
|
||||||
|
function place(base, placed) {
|
||||||
|
var cand = [{ x: 0, y: 0 }];
|
||||||
|
var k, m, sg;
|
||||||
|
for (k = 1; k <= 6; k++) {
|
||||||
|
m = Math.ceil(k / 2) * STEP; sg = (k % 2 === 1) ? 1 : -1;
|
||||||
|
cand.push({ x: m * sg, y: 0 });
|
||||||
|
}
|
||||||
|
for (k = 1; k <= 4; k++) {
|
||||||
|
m = Math.ceil(k / 2) * Math.round(MARK_H * 0.9); sg = (k % 2 === 1) ? 1 : -1;
|
||||||
|
cand.push({ x: 0, y: m * sg });
|
||||||
|
}
|
||||||
|
var hit = function (x, y) {
|
||||||
|
for (var j = 0; j < placed.length; j++) {
|
||||||
|
var q = placed[j];
|
||||||
|
if (Math.abs(x - q.x) < MARK_W && Math.abs(y - q.y) < MARK_H) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
var pick = null;
|
||||||
|
for (var i = 0; i < cand.length; i++) {
|
||||||
|
if (!hit(base.x + cand[i].x, base.y + cand[i].y)) { pick = cand[i]; break; }
|
||||||
|
}
|
||||||
|
if (!pick) pick = cand[0];
|
||||||
|
placed.push({ x: base.x + pick.x, y: base.y + pick.y });
|
||||||
|
return pick;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 缩放会改变图标之间的屏幕距离,所以每次缩放结束都要重排一次。
|
||||||
|
只改 offset,不重建标记 —— 重建会把点击事件也一起丢掉。 */
|
||||||
|
function relayout() {
|
||||||
|
var placed = [];
|
||||||
|
markers.forEach(function (mk) {
|
||||||
|
var base = null;
|
||||||
|
try {
|
||||||
|
base = map.lngLatToContainer(mk.getPosition());
|
||||||
|
} catch (e) {
|
||||||
|
base = null;
|
||||||
|
}
|
||||||
|
if (!base) return;
|
||||||
|
var off = place(base, placed);
|
||||||
|
mk.setOffset(new AMap.Pixel(off.x, off.y));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
relayout();
|
||||||
|
map.on('zoomend', relayout);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
Reference in New Issue
Block a user