路线地图:重合的标记与线段各自让开(批 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 渲染、内容断言全过。
This commit is contained in:
2026-09-24 15:48:18 +08:00
parent 142b187911
commit 9a11ceff94
+115 -12
View File
@@ -169,47 +169,146 @@ require dirname(__DIR__) . '/comments/_block.php';
map = new AMap.Map(el, { viewMode: '2D', zoom: 5 }); map = new AMap.Map(el, { viewMode: '2D', zoom: 5 });
var overlays = []; // 当前画上去的所有覆盖物;地理编码补点后整体重画 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 数据画。 /* 全量重画:先清掉旧覆盖物再按当前 route 数据画。
地理编码是异步的,一个点查回来就重画一次,数据是幂等的。 */ 地理编码是异步的,一个点查回来就重画一次,数据是幂等的。
function drawRoute() { fromZoom=true 表示这次是「用户缩放了」触发的重排:只重排标记,
不再 setFitView —— 否则会和缩放事件互相触发,还会把用户手动
缩放好的视野又拉回去。 */
function drawRoute(fromZoom) {
overlays.forEach(function (o) { map.remove(o); }); overlays.forEach(function (o) { map.remove(o); });
overlays = []; overlays = [];
var inPath = []; // 节点内:出发地 → 到达地(实线) var inPath = []; // 节点内:出发地 → 到达地(实线)
var hopPath = []; // 节点之间:上一站到达地 → 下一站出发地(虚线) var hopPath = []; // 节点之间:上一站到达地 → 下一站出发地(虚线)
var prevEnd = null; var prevEnd = null;
var shiftSeg = makeSegOffsetter();
var placer = makePlacer(); // 标记按屏幕像素排布,撞上就挪开
var dotSeen = {}; // 到达地红点:同一点位只画一个
route.forEach(function (n, i) { route.forEach(function (n, i) {
var s = n.start, en = n.end; var s = n.start, en = n.end;
if (s && en) inPath.push([s.lng, s.lat], [en.lng, en.lat]); if (s && en) {
if (s && prevEnd) hopPath.push([prevEnd.lng, prevEnd.lat], [s.lng, s.lat]); 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} 对象直接传给 ⚠️ position 必须是 [lng, lat] 数组 —— 把 {lng,lat} 对象直接传给
AMap 会抛 Invalid Object,forEach 当场中断、整张地图一个覆盖物 AMap 会抛 Invalid Object,forEach 当场中断、整张地图一个覆盖物
都不剩(批 11 上线后踩的坑,光秃秃地图就是它)。 */ 都不剩(批 11 上线后踩的坑,光秃秃地图就是它)。 */
var pos = (s && en) var pos = markerPos(n);
? [(s.lng + en.lng) / 2, (s.lat + en.lat) / 2]
: (s ? [s.lng, s.lat] : (en ? [en.lng, en.lat] : null));
if (pos) { if (pos) {
var html = '<div class="route-marker" title="' + n.title.replace(/"/g, '&quot;') + '">' /* 先问地图「这个经纬度现在在屏幕哪个像素上」,再和已放的标记比距离:
坐标不同但屏幕位置挨着(几公里的两个点、或往返的中点)都要挪开 */
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 + (n.img
? '<img src="' + n.img + '" alt="">' ? '<img src="' + n.img + '" alt="">'
: '<span class="route-marker-emoji">' + n.icon + '</span>') : '<span class="route-marker-emoji">' + n.icon + '</span>')
+ '<span class="route-marker-num">' + (i + 1) + '</span>' + '<span class="route-marker-num">' + (i + 1) + '</span>'
+ '</div>'; + '</div>';
var marker = new AMap.Marker({ var opts = {
position: pos, position: pos,
content: html, content: html,
anchor: 'center', anchor: 'center',
map: map, 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; }); marker.on('click', function () { window.location.href = n.url; });
overlays.push(marker); overlays.push(marker);
} }
/* 到达地小圆点(和出发地不是同一点时才画) */ /* 到达地小圆点(和出发地不是同一点时才画)。
同一点位只画一个:往返行程两端的红点本来就该是同一个。 */
if (en && (!s || en.lng !== s.lng || en.lat !== s.lat)) { 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({ var dot = new AMap.Marker({
position: [en.lng, en.lat], position: [en.lng, en.lat],
content: '<div class="route-end-dot"></div>', content: '<div class="route-end-dot"></div>',
@@ -219,6 +318,7 @@ require dirname(__DIR__) . '/comments/_block.php';
dot.on('click', function () { window.location.href = n.url; }); dot.on('click', function () { window.location.href = n.url; });
overlays.push(dot); overlays.push(dot);
} }
}
prevEnd = en || s; prevEnd = en || s;
}); });
@@ -235,9 +335,12 @@ require dirname(__DIR__) . '/comments/_block.php';
strokeStyle: 'dashed', strokeOpacity: 0.8, map: map, strokeStyle: 'dashed', strokeOpacity: 0.8, map: map,
})); }));
} }
if (overlays.length) map.setFitView(null, false, [50, 50, 50, 50]); if (overlays.length && !fromZoom) map.setFitView(null, false, [50, 50, 50, 50]);
} }
drawRoute(); drawRoute();
/* 缩放会改变标记之间的屏幕距离:缩小时原本分开的标记可能又叠在一起,
所以缩放结束后按新的像素位置重排一次(不重新适配视野)。 */
map.on('zoomend', function () { drawRoute(true); });
/* 只有地名、没有坐标的地点:向高德地理编码查坐标(插件按需加载)。 /* 只有地名、没有坐标的地点:向高德地理编码查坐标(插件按需加载)。
查到就补进数据并重画;查不到(地名太偏/写错)保持缺省,不硬造位置。 */ 查到就补进数据并重画;查不到(地名太偏/写错)保持缺省,不硬造位置。 */