diff --git a/assets/style.css b/assets/style.css index ae892fd..cbbd7e6 100644 --- a/assets/style.css +++ b/assets/style.css @@ -1324,23 +1324,44 @@ video.video-thumb.video-thumb-mini { /* 联想选中后回填的详细地址(让人看见记下来的是什么) */ .loc-detail { margin-top: 6px; font-size: 12.5px; word-break: break-all; } -/* ── 22. 首页:旅行地图 + 活动列表两个标签页(批 16)───────────── */ -/* 一次旅行一枚图标:圆形(有封面放封面,没有放 📍)+ 下面一行活动名 */ -.home-marker-wrap { display: flex; flex-direction: column; align-items: center; gap: 3px; cursor: pointer; } -.home-marker { - width: 44px; height: 44px; border-radius: 50%; - border: 3px solid #fff; box-shadow: 0 2px 8px rgba(0, 0, 0, .35); - background: var(--surface); overflow: hidden; - display: flex; align-items: center; justify-content: center; font-size: 22px; +/* ── 22. 首页:旅行地图 + 活动列表两个标签页(批 16,批 22 改卡片)── */ +/* 一次旅行一枚「方形小卡片」:左边封面图(没有放 📍),右边活动名 + + 地点/参与人摘要。同城/邻近的旅行被扇开到外围时,用一条引线连回真实位置 + (线是 marker content 里的 SVG;外层画布 pointer-events:none, + 不挡底图拖拽,只有卡片本身可点)。 */ +.home-pin { position: relative; width: 600px; height: 600px; pointer-events: none; } +/* AMap 的标记包装层 .amap-marker 尺寸跟着 content(600×600)走、默认可命中, + 会把别家卡片的点击整层挡住(amap-marker intercepts pointer events)。 + 整层禁掉命中、只留卡片本身可点 —— 点击事件沿 DOM 冒泡,AMap 的 + marker click 照常触发。 */ +#home-map-canvas .amap-marker { pointer-events: none !important; } +.home-pin svg { position: absolute; left: 0; top: 0; overflow: visible; } +.home-pin-line { stroke: #8aa0b8; stroke-width: 1.5; } +.home-pin-dot { fill: #3b82f6; } +.home-card { + position: absolute; transform: translate(-50%, -50%); + display: flex; align-items: center; gap: 7px; + padding: 5px 10px 5px 5px; background: rgba(255, 255, 255, .96); + border: 1px solid var(--border); border-radius: 10px; + box-shadow: 0 2px 8px rgba(0, 0, 0, .25); + cursor: pointer; pointer-events: auto; + width: max-content; max-width: 190px; } -.home-marker img { width: 100%; height: 100%; object-fit: cover; display: block; } -.home-marker-emoji { line-height: 1; } -.home-marker-label { - max-width: 92px; padding: 1px 7px; border-radius: 999px; - background: rgba(255, 255, 255, .94); color: var(--text); - font-size: 11.5px; line-height: 17px; white-space: nowrap; - overflow: hidden; text-overflow: ellipsis; - box-shadow: 0 1px 4px rgba(0, 0, 0, .22); +.home-card:hover { border-color: var(--accent); } +.home-card-img { + width: 42px; height: 42px; border-radius: 7px; overflow: hidden; flex: none; + background: var(--bg-soft); display: flex; align-items: center; justify-content: center; + font-size: 20px; +} +.home-card-img img { width: 100%; height: 100%; object-fit: cover; display: block; } +.home-card-body { min-width: 0; } +.home-card-title { + font-size: 12.5px; font-weight: 600; color: var(--text); line-height: 1.35; + max-width: 122px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.home-card-sub { + font-size: 11px; color: var(--text-soft); line-height: 1.4; margin-top: 1px; + max-width: 122px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* 列表:每行一条 —— 左边封面、右边标题/简述/参与人/摘要 */ diff --git a/templates/activities/index.php b/templates/activities/index.php index fd80f03..6298bde 100644 --- a/templates/activities/index.php +++ b/templates/activities/index.php @@ -14,6 +14,9 @@ foreach ($activities as $a) { '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']]), ]; } @@ -148,12 +151,17 @@ foreach ($activities as $a) { } if (!spots.length) return; - /* 图标下面还挂了一行活动名,整块是 56×64 的**矩形**,所以这里按矩形判重叠。 - (详情页那两处是纯圆标,才按中心距判 —— 形状不同判定就不同,别混用。) */ - var MARK_W = 56, MARK_H = 64, STEP = MARK_W + 12; + /* ── 批 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 > 8) ? t.slice(0, 8) + '…' : (t || ''); + return (t && t.length > 10) ? t.slice(0, 10) + '…' : (t || ''); } function esc(s) { return String(s === null || s === undefined ? '' : s) @@ -167,26 +175,52 @@ foreach ($activities as $a) { map = new AMap.Map(el, { viewMode: '2D', zoom: 5 }); window.__homeMap = map; - var markers = []; - spots.forEach(function (s) { + 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 ? '' - : '📍'; + : '📍'; + 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 = '
' - + '
' + inner + '
' - + '
' + esc(shortTitle(s.title)) + '
' - + '
'; + var html = '
' + + ''; + if (moved) { + html += '' + + ''; + } + html += '' + + '
' + + '
' + inner + '
' + + '
' + + '
' + esc(shortTitle(s.title)) + '
' + + (sub.length ? '
' + sub.join(' · ') + '
' : '') + + '
'; + 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: html, + content: cardHtml(s, 0, 0), anchor: 'center', map: map, }); mk.on('click', function () { window.location.href = s.url; }); - markers.push(mk); + markers.push({ mk: mk, spot: s }); }); /* 适配视野,但**别放到街道级**:只有一次旅行(或几次都在同城)时 @@ -200,49 +234,125 @@ foreach ($activities as $a) { } 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; + /* 椭圆判碰:横向半轴按卡宽、纵向按卡高 —— 水平相邻要留出卡宽、 + 垂直相邻只要留出卡高(卡片是横条,椭圆比圆省地方)。 */ + 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; } - /* 缩放会改变图标之间的屏幕距离,所以每次缩放结束都要重排一次。 - 只改 offset,不重建标记 —— 重建会把点击事件也一起丢掉。 */ + /* 重排:先把屏幕上挨得近的标记聚成簇(并查集,同城/邻近的旅行), + 再把每簇**环形均匀扇开**——起始角优先朝向地图中心(往空旷处扇), + 再每 22.5° 环绕一周逐个尝试;半径从 70px 起步逐轮放大,直到簇内 + 两两、与其他簇已放的卡片、以及**地图容器边界**都不冲突为止。 + 单枚标记原地不动。缩放/换视野/改窗口后重跑。 + (边界约束是批 22 修的:原先固定从正上方开始扇,城市贴近地图上缘时 + 卡片被甩到容器外、被上方的标签栏挡住,点不到。) */ function relayout() { - var placed = []; - markers.forEach(function (mk) { + var items = []; + markers.forEach(function (m) { var base = null; try { - base = map.lngLatToContainer(mk.getPosition()); + base = map.lngLatToContainer(new AMap.LngLat(m.spot.lng, m.spot.lat)); } catch (e) { base = null; } - if (!base) return; - var off = place(base, placed); - mk.setOffset(new AMap.Pixel(off.x, off.y)); + 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();