Files
TripLog/templates/activities/show.php
T
laoyang 9a11ceff94 路线地图:重合的标记与线段各自让开(批 14)
问题:往返行程(A→B 去程 + B→A 返程)两段的**中点完全相同**、线段也
完全重合,后画的那一枚会把先画的整枚盖住,看起来只剩一段。

两类重合分别处理:
- **线段重合**:同一段(不分方向,A→B 与 B→A 视为同一键)第 2 次起
  沿**法线**错开(长段按比例、短段给下限),像双向车道各占一边。
- **标记重合**:不再只比坐标字符串 —— 坐标不同但屏幕上挨着的两点
  (相差几公里的两个地点,在"看全国"的缩放级别下像素位置几乎一致)
  同样是重合。改为先问地图「这个经纬度现在在屏幕哪个像素上」
  (map.lngLatToContainer),再用贪心排布把撞上的标记按像素左右挪开。
- 到达地红点:同一点位只画一个(往返两端本来就是同一个)。
- **缩放后重排**(zoomend):缩放会改变标记间的屏幕距离,缩小时原本
  分开的标记可能又叠回去。重排时**不再 setFitView**(fromZoom 标志),
  否则与缩放事件互相触发、还会把用户手动缩放好的视野拉回去。

验证:_page11.php 夹具新增返程节点(哈尔滨 → 青岛),_map12.js 新增
「两两求矩形交集」的几何断言 —— 它先抓出真重合(34×39px 重叠),
修复后 0 重叠并确认往返两枚并排 46px、相邻两枚错开 52px。
本地全链 12 步全绿(渲染检查 354 项、真 AMap 浏览器 13 项);
远端 e2e 353 项 0 失败;部署静态断言、双 PHP 渲染、内容断言全过。
2026-09-24 15:48:18 +08:00

375 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.
<div class="page-header">
<div>
<h1><?= e($activity['title']) ?></h1>
<p class="muted"><?= e($activity['description'] ?: '暂无简介') ?></p>
</div>
<div class="actions" style="display:flex; gap:8px; flex-wrap:wrap;">
<a href="<?= url('nodes/create', ['activity_id' => $activity['id'], 'category' => 'plan']) ?>" class="btn">+ 计划节点</a>
<a href="<?= url('nodes/create', ['activity_id' => $activity['id'], 'category' => 'reality']) ?>" class="btn-primary">+ 执行节点</a>
<a href="<?= url('activities/edit', ['id' => $activity['id']]) ?>" class="btn">编辑活动</a>
<form method="post" action="<?= url('activities/destroy') ?>" style="display:inline;"
onsubmit="return confirm('确定删除活动「<?= e($activity['title']) ?>」?\n该活动下的所有节点、附件、评论都会一并删除,且不可恢复。');">
<input type="hidden" name="_csrf" value="<?= e(csrf_token()) ?>">
<input type="hidden" name="id" value="<?= (int)$activity['id'] ?>">
<button type="submit" class="btn btn-danger">删除活动</button>
</form>
</div>
</div>
<?php if (!empty($activity['cover_image'])): ?>
<div class="activity-cover">
<img src="<?= e(media_cover((int)$activity['id'])) ?>" alt="">
</div>
<?php endif; ?>
<?php $expenseSummary = $expenseSummary ?? ['total' => 0.0, 'count' => 0]; ?>
<?php if ((int)$expenseSummary['count'] > 0): ?>
<div class="activity-stats">
<span class="activity-stat">💰 共 <?= (int)$expenseSummary['count'] ?> 笔</span>
<span class="activity-stat">总计 ¥<?= number_format((float)$expenseSummary['total'], 2) ?></span>
</div>
<?php endif; ?>
<?php
/* ── 路线地图(批 10 引入,批 11 调整位置与标记逻辑)──────────────
位置放在封面之下、时间轴之前:行程一长,拉到底才看到地图的体验很差。
按时间顺序把执行节点的起终点连成路线:节点内实线、节点之间虚线;
圆形标记放在**这段行程线的中点**(缺一头就放有的一头)——
有图片附件显示图片,没有显示节点类型 emoji,左上角带序号,
点一下打开该节点详情。
只有地名、没坐标的地点(偏僻地方下拉选不到点位):浏览器端调
高德地理编码(AMap.Geocoder)按地名查坐标,查到就补进路线重画;
查不到就保持缺省,不硬造位置。AMap 2.0 已由 layout.php 全局加载。 */
$routeNodes = [];
$nodeFirstImage = $nodeFirstImage ?? [];
foreach (($realityNodes ?? []) as $rn) {
$s = json_decode_field($rn['start_location'] ?? null);
$e = json_decode_field($rn['end_location'] ?? null);
$hasS = !empty($s['lng']) && !empty($s['lat']);
$hasE = !empty($e['lng']) && !empty($e['lat']);
$sname = trim((string)($s['name'] ?? ''));
$ename = trim((string)($e['name'] ?? ''));
if (!$hasS && !$hasE && $sname === '' && $ename === '') {
continue; // 坐标和地名一个都没有的节点不上地图
}
$imgId = (int)($nodeFirstImage[(int)$rn['id']] ?? 0);
$routeNodes[] = [
'id' => (int)$rn['id'],
'title' => (string)($rn['title'] ?: '(无标题节点)'),
'icon' => (string)($rn['node_type_icon'] ?: '📍'),
'img' => $imgId > 0 ? media_att($imgId) : '',
'url' => url('nodes/show', ['id' => (int)$rn['id']]),
'sname' => $sname, // 出发地名:没有坐标时交给高德地理编码兜底
'ename' => $ename, // 到达地名:同上
'saddr' => trim((string)($s['address'] ?? '')), // 详细地址:地理编码优先用它
'eaddr' => trim((string)($e['address'] ?? '')),
'start' => $hasS ? ['lng' => (float)$s['lng'], 'lat' => (float)$s['lat']] : null,
'end' => $hasE ? ['lng' => (float)$e['lng'], 'lat' => (float)$e['lat']] : null,
];
}
?>
<?php if ($routeNodes !== []): ?>
<div class="route-tabs" role="tablist" aria-label="行程地图与节点详情切换">
<button type="button" class="route-tab is-active" data-panel="route-panel-map"
aria-selected="true">🗺️ 行程地图</button>
<button type="button" class="route-tab" data-panel="route-panel-nodes"
aria-selected="false">📋 计划与实际</button>
</div>
<div class="route-panel" id="route-panel-map">
<section class="route-map-section">
<div class="route-map" id="route-map-canvas"
data-route="<?= e(json_encode($routeNodes, JSON_UNESCAPED_UNICODE)) ?>"></div>
<p class="muted route-map-hint">
按时间顺序连出的执行节点路线(节点间虚线衔接),圆标在每段行程的中间,点它可打开对应节点。
<span class="route-legend-end">红点</span> = 到达地。只有地名没坐标的地点会自动向高德查询定位,太偏僻的地名可能查不到。
</p>
</section>
</div>
<div class="route-panel" id="route-panel-nodes" hidden>
<?php endif; ?>
<div class="timeline-grid">
<section class="timeline-col">
<h2>📋 计划 (<?= count($planNodes) ?>)</h2>
<?php if (empty($planNodes)): ?>
<p class="muted">暂无计划节点</p>
<?php else: ?>
<?php foreach ($planNodes as $n): ?>
<?php $cardCategory = 'plan'; require __DIR__ . '/_node_card.php'; ?>
<?php endforeach; ?>
<?php endif; ?>
</section>
<section class="timeline-col">
<h2>🎬 实际 (<?= count($realityNodes) ?>)</h2>
<?php if (empty($realityNodes)): ?>
<p class="muted">暂无执行节点</p>
<?php else: ?>
<?php foreach ($realityNodes as $n): ?>
<?php $cardCategory = 'reality'; require __DIR__ . '/_node_card.php'; ?>
<?php endforeach; ?>
<?php endif; ?>
</section>
</div>
<?php
$commentTargetType = \App\Comments::TARGET_ACTIVITY;
$commentTargetId = (int)$activity['id'];
require dirname(__DIR__) . '/comments/_block.php';
?>
<?php if ($routeNodes !== []): ?>
</div>
<script>
(function () {
'use strict';
/* ── 标签页切换:行程地图 / 计划与实际 ──────────────────────
地图标签默认打开(地图是主视图);切回来时重新 setFitView,
因为容器隐藏期间尺寸没有意义。 */
var tabs = Array.prototype.slice.call(document.querySelectorAll('.route-tab'));
var panels = {
'route-panel-map': document.getElementById('route-panel-map'),
'route-panel-nodes': document.getElementById('route-panel-nodes'),
};
var map = null; // 下面初始化后挂进来,供切换标签时重新适配视野
tabs.forEach(function (btn) {
btn.addEventListener('click', function () {
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 (id) {
if (panels[id]) panels[id].hidden = (id !== btn.getAttribute('data-panel'));
});
if (btn.getAttribute('data-panel') === 'route-panel-map' && map) {
map.setFitView(null, false, [50, 50, 50, 50]);
}
});
});
/* ── 路线地图(AMap 2.0,layout.php 已全局加载)────────────── */
var el = document.getElementById('route-map-canvas');
if (!el) return;
/* AMap 脚本加载失败(罕见)时给一句人话,不白屏 */
if (typeof AMap === 'undefined' || window.__AMAP_ERROR) {
el.innerHTML = '<p class="route-map-fallback">地图脚本没有加载成功,暂时无法显示路线。</p>';
return;
}
var route;
try {
route = JSON.parse(el.getAttribute('data-route') || '[]');
} catch (e) {
return;
}
if (!route.length) return;
map = new AMap.Map(el, { viewMode: '2D', zoom: 5 });
var overlays = []; // 当前画上去的所有覆盖物;地理编码补点后整体重画
/* ── 防重合(批 14)────────────────────────────────────────
两类重合,都要治:
① 往返:A→B 与 B→A 中点是同一个、线段也完全重合
→ 线段沿**法线**错开(像双向车道各占一边),标记在屏幕上错开;
② 距离很近的地点(相差几公里):坐标不同,但在"看全国"的缩放级别下
像素位置几乎一致 → 只能按**屏幕像素**判定碰撞再错开。
所以标记一律按像素排布(与缩放级别同步重算),不再只比坐标字符串。
注意:错开只改「怎么画」,不改数据 —— prevEnd 仍用原始坐标。 */
var MARK_W = 46; // 标记直径 + 间隙(CSS 里圆标 40px)
var MARK_H = 44;
function segKey(p1, p2) {
var a = p1[0].toFixed(5) + ',' + p1[1].toFixed(5);
var b = p2[0].toFixed(5) + ',' + p2[1].toFixed(5);
return a < b ? a + '|' + b : b + '|' + a; // A→B 与 B→A 同一键
}
function posKey(p) {
return p[0].toFixed(5) + ',' + p[1].toFixed(5);
}
function markerPos(n) {
var s = n.start, en = n.end;
if (s && en) return [(s.lng + en.lng) / 2, (s.lat + en.lat) / 2];
if (s) return [s.lng, s.lat];
if (en) return [en.lng, en.lat];
return null;
}
/** 同一段重复出现时,沿法线左右交替错开(+1 / -1 / +2 / -2 …) */
function makeSegOffsetter() {
var seen = {};
return function (p1, p2) {
var key = segKey(p1, p2);
var n = seen[key] || 0;
seen[key] = n + 1;
if (n === 0) return [p1, p2];
var dx = p2[0] - p1[0], dy = p2[1] - p1[1];
var len = Math.sqrt(dx * dx + dy * dy) || 1e-9;
var mag = Math.max(len * 0.06, 0.0008); // 长段按比例、短段给下限
var k = (n % 2 === 1) ? (n + 1) / 2 : -(n / 2);
var ox = (-dy / len) * mag * k;
var oy = (dx / len) * mag * k;
return [[p1[0] + ox, p1[1] + oy], [p2[0] + ox, p2[1] + oy]];
};
}
/** 贪心排布:和已放好的标记撞上就左右交替挪开,返回像素偏移 */
function makePlacer() {
var placed = []; // 已占用的屏幕坐标(容器像素)
return {
place: function (base, id) {
var dx = 0, guard = 0, k = 0;
while (guard++ < 30) {
var hit = false;
for (var j = 0; j < placed.length; j++) {
var q = placed[j];
if (Math.abs(base.x + dx - q.x) < MARK_W
&& Math.abs(base.y - q.y) < MARK_H) {
hit = true;
break;
}
}
if (!hit) break;
k++;
dx = (k % 2 === 1) ? Math.ceil(k / 2) * MARK_W : -Math.ceil(k / 2) * MARK_W;
}
placed.push({ x: base.x + dx, y: base.y, id: id });
return dx;
},
reset: function () { placed = []; },
};
}
/* 全量重画:先清掉旧覆盖物再按当前 route 数据画。
地理编码是异步的,一个点查回来就重画一次,数据是幂等的。
fromZoom=true 表示这次是「用户缩放了」触发的重排:只重排标记,
不再 setFitView —— 否则会和缩放事件互相触发,还会把用户手动
缩放好的视野又拉回去。 */
function drawRoute(fromZoom) {
overlays.forEach(function (o) { map.remove(o); });
overlays = [];
var inPath = []; // 节点内:出发地 → 到达地(实线)
var hopPath = []; // 节点之间:上一站到达地 → 下一站出发地(虚线)
var prevEnd = null;
var shiftSeg = makeSegOffsetter();
var placer = makePlacer(); // 标记按屏幕像素排布,撞上就挪开
var dotSeen = {}; // 到达地红点:同一点位只画一个
route.forEach(function (n, i) {
var s = n.start, en = n.end;
if (s && en) {
var seg = shiftSeg([s.lng, s.lat], [en.lng, en.lat]);
inPath.push(seg[0], seg[1]);
}
if (s && prevEnd) {
var hop = shiftSeg([prevEnd.lng, prevEnd.lat], [s.lng, s.lat]);
hopPath.push(hop[0], hop[1]);
}
/* 圆形标记放这段行程线的中点(起终点都有才取中点),缺一头就放有的一头。
⚠️ position 必须是 [lng, lat] 数组 —— 把 {lng,lat} 对象直接传给
AMap 会抛 Invalid Object,forEach 当场中断、整张地图一个覆盖物
都不剩(批 11 上线后踩的坑,光秃秃地图就是它)。 */
var pos = markerPos(n);
if (pos) {
/* 先问地图「这个经纬度现在在屏幕哪个像素上」,再和已放的标记比距离:
坐标不同但屏幕位置挨着(几公里的两个点、或往返的中点)都要挪开 */
var base = null;
try {
base = map.lngLatToContainer(new AMap.LngLat(pos[0], pos[1]));
} catch (e) {
base = null;
}
var dx = base ? placer.place(base, i) : 0;
var html ='<div class="route-marker" title="' + n.title.replace(/"/g, '&quot;') + '">'
+ (n.img
? '<img src="' + n.img + '" alt="">'
: '<span class="route-marker-emoji">' + n.icon + '</span>')
+ '<span class="route-marker-num">' + (i + 1) + '</span>'
+ '</div>';
var opts = {
position: pos,
content: html,
anchor: 'center',
map: map,
};
/* 撞上了就整体往旁边挪 —— 用像素偏移,谁也不挡谁 */
if (dx !== 0) {
opts.offset = new AMap.Pixel(dx, 0);
}
var marker = new AMap.Marker(opts);
marker.on('click', function () { window.location.href = n.url; });
overlays.push(marker);
}
/* 到达地小圆点(和出发地不是同一点时才画)。
同一点位只画一个:往返行程两端的红点本来就该是同一个。 */
if (en && (!s || en.lng !== s.lng || en.lat !== s.lat)) {
var dk = posKey([en.lng, en.lat]);
if (!dotSeen[dk]) {
dotSeen[dk] = 1;
var dot = new AMap.Marker({
position: [en.lng, en.lat],
content: '<div class="route-end-dot"></div>',
anchor: 'center',
map: map,
});
dot.on('click', function () { window.location.href = n.url; });
overlays.push(dot);
}
}
prevEnd = en || s;
});
if (inPath.length > 1) {
overlays.push(new AMap.Polyline({
path: inPath, strokeColor: '#3B82F6', strokeWeight: 5,
strokeOpacity: 0.9, lineJoin: 'round', showDir: true, map: map,
}));
}
if (hopPath.length) {
overlays.push(new AMap.Polyline({
path: hopPath, strokeColor: '#94A3B8', strokeWeight: 3,
strokeStyle: 'dashed', strokeOpacity: 0.8, map: map,
}));
}
if (overlays.length && !fromZoom) map.setFitView(null, false, [50, 50, 50, 50]);
}
drawRoute();
/* 缩放会改变标记之间的屏幕距离:缩小时原本分开的标记可能又叠在一起,
所以缩放结束后按新的像素位置重排一次(不重新适配视野)。 */
map.on('zoomend', function () { drawRoute(true); });
/* 只有地名、没有坐标的地点:向高德地理编码查坐标(插件按需加载)。
查到就补进数据并重画;查不到(地名太偏/写错)保持缺省,不硬造位置。 */
function geocode(name, cb) {
if (!name) return;
AMap.plugin('AMap.Geocoder', function () {
try {
new AMap.Geocoder({}).getLocation(name, function (status, result) {
if (status === 'complete' && result && result.geocodes && result.geocodes.length
&& result.geocodes[0].location) {
var loc = result.geocodes[0].location;
cb({ lng: Number(loc.getLng()), lat: Number(loc.getLat()) });
}
});
} catch (e) { /* 地理编码服务不可用时保持原状 */ }
});
}
/* 没有坐标的点:优先拿**详细地址**去查(比简化地名准得多),没记地址才用地名 */
route.forEach(function (n) {
if (!n.start) {
var qs = n.saddr || n.sname;
if (qs) geocode(qs, function (p) { n.start = p; drawRoute(); });
}
if (!n.end) {
var qe = n.eaddr || n.ename;
if (qe) geocode(qe, function (p) { n.end = p; drawRoute(); });
}
});
})();
</script>
<?php endif; ?>