'use strict'; // ------------------------------------------------------------------ 通用工具 const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); const round = (v, n = 0) => { const p = 10 ** n; return Math.round((Number(v) || 0) * p) / p; }; /** 带超时的 fetch,返回原始 Response */ async function fetchWithTimeout(url, opts = {}, timeoutMs = 15000) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeoutMs); try { return await fetch(url, { ...opts, signal: ctrl.signal }); } finally { clearTimeout(timer); } } async function fetchJSON(url, opts = {}, timeoutMs = 15000, retries = 2) { let lastErr; for (let attempt = 0; attempt <= retries; attempt += 1) { try { const res = await fetchWithTimeout(url, opts, timeoutMs); if (!res.ok) { const text = await res.text().catch(() => ''); // 4xx 是确定性错误,不必重试 if (res.status >= 400 && res.status < 500) { throw new Error(`HTTP ${res.status} ${res.statusText} ${text.slice(0, 160)}`); } throw new Error(`HTTP ${res.status} ${res.statusText} ${text.slice(0, 160)}`); } return await res.json(); } catch (err) { lastErr = err; if (attempt < retries) await new Promise((r) => setTimeout(r, 300 * (attempt + 1))); } } throw lastErr; } /** 并发限制的 map,失败不中断整体 */ async function mapLimit(items, limit, fn) { const out = new Array(items.length); let cursor = 0; const workers = Array.from({ length: Math.min(limit, items.length) }, async () => { while (true) { const i = cursor++; if (i >= items.length) return; try { out[i] = await fn(items[i], i); } catch (err) { out[i] = null; console.warn(`[mapLimit] ${i} failed: ${err.message}`); } } }); await Promise.all(workers); return out; } /** 安全地尝试,失败返回 fallback */ async function attempt(label, fn, fallback) { try { return await fn(); } catch (err) { console.warn(`[${label}] ${err.message}`); return typeof fallback === 'function' ? fallback(err) : fallback; } } // ------------------------------------------------------------------ 地理计算 const R_EARTH = 6371; const toRad = (d) => (d * Math.PI) / 180; function haversine(lat1, lon1, lat2, lon2) { const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; return 2 * R_EARTH * Math.asin(Math.sqrt(a)); } /** 方位角:0 = 正北,顺时针递增 */ function bearing(lat1, lon1, lat2, lon2) { const p1 = toRad(lat1); const p2 = toRad(lat2); const dl = toRad(lon2 - lon1); const y = Math.sin(dl) * Math.cos(p2); const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl); return (Math.atan2(y, x) * 180) / Math.PI; } const COMPASS_8 = ['北', '东北', '东', '东南', '南', '西南', '西', '西北']; function compassLabel(deg) { return COMPASS_8[Math.round((((deg % 360) + 360) % 360) / 45) % 8]; } /** WGS-84 -> GCJ-02(国内高德/腾讯地图需要火星坐标) */ function wgs84ToGcj02(lng, lat) { const PI = Math.PI; const A = 6378245.0; const EE = 0.00669342162296594323; const outOfChina = (l, b) => l < 73.66 || l > 135.05 || b < 3.86 || b > 53.55; if (outOfChina(lng, lat)) return [lng, lat]; const transformLat = (x, y) => { let r = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x)); r += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3; r += ((20 * Math.sin(y * PI) + 40 * Math.sin((y / 3) * PI)) * 2) / 3; r += ((160 * Math.sin((y / 12) * PI) + 320 * Math.sin((y * PI) / 30)) * 2) / 3; return r; }; const transformLon = (x, y) => { let r = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x)); r += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3; r += ((20 * Math.sin(x * PI) + 40 * Math.sin((x / 3) * PI)) * 2) / 3; r += ((150 * Math.sin((x / 12) * PI) + 300 * Math.sin((x / 30) * PI)) * 2) / 3; return r; }; let dLat = transformLat(lng - 105, lat - 35); let dLon = transformLon(lng - 105, lat - 35); const radLat = (lat / 180) * PI; let magic = Math.sin(radLat); magic = 1 - EE * magic * magic; const sqrtMagic = Math.sqrt(magic); dLat = (dLat * 180) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI); dLon = (dLon * 180) / (A / sqrtMagic * Math.cos(radLat) * PI); return [lng + dLon, lat + dLat]; } /** GCJ-02 -> WGS-84(迭代逼近,误差 < 1m),用于把高德结果转回通用坐标 */ function gcj02ToWgs84(lng, lat) { let wLng = lng; let wLat = lat; for (let i = 0; i < 5; i += 1) { const [gLng, gLat] = wgs84ToGcj02(wLng, wLat); wLng += lng - gLng; wLat += lat - gLat; } return [wLng, wLat]; } /** 由中心点与公里半径得到 bbox:minLon,minLat,maxLon,maxLat */ function bboxFromRadius(lat, lon, radiusKm) { const dLat = radiusKm / 111.32; const cos = Math.max(0.2, Math.cos(toRad(lat))); const dLon = radiusKm / (111.32 * cos); return [lon - dLon, lat - dLat, lon + dLon, lat + dLat].map((v) => round(v, 4)); } // -------------------------------------------------------------- 日期工具 function todayISO() { const d = new Date(); return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 10); } function isoToUTC(iso) { const [y, m, d] = iso.split('-').map(Number); return Date.UTC(y, m - 1, d); } function utcToISO(ms) { return new Date(ms).toISOString().slice(0, 10); } function addDaysISO(iso, n) { return utcToISO(isoToUTC(iso) + n * 86400000); } function addYearsISO(iso, n) { const [y, m, d] = iso.split('-').map(Number); // 处理 2/29 const day = m === 2 && d === 29 ? 28 : d; return `${y + n}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`; } function diffDays(aISO, bISO) { return Math.round((isoToUTC(bISO) - isoToUTC(aISO)) / 86400000); } function eachDate(startISO, endISO, cap = 31) { const out = []; const n = Math.min(cap, Math.max(0, diffDays(startISO, endISO)) + 1); for (let i = 0; i < n; i += 1) out.push(addDaysISO(startISO, i)); return out; } function weekdayCN(iso) { return ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][new Date(isoToUTC(iso)).getUTCDay()]; } function isValidISO(s) { return /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(isoToUTC(s)); } function chunk(arr, size) { const out = []; for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); return out; } // ------------------------------------------------------------------- 其它 function normalizeName(s) { return String(s || '').replace(/[\s·・()()\[\]【】"'"']/g, '').toLowerCase(); } function esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } module.exports = { clamp, round, fetchWithTimeout, fetchJSON, mapLimit, attempt, haversine, bearing, compassLabel, bboxFromRadius, wgs84ToGcj02, gcj02ToWgs84, todayISO, isoToUTC, utcToISO, addDaysISO, addYearsISO, diffDays, eachDate, weekdayCN, isValidISO, chunk, normalizeName, esc, };