Files
tamakyi 6aee7e441b 给显示区编辑器补上长度参照:比例尺 + 直径标注
用户反馈「专题里的圆无论怎么缩放大小都一样,20km 却覆盖了整个南宁」。实测
三处画圆(编辑器 L.circle、改半径把手、前台迷雾裁剪圆)**全都是地理锚定的**:
编辑器圆直径 z9/z11/z13 = 178/710/2842px(期望 177/711/2847)、同一缩放下改半径
3/10/25/50km → 42/142/356/710px(期望 43/142/355/711)、把手距圆心 20.038km
(该圆半径 20km)、前台裁剪圆换算始终 ≈20km。也就是**复现不出「大小不变」**。

真正的问题是缺**参照物**:编辑器地图以前没有比例尺(前台专题页一直有),
圈圆时只能靠地名猜。用他自己的数据验证「20km 覆盖南宁」是对的——圆心 20km 内
落了他 477 个地点中的 45 个,最远到西乡塘的广西民族大学相思湖校区(14.5km)
与良庆的五象万象汇(10.2km),正是南宁建成区的跨度。

所以这一版补的是参照物,不改任何几何逻辑:
- 编辑器地图加 L.control.scale(跟前台一致,公制)。
- 半径旁边标出跨度:`spanLabel()` 把半径换成「约 40 公里宽」(<1 公里改用米,
  免得出「约 0.1 公里宽」这种别扭说法);圆列表每行也标,新圆半径旁边实时跟着变。
- 守卫 TestTopicAreaEditorGivesSizeReference 钉住这两样(变异验证过:去掉比例尺立刻红)。
  它顺手抓到我自己的疏漏——模板的 init 调用漏传了 spanId,靠脚本默认值兜着,已补上显式传参。

验证:topic-areas.test.js 55 条(新增 11 条 spanLabel 边界);无头实测比例尺在
(z10 显示 10 km)、新圆半径 3→「约 6 公里宽」/25→「约 50 公里宽」实时更新、
圆列表 20→「约 40 公里宽」,全程无 JS 报错。
2026-10-13 10:37:10 +08:00

395 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 专题「显示区」编辑器:在地图上圈一组圆(后台专题表单用)。
*
* 交互:
* · 地图上点一下 = 以「新圆半径」加一个圆
* · 拖圆心移动、拖圆边上的把手改半径
* · 列表里可改半径、定位、删除;「清除全部」一次清空
* · 变化立刻写进隐藏字段 areas(JSON) 与 crs(底图坐标系),随专题表单一起提交
* · 初值读隐藏字段自身(不是另传一份),脚本没加载上时提交的仍是服务端渲染的原值
*
* 坐标系:地图上的一切都在**底图坐标系**里(天地图那类底图是 WGS-84),
* 提交时把 crs 一起带上,由后端换算成站点统一的 GCJ-02 再存 —— 与 /admin/fog-mask 同一套约定。
*
* 纯函数(validate / serialize / add / remove / setCenter / setRadius)单独暴露,
* 供 node 单测;DOM 部分只在 init() 里跑,且等 load 之后再初始化
* (Leaflet 由 layout 在 content 之后引入,脚本执行时 window.L 还没有)。
*/
(function (global) {
'use strict';
// 这几个上限必须与后端 normalizeTopicAreas 一致:前端放行、后端拒绝的话,
// 用户只会看到一句红字却不知道改哪里。
var MAX_AREAS = 20;
var MIN_KM = 0.05;
var MAX_KM = 2000;
function num(v) { var n = parseFloat(v); return isFinite(n) ? n : NaN; }
// 坐标 6 位、半径 3 位:够用,且不让 JSON 里出现一长串浮点尾巴
function round6(v) { return Math.round(v * 1e6) / 1e6; }
function round3(v) { return Math.round(v * 1e3) / 1e3; }
function normalize(a) {
a = a || {};
return { lat: round6(num(a.lat)), lon: round6(num(a.lon)), radiusKm: round3(num(a.radiusKm)) };
}
// 返回错误信息;空串 = 通过。「空数组」是合法的(不限区域),不是错误。
function validate(areas) {
areas = areas || [];
if (!areas.length) return '';
if (areas.length > MAX_AREAS) return '显示区最多 ' + MAX_AREAS + ' 个';
for (var i = 0; i < areas.length; i++) {
var a = areas[i], who = '第 ' + (i + 1) + ' 个圆';
if (!isFinite(a.lat) || !isFinite(a.lon) || !isFinite(a.radiusKm)) return who + '的数值不合法';
if (Math.abs(a.lat) > 90 || Math.abs(a.lon) > 180) return who + '的圆心超出经纬度范围';
if (a.radiusKm < MIN_KM || a.radiusKm > MAX_KM) {
return who + '的半径要在 ' + MIN_KM + ' ~ ' + MAX_KM + ' 公里之间';
}
}
return '';
}
function serialize(areas) { return JSON.stringify((areas || []).map(normalize)); }
function parse(raw) {
var v;
try { v = JSON.parse(raw || '[]'); } catch (e) { return []; }
return Array.isArray(v) ? v.map(normalize) : [];
}
function add(areas, a) {
var out = (areas || []).slice();
if (out.length < MAX_AREAS) out.push(normalize(a));
return out;
}
function remove(areas, i) {
var out = (areas || []).slice();
if (i >= 0 && i < out.length) out.splice(i, 1);
return out;
}
function setCenter(areas, i, lat, lon) {
var out = (areas || []).slice();
if (i >= 0 && i < out.length) out[i] = normalize({ lat: lat, lon: lon, radiusKm: out[i].radiusKm });
return out;
}
function setRadius(areas, i, km) {
var out = (areas || []).slice();
if (i >= 0 && i < out.length) out[i] = normalize({ lat: out[i].lat, lon: out[i].lon, radiusKm: km });
return out;
}
// 半径(公里)换算成经纬度增量:摆「改半径」把手、以及把圆心移回视野用得上
function latDelta(km) { return km / 111.32; }
function lonDelta(lat, km) {
var c = Math.cos(lat * Math.PI / 180);
if (c < 0.01) c = 0.01;
return km / (111.32 * c);
}
// 半径的「人话版」跨度:半径 20 公里 ⇒「约 40 公里宽」。
// 光写「半径 20 公里」很容易被读小 —— 直径才是眼睛看到的那个宽度。
function spanLabel(radiusKm) {
var d = num(radiusKm) * 2;
if (!isFinite(d) || d <= 0) return '';
if (d < 1) return '约 ' + Math.round(d * 1000) + ' 米宽';
if (d < 10) return '约 ' + (Math.round(d * 10) / 10) + ' 公里宽';
return '约 ' + Math.round(d) + ' 公里宽';
}
// 两点距离(公里),球面公式;拖把手时用它反推半径
function kmBetween(a, b) {
var dLat = (b[0] - a[0]) * Math.PI / 180, dLon = (b[1] - a[1]) * Math.PI / 180;
var s = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(a[0] * Math.PI / 180) * Math.cos(b[0] * Math.PI / 180) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
return 6371 * 2 * Math.asin(Math.min(1, Math.sqrt(s)));
}
var api = {
MAX_AREAS: MAX_AREAS, MIN_KM: MIN_KM, MAX_KM: MAX_KM,
normalize: normalize, validate: validate, serialize: serialize, parse: parse,
add: add, remove: remove, setCenter: setCenter, setRadius: setRadius,
latDelta: latDelta, lonDelta: lonDelta, kmBetween: kmBetween,
spanLabel: spanLabel
};
// ---------- 以下需要 DOM 与 Leaflet ----------
function whenReady(cb) {
if (document.readyState === 'complete') { setTimeout(cb, 0); return; }
global.addEventListener('load', cb);
}
// 兜底底图:与 /admin/fog-mask 同一套(带密钥的图层由服务端转发,免密钥的是浏览器直连的 {s} 模板)
var FALLBACK_DEF = {
url: 'https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}',
maxZoom: 18, attr: '© 高德地图', crs: 'gcj02'
};
var FALLBACK_SUBS = ['1', '2', '3', '4'];
api.init = function (opts) {
opts = opts || {};
var el = document.getElementById(opts.mapId || 'area-map');
var form = document.getElementById(opts.formId || 'topic-form');
if (!el || !form) return;
var hidden = form.querySelector('input[name=areas]');
var crsIn = form.querySelector('input[name=crs]');
var listBox = document.getElementById(opts.listId || 'area-list');
var hint = document.getElementById(opts.hintId || 'area-hint');
var radiusIn = document.getElementById(opts.radiusId || 'area-new-km');
var newSpan = document.getElementById(opts.spanId || 'area-new-span');
var saveBtn = form.querySelector('button[type=submit]');
if (!hidden || !listBox) return;
// 初值就取自隐藏字段本身:这样脚本没加载上时,表单提交的仍是服务端渲染进去的原值,
// 不会把已有的显示区「静默清空」。
var areas = parse(hidden.value);
whenReady(function () {
if (!global.L) return;
fetch('/api/map-config', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (c) {
start((c && c.base && c.base.length) ? c.base[0] : FALLBACK_DEF,
(c && c.subs && c.subs.length) ? c.subs : FALLBACK_SUBS);
})
.catch(function () { start(FALLBACK_DEF, FALLBACK_SUBS); });
});
function start(def, subs) {
var map = L.map(el);
L.tileLayer(def.url, {
subdomains: subs, maxZoom: def.maxZoom || 18, minZoom: 3, attribution: def.attr || ''
}).addTo(map);
// 比例尺是必须的:没有它,圈圆时手上没有任何长度参照,只能靠地名猜,
// 「20 公里到底多大」就变成了拍脑袋(前台的地图一直有,这里以前漏了)。
L.control.scale({ imperial: false, position: 'bottomleft' }).addTo(map);
if (crsIn) crsIn.value = def.crs || 'gcj02';
var layer = L.layerGroup().addTo(map);
var views = []; // 与 areas 同下标:{circle, dot, handle}
// 供无头验证用的入口(与 window.rondMap._state 同一惯例):
// 不给个把手,就只能靠真的鼠标点地图,CDP 下很难稳定复现。
api._map = map;
api._areas = function () { return areas; };
function newRadius() {
var v = parseFloat(radiusIn && radiusIn.value);
if (!isFinite(v) || v < MIN_KM || v > MAX_KM) return 3;
return v;
}
// 「新圆半径」旁边实时显示它会画多大:落点之前就能判断合不合适
function syncNewSpan() {
if (newSpan) newSpan.textContent = spanLabel(newRadius());
}
if (radiusIn) radiusIn.addEventListener('input', syncNewSpan);
// 整块重画:圆最多 20 个,重建比增量对齐简单得多,也不会漏掉「删了中间一个」的下标错位
function redraw() {
layer.clearLayers();
views = [];
for (var i = 0; i < areas.length; i++) {
(function (idx) {
var a = areas[idx];
var circle = L.circle([a.lat, a.lon], {
radius: a.radiusKm * 1000, color: '#d9534f', weight: 1.5, fillOpacity: 0.12
}).addTo(layer);
var dot = L.marker([a.lat, a.lon], {
draggable: true, icon: dotIcon(),
title: '拖动移动第 ' + (idx + 1) + ' 个显示区'
}).addTo(layer);
var handle = L.marker([a.lat, a.lon + lonDelta(a.lat, a.radiusKm)], {
draggable: true, icon: handleIcon(),
title: '拖动改半径'
}).addTo(layer);
dot.on('drag', function (e) {
var ll = e.target.getLatLng();
circle.setLatLng(ll);
handle.setLatLng([ll.lat, ll.lng + lonDelta(ll.lat, areas[idx].radiusKm)]);
});
dot.on('dragend', function (e) {
var ll = e.target.getLatLng();
areas = setCenter(areas, idx, ll.lat, ll.lng);
sync();
});
handle.on('drag', function (e) {
var ll = e.target.getLatLng();
var km = kmBetween([areas[idx].lat, areas[idx].lon], [ll.lat, ll.lng]);
if (!isFinite(km) || km < MIN_KM) km = MIN_KM;
circle.setRadius(km * 1000);
});
handle.on('dragend', function (e) {
var ll = e.target.getLatLng();
var km = kmBetween([areas[idx].lat, areas[idx].lon], [ll.lat, ll.lng]);
if (!isFinite(km) || km < MIN_KM) km = MIN_KM;
areas = setRadius(areas, idx, km);
sync();
});
views.push({ circle: circle, dot: dot, handle: handle });
})(i);
}
}
function dotIcon() {
return L.divIcon({ className: 'area-dot', iconSize: [14, 14], iconAnchor: [7, 7] });
}
function handleIcon() {
return L.divIcon({ className: 'area-handle', iconSize: [16, 16], iconAnchor: [8, 8] });
}
function sync() {
hidden.value = serialize(areas);
redraw();
renderList();
syncNewSpan();
var msg = validate(areas);
if (hint) {
if (msg) {
hint.textContent = msg;
hint.className = 'err';
} else {
hint.className = '';
hint.textContent = areas.length
? '已圈 ' + areas.length + ' 个显示区;点地图继续加,拖圆心移动、拖圆边改半径'
: '在图上点一下就是圆心;不圈就是「时间范围内的地点画到哪算哪」';
}
}
// 校验不过就别让提交(后端也会再拦一道,这里只是别浪费一次往返)
if (saveBtn) saveBtn.disabled = !!msg;
}
function renderList() {
listBox.textContent = '';
if (!areas.length) {
var empty = document.createElement('div');
empty.className = 'muted';
empty.textContent = '还没有显示区。';
listBox.appendChild(empty);
return;
}
areas.forEach(function (a, idx) {
var row = document.createElement('div');
row.className = 'area-row';
var tag = document.createElement('span');
tag.className = 'area-tag';
tag.textContent = '#' + (idx + 1);
row.appendChild(tag);
var coord = document.createElement('span');
coord.className = 'mono muted';
coord.textContent = a.lat.toFixed(5) + ', ' + a.lon.toFixed(5);
row.appendChild(coord);
var lab = document.createElement('label');
lab.className = 'area-km';
// 半径输入套一层定宽容器:站点的全局规则给所有 input 上了 width:100% 且带 10 个
// :not()(特异性 (0,11,1)),直接给 input 写宽度是压不过的 —— 用容器定宽就绕开了。
var box = document.createElement('span');
box.className = 'area-km-box';
var inp = document.createElement('input');
inp.type = 'number';
inp.min = String(MIN_KM);
inp.max = String(MAX_KM);
// step 必须 any:它是相对 min 递增的,min=0.05 + step=0.5 的合法值只有
// 0.05/0.55/1.05…,填 3 或 3.5 会被浏览器判非法并弹原生提示(「两个最接近的
// 有效值分别为 3.05 和 3.55」)—— 那是浏览器行为,JS 与后端都拦不到。
inp.step = 'any';
inp.value = String(a.radiusKm);
inp.addEventListener('input', function () {
areas = setRadius(areas, idx, parseFloat(inp.value));
sync();
focusList(idx);
});
box.appendChild(inp);
lab.appendChild(box);
var unit = document.createElement('span');
unit.className = 'muted';
unit.textContent = '公里';
lab.appendChild(unit);
row.appendChild(lab);
// 「半径 20 公里」旁边补一句直径 —— 眼睛量的是宽度,光看半径容易读小
var span = document.createElement('span');
span.className = 'muted area-span';
span.textContent = spanLabel(a.radiusKm);
row.appendChild(span);
var go = document.createElement('button');
go.type = 'button';
go.className = 'btn sm';
go.textContent = '定位';
go.addEventListener('click', function () {
map.setView([a.lat, a.lon], Math.max(map.getZoom(), 12));
});
row.appendChild(go);
var del = document.createElement('button');
del.type = 'button';
del.className = 'btn sm';
del.textContent = '删除';
del.addEventListener('click', function () {
areas = remove(areas, idx);
sync();
});
row.appendChild(del);
listBox.appendChild(row);
});
}
// 改半径会整块重画,输入框会失焦 —— 把焦点还给同一行,否则连打第二个数字都困难
function focusList(idx) {
var inputs = listBox.querySelectorAll('.area-km input');
if (inputs[idx]) {
inputs[idx].focus();
var v = inputs[idx].value;
try { inputs[idx].setSelectionRange(v.length, v.length); } catch (e) { /* number 型不支持,忽略 */ }
}
}
map.on('click', function (e) {
if (areas.length >= MAX_AREAS) {
if (hint) { hint.textContent = '显示区最多 ' + MAX_AREAS + ' 个'; hint.className = 'err'; }
return;
}
areas = add(areas, { lat: e.latlng.lat, lon: e.latlng.lng, radiusKm: newRadius() });
sync();
});
var clearBtn = document.getElementById(opts.clearId || 'area-clear');
if (clearBtn) {
clearBtn.addEventListener('click', function () {
if (!areas.length) return;
if (!global.confirm('清空全部显示区(等于不限区域)。确认?')) return;
areas = [];
sync();
});
}
// 初始视野:有圆就框住所有圆,否则落在站长自己的地点范围上
if (areas.length) {
var b = null;
areas.forEach(function (a) {
var dLat = latDelta(a.radiusKm), dLon = lonDelta(a.lat, a.radiusKm);
var box = L.latLngBounds([[a.lat - dLat, a.lon - dLon], [a.lat + dLat, a.lon + dLon]]);
b = b ? b.extend(box) : box;
});
map.fitBounds(b, { padding: [20, 20] });
} else if (opts.home) {
map.fitBounds(L.latLngBounds(
[[opts.home.minLat, opts.home.minLon], [opts.home.maxLat, opts.home.maxLon]]),
{ padding: [20, 20] });
} else {
map.setView([34.5, 108.9], 4);
}
sync();
}
};
if (typeof module !== 'undefined' && module.exports) module.exports = api;
global.rondTopicAreas = api;
})(typeof window !== 'undefined' ? window : globalThis);