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