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

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

- 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
+39 -10
View File
@@ -265,25 +265,33 @@ func TestDateInputsGoThroughAttachAll(t *testing.T) {
}
}
// ---------- 专题的迷雾要和「区域」成对:模板传 fogArea、地图脚本认 fogArea ----------
// ---------- 专题的迷雾要和「显示区」成对:模板传 fogAreas、地图脚本认 fogAreas ----------
//
// 2026-10-10 用户实测发现:专题圈了区域,迷雾却还是全局的。而管理页原本的说明写的是
// 「区域内的世界迷雾由地图按视野自动呈现,不需要单独圈选」—— **文档与实现不一致**
// (迷雾一直是全局瓦片,完全不认区域),这种「说明写了、实现没做」最容易蒙混过关。
//
// 这条守卫钉住两端与一处顺序:
// ① topic.html 必须把专题区域传给地图;
// ② rond-map.js 必须实现圆形收口(单独 pane + clip-path,不能裁默认 tilePane——底图在里面);
// ③ 裁剪圆的注册必须排在 applyFit() 之后:视图还没定时 latLngToContainerPoint() 会抛
// 这条守卫钉住两端与两处顺序:
// ① topic.html 必须把专题显示区传给地图(且**无条件**传,不限区域时是空数组);
// ② rond-map.js 必须实现收口(单独 pane + clip-path,不能裁默认 tilePane——底图在里面)
// 且多圆要交给 fog-clip.js 的 pathFor;
// ③ fog-clip.js 必须在 rond-map.js **之前**加载(否则 window.rondFogClip 还没有);
// ④ 裁剪的注册必须排在 applyFit() 之后:视图还没定时 latLngToContainerPoint() 会抛
// 「Set map center and zoom first.」,那行异常会把后面的图层添加一起打断
// (实测:pane 建好了、迷雾瓦片一张都没有)。
func TestTopicFogIsClippedToArea(t *testing.T) {
func TestTopicFogIsClippedToAreas(t *testing.T) {
tpl, err := tmplFS.ReadFile("templates/topic.html")
if err != nil {
t.Fatalf("读取 topic.html 失败: %v", err)
}
if !strings.Contains(string(tpl), "fogArea:") {
t.Error("topic.html 没把专题区域(fogArea)传给地图 —— 迷雾不会被收口到那个圆内")
body := string(tpl)
if !strings.Contains(body, "fogAreas:") {
t.Error("topic.html 没把专题显示区(fogAreas)传给地图 —— 迷雾不会被收口")
}
// 无条件传(不要包在 {{if .Topic.HasArea}} 里):条件分支是「有区域忘了新增分支」
// 这类漏传的温床,而空数组对脚本来说就是「不限区域」,没必要分两条路。
if !strings.Contains(body, "fogAreas: {{.Topic.AreasJSON}}") {
t.Error("fogAreas 应当无条件下发 {{.Topic.AreasJSON}}(不限区域时为空数组),别包在条件里")
}
js, err := staticFS.ReadFile("static/js/rond-map.js")
@@ -291,9 +299,9 @@ func TestTopicFogIsClippedToArea(t *testing.T) {
t.Fatalf("读取 rond-map.js 失败: %v", err)
}
src := string(js)
for _, needle := range []string{"opts.fogArea", "createPane('rondFog')", "clipPath"} {
for _, needle := range []string{"opts.fogAreas", "createPane('rondFog')", "clipPath", "rondFogClip.pathFor"} {
if !strings.Contains(src, needle) {
t.Errorf("rond-map.js 里缺 %q —— 迷雾的圆形收口没实现或被改掉了", needle)
t.Errorf("rond-map.js 里缺 %q —— 迷雾的收口没实现或被改掉了", needle)
}
}
// 顺序:注册点必须排在 `var applyFit = function` 之后 —— 放早了(比如塞在迷雾图层那段里)
@@ -304,6 +312,27 @@ func TestTopicFogIsClippedToArea(t *testing.T) {
t.Error("applyFogClip 的注册必须排在 applyFit 定义(也就是视图设定)之后 —— " +
"视图未定时算裁剪圆会抛错,并把后面的图层添加一起打断")
}
// 多圆路径拼在 fog-clip.js 里,它必须在 rond-map.js 之前加载
clip, err := staticFS.ReadFile("static/js/fog-clip.js")
if err != nil {
t.Fatalf("读取 fog-clip.js 失败: %v", err)
}
if !strings.Contains(string(clip), "rondFogClip") {
t.Error("fog-clip.js 没有暴露 rondFogClip —— rond-map.js 拿不到路径构造函数")
}
layout, err := tmplFS.ReadFile("templates/layout.html")
if err != nil {
t.Fatalf("读取 layout.html 失败: %v", err)
}
lay := string(layout)
iClip := strings.Index(lay, "/static/js/fog-clip.js")
iMap := strings.Index(lay, "/static/js/rond-map.js")
if iClip < 0 {
t.Error("layout.html 没引入 fog-clip.js —— 专题的多圆裁剪会静默退化成「不裁剪」")
} else if iMap < 0 || iClip > iMap {
t.Error("fog-clip.js 必须排在 rond-map.js 之前加载")
}
}
// ---------- 字阶纪律:app.css 与模板内联样式都只走令牌 ----------
+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= 上下文由页面自己处理)。
+36
View File
@@ -0,0 +1,36 @@
/* 迷雾裁剪路径:把若干「容器像素圆」拼成一段 clip-path: path() 数据。
*
* 为什么自己拼 path():clip-path: circle() 只表达得了一个圆,而专题的显示区是一组圆。
* 每个圆一段子路径、圆弧一律 sweep=0(**同绕向**)—— nonzero 填充规则下多段自然取并集,
* 重叠处不会互相挖洞;如果两段绕向相反,重叠区会被抵消成一个洞。
* 用两段半圆弧拼整圆,而不是往 DOM 里插 <circle>,是为了整串塞进一个 path(),
* 不用额外维护 SVG 节点与它的坐标系。
*
* 单独一个文件(而不是塞进 rond-map.js)是为了能直接跑 node 单测:
* rond-map.js 是个大 IIFE,函数取不出来。
*/
(function (global) {
'use strict';
// circles: [{x, y, r}],单位都是**地图容器像素**(与 clip-path 的坐标系一致)。
// 返回可直接赋给 style.clipPath 的字符串;没有圆时返回空串(调用方据此不改样式)。
function pathFor(circles) {
if (!circles || !circles.length) return '';
var d = '';
for (var i = 0; i < circles.length; i++) {
var c = circles[i];
var x = c.x, y = c.y;
// 半径下限 1px:缩到极小时 r=0 会让路径退化(浏览器可能整段忽略)
var r = Math.max(1, c.r);
var rr = r.toFixed(1), w = (2 * r).toFixed(1);
d += 'M' + (x - r).toFixed(1) + ' ' + y.toFixed(1) +
'a' + rr + ' ' + rr + ' 0 1 0 ' + w + ' 0' +
'a' + rr + ' ' + rr + ' 0 1 0 ' + (-2 * r).toFixed(1) + ' 0Z';
}
return 'path("' + d + '")';
}
var api = { pathFor: pathFor };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
global.rondFogClip = api;
})(typeof window !== 'undefined' ? window : globalThis);
+104
View File
@@ -0,0 +1,104 @@
/* fog-clip.js 的单元测试(node 直接跑,无依赖)。
* 用法:node internal/web/static/js/fog-clip.test.js
*
* 这里不比对字符串,而是把生成的 path 数据**解析回圆弧**再验几何:
* 拼错一个数字(半径、圆心、绕向)都能被抓住,而「看起来差不多」的字符串比对做不到。
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const src = fs.readFileSync(path.join(__dirname, 'fog-clip.js'), 'utf8');
const mod = {};
const sandbox = { window: {}, module: mod };
vm.createContext(sandbox);
vm.runInContext(src, sandbox);
const api = sandbox.window.rondFogClip;
let pass = 0, fail = 0;
function ok(cond, label, extra) {
if (cond) { pass++; console.log(' ok ' + label); }
else { fail++; console.log(' FAIL ' + label + (extra ? '\n ' + extra : '')); }
}
// 把 path("M…a…a…Z M…a…a…Z") 解析成 [{cx, cy, r, sweep}]
// 只认本模块生成的那种形式:M x y 后跟两段 `a r r 0 1 s dx dy`
function parsePath(v) {
const m = /^path\("(.*)"\)$/.exec(v);
if (!m) return null;
const out = [];
const subs = m[1].split('M').slice(1);
for (const s of subs) {
const t = s.match(/^([-\d.]+) ([-\d.]+)a([-\d.]+) ([-\d.]+) 0 1 ([01]) ([-\d.]+) 0a([-\d.]+) ([-\d.]+) 0 1 ([01]) ([-\d.]+) 0Z$/);
if (!t) return null;
const x0 = +t[1], y = +t[2], r1 = +t[3], r2 = +t[4], sw1 = +t[5], dx1 = +t[6];
const r3 = +t[7], r4 = +t[8], sw2 = +t[9], dx2 = +t[10];
if (r1 !== r2 || r3 !== r4 || r1 !== r3) return null; // 两个半径必须一致
if (Math.abs(dx1 - 2 * r1) > 0.05 || Math.abs(dx2 + 2 * r1) > 0.05) return null;
out.push({ cx: x0 + r1, cy: y, r: r1, sweep: [sw1, sw2] });
}
return out;
}
function near(a, b) { return Math.abs(a - b) < 0.15; }
console.log('=== 一个圆:几何反解必须与输入一致 ===');
(function () {
const v = api.pathFor([{ x: 100, y: 200, r: 50 }]);
const cs = parsePath(v);
ok(cs && cs.length === 1, '应解析出 1 个子路径', v);
if (!cs || !cs.length) return;
ok(near(cs[0].cx, 100) && near(cs[0].cy, 200) && near(cs[0].r, 50),
'圆心半径应为 (100,200) r=50', JSON.stringify(cs[0]));
})();
console.log('=== 多个圆:每个圆一段子路径,且同绕向(并集的前提)===');
(function () {
const input = [{ x: 100, y: 200, r: 50 }, { x: 300, y: 400, r: 25 }, { x: 140, y: 210, r: 50 }];
const v = api.pathFor(input);
const cs = parsePath(v);
ok(cs && cs.length === 3, '应解析出 3 个子路径', v);
if (!cs || cs.length !== 3) return;
input.forEach((c, i) => {
ok(near(cs[i].cx, c.x) && near(cs[i].cy, c.y) && near(cs[i].r, c.r),
'第 ' + (i + 1) + ' 个圆与原输入一致', JSON.stringify(cs[i]) + ' vs ' + JSON.stringify(c));
});
// 绕向必须全都一样:两段圆弧 sweep 相同。若某个圆反向,重叠区会被 nonzero 规则抵消成洞。
const allSame = cs.every(c => c.sweep[0] === cs[0].sweep[0] && c.sweep[1] === cs[0].sweep[1]);
ok(allSame, '所有子路径绕向一致(否则重叠处会被挖成洞)', JSON.stringify(cs.map(c => c.sweep)));
// 第 1、3 个圆是重叠的,仍应各是一段完整子路径(不合并、不挖洞)
ok(cs.length === 3, '重叠的两个圆不会被合并或抵消');
})();
console.log('=== 退化输入 ===');
(function () {
ok(api.pathFor([]) === '', '空数组 → 空串(调用方据此不改样式)');
ok(api.pathFor(null) === '', 'null → 空串');
ok(api.pathFor(undefined) === '', 'undefined → 空串');
// 半径 0 会让路径退化,必须夹到 >= 1px
const v = api.pathFor([{ x: 10, y: 10, r: 0 }]);
const cs = parsePath(v);
ok(cs && cs.length === 1 && cs[0].r >= 1, '半径 0 被夹到 >= 1px', v);
// 负半径同理(拖拽时可能算出负数)
const v2 = api.pathFor([{ x: 10, y: 10, r: -5 }]);
const cs2 = parsePath(v2);
ok(cs2 && cs2.length === 1 && cs2[0].r >= 1, '负半径被夹到 >= 1px', v2);
})();
console.log('=== 大量圆:个数与顺序不能错位 ===');
(function () {
const input = [];
for (let i = 0; i < 20; i++) input.push({ x: i * 10, y: 500 - i * 3, r: 4 + i });
const cs = parsePath(api.pathFor(input));
ok(cs && cs.length === 20, '20 个圆 → 20 个子路径');
if (!cs || cs.length !== 20) return;
let bad = -1;
input.forEach((c, i) => {
if (bad < 0 && !(near(cs[i].cx, c.x) && near(cs[i].cy, c.y) && near(cs[i].r, c.r))) bad = i;
});
ok(bad < 0, '20 个圆的顺序与数值都对应上' + (bad >= 0 ? '(第 ' + bad + ' 个错位)' : ''));
})();
console.log('\n=== 汇总 ===');
console.log(' pass=' + pass + ' fail=' + fail);
process.exit(fail === 0 ? 0 : 1);
+30 -17
View File
@@ -704,24 +704,42 @@
// 专题可以在管理页关掉迷雾(opts.showFog=false),此时图层、浓度滑杆、图层开关都不出现。
var wantFog = opts.showFog !== false;
var fogLayer = null;
/* 专题可以圈一个区域(opts.fogArea = {lat, lon, radiusKm}):此时迷雾只在该圆内显示。
做法是给迷雾**单独一个 pane** 再对这个 pane 做圆形 clip-path —— 不能直接裁默认的
/* 专题可以圈一组区域(opts.fogAreas = [{lat, lon, radiusKm}, ...]):此时迷雾只在
这些圆的**并集**内显示。
做法是给迷雾**单独一个 pane** 再对这个 pane 做 clip-path —— 不能直接裁默认的
tilePane,底图也在那里面,会把地图一起裁掉。
圆心必须跟 fitCircle 一样先 project() 到底图坐标系,否则会整体偏出几百米。
多圆用 path() 而不是 circle()(后者只表达得了一个圆);拼路径的活交给 fog-clip.js
(那样能跑 node 单测),这里只负责把经纬度换算成容器像素。
圆心必须像 fitBBox 那样先 project() 到底图坐标系,否则会整体偏出几百米。
clip-path 不认识的老浏览器只是「不裁剪」(退回全局迷雾),不会出错。 */
var fogPane = null, applyFogClip = null;
if (wantFog && cfg.fog && cfg.fog.blocks > 0 && opts.fogArea && opts.fogArea.radiusKm > 0) {
var fogPane = null, applyFogClip = null, fogCircles = [];
var fogAreaOpts = opts.fogAreas || [];
for (var fa = 0; fa < fogAreaOpts.length; fa++) {
var area = fogAreaOpts[fa];
if (!area || !(area.radiusKm > 0)) continue;
var areaC = project(area.lat, area.lon, def.crs);
fogCircles.push({
center: L.latLng(areaC[0], areaC[1]),
// 正北方向同经度偏移 radiusKm,用来量出容器像素半径(随缩放自然变化)
north: L.latLng(areaC[0] + (area.radiusKm * 1000) / 111320, areaC[1])
});
}
if (wantFog && cfg.fog && cfg.fog.blocks > 0 && fogCircles.length) {
map.createPane('rondFog');
fogPane = map.getPane('rondFog');
fogPane.style.zIndex = 250; // 底图瓦片 200 之上、覆盖物 400 之下
fogPane.style.pointerEvents = 'none';
var fogC = project(opts.fogArea.lat, opts.fogArea.lon, def.crs);
var fogCenter = L.latLng(fogC[0], fogC[1]);
var fogNorth = L.latLng(fogCenter.lat + (opts.fogArea.radiusKm * 1000) / 111320, fogCenter.lng);
applyFogClip = function () {
var p = map.latLngToContainerPoint(fogCenter);
var r = Math.max(1, p.distanceTo(map.latLngToContainerPoint(fogNorth)));
var v = 'circle(' + r.toFixed(1) + 'px at ' + p.x.toFixed(1) + 'px ' + p.y.toFixed(1) + 'px)';
// 脚本没加载上就退回「不裁剪」,别抛错把地图初始化打断
if (!window.rondFogClip) return;
var px = [];
for (var i = 0; i < fogCircles.length; i++) {
var p = map.latLngToContainerPoint(fogCircles[i].center);
px.push({ x: p.x, y: p.y,
r: Math.max(1, p.distanceTo(map.latLngToContainerPoint(fogCircles[i].north))) });
}
var v = window.rondFogClip.pathFor(px);
if (!v) return;
fogPane.style.clipPath = v;
fogPane.style.webkitClipPath = v;
};
@@ -764,13 +782,8 @@
// 专题页要的是「区域视野」而不是「点集视野」:只框住点的话,
// 只去过一两个地方时视野会缩得过小,区域内的迷雾也看不全。
// 传 fitCircle(中心 + 半径)或 fitBBox 就能覆盖默认的点集贴合。
// 传 fitBBox(专题显示区的并集外接矩形)就能覆盖默认的点集贴合。
var applyFit = function () {
if (opts.fitCircle && opts.fitCircle.radiusKm > 0) {
var c = project(opts.fitCircle.lat, opts.fitCircle.lon, def.crs);
map.fitBounds(L.latLng(c[0], c[1]).toBounds(opts.fitCircle.radiusKm * 2000));
return;
}
if (opts.fitBBox) {
var b = opts.fitBBox;
var p1 = project(b.minLat, b.minLon, def.crs), p2 = project(b.maxLat, b.maxLon, def.crs);
+2
View File
@@ -241,6 +241,8 @@
{{if .HasMap}}
<script src="/static/vendor/leaflet/leaflet.js?v={{.AssetV}}"></script>
{{if or (eq .Nav "map") (eq .Nav "topics")}}<script src="/static/vendor/markercluster/leaflet.markercluster.js?v={{.AssetV}}"></script>{{end}}
{{/* fog-clip.js 必须在 rond-map.js **之前**:专题的迷雾裁剪要拿它拼多圆的 clip-path: path() */}}
<script src="/static/js/fog-clip.js?v={{.AssetV}}"></script>
<script src="/static/js/rond-map.js?v={{.AssetV}}"></script>
{{end}}
{{/* 日期选择器 + 手输引导。**无条件加载**(js+css+l10n 约 18KB gzip),两个理由:
+3 -3
View File
@@ -79,11 +79,11 @@
window.rondMap.full({
id: 'map',
points: {{.PointsJSON}},
fitCircle: {{.FitCircleJSON}},
fitBBox: {{.FitBBoxJSON}},
showFog: {{if .Topic.ShowFog}}true{{else}}false{{end}},
{{/* 圈了区域的专题:迷雾只在该圆内显示(rond-map.js 里给迷雾单独一个 pane 做 clip-path) */}}
{{if .Topic.HasArea}}fogArea: {lat: {{.Topic.CenterLat}}, lon: {{.Topic.CenterLon}}, radiusKm: {{.Topic.RadiusKm}}},{{end}}
{{/* 圈了显示区的专题:迷雾只在**这些圆的并集**内显示(rond-map.js 给迷雾单独一个
pane,再用 fog-clip.js 拼出多圆的 clip-path: path())。不限区域时是空数组。 */}}
fogAreas: {{.Topic.AreasJSON}},
back: '{{.BackQS}}'
});
}
+20 -15
View File
@@ -312,25 +312,31 @@ func TestTopicAreasBoundsCoversEveryCircle(t *testing.T) {
}
}
// 贴合参数:有显示区就走 bbox、不再给 fitCircle(多圆表达不了圆心+半径)。
func TestFitParamsUsesBBoxForAreas(t *testing.T) {
circle, bbox := fitParams(Topic{Areas: []TopicArea{{Lat: 25, Lon: 100, RadiusKm: 3}}}, nil)
if string(circle) != "null" {
t.Errorf("限定显示区时不该再给 fitCircle,实得 %s", circle)
}
// 贴合参数:有显示区就用所有圆的并集外接矩形,否则退回点集包围盒;
// 两者都没有时给 null(脚本据此再退回它自己的 fit)。
func TestFitParamsUsesBBox(t *testing.T) {
var b struct{ MinLat, MaxLat, MinLon, MaxLon float64 }
bbox := fitParams(Topic{Areas: []TopicArea{{Lat: 25, Lon: 100, RadiusKm: 3}}}, nil)
if err := json.Unmarshal([]byte(bbox), &b); err != nil {
t.Fatalf("fitBBox 不是合法 JSON: %s (%v)", bbox, err)
t.Fatalf("有显示区时 fitBBox 不是合法 JSON: %s (%v)", bbox, err)
}
if !(b.MinLat < 25 && b.MaxLat > 25 && b.MinLon < 100 && b.MaxLon > 100) {
t.Errorf("fitBBox 应包住圆心:%+v", b)
}
// 不限区域时仍退回点集包围盒
circle, bbox = fitParams(Topic{}, []MapPoint{{Lat: 21, Lon: 99}, {Lat: 23, Lon: 103}})
if string(circle) != "null" {
t.Errorf("点集路径不该给 fitCircle,实得 %s", circle)
// 多圆:矩形要同时包住两个圆,而不是只包住第一个
bbox = fitParams(Topic{Areas: []TopicArea{
{Lat: 25, Lon: 100, RadiusKm: 3}, {Lat: 30, Lon: 110, RadiusKm: 5}}}, nil)
if err := json.Unmarshal([]byte(bbox), &b); err != nil {
t.Fatalf("多圆 fitBBox 不是合法 JSON: %s (%v)", bbox, err)
}
if !(b.MinLat < 25 && b.MaxLat > 30 && b.MinLon < 100 && b.MaxLon > 110) {
t.Errorf("多圆 fitBBox 应同时包住两个圆:%+v", b)
}
// 不限区域时退回点集包围盒
bbox = fitParams(Topic{}, []MapPoint{{Lat: 21, Lon: 99}, {Lat: 23, Lon: 103}})
if err := json.Unmarshal([]byte(bbox), &b); err != nil {
t.Fatalf("点集 fitBBox 不是合法 JSON: %s", bbox)
}
@@ -338,10 +344,9 @@ func TestFitParamsUsesBBoxForAreas(t *testing.T) {
t.Errorf("点集包围盒 = %+v", b)
}
// 两者都没有 → 两个 null
circle, bbox = fitParams(Topic{}, nil)
if string(circle) != "null" || string(bbox) != "null" {
t.Errorf("既无显示区又无点时应给两个 null,实得 %s / %s", circle, bbox)
// 两者都没有 → null,交给脚本自己的点集贴合
if got := fitParams(Topic{}, nil); string(got) != "null" {
t.Errorf("既无显示区又无点时应给 null,实得 %s", got)
}
}
+4 -5
View File
@@ -1664,11 +1664,10 @@ type TopicData struct {
Topic Topic
Points []MapPoint
IsAdmin bool
// PointsJSON / FitCircleJSON / FitBBoxJSON 内联给地图脚本
// (template.JS 跳过转义,避免被二次转义搞坏)。两个 fit 只有一个非 null。
PointsJSON template.JS
FitCircleJSON template.JS
FitBBoxJSON template.JS
// PointsJSON / FitBBoxJSON 内联给地图脚本
// (template.JS 跳过转义,避免被二次转义搞坏);FitBBoxJSON 为 null 时脚本退回点集贴合。
PointsJSON template.JS
FitBBoxJSON template.JS
}
// trackTag 把配置里的追踪脚本转成可输出的 HTML。
+30
View File
@@ -149,6 +149,36 @@ if [ -z "$RUN" ] && [ "$PKGS" = "./..." ]; then
ok "纯净往返包已生成:$(basename "$CLEAN_OUT")"
fi
# --- 第 1.5 段:前端脚本单测(node 直跑,不依赖数据库)--------------------------
# internal/web/static/js/*.test.js 是纯函数用例(日期解析、迷雾裁剪路径拼装)。
# 以前只靠人记得手动跑 = 等于没有:改了脚本没人拦。这里挂进验收。
# 找不到 node 时**直接失败**而不是「跳过」—— 跳过会让这次验收看着全绿,
# 而实际上前端脚本一行都没验(这正是「0 skip」那条纪律要防的事)。
NODE_BIN="${NODE_BIN:-}"
if [ -z "$NODE_BIN" ]; then
for cand in "$(command -v node 2>/dev/null)" \
"$HOME/.workbuddy/binaries/node/versions/22.22.2-6/node.exe" \
"/c/Program Files/nodejs/node.exe"; do
if [ -n "$cand" ] && [ -x "$cand" ]; then NODE_BIN="$cand"; break; fi
done
fi
[ -n "$NODE_BIN" ] || fail "找不到 node(前端脚本单测跑不了)。用 NODE_BIN=/path/to/node 指定后再跑"
JSPASS=0; JSFAIL=0
for t in "$ROOT"/internal/web/static/js/*.test.js; do
[ -f "$t" ] || continue
if jsout=$("$NODE_BIN" "$t" 2>&1); then
n=$(printf '%s\n' "$jsout" | sed -n 's/.*pass=\([0-9]\{1,\}\).*/\1/p' | tail -1)
JSPASS=$((JSPASS + ${n:-0}))
ok "前端单测 $(basename "$t"):pass=${n:-0}"
else
printf '%s\n' "$jsout" | tail -25
JSFAIL=$((JSFAIL + 1))
echo "✗ 前端单测失败:$(basename "$t")"
fi
done
[ "$JSFAIL" = 0 ] || fail "前端单测有 $JSFAIL 个文件失败"
echo "== 前端单测合计:pass=$JSPASS,失败文件 $JSFAIL"
# --- 第二段:全量(或定点)------------------------------------------------------
RUNARG=""
[ -n "$RUN" ] && RUNARG="-run $RUN"