Files
TripLog/templates/activities/index.php
T
laoyang 28fb9f6112 地图铺满整个视口宽度(批 18)
反馈:界面宽度是固定的(正文版心 1080px 居中),地图也被框在这一条里。
按用户选择:**只让地图突破版心、占满整屏**,列表与文字仍保持版心(宽屏下好读)。

- 首页「旅行地图」与详情页「行程路线」都铺满整个视口宽度,
  高度改成 `clamp(460px, calc(100vh - 190px), 1000px)` —— 屏幕越大容器越大,
  地图显示的地理范围也越大(AMap 填满容器 + fitView 按当前视野重算),
  等于跟着分辨率自动缩放。
- 改窗口大小 / 旋转屏幕时:重算出量 → `map.resize()` → 重新适配视野并重排标记
  (200ms 防抖)。

🔴 出血量为什么由 JS 给(模板里的 `bleed()`):
版心留白是 `max(gutter, (100% - 1080px)/2)`,**自定义属性里的 100% 是在「使用它的
那个元素」上解析的** —— 把它取负直接写在地图元素(它在版心内部,宽 1080)上,
算出来只有 -gutter,实测地图只铺到 1128/1440。
所以改成读 container 实际渲染出来的 `padding-left` 再取负。
也不写 `width: 100vw`:它含滚动条宽度,会让整页横向溢出。

验证:两条几何断言钉住「铺满」+「不溢出」(详情页 18 项 / 首页 29 项),
首页还逐档改视口(390 / 768 / 1280 / 1920 / 2560)各量一次 —— 是真的改视口,
不是只看默认尺寸。另重抓了一次线上快照,用工作树 CSS 跑布局几何检查 385 项。
本地全链 13 步全绿(渲染检查 372 项);远端 e2e 365 项 0 失败;
部署静态断言 213 条 0 FAIL、双 PHP 渲染逐字节一致、内容断言全过。
2026-09-24 18:59:03 +08:00

281 lines
12 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']) : '',
'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;
/* 图标下面还挂了一行活动名,整块是 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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/* 地图铺满整个视口宽度:把正文版心的左右留白用负 margin 还回去。
⚠️ 不能用 CSS `calc(-1 * var(--shell-pad))`:自定义属性里的 `100%` 是在
**使用它的元素**上解析的,写在地图(它在版心内部,宽 1080)上算出来
只有 -gutter,实测只能铺到 1128/1440。这里按 container 实际渲染出的
padding 反推,才铺得满;也不用 100vw(含滚动条宽度会撑出横向滚动条)。 */
function bleed() {
var host = el.closest('.container');
if (!host) return;
var pad = parseFloat(window.getComputedStyle(host).paddingLeft) || 0;
if (pad <= 0) return;
el.style.marginLeft = (-pad) + 'px';
el.style.marginRight = (-pad) + 'px';
}
bleed();
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);
/* 改窗口大小 / 旋转屏幕 / 换分辨率:地图容器跟着变(宽度是铺满视口的),
视野和图标排布都得按新尺寸重算一次。防抖 200ms,别在拖动时反复重排。 */
var resizeTimer = null;
window.addEventListener('resize', function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
if (!map) return;
bleed(); // 版心留白变了,出血量跟着变
map.resize();
fitView();
relayout();
}, 200);
});
})();
</script>
<?php endif; ?>
<?php endif; ?>