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

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

- 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
+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);