照片墙地图的视觉语言很直觉:地图在中间,城市是钉子,照片是拍立得,拉线把钉子和照片连起来。但"把 28 个城市的照片组自动摆到合适的位置"是一个非平凡的布局问题。

build-map-wall.js 用约 880 行代码解决了这个问题。

问题定义

在一个固定 viewBox 的 SVG 画布上,有 28 个城市图钉(位置由经纬度投影决定)。每个城市需要放置一组 1-3 张拍立得照片。约束条件:

  1. 照片组不能与其他照片组重叠
  2. 照片组不能覆盖地图多边形(省份边界)
  3. 照片组不能超出 viewBox 边界
  4. 照片组不能遮挡界面控件保留区(返回链接、缩放按钮)
  5. 拉线长度必须大于最小阈值

这是一个带约束的二维装箱问题。

投影与网格

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(城市名) 决定。每次构建产生完全相同的布局结果。

布局后验证

放置完成后运行五项校验:

  1. 所有 expanded rect 在 viewBox 内
  2. 所有 expanded rect 不与保留区相交
  3. 所有 expanded rect 不与地图安全区相交
  4. 任意两组 expanded rect 不重叠
  5. 所有拉线长度 ≥ 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 是纯静态的。