/* 专题「显示区」编辑器:在地图上圈一组圆(后台专题表单用)。 * * 交互: * · 地图上点一下 = 以「新圆半径」加一个圆 * · 拖圆心移动、拖圆边上的把手改半径 * · 列表里可改半径、定位、删除;「清除全部」一次清空 * · 变化立刻写进隐藏字段 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);