专题的多圆显示区:前台按并集裁剪迷雾
上一批把显示区改成了「一组圆」,这一批让前台真正认多个圆。 - 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:
@@ -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);
|
||||
Reference in New Issue
Block a user