① 首页地图圆形图标改成方形小卡片:左侧封面图(无封面放 📍),
右侧活动名(10 字截断)+ 地点/参与人摘要(超出 5 位显示「等 N 人」)。
② 同城/邻近(屏幕中心距 <110px)的活动归为一簇,按环形扇开拉到
外围规则排开,SVG 引线连回真实位置;单独的活动原地不动不画线。
判碰用椭圆(横半轴卡宽/纵半轴卡高),并查集聚类,半径 70→300 逐轮
放大;缩放/换视野/改窗口后重排。卡片仍锚定真实坐标,点击进活动。
③ 修复扇开越界:原先固定从正上方开始扇,城市贴近地图上缘时卡片被
甩到容器外(y=-29)、被上方标签栏挡住点不到。改为起始角优先朝向
地图中心、每 22.5° 环绕尝试,且候选点必须落在地图容器边界内;
全部放不下时钳回边界内兜底。
验证:本地全链(含 _map16 真浏览器:3 卡片、扇形拉出、引线、椭圆
判碰、容器边界不越界、5 档视口、点击进详情)全绿。
378 lines
17 KiB
PHP
378 lines
17 KiB
PHP
<?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, '&').replace(/</g, '<')
|
||
.replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
/* 批 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; ?>
|