Files
TripLog/templates/activities/index.php
T
laoyang c72f9d0b14 首页地图图标改「左图右文」方形卡片 + 同城拥挤扇形拉出排布(批 22)
① 首页地图圆形图标改成方形小卡片:左侧封面图(无封面放 📍),
  右侧活动名(10 字截断)+ 地点/参与人摘要(超出 5 位显示「等 N 人」)。

② 同城/邻近(屏幕中心距 <110px)的活动归为一簇,按环形扇开拉到
  外围规则排开,SVG 引线连回真实位置;单独的活动原地不动不画线。
  判碰用椭圆(横半轴卡宽/纵半轴卡高),并查集聚类,半径 70→300 逐轮
  放大;缩放/换视野/改窗口后重排。卡片仍锚定真实坐标,点击进活动。

③ 修复扇开越界:原先固定从正上方开始扇,城市贴近地图上缘时卡片被
  甩到容器外(y=-29)、被上方标签栏挡住点不到。改为起始角优先朝向
  地图中心、每 22.5° 环绕尝试,且候选点必须落在地图容器边界内;
  全部放不下时钳回边界内兜底。

验证:本地全链(含 _map16 真浏览器:3 卡片、扇形拉出、引线、椭圆
判碰、容器边界不越界、5 档视口、点击进详情)全绿。
2026-09-24 23:19:02 +08:00

378 lines
17 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 画布(真实位置在画布中心)
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) {
var moved = Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5;
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) {
html += '<line class="home-pin-line" x1="' + C + '" y1="' + C + '"'
+ ' x2="' + (C + dx) + '" y2="' + (C + dy) + '"/>'
+ '<circle class="home-pin-dot" cx="' + C + '" cy="' + C + '" r="3"/>';
}
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) {
/* ⚠️ position 必须是 [lng, lat] 数组:传 {lng,lat} 裸对象会让 AMap 抛错,
整个绘制循环当场中断(批 11 的「光秃秃地图」就是它)。 */
var mk = new AMap.Marker({
position: [s.lng, s.lat],
content: cardHtml(s, 0, 0),
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) {
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 });
});
/* 并查集:中心距 < 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));
});
}
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; ?>