专题的多圆显示区:前台按并集裁剪迷雾

上一批把显示区改成了「一组圆」,这一批让前台真正认多个圆。

- clip-path 从 circle() 换成 path():circle() 只表达得了一个圆。每个圆一段
  圆弧子路径、**同绕向**(sweep=0),nonzero 填充规则下多段自然取并集,
  重叠处不会互相挖洞。
- 拼路径的活单独放 fog-clip.js(rond-map.js 是个大 IIFE,函数取不出来),
  这样能直接跑 node 单测;layout 里必须排在 rond-map.js 之前加载。
- 顺带清掉已经死掉的 fitCircle:多圆之后「圆心 + 半径」表达不了,留着就是
  两套并行的贴合口径。fitParams 现在只给 fitBBox(单圆时外接矩形正是那个
  圆的外接正方形,视野等价)。
- 守卫改名 TestTopicFogIsClippedToAreas,钉住两端(模板发 fogAreas ↔ 脚本认
  fogAreas)与两处顺序(裁剪注册在 applyFit 之后、fog-clip.js 在 rond-map.js
  之前);fogAreas 必须**无条件**下发(空数组 = 不限区域),不要包在条件里。
- verify.sh 挂进前端单测:以前只靠人记得手动跑 = 等于没有。找不到 node 直接
  失败而不是跳过(跳过会让验收看着全绿、实际一行前端脚本都没验)。

验证(临时两圆专题,SQL 造的,已删):
- fog-clip.test.js 15 条:把生成的 path 解析回圆弧再验几何,含绕向一致性与
  半径 0/负数的夹取。
- 端到端像素验证:自校准出「CSS 容器坐标 → 图像像素」(scale≈2、offset≈19.2,
  正是 site_cdp 的 --clip 外扩 10px×scale,别再手算);三张掩码图证明并集语义
  —— A 独有点只有 A 亮、B 独有点只有 B 亮、**重叠中心两圆都亮且并集也亮
  (无洞)**,整体集合差异 0.013%。
- 迷雾掩码:86,776 个像素,按两圆并集的解析式判 **圆外 0 个**;两个圆内分别
  有 52,609 / 46,757 个迷雾像素 ⇒ 第二个圆真的被认了,不是只看第一个。
This commit is contained in:
tamakyi
2026-10-12 16:44:55 +08:00
parent 1bb35dbbc9
commit 44763dfc3f
10 changed files with 277 additions and 59 deletions
+9 -9
View File
@@ -132,7 +132,7 @@ func topicAreasFromForm(r *http.Request) ([]TopicArea, error) {
}
// topicAreasBounds 求所有圆的并集外接矩形(GCJ-02 度)。
// 只有一个圆时它就是这个圆的外接正方形,与原来 fitCircle 那条路(toBounds(2r km))等价,
// 只有一个圆时它就是这个圆的外接正方形,与早先那条 fitCircle(toBounds(2r km))等价,
// 所以单圆专题的视野不变;多圆只有外接矩形才有意义。
func topicAreasBounds(areas []TopicArea) (minLat, maxLat, minLon, maxLon float64, ok bool) {
if len(areas) == 0 {
@@ -634,10 +634,10 @@ func (s *Server) topicPage(w http.ResponseWriter, r *http.Request) {
s.serverError(w, r, err)
return
}
fitCircle, fitBBox := fitParams(t, points)
fitBBox := fitParams(t, points)
d := TopicData{
Page: p, Topic: t, Points: points, IsAdmin: p.IsAdmin(),
PointsJSON: template.JS(js), FitCircleJSON: fitCircle, FitBBoxJSON: fitBBox,
PointsJSON: template.JS(js), FitBBoxJSON: fitBBox,
}
s.render(w, "topic", d)
}
@@ -661,17 +661,17 @@ func (s *Server) applyNotesToPoints(ctx context.Context, datasetID int64, points
// fitParams 生成把地图视野框到专题范围的脚本参数:限定显示区时用**所有圆的并集外接矩形**,
// 否则用地点包围盒——两者都能保证「区域内的迷雾」正好落在视野里。
//
// 多圆之后不再有「圆心 + 半径」可言(fitCircle 只表达得了一个圆),统一走 fitBBox:
// 单圆时外接矩形就是这个圆的外接正方形,与原来 fitCircle 那条路等价。
func fitParams(t Topic, points []MapPoint) (circle, bbox template.JS) {
// 早先这里还给过一个 fitCircle(中心 + 半径),多圆之后放弃:它只表达得了一个圆,
// 留着就是两套并行的贴合口径。单圆时外接矩形正是那个圆的外接正方形,视野与原路径等价。
func fitParams(t Topic, points []MapPoint) template.JS {
if minLat, maxLat, minLon, maxLon, ok := topicAreasBounds(t.AreaList()); ok {
b, _ := json.Marshal(map[string]any{
"minLat": minLat, "maxLat": maxLat, "minLon": minLon, "maxLon": maxLon,
})
return template.JS("null"), template.JS(b)
return template.JS(b)
}
if len(points) == 0 {
return template.JS("null"), template.JS("null")
return template.JS("null")
}
minLat, maxLat := points[0].Lat, points[0].Lat
minLon, maxLon := points[0].Lon, points[0].Lon
@@ -682,7 +682,7 @@ func fitParams(t Topic, points []MapPoint) (circle, bbox template.JS) {
b, _ := json.Marshal(map[string]any{
"minLat": minLat, "maxLat": maxLat, "minLon": minLon, "maxLon": maxLon,
})
return template.JS("null"), template.JS(b)
return template.JS(b)
}
// redirectTopics 把结果带回专题管理页(保留可能的 ?edit= 上下文由页面自己处理)。