Files
rond-with-you/internal/web/static/js/topic-areas.js
T
tamakyi 7ab33227a5 专题显示区改成在地图上点选(后台编辑器)
用户诉求的后半:不该让他手填经纬度和半径,应该在地图上圈。三个手填输入框
换成地图 + 圆列表:点地图加圆、拖圆心移动、拖圆边改半径、列表里可改半径/
定位/删除、可一次清空;改动立刻写进隐藏字段 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 报错。
2026-10-12 17:35:00 +08:00

365 lines
14 KiB
JavaScript
Raw 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);
}
// 两点距离(公里),球面公式;拖把手时用它反推半径
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);