照片墙地图的视觉语言很直觉:地图在中间,城市是钉子,照片是拍立得,拉线把钉子和照片连起来。但"把 28 个城市的照片组自动摆到合适的位置"是一个非平凡的布局问题。
build-map-wall.js 用约 880 行代码解决了这个问题。
问题定义
在一个固定 viewBox 的 SVG 画布上,有 28 个城市图钉(位置由经纬度投影决定)。每个城市需要放置一组 1-3 张拍立得照片。约束条件:
- 照片组不能与其他照片组重叠
- 照片组不能覆盖地图多边形(省份边界)
- 照片组不能超出 viewBox 边界
- 照片组不能遮挡界面控件保留区(返回链接、缩放按钮)
- 拉线长度必须大于最小阈值
这是一个带约束的二维装箱问题。
投影与网格
Mercator 投影
GeoJSON 省份边界通过 Mercator 投影转换为 SVG 坐标。fitProjection() 遍历所有坐标点找到包围盒,计算缩放和平移使地图适配中央 mapRect:
viewBox: 1240 × 720 (桌面)
mapRect: x=230, y=128, w=620, h=470
候选网格
在整个 viewBox 上生成细粒度网格。桌面端步长 gridStepX=10, gridStepY=5,产生约 120×120 = 14,400 个候选位置。每个候选位置是一个 (x, y) 坐标,代表照片组左上角的放置点。
网格生成结果按 (expandedW, expandedH, viewBox) 缓存,相同尺寸的照片组共享同一套候选网格。
多因子评分
每个候选位置按以下因子打分,分数越低越优先:
| 因子 | 计算方式 | 权重/范围 | 意图 |
|---|---|---|---|
| 距离 | 照片组中心到城市图钉的欧氏距离 | 直接累加 | 就近原则 |
| 方向惩罚 | 候选位置与"城市→地图中心"向量的反向点积 | max(0, -dot) × 80 |
照片应该向外展开,不压向地图中心 |
| 侧边惩罚 | 东侧城市放到左侧 +220,西侧城市放到右侧 +28 | 非对称 | 右侧空间更大,东侧城市优先用右侧 |
| 呼吸空间 | 东侧城市 + 右侧位置 → -70 | 奖励 | 鼓励东部城市利用右侧留白 |
| 远左惩罚 | 候选位置 x < 地图中心 - 160 → +70 | 惩罚 | 避免照片挤到最左边 |
| 中心引力 | 到地图中心的距离 × 0.015 | 微弱 | 防止照片飘太远 |
| 确定性抖动 | hash(城市名) % 17 × 0.01 |
极微 | 打破平局,保证结果可复现 |
评分函数:
score = dist + directionPenalty + sidePenalty
+ rightBreathingRoomBonus + farLeftPenalty
+ centerPull + deterministicJitter
所有候选位置按分数排序,从低到高依次尝试放置。
约束检测
每个候选位置在放置前需要通过四重检测:
1. 组间不重叠
与已放置的所有照片组的 expanded rect(含 bleed 边距)做 AABB 碰撞检测。
2. 不超出边界
expanded rect 的四条边必须在 viewBox 范围内。
3. 不遮挡 UI
expanded rect 不与 reservedRects 数组中的任何保留区相交。桌面端保留两个区域:左上角返回链接和右上角缩放按钮。
4. 不覆盖地图
这是最复杂的约束。使用射线法(ray casting)判断候选矩形是否与任何省份多边形相交:
1. 在候选矩形周围扩展 mapSafePadding 安全边距
2. 在扩展矩形内按 sampleStep 步长采样网格点
3. 对每个采样点做 pointInPolygon 检测
4. 任何采样点在多边形内 → 候选位置不合法
检测结果用 Map 缓存(mapSafetyCache),相同矩形不重复计算。
降级放置
放置策略逐级降级:
尝试 1: maxPhotosPerCity 张(最多 3 张),标准间距
尝试 2: 2 张,标准间距
尝试 3: 1 张,标准间距
尝试 4: 1 张,紧凑间距(compact 模式)
全部失败 → 抛出异常,列出当前已放置数量和调整建议
每次降级都重新计算照片组尺寸、重新生成网格、重新评分、重新遍历所有候选位置。这保证了空间紧张时优先减少照片数量,而不是强行塞进不合适的位置。
确定性布局
整个布局过程不使用 Math.random()。所有"随机"效果来自哈希函数:
function hash(value) {
let h = 0;
for (const ch of String(value)) h = ((h << 5) - h + ch.charCodeAt(0)) | 0;
return Math.abs(h);
}
照片的旋转角度、尺寸微调、图钉颜色、位置抖动都由 hash(照片名) 或 hash(城市名) 决定。每次构建产生完全相同的布局结果。
布局后验证
放置完成后运行五项校验:
- 所有 expanded rect 在 viewBox 内
- 所有 expanded rect 不与保留区相交
- 所有 expanded rect 不与地图安全区相交
- 任意两组 expanded rect 不重叠
- 所有拉线长度 ≥
minThreadLen(桌面 40px,移动 24px)
任何一项失败都会打印具体错误并抛出异常,阻止生成有缺陷的页面。
渲染
每个城市生成三个视觉元素:
图钉 — 彩色圆点(r=4.6),带高光小圆和 72px 透明点击热区。颜色从 6 色调色板中按城市名哈希选取。
拉线 — SVG <path>,从图钉到最近照片图钉的直线。使用 pathLength="1" 归一化路径长度,通过 stroke-dashoffset 动画实现"画线"效果。如果最近图钉距离小于 minThreadLen,等比延长拉线。
拍立得组 — SVG <foreignObject> 内嵌 HTML <div>,绝对定位排列 1-3 张拍立得。每张照片有随机旋转(-4° 到 +4°)、随机宽度(在 min/max 范围内按宽高比计算)、随机图钉颜色。
双视口
桌面和移动端各有一套配置:
| 参数 | 桌面 | 移动端 |
|---|---|---|
| viewBox | 1240 × 720 | 560 × 900 |
| mapRect | 620 × 470 居中 | 420 × 315 偏上 |
| 最大照片数 | 3 | 3 |
| 拍立得宽度 | 48-58px | 30-44px |
| 最小拉线 | 40px | 24px |
| 最大缩放 | 5.5x | 8x |
两套 SVG 通过 CSS 媒体查询切换 display。两套布局在构建时独立计算,各自有独立的网格、评分和放置结果。
客户端交互
运行时只需处理平移和缩放,不需要重新计算布局。使用 Pointer Events 实现:
- 滚轮缩放(以鼠标位置为中心,1x ~ 5.5x/8x)
- 拖拽平移(缩放 > 1x 时启用,边界约束)
- 双指捏合缩放(保留中心点)
- 双击缩放(切换 1x / 3x)
- 缩放时图钉反向缩放(
1/scale),保持视觉大小不变
布局在构建时完成,运行时只做变换。这意味着 880 行算法代码只在 npm run build-map-wall 时运行一次,生成的 HTML 是纯静态的。