Files
TripLog/templates/activities/index.php
T
laoyang 5645a8db5c 首页地图引线加箭头指向真实位置(批 23)
① 被扇开的卡片,引线改成「从卡片指向真实坐标」:箭头画在真实位置那一端
  (marker-end + SVG marker),终点退开 DOT_R+2 px 免得盖住圆点。活动多了
  也能一眼看出这张卡片归哪个点 —— 这是用户「活动特别多会不会乱」的核心诉求。

② 箭头 marker 的 id 逐枚唯一(home-arrow-<下标>):每枚标记的 content 各自
  带一份 <defs>,同页 id 重名会指向先出现的那份,属隐患。

验证:本地全链新增 10c2「首页地图密集活动排布」步骤 —— 密集夹具
(8 个活动:东北簇 3 + 长三角簇 3 + 孤立三亚/成都)断言卡片两两不叠、
都在地图容器内、扇开的都有箭头且箭头落点离自己真实坐标 <= 8px、marker id
不重名、孤立的两枚原地不动、密集下仍可点进活动;_map16 亦加两条箭头断言。
部署哨兵新增批 23 段 4 条(数代码形式字面量,防注释打挂)。
2026-09-26 20:41:28 +08:00

392 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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']) : '',
// 批 22:地图卡片右侧的简要参与人(前两位 + 总人数),与列表同一份聚合口径
'members' => array_slice($st['members'] ?? [], 0, 2),
'memberCount' => (int)($st['member_count'] ?? 0),
'url' => url('activities/show', ['id' => $a['id']]),
];
}
?>
<div class="page-header">
<h1>活动列表</h1>
</div>
<?php if (empty($activities)): ?>
<div class="empty">
<p>还没有任何活动</p>
<a href="<?= url('activities/create') ?>" class="btn-primary">创建第一个活动</a>
</div>
<?php else: ?>
<?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" 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
$st = $stats[(int)$a['id']] ?? null;
$placeCount = (int)($st['place_count'] ?? 0);
$placeMain = (string)($st['place_main'] ?? '');
$nodeCount = (int)($st['node_count'] ?? 0);
$days = (int)($st['days'] ?? 0);
$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>
<p class="muted act-row-desc"><?= e($a['description'] ?: '暂无简介') ?></p>
<?php if ($members): ?>
<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>
</a>
<?php endforeach; ?>
</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;
/* ── 批 22:图标改成「方形小卡片」(左图右文:活动名 + 地点/参与人摘要)。
同城/邻近的旅行在屏幕上挤在一起时,按**环形扇开**拉到外围规则排开,
并用一条引线连回真实位置 —— 线画在 marker content 的 SVG 里,
position 仍是真实坐标(点卡片进对应活动,线只是视觉引导)。
没被挤到的旅行不偏移也不画线,卡片就落在真实位置上。 */
var CARD_W = 186, CARD_H = 58; // 卡片名义尺寸(按中心距判碰用)
var CLUSTER_DIST = 110; // 中心距小于它 → 归为同簇(要扇开)
var CANVAS = 600, C = CANVAS / 2; // 每枚标记的 content 画布(真实位置在画布中心)
var DOT_R = 3; // 真实位置那个小圆点的半径(引线箭头要给它让位)
function shortTitle(t) {
return (t && t.length > 10) ? t.slice(0, 10) + '…' : (t || '');
}
function esc(s) {
return String(s === null || s === undefined ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/* 批 21:地图不再铺满整个视口 —— 用户反馈「上面正文固定宽、下面地图铺满屏」
看着奇怪,改回与正文版心同宽(批 18 的负 margin 出血逻辑 bleed() 已删)。 */
map = new AMap.Map(el, { viewMode: '2D', zoom: 5 });
window.__homeMap = map;
function shortMembers(s) {
var names = (s.members || []).map(function (m) { return m.name; });
var t = names.join('、');
if (s.memberCount > names.length) t += ' 等 ' + s.memberCount + ' 人';
return t;
}
function cardHtml(s, dx, dy, idx) {
var moved = Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5;
/* 箭头 marker 的 id 必须逐枚唯一:每枚标记的 content 都自带一份 <defs>,
同页里 id 重名会指向先出现的那份(虽然样子一样,但属于隐患)。 */
var arrowId = 'home-arrow-' + (idx || 0);
var inner = s.img
? '<img src="' + esc(s.img) + '" alt="">'
: '<span>📍</span>';
var sub = [];
if (s.place) sub.push(esc(s.place));
var mem = shortMembers(s);
if (mem) sub.push(esc(mem));
var tip = s.place ? s.title + '(' + s.place + ')' : s.title;
var html = '<div class="home-pin">'
+ '<svg width="' + CANVAS + '" height="' + CANVAS + '">';
if (moved) {
/* 引线**从卡片指向真实位置**:箭头画在真实位置那一端(marker-end),
活动多了也能一眼看出这张卡片归哪个点;终点退开 DOT_R+2 px,
免得箭头盖住圆心那个小圆点。 */
var len = Math.hypot(dx, dy) || 1;
var gap = DOT_R + 2;
var ex = C + dx / len * gap, ey = C + dy / len * gap;
html += '<defs><marker id="' + arrowId + '" viewBox="0 0 10 10"'
+ ' refX="9" refY="5" markerWidth="9" markerHeight="9"'
+ ' markerUnits="userSpaceOnUse" orient="auto">'
+ '<path class="home-arrow" d="M0,1 L10,5 L0,9 z"/></marker></defs>'
+ '<line class="home-pin-line" x1="' + (C + dx) + '" y1="' + (C + dy) + '"'
+ ' x2="' + ex + '" y2="' + ey + '" marker-end="url(#' + arrowId + ')"/>'
+ '<circle class="home-pin-dot" cx="' + C + '" cy="' + C + '" r="' + DOT_R + '"/>';
}
html += '</svg>'
+ '<div class="home-card" style="left:' + (C + dx) + 'px;top:' + (C + dy) + 'px;"'
+ ' title="' + esc(tip) + '">'
+ '<div class="home-card-img">' + inner + '</div>'
+ '<div class="home-card-body">'
+ '<div class="home-card-title">' + esc(shortTitle(s.title)) + '</div>'
+ (sub.length ? '<div class="home-card-sub">' + sub.join(' · ') + '</div>' : '')
+ '</div></div></div>';
return html;
}
var markers = []; // { mk: AMap.Marker, spot: spot }
spots.forEach(function (s, si) {
/* ⚠️ position 必须是 [lng, lat] 数组:传 {lng,lat} 裸对象会让 AMap 抛错,
整个绘制循环当场中断(批 11 的「光秃秃地图」就是它)。 */
var mk = new AMap.Marker({
position: [s.lng, s.lat],
content: cardHtml(s, 0, 0, si),
anchor: 'center',
map: map,
});
mk.on('click', function () { window.location.href = s.url; });
markers.push({ mk: mk, spot: s });
});
/* 适配视野,但**别放到街道级**:只有一次旅行(或几次都在同城)时
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 farEnough(a, b) {
var dx = Math.abs(a.x - b.x) / (CARD_W / 2 + 8);
var dy = Math.abs(a.y - b.y) / (CARD_H / 2 + 8);
return (dx * dx + dy * dy) >= 1;
}
/* 重排:先把屏幕上挨得近的标记聚成簇(并查集,同城/邻近的旅行),
再把每簇**环形均匀扇开**——起始角优先朝向地图中心(往空旷处扇),
再每 22.5° 环绕一周逐个尝试;半径从 70px 起步逐轮放大,直到簇内
两两、与其他簇已放的卡片、以及**地图容器边界**都不冲突为止。
单枚标记原地不动。缩放/换视野/改窗口后重跑。
(边界约束是批 22 修的:原先固定从正上方开始扇,城市贴近地图上缘时
卡片被甩到容器外、被上方的标签栏挡住,点不到。) */
function relayout() {
var items = [];
markers.forEach(function (m, mi) {
var base = null;
try {
base = map.lngLatToContainer(new AMap.LngLat(m.spot.lng, m.spot.lat));
} catch (e) {
base = null;
}
items.push({ mk: m.mk, spot: m.spot, base: base, dx: 0, dy: 0, idx: mi });
});
/* 并查集:中心距 < CLUSTER_DIST 的归为同簇 */
var parent = items.map(function (_, i) { return i; });
function find(i) {
while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; }
return i;
}
for (var i = 0; i < items.length; i++) {
if (!items[i].base) continue;
for (var j = i + 1; j < items.length; j++) {
if (!items[j].base) continue;
if (Math.hypot(items[i].base.x - items[j].base.x,
items[i].base.y - items[j].base.y) < CLUSTER_DIST) {
parent[find(i)] = find(j);
}
}
}
var groups = {};
items.forEach(function (it, i) {
if (!it.base) return;
var r = find(i);
(groups[r] = groups[r] || []).push(it);
});
var placedPts = []; // 其他簇已放的卡片中心(跨簇也要防叠)
var mapW = 0, mapH = 0;
try {
mapW = map.getContainer().clientWidth || 0;
mapH = map.getContainer().clientHeight || 0;
} catch (e2) { mapW = 0; mapH = 0; }
/* 卡片中心不许越出地图容器:上下留半张卡高、左右留半张卡宽 */
function inBounds(p) {
if (!mapW || !mapH) return true;
return p.x >= CARD_W / 2 + 6 && p.x <= mapW - CARD_W / 2 - 6
&& p.y >= CARD_H / 2 + 6 && p.y <= mapH - CARD_H / 2 - 6;
}
Object.keys(groups).forEach(function (g) {
var grp = groups[g];
if (grp.length === 1) {
placedPts.push({ x: grp[0].base.x, y: grp[0].base.y });
return; // 单枚:原地,不偏移不画线
}
/* 簇中心 = 各真实位置的平均;起始角优先朝地图中心,再每 22.5° 环绕 */
var cx = 0, cy = 0;
grp.forEach(function (it) { cx += it.base.x; cy += it.base.y; });
cx /= grp.length; cy /= grp.length;
var toCenter = (mapW && mapH)
? Math.atan2(mapH / 2 - cy, mapW / 2 - cx) : 0;
var best = null;
for (var ai = 0; ai < 16 && !best; ai++) {
var a0 = toCenter + ai * Math.PI / 8;
for (var R = 70; R <= 300; R += 24) {
var pts = [], okAll = true;
for (var k = 0; k < grp.length; k++) {
var a = a0 + k * 2 * Math.PI / grp.length;
var p = { x: cx + R * Math.cos(a), y: cy + R * Math.sin(a) };
if (!inBounds(p)) { okAll = false; break; }
pts.push(p);
}
if (!okAll) continue;
for (var p1 = 0; p1 < pts.length && okAll; p1++) {
for (var q = 0; q < p1 && okAll; q++) {
if (!farEnough(pts[p1], pts[q])) okAll = false;
}
for (var q2 = 0; q2 < placedPts.length && okAll; q2++) {
if (!farEnough(pts[p1], placedPts[q2])) okAll = false;
}
}
if (okAll) { best = pts; break; }
}
}
if (!best) {
/* 各角度半径都放不下(地图太挤/太小):按优先方向摆一圈后
把坐标钳回容器内 —— 宁可挤一点也不许出界。 */
best = grp.map(function (it3, k3) {
var a3 = toCenter + k3 * 2 * Math.PI / grp.length;
var px = cx + 300 * Math.cos(a3), py = cy + 300 * Math.sin(a3);
if (mapW && mapH) {
px = Math.max(CARD_W / 2 + 6, Math.min(mapW - CARD_W / 2 - 6, px));
py = Math.max(CARD_H / 2 + 6, Math.min(mapH - CARD_H / 2 - 6, py));
}
return { x: px, y: py };
});
}
grp.forEach(function (it, k2) {
it.dx = best[k2].x - it.base.x;
it.dy = best[k2].y - it.base.y;
placedPts.push(best[k2]);
});
});
items.forEach(function (it) {
if (it.base) it.mk.setContent(cardHtml(it.spot, it.dx, it.dy, it.idx));
});
}
relayout();
map.on('zoomend', relayout);
/* 改窗口大小 / 旋转屏幕 / 换分辨率:地图容器跟着版心变,视野和图标排布
都得按新尺寸重算一次。防抖 200ms,别在拖动时反复重排。 */
var resizeTimer = null;
window.addEventListener('resize', function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
if (!map) return;
map.resize();
fitView();
relayout();
}, 200);
});
})();
</script>
<?php endif; ?>
<?php endif; ?>