专题显示区改成在地图上点选(后台编辑器)
用户诉求的后半:不该让他手填经纬度和半径,应该在地图上圈。三个手填输入框
换成地图 + 圆列表:点地图加圆、拖圆心移动、拖圆边改半径、列表里可改半径/
定位/删除、可一次清空;改动立刻写进隐藏字段 areas(JSON) 与 crs,随专题表单
一起提交。
- 复用 /admin/fog-mask 那套约定:/api/map-config 取底图、底图坐标系随表单带上、
后端换算成 GCJ-02。不另起炉灶。
- areas 的初值**渲染进隐藏字段**(不是只塞给 JS):脚本没加载上时提交的仍是原值,
否则一次保存就把已有显示区静默清空。
- 地图初始视野取生效数据集的地点包围盒(placesBounds),一上来就落在自己的地界上,
而不是全国视野。
- 上限(20 个 / 0.05~2000 km)前端也校验一份,并**禁用提交**;两边数字用守卫比对,
免得出现「前端放行、后端拒绝,用户不知道改哪里」。
实测抓到并修掉的 bug:/admin/topics 没设 p.HasMap ⇒ 布局不引入 Leaflet,
表现是脚本加载了、rondTopicAreas 也在,就是 window.L 永远 undefined,init 静默
返回、控制台不报错、页面像「什么都没发生」。已加守卫钉住这一点。
其余:
- topic-areas.js 只暴露纯函数(校验/序列化/增删改的不可变性/几何换算)供 node 单测,
另加一个最小测试钩子 api._map 供无头验证(同 window.rondMap._state 惯例)。
- CSS 用定宽容器装半径输入框:站点全局 input 规则是 width:100% 且带 10 个 :not()
(特异性 (0,11,1)),直接给 input 写宽度压不过。
- gofmt:文档注释里的缩进列表按 Go 1.19 的规则重排;顺带把工作区的 CRLF 归一到 LF
(.gitattributes 早就声明 eol=lf,blob 本来也是 LF,所以 git diff 无额外噪声)。
验证:
- topic-areas.test.js 44 条(含「增删改返回新数组不原地改」、半径换算自洽性)。
- 三个新守卫:两边上限一致、模板契约(旧输入框必须没了 + 挂点齐全 + 初值来源)、
topicsAdminPage 必须设 HasMap。
- 无头端到端:两击成两圆 → 改半径 7.5 → 删第 1 个 → 越界半径被拦且禁用提交 →
提交 200 且落库 areas=[{lat:24.72,lon:108.1,radiusKm:7.5}](废弃列镜像 7.5);全程无 JS 报错。
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
/* 专题「显示区」编辑器:在地图上圈一组圆(后台专题表单用)。
|
||||
*
|
||||
* 交互:
|
||||
* · 地图上点一下 = 以「新圆半径」加一个圆
|
||||
* · 拖圆心移动、拖圆边上的把手改半径
|
||||
* · 列表里可改半径、定位、删除;「清除全部」一次清空
|
||||
* · 变化立刻写进隐藏字段 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);
|
||||
}
|
||||
|
||||
// 两点距离(公里),球面公式;拖把手时用它反推半径
|
||||
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
|
||||
};
|
||||
|
||||
// ---------- 以下需要 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 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);
|
||||
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;
|
||||
}
|
||||
|
||||
// 整块重画:圆最多 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();
|
||||
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);
|
||||
inp.step = '0.5';
|
||||
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);
|
||||
|
||||
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);
|
||||
Reference in New Issue
Block a user