diff --git a/internal/web/frontend_fixes_test.go b/internal/web/frontend_fixes_test.go index 03bbbbd..0bc2a91 100644 --- a/internal/web/frontend_fixes_test.go +++ b/internal/web/frontend_fixes_test.go @@ -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 与模板内联样式都只走令牌 ---------- diff --git a/internal/web/handlers_topics.go b/internal/web/handlers_topics.go index bb02749..0b25dbe 100644 --- a/internal/web/handlers_topics.go +++ b/internal/web/handlers_topics.go @@ -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= 上下文由页面自己处理)。 diff --git a/internal/web/static/js/fog-clip.js b/internal/web/static/js/fog-clip.js new file mode 100644 index 0000000..47eb13c --- /dev/null +++ b/internal/web/static/js/fog-clip.js @@ -0,0 +1,36 @@ +/* 迷雾裁剪路径:把若干「容器像素圆」拼成一段 clip-path: path() 数据。 + * + * 为什么自己拼 path():clip-path: circle() 只表达得了一个圆,而专题的显示区是一组圆。 + * 每个圆一段子路径、圆弧一律 sweep=0(**同绕向**)—— nonzero 填充规则下多段自然取并集, + * 重叠处不会互相挖洞;如果两段绕向相反,重叠区会被抵消成一个洞。 + * 用两段半圆弧拼整圆,而不是往 DOM 里插 ,是为了整串塞进一个 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); diff --git a/internal/web/static/js/fog-clip.test.js b/internal/web/static/js/fog-clip.test.js new file mode 100644 index 0000000..abfa46a --- /dev/null +++ b/internal/web/static/js/fog-clip.test.js @@ -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); diff --git a/internal/web/static/js/rond-map.js b/internal/web/static/js/rond-map.js index 07823eb..d7a15aa 100644 --- a/internal/web/static/js/rond-map.js +++ b/internal/web/static/js/rond-map.js @@ -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); diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 8f8b4d6..4b996a8 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -241,6 +241,8 @@ {{if .HasMap}} {{if or (eq .Nav "map") (eq .Nav "topics")}}{{end}} +{{/* fog-clip.js 必须在 rond-map.js **之前**:专题的迷雾裁剪要拿它拼多圆的 clip-path: path() */}} + {{end}} {{/* 日期选择器 + 手输引导。**无条件加载**(js+css+l10n 约 18KB gzip),两个理由: diff --git a/internal/web/templates/topic.html b/internal/web/templates/topic.html index 323449f..7f8c01c 100644 --- a/internal/web/templates/topic.html +++ b/internal/web/templates/topic.html @@ -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}}' }); } diff --git a/internal/web/topic_areas_test.go b/internal/web/topic_areas_test.go index 5da7c15..9bf2c5b 100644 --- a/internal/web/topic_areas_test.go +++ b/internal/web/topic_areas_test.go @@ -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) } } diff --git a/internal/web/view.go b/internal/web/view.go index 20e86f1..ee59b94 100644 --- a/internal/web/view.go +++ b/internal/web/view.go @@ -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。 diff --git a/scripts/verify.sh b/scripts/verify.sh index 720a1b6..31823af 100644 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -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"