upstream: integrar Oasis 1.3.1

Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.

Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).

Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
This commit is contained in:
oasis-bot 2026-10-18 02:52:17 +02:00
parent 8d838dd8b1
commit c332afd6ea
265 changed files with 80970 additions and 29167 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

80
src/maps/map_data.js Normal file
View file

@ -0,0 +1,80 @@
const fs = require("fs");
const path = require("path");
const DATA_DIR = path.join(__dirname, "data");
const loaded = new Map();
const load = (name) => {
if (loaded.has(name)) return loaded.get(name);
let data = { f: [] };
try { data = JSON.parse(fs.readFileSync(path.join(DATA_DIR, `${name}.json`), "utf8")); } catch (_) {}
loaded.set(name, data);
return data;
};
const countries = (detail) => load(detail === "high" ? "countries_50m" : "countries_110m").f;
const coast = () => load("coast_50m").f;
const lakes = () => load("lakes_50m").f;
const rivers = () => load("rivers_50m").f;
const states = () => load("states_50m").f;
const places = (detail) => load(detail === "high" ? "places_50m" : "places_110m").f;
const fold = (s) => String(s || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim();
let placeIndex = null;
const buildPlaceIndex = () => {
if (placeIndex) return placeIndex;
const list = [];
for (const p of places("high")) {
list.push({ kind: "place", name: p.n, country: p.a, iso: p.i, pop: p.p || 0, capital: !!p.k, lat: p.la, lng: p.lo, key: fold(p.n) });
}
for (const c of countries("low")) {
list.push({ kind: "country", name: c.n, country: "", iso: c.c, pop: Number.MAX_SAFE_INTEGER, capital: false, lat: c.ly, lng: c.lx, bbox: c.b, key: fold(c.n) });
}
for (const c of countries("high")) {
if (list.some((e) => e.kind === "country" && e.key === fold(c.n))) continue;
list.push({ kind: "country", name: c.n, country: "", iso: c.c, pop: Number.MAX_SAFE_INTEGER, capital: false, lat: c.ly, lng: c.lx, bbox: c.b, key: fold(c.n) });
}
placeIndex = list;
return list;
};
const searchPlaces = (query, limit = 5) => {
const q = fold(query);
if (!q) return [];
const index = buildPlaceIndex();
const scored = [];
for (const e of index) {
let score = 0;
if (e.key === q) score = 3;
else if (e.key.startsWith(q)) score = 2;
else if (e.key.includes(q)) score = 1;
else if (e.iso && fold(e.iso) === q) score = 2;
if (!score) continue;
scored.push({ e, score });
}
scored.sort((a, b) => b.score - a.score || b.e.pop - a.e.pop || a.e.key.localeCompare(b.e.key));
return scored.slice(0, limit).map(({ e }) => ({ ...e, key: undefined }));
};
const toRad = (d) => d * Math.PI / 180;
const distanceKm = (lat1, lng1, lat2, lng2) => {
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
const nearestPlace = (lat, lng, maxKm = 300) => {
if (typeof lat !== "number" || typeof lng !== "number" || !isFinite(lat) || !isFinite(lng)) return null;
let best = null;
for (const p of places("high")) {
if (Math.abs(p.la - lat) > 4) continue;
const d = distanceKm(lat, lng, p.la, p.lo);
if (d > maxKm) continue;
if (!best || d < best.km) best = { name: p.n, country: p.a, iso: p.i, km: Math.round(d), lat: p.la, lng: p.lo };
}
return best;
};
module.exports = { load, countries, coast, lakes, rivers, states, places, searchPlaces, nearestPlace, distanceKm, fold };

View file

@ -1,238 +1,608 @@
const { execFileSync } = require("child_process");
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
const data = require("./map_data");
const BASE_MAP = path.join(__dirname, "..", "client", "assets", "images", "worldmap-z2.png");
const CACHE_DIR = path.join(__dirname, "cache");
const TILES_DIR = path.join(__dirname, "tiles");
const MAP_W = 1024;
const MAP_H = 1024;
const MAP_H = 640;
const TILE = 256;
const MIN_ZOOM = 2;
const MAX_ZOOM = 9;
const TILE_MAX_ZOOM = 6;
const TILE_PATH = "/maptiles";
const TILE_ENV = process.env.OASIS_MAP_TILES || "";
const CRISP_ZOOM = 9;
const MAX_LAT = 85.0511;
const GRID_COLS = 24;
const GRID_ROWS = 15;
const CLUSTER_PX = 28;
const EDGE = 6;
const POPUP_W = 320;
const POPUP_H = 400;
const POPUP_GAP = 10;
const latLngToPx = (lat, lng) => {
const latRad = lat * Math.PI / 180;
const x = Math.round((lng + 180) / 360 * MAP_W);
const y = Math.round((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * MAP_H);
return { x: Math.max(12, Math.min(MAP_W - 12, x)), y: Math.max(12, Math.min(MAP_H - 12, y)) };
};
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const num = (v) => { const n = typeof v === "number" ? v : parseFloat(v); return isFinite(n) ? n : null; };
const r1 = (v) => Math.round(v * 10) / 10;
const r4 = (v) => Math.round(v * 10000) / 10000;
const esc = (s) => String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const pxToLatLng = (px, py) => {
const lng = px / MAP_W * 360 - 180;
const n = Math.PI - 2 * Math.PI * py / MAP_H;
const lat = 180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
return { lat: Math.round(lat * 100) / 100, lng: Math.round(lng * 100) / 100 };
};
const getMaxTileZoom = () => {
try {
const dirs = fs.readdirSync(TILES_DIR).filter(d => /^\d+$/.test(d) && fs.existsSync(path.join(TILES_DIR, d, '0_0.png')));
return dirs.length ? Math.max(...dirs.map(Number)) : 0;
} catch (_) { return 0; }
};
const getViewportBounds = (centerLat, centerLng, zoom) => {
const effectiveZ = Math.min(zoom, getMaxTileZoom());
const n = Math.pow(2, effectiveZ);
const tileSize = 256;
const worldPx = n * tileSize;
const scale = Math.pow(2, zoom - effectiveZ);
const vw = MAP_W / scale;
const vh = MAP_H / scale;
const latRad = centerLat * Math.PI / 180;
const cx = (centerLng + 180) / 360 * worldPx;
const cy = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * worldPx;
const x0 = cx - vw / 2;
const y0 = cy - vh / 2;
const x1 = cx + vw / 2;
const y1 = cy + vh / 2;
const pxToLng = (px) => px / worldPx * 360 - 180;
const pxToLat = (py) => { const nn = Math.PI - 2 * Math.PI * py / worldPx; return 180 / Math.PI * Math.atan(0.5 * (Math.exp(nn) - Math.exp(-nn))); };
const worldPx = (zoom) => TILE * Math.pow(2, zoom);
const project = (lat, lng, zoom) => {
const w = worldPx(zoom);
const la = clamp(lat, -MAX_LAT, MAX_LAT) * Math.PI / 180;
return {
latMin: Math.max(-85, pxToLat(Math.min(y1, worldPx - 1))),
latMax: Math.min(85, pxToLat(Math.max(y0, 0))),
lngMin: Math.max(-180, pxToLng(Math.max(x0, 0))),
lngMax: Math.min(180, pxToLng(Math.min(x1, worldPx - 1)))
x: (lng + 180) / 360 * w,
y: (1 - Math.log(Math.tan(Math.PI / 4 + la / 2)) / Math.PI) / 2 * w
};
};
const renderMapWithPins = (markers, mainIdx) => {
const pins = (Array.isArray(markers) ? markers : [])
.filter((m) => m && typeof m.lat === "number" && typeof m.lng === "number")
.map((m, i) => ({ ...latLngToPx(m.lat, m.lng), main: i === (mainIdx || 0) }));
const unproject = (x, y, zoom) => {
const w = worldPx(zoom);
const n = Math.PI - 2 * Math.PI * y / w;
return { lat: 180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))), lng: x / w * 360 - 180 };
};
const hash = crypto.createHash("md5")
.update(pins.map((p) => `${p.x},${p.y},${p.main}`).join(";"))
.digest("hex")
.slice(0, 12);
const latLngToPx = (lat, lng) => {
const p = project(lat, lng, MIN_ZOOM);
return { x: Math.round(p.x), y: Math.round(p.y) };
};
const outFile = path.join(CACHE_DIR, `map_${hash}.png`);
const pxToLatLng = (px, py) => {
const p = unproject(px, py, MIN_ZOOM);
return { lat: Math.round(p.lat * 100) / 100, lng: Math.round(p.lng * 100) / 100 };
};
if (fs.existsSync(outFile)) return `map_${hash}.png`;
const normalizeZoom = (z) => clamp(Math.round(num(z) == null ? MIN_ZOOM : num(z)), MIN_ZOOM, MAX_ZOOM);
const script = `
from PIL import Image, ImageDraw
import sys, json
const makeView = (centerLat, centerLng, zoom) => {
const z = normalizeZoom(zoom);
const w = worldPx(z);
const c = project(num(centerLat) == null ? 0 : num(centerLat), num(centerLng) == null ? 0 : num(centerLng), z);
const cx = w <= MAP_W ? w / 2 : clamp(c.x, MAP_W / 2, w - MAP_W / 2);
const cy = w <= MAP_H ? w / 2 : clamp(c.y, MAP_H / 2, w - MAP_H / 2);
const center = unproject(cx, cy, z);
return { zoom: z, lat: r4(center.lat), lng: r4(center.lng), x0: cx - MAP_W / 2, y0: cy - MAP_H / 2, world: w };
};
pins = json.loads(sys.argv[1])
im = Image.open(sys.argv[2]).copy()
draw = ImageDraw.Draw(im)
const toScreen = (view, lat, lng) => {
const p = project(lat, lng, view.zoom);
return { x: p.x - view.x0, y: p.y - view.y0 };
};
def draw_pin(draw, x, y, main):
sw = 7 if main else 5
sh = 34 if main else 26
head = 11 if main else 8
clr = '#e74c3c' if main else '#3498db'
dark = '#c0392b' if main else '#2980b9'
cy = y - sh
draw.polygon([(x, y + 2), (x - sw - 1, cy + sw), (x + sw + 1, cy + sw)], fill='white')
draw.polygon([(x, y), (x - sw, cy + sw), (x + sw, cy + sw)], fill=clr)
draw.ellipse([x - head - 2, cy - head - 2, x + head + 2, cy + head + 2], fill='white')
draw.ellipse([x - head, cy - head, x + head, cy + head], fill=clr, outline=dark, width=2)
draw.ellipse([x - head // 3, cy - head // 3, x + head // 3, cy + head // 3], fill='white')
const fromScreen = (view, x, y) => {
const p = unproject(view.x0 + x, view.y0 + y, view.zoom);
return { lat: r4(clamp(p.lat, -MAX_LAT, MAX_LAT)), lng: r4(clamp(p.lng, -180, 180)) };
};
for p in pins:
draw_pin(draw, p['x'], p['y'], p.get('main', False))
const getViewportBounds = (centerLat, centerLng, zoom) => {
const view = makeView(centerLat, centerLng, zoom);
const nw = fromScreen(view, 0, 0);
const se = fromScreen(view, MAP_W, MAP_H);
return { latMin: se.lat, latMax: nw.lat, lngMin: nw.lng, lngMax: se.lng };
};
im.save(sys.argv[3], optimize=True)
`;
try {
fs.mkdirSync(CACHE_DIR, { recursive: true });
execFileSync("python3", [
"-c", script,
JSON.stringify(pins),
BASE_MAP,
outFile
], { timeout: 10000 });
} catch (e) {
return null;
const fitView = (points, opts = {}) => {
const pts = (Array.isArray(points) ? points : []).filter((p) => p && num(p.lat) != null && num(p.lng) != null);
const single = normalizeZoom(opts.singleZoom || 8);
if (!pts.length) return { zoom: MIN_ZOOM, lat: 10, lng: 0 };
if (pts.length === 1) return { zoom: single, lat: num(pts[0].lat), lng: num(pts[0].lng) };
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const p of pts) {
const q = project(num(p.lat), num(p.lng), 0);
x0 = Math.min(x0, q.x); x1 = Math.max(x1, q.x); y0 = Math.min(y0, q.y); y1 = Math.max(y1, q.y);
}
const pad = opts.pad == null ? 70 : opts.pad;
const dx = Math.max(x1 - x0, 1e-6);
const dy = Math.max(y1 - y0, 1e-6);
const z = Math.floor(Math.log2(Math.min((MAP_W - 2 * pad) / dx, (MAP_H - 2 * pad) / dy)));
const c = unproject((x0 + x1) / 2, (y0 + y1) / 2, 0);
return { zoom: clamp(z, MIN_ZOOM, Math.min(MAX_ZOOM, opts.maxZoom || MAX_ZOOM)), lat: r4(c.lat), lng: r4(c.lng) };
};
return `map_${hash}.png`;
const parseView = (raw) => {
const s = Array.isArray(raw) ? raw[raw.length - 1] : raw;
const m = /^\s*(\d{1,2})\/(-?\d+(?:\.\d+)?)\/(-?\d+(?:\.\d+)?)\s*$/.exec(String(s || ""));
if (!m) return null;
return { zoom: normalizeZoom(m[1]), lat: clamp(parseFloat(m[2]), -MAX_LAT, MAX_LAT), lng: clamp(parseFloat(m[3]), -180, 180) };
};
const viewParam = (v) => `${normalizeZoom(v.zoom)}/${r4(v.lat)}/${r4(v.lng)}`;
const parsePick = (raw) => {
const s = Array.isArray(raw) ? raw[raw.length - 1] : raw;
const m = /^\s*(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)\s*$/.exec(String(s || ""));
if (!m) return null;
const lat = parseFloat(m[1]), lng = parseFloat(m[2]);
if (Math.abs(lat) > 90 || Math.abs(lng) > 180) return null;
return { lat: r4(lat), lng: r4(lng) };
};
const resolveView = (params = {}, pins = [], opts = {}) => {
const explicit = parseView(params.view);
if (explicit) return makeView(explicit.lat, explicit.lng, explicit.zoom);
const clat = num(Array.isArray(params.clat) ? params.clat[0] : params.clat);
const clng = num(Array.isArray(params.clng) ? params.clng[0] : params.clng);
const zoomParam = num(Array.isArray(params.zoom) ? params.zoom[0] : params.zoom);
if (clat != null && clng != null) return makeView(clat, clng, zoomParam == null ? (opts.singleZoom || 8) : zoomParam);
const fit = fitView(pins, opts);
if (zoomParam != null && zoomParam >= MIN_ZOOM) return makeView(opts.focusLat != null ? opts.focusLat : fit.lat, opts.focusLng != null ? opts.focusLng : fit.lng, zoomParam);
return makeView(fit.lat, fit.lng, fit.zoom);
};
const metersPerPx = (view) => 156543.03392 * Math.cos(view.lat * Math.PI / 180) / Math.pow(2, view.zoom);
const bboxHit = (b, vb) => b[0] <= vb[2] && b[2] >= vb[0] && b[1] <= vb[3] && b[3] >= vb[1];
const clipPolygon = (pts, x0, y0, x1, y1) => {
let out = pts;
const passes = [
(p) => p.x >= x0, (p, q) => ({ x: x0, y: p.y + (q.y - p.y) * (x0 - p.x) / (q.x - p.x) }),
(p) => p.x <= x1, (p, q) => ({ x: x1, y: p.y + (q.y - p.y) * (x1 - p.x) / (q.x - p.x) }),
(p) => p.y >= y0, (p, q) => ({ x: p.x + (q.x - p.x) * (y0 - p.y) / (q.y - p.y), y: y0 }),
(p) => p.y <= y1, (p, q) => ({ x: p.x + (q.x - p.x) * (y1 - p.y) / (q.y - p.y), y: y1 })
];
for (let i = 0; i < passes.length && out.length; i += 2) {
const inside = passes[i], cross = passes[i + 1];
const res = [];
let prev = out[out.length - 1];
for (const cur of out) {
const ci = inside(cur), pi = inside(prev);
if (ci) {
if (!pi) res.push(cross(prev, cur));
res.push(cur);
} else if (pi) {
res.push(cross(prev, cur));
}
prev = cur;
}
out = res;
}
return out;
};
const clipSegment = (a, b, x0, y0, x1, y1) => {
let t0 = 0, t1 = 1;
const dx = b.x - a.x, dy = b.y - a.y;
const checks = [[-dx, a.x - x0], [dx, x1 - a.x], [-dy, a.y - y0], [dy, y1 - a.y]];
for (const [p, q] of checks) {
if (p === 0) { if (q < 0) return null; continue; }
const r = q / p;
if (p < 0) { if (r > t1) return null; if (r > t0) t0 = r; }
else { if (r < t0) return null; if (r < t1) t1 = r; }
}
return [{ x: a.x + t0 * dx, y: a.y + t0 * dy }, { x: a.x + t1 * dx, y: a.y + t1 * dy }];
};
const simplify = (pts, tol) => {
if (pts.length <= 2) return pts;
const keep = new Uint8Array(pts.length);
keep[0] = 1; keep[pts.length - 1] = 1;
const stack = [[0, pts.length - 1]];
const tol2 = tol * tol;
while (stack.length) {
const [a, b] = stack.pop();
if (b - a < 2) continue;
const ax = pts[a].x, ay = pts[a].y, bx = pts[b].x, by = pts[b].y;
const dx = bx - ax, dy = by - ay;
const len2 = dx * dx + dy * dy;
let best = -1, bestD = 0;
for (let i = a + 1; i < b; i++) {
let d;
if (len2 === 0) { d = (pts[i].x - ax) ** 2 + (pts[i].y - ay) ** 2; }
else {
const t = clamp(((pts[i].x - ax) * dx + (pts[i].y - ay) * dy) / len2, 0, 1);
d = (pts[i].x - ax - t * dx) ** 2 + (pts[i].y - ay - t * dy) ** 2;
}
if (d > bestD) { bestD = d; best = i; }
}
if (best >= 0 && bestD > tol2) { keep[best] = 1; stack.push([a, best], [best, b]); }
}
const out = [];
for (let i = 0; i < pts.length; i++) if (keep[i]) out.push(pts[i]);
return out;
};
const projectFlat = (flat, view, tol) => {
const out = [];
let lx = NaN, ly = NaN;
for (let i = 0; i < flat.length; i += 2) {
const p = toScreen(view, flat[i + 1], flat[i]);
if (Math.abs(p.x - lx) + Math.abs(p.y - ly) < tol) continue;
out.push(p);
lx = p.x; ly = p.y;
}
return simplify(out, tol);
};
const pathOf = (pts, close) => {
if (pts.length < 2) return "";
let d = `M${r1(pts[0].x)} ${r1(pts[0].y)}`;
for (let i = 1; i < pts.length; i++) d += `L${r1(pts[i].x)} ${r1(pts[i].y)}`;
return close ? d + "Z" : d;
};
const polygonPath = (rings, view, tol) => {
let d = "";
for (const ring of rings) {
const pts = projectFlat(ring, view, tol);
if (pts.length < 3) continue;
const clipped = clipPolygon(pts, -EDGE, -EDGE, MAP_W + EDGE, MAP_H + EDGE);
if (clipped.length >= 3) d += pathOf(clipped, true);
}
return d;
};
const linePath = (flat, view, tol) => {
const pts = projectFlat(flat, view, tol);
let d = "";
let run = [];
const flush = () => { if (run.length >= 2) d += pathOf(run, false); run = []; };
for (let i = 1; i < pts.length; i++) {
const seg = clipSegment(pts[i - 1], pts[i], -EDGE, -EDGE, MAP_W + EDGE, MAP_H + EDGE);
if (!seg) { flush(); continue; }
if (!run.length || Math.abs(run[run.length - 1].x - seg[0].x) + Math.abs(run[run.length - 1].y - seg[0].y) > 0.05) { flush(); run.push(seg[0]); }
run.push(seg[1]);
}
flush();
return d;
};
const viewBoxLatLng = (view) => {
const nw = fromScreen(view, -EDGE, -EDGE);
const se = fromScreen(view, MAP_W + EDGE, MAP_H + EDGE);
return [nw.lng, se.lat, se.lng, nw.lat];
};
const baseCache = new Map();
const cacheGet = (key) => baseCache.get(key);
const cacheSet = (key, val) => {
if (baseCache.size >= 64) baseCache.delete(baseCache.keys().next().value);
baseCache.set(key, val);
};
const tileZoom = (zoom, thumb) => clamp(Math.floor(zoom) - (thumb ? 2 : 0), 0, TILE_MAX_ZOOM);
const tileCoverage = (view, opts = {}) => {
const tz = tileZoom(view.zoom, !!opts.thumb);
const size = TILE * Math.pow(2, view.zoom - tz);
const n = Math.pow(2, tz);
const tx0 = Math.max(0, Math.floor(view.x0 / size)), tx1 = Math.min(n - 1, Math.ceil((view.x0 + MAP_W) / size) - 1);
const ty0 = Math.max(0, Math.floor(view.y0 / size)), ty1 = Math.min(n - 1, Math.ceil((view.y0 + MAP_H) / size) - 1);
const out = [];
for (let ty = ty0; ty <= ty1; ty++) {
for (let tx = tx0; tx <= tx1; tx++) out.push({ z: tz, x: tx, y: ty, sx: tx * size - view.x0, sy: ty * size - view.y0, size });
}
return out;
};
const tileHref = (base, t) => `${base}/${t.z}/${t.x}_${t.y}.jpg`;
const tileBaseOf = (opts) => opts.tileBase || TILE_ENV || `${opts.publicBase || ""}${TILE_PATH}`;
const renderRaster = (view, opts) => {
const tiles = tileCoverage(view, { thumb: !!opts.thumb });
if (!tiles.length) return "";
const base = tileBaseOf(opts);
const clipId = `${opts.id || "map"}-clip`;
let out = `<clipPath id="${esc(clipId)}"><rect x="0" y="0" width="${MAP_W}" height="${MAP_H}"/></clipPath><g class="map-raster" clip-path="url(#${esc(clipId)})">`;
for (const t of tiles) {
out += `<image class="map-tile" href="${esc(tileHref(base, t))}" x="${r1(t.sx)}" y="${r1(t.sy)}" width="${t.size + 1}" height="${t.size + 1}" preserveAspectRatio="none"/>`;
}
return out + "</g>";
};
const graticuleStep = (zoom) => zoom <= 2 ? 30 : zoom === 3 ? 15 : zoom === 4 ? 10 : 0;
const renderGraticule = (view, vb) => {
const step = graticuleStep(view.zoom);
if (!step) return "";
let d = "";
for (let lng = Math.ceil(vb[0] / step) * step; lng <= vb[2]; lng += step) {
const x = r1(toScreen(view, 0, lng).x);
d += `M${x} 0L${x} ${MAP_H}`;
}
for (let lat = Math.ceil(vb[1] / step) * step; lat <= vb[3]; lat += step) {
if (Math.abs(lat) >= 85) continue;
const y = r1(toScreen(view, lat, 0).y);
d += `M0 ${y}L${MAP_W} ${y}`;
}
return d ? `<path class="map-grat" d="${d}"/>` : "";
};
const renderBase = (view, detail) => {
const key = `${view.zoom}|${Math.round(view.x0)}|${Math.round(view.y0)}|${detail}`;
const hit = cacheGet(key);
if (hit) return hit;
const z = view.zoom;
const tol = detail === "low" ? 4 : z <= 3 ? 1.1 : 0.7;
const vb = viewBoxLatLng(view);
const high = z >= 5;
let out = detail === "low" ? "" : renderGraticule(view, vb);
let borders = "";
for (const c of data.countries(high ? "high" : "low")) {
if (!bboxHit(c.b, vb)) continue;
for (const poly of c.p) {
const d = polygonPath(poly, view, tol);
if (d) borders += `<path class="map-border" d="${d}"/>`;
}
}
out += `<g class="map-countries">${borders}</g>`;
if (high && detail !== "low") {
let st = "";
for (const s of data.states()) {
if (s.z > z || !bboxHit(s.b, vb)) continue;
const d = linePath(s.l, view, tol);
if (d) st += `<path class="map-state" d="${d}"/>`;
}
if (st) out += `<g class="map-states">${st}</g>`;
}
if (z >= CRISP_ZOOM && detail !== "low") {
let co = "";
for (const c of data.coast()) {
if (!bboxHit(c.b, vb)) continue;
const d = linePath(c.l, view, tol);
if (d) co += `<path class="map-coast" d="${d}"/>`;
}
for (const l of data.lakes()) {
if (l.z > z || !bboxHit(l.b, vb)) continue;
for (const poly of l.p) {
const d = polygonPath(poly, view, tol);
if (d) co += `<path class="map-lake" d="${d}"/>`;
}
}
if (co) out += `<g class="map-coasts">${co}</g>`;
}
cacheSet(key, out);
return out;
};
const textWidth = (s, size) => String(s || "").length * size * 0.58;
const collides = (boxes, b) => boxes.some((o) => b.x0 < o.x1 && b.x1 > o.x0 && b.y0 < o.y1 && b.y1 > o.y0);
const renderLabels = (view, boxes) => {
const z = view.zoom;
let out = "";
if (z >= 3 && z <= 7) {
const list = data.countries(z >= 5 ? "high" : "low").filter((c) => c.ml <= z && c.n).sort((a, b) => a.r - b.r);
for (const c of list) {
const p = toScreen(view, c.ly, c.lx);
if (p.x < 20 || p.x > MAP_W - 20 || p.y < 10 || p.y > MAP_H - 10) continue;
const w = textWidth(c.n, 14);
const b = { x0: p.x - w / 2 - 4, x1: p.x + w / 2 + 4, y0: p.y - 10, y1: p.y + 6 };
if (collides(boxes, b)) continue;
boxes.push(b);
out += `<text class="map-label-country" x="${r1(p.x)}" y="${r1(p.y)}">${esc(c.n)}</text>`;
}
}
if (z >= 2) {
const list = data.places(z >= 5 ? "high" : "low").filter((p) => p.z <= z).sort((a, b) => (b.k - a.k) || (b.p - a.p));
for (const pl of list) {
const p = toScreen(view, pl.la, pl.lo);
if (p.x < 4 || p.x > MAP_W - 4 || p.y < 4 || p.y > MAP_H - 4) continue;
const w = textWidth(pl.n, 12);
const dot = { x0: p.x - 5, x1: p.x + 5, y0: p.y - 5, y1: p.y + 5 };
if (collides(boxes, dot)) continue;
const lb = { x0: p.x + 5, x1: p.x + 9 + w, y0: p.y - 8, y1: p.y + 8 };
const withLabel = !collides(boxes, lb) && lb.x1 <= MAP_W;
boxes.push(dot);
if (withLabel) boxes.push(lb);
const cls = pl.k ? "map-place-dot map-place-capital" : "map-place-dot";
out += `<circle class="${cls}" cx="${r1(p.x)}" cy="${r1(p.y)}" r="${pl.k ? 3.4 : 2.4}"/>`;
if (withLabel) out += `<text class="map-place-label${pl.k ? " map-place-label-capital" : ""}" x="${r1(p.x + 7)}" y="${r1(p.y + 4)}">${esc(pl.n)}</text>`;
}
}
return out ? `<g class="map-labels">${out}</g>` : "";
};
const niceScale = (view) => {
const mpp = metersPerPx(view);
const target = 150 * mpp;
const pow = Math.pow(10, Math.floor(Math.log10(target)));
const nice = [1, 2, 5, 10].map((n) => n * pow).filter((n) => n <= target).pop() || pow;
const px = nice / mpp;
const label = nice >= 1000 ? `${Math.round(nice / 1000)} km` : `${Math.round(nice)} m`;
return { px, label };
};
const renderScale = (view) => {
const { px, label } = niceScale(view);
const x = 14, y = MAP_H - 16;
return `<g class="map-scale"><path class="map-scale-line" d="M${x} ${y - 5}L${x} ${y}L${r1(x + px)} ${y}L${r1(x + px)} ${y - 5}"/><text class="map-scale-text" x="${x + 2}" y="${y - 8}">${esc(label)} · z${view.zoom}</text></g>`;
};
const renderAttribution = () => `<text class="map-attrib" x="${MAP_W - 8}" y="${MAP_H - 8}">Natural Earth</text>`;
const truncate = (s, n) => {
const line = String(s || "").split(/\r?\n/)[0].trim();
return line.length > n ? line.slice(0, n - 1) + "…" : line;
};
const pinPath = (s) => `M0 0C${-7 * s} ${-9 * s} ${-11 * s} ${-14 * s} ${-11 * s} ${-21 * s}A${11 * s} ${11 * s} 0 1 1 ${11 * s} ${-21 * s}C${11 * s} ${-14 * s} ${7 * s} ${-9 * s} 0 0Z`;
const clusterPins = (placed, view) => {
if (view.zoom >= MAX_ZOOM) return placed.map((p) => ({ ...p, members: [p] }));
const clusters = [];
for (const p of placed) {
const near = clusters.find((c) => Math.abs(c.x - p.x) <= CLUSTER_PX && Math.abs(c.y - p.y) <= CLUSTER_PX);
if (near) {
near.members.push(p);
near.x = near.members.reduce((s, m) => s + m.x, 0) / near.members.length;
near.y = near.members.reduce((s, m) => s + m.y, 0) / near.members.length;
} else {
clusters.push({ x: p.x, y: p.y, members: [p] });
}
}
return clusters;
};
const renderPins = (view, pins, opts, boxes, hit = {}) => {
const placed = [];
pins.forEach((m, i) => {
const lat = num(m.lat), lng = num(m.lng);
if (lat == null || lng == null) return;
const p = toScreen(view, lat, lng);
if (p.x < -20 || p.x > MAP_W + 20 || p.y < -10 || p.y > MAP_H + 50) return;
placed.push({ ...m, lat, lng, x: p.x, y: p.y, idx: i });
});
if (!placed.length) return "";
const clusters = clusterPins(placed.filter((p) => !p.pick), view);
for (const p of placed.filter((p) => p.pick)) clusters.push({ x: p.x, y: p.y, members: [p], pick: true });
clusters.sort((a, b) => a.y - b.y);
const quiet = !!opts.popup;
let out = "";
for (const c of clusters) {
const x = r1(c.x), y = r1(c.y);
if (c.members.length > 1) {
const n = c.members.length;
const center = fromScreen(view, c.x, c.y);
if (c.members.some((m) => m.focus)) hit.focus = { x: c.x, y: c.y, top: 20 };
let gap = Infinity;
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) gap = Math.min(gap, Math.max(Math.abs(c.members[i].x - c.members[j].x), Math.abs(c.members[i].y - c.members[j].y)));
const splitZoom = gap > 0 ? view.zoom + Math.ceil(Math.log2((CLUSTER_PX + 1) / gap)) : MAX_ZOOM;
const href = opts.viewHref ? opts.viewHref({ zoom: clamp(Math.max(splitZoom, view.zoom + 1), MIN_ZOOM, MAX_ZOOM), lat: center.lat, lng: center.lng }) : null;
const title = opts.clusterTitle ? `${n} ${opts.clusterTitle}` : String(n);
const glyph = `<g class="map-cluster" transform="translate(${x} ${y})"><title>${esc(title)}</title><circle class="map-cluster-ring" r="19"/><circle class="map-cluster-dot" r="14"/><text class="map-cluster-count" y="4.5">${n}</text></g>`;
out += href ? `<a href="${esc(href)}">${glyph}</a>` : glyph;
boxes.push({ x0: c.x - 20, x1: c.x + 20, y0: c.y - 20, y1: c.y + 20 });
continue;
}
const m = c.members[0];
if (m.pick) {
out += `<g class="map-pin-pick" transform="translate(${x} ${y})"><circle class="map-pick-ring" r="11"/><path class="map-pick-cross" d="M-16 0H-6M6 0H16M0 -16V-6M0 6V16"/><circle class="map-pick-center" r="2.5"/></g>`;
continue;
}
const s = m.main ? 1.25 : 1;
const cls = ["map-pin", m.main ? "map-pin-main" : "", m.focus ? "map-pin-focus" : ""].filter(Boolean).join(" ");
let g = `<g class="${cls}" transform="translate(${x} ${y})">`;
if (m.title) g += `<title>${esc(m.title)}</title>`;
g += `<path class="map-pin-body" d="${pinPath(s)}"/><circle class="map-pin-eye" cy="${-21 * s}" r="${4 * s}"/>`;
const silent = quiet && m.focus;
if (silent) hit.focus = { x: c.x, y: c.y, top: 32 * s };
const label = silent ? "" : truncate(m.label, 28);
if (label) {
const w = textWidth(label, 12) + 12;
const ly = -21 * s - 11 * s - 16;
g += `<rect class="map-pin-label-bg" x="${r1(-w / 2)}" y="${r1(ly - 12)}" width="${r1(w)}" height="17" rx="4"/><text class="map-pin-label" y="${r1(ly)}">${esc(label)}</text>`;
boxes.push({ x0: c.x - w / 2, x1: c.x + w / 2, y0: c.y + ly - 12, y1: c.y });
} else {
boxes.push({ x0: c.x - 12, x1: c.x + 12, y0: c.y - 34, y1: c.y });
}
if (m.image && !silent) {
const size = 38;
g += `<rect class="map-pin-img-frame" x="${r1(13 * s)}" y="${r1(-21 * s - size / 2 - 2)}" width="${size + 4}" height="${size + 4}" rx="4"/><image class="map-pin-img" href="${esc(m.image)}" x="${r1(13 * s + 2)}" y="${r1(-21 * s - size / 2)}" width="${size}" height="${size}" preserveAspectRatio="xMidYMid slice"/>`;
boxes.push({ x0: c.x + 13 * s, x1: c.x + 13 * s + size + 4, y0: c.y - 21 * s - size / 2 - 2, y1: c.y - 21 * s + size / 2 + 2 });
}
g += "</g>";
out += m.href ? `<a href="${esc(m.href)}">${g}</a>` : g;
}
return `<g class="map-pins">${out}</g>`;
};
const popupPlacement = (at) => {
const roomAbove = at.y - at.top - POPUP_GAP;
const roomBelow = MAP_H - at.y - POPUP_GAP;
const below = roomAbove < POPUP_H && roomBelow > roomAbove;
const h = Math.min(POPUP_H, Math.max(below ? roomBelow : roomAbove, 60));
const x = clamp(at.x - POPUP_W / 2, 4, MAP_W - POPUP_W - 4);
const y = below ? at.y + POPUP_GAP : at.y - at.top - POPUP_GAP - h;
return { x, y, w: POPUP_W, h, below, tipX: clamp(at.x, x + 14, x + POPUP_W - 14) };
};
const renderPopup = (at, html) => {
if (!at || !html) return "";
const p = popupPlacement(at);
const pointer = p.below
? `M${r1(p.tipX - 8)} ${r1(p.y)}L${r1(p.tipX)} ${r1(p.y - 8)}L${r1(p.tipX + 8)} ${r1(p.y)}Z`
: `M${r1(p.tipX - 8)} ${r1(p.y + p.h)}L${r1(p.tipX)} ${r1(p.y + p.h + 8)}L${r1(p.tipX + 8)} ${r1(p.y + p.h)}Z`;
return `<g class="map-popup${p.below ? " map-popup--below" : ""}"><path class="map-popup-pointer" d="${pointer}"/>` +
`<foreignObject x="${r1(p.x)}" y="${r1(p.y)}" width="${p.w}" height="${r1(p.h)}">` +
`<div xmlns="http://www.w3.org/1999/xhtml" class="map-popup-wrap"><div class="map-popup-card">${html}</div></div></foreignObject></g>`;
};
const renderGrid = (view, opts) => {
const pick = opts.pick;
const legacy = opts.clickUrl;
if (!pick && !legacy) return "";
const cells = [];
for (let gy = 0; gy < GRID_ROWS; gy++) {
for (let gx = 0; gx < GRID_COLS; gx++) {
const c = fromScreen(view, (gx + 0.5) / GRID_COLS * MAP_W, (gy + 0.5) / GRID_ROWS * MAP_H);
const val = `${c.lat},${c.lng}`;
if (pick) {
cells.push(`<button form="${esc(pick.form)}" formmethod="GET" formaction="${esc(pick.action)}" name="${esc(pick.name || "pick")}" value="${val}" class="map-pick-cell" title="${val}"></button>`);
} else {
const latParam = opts.latParam || "lat", lngParam = opts.lngParam || "lng";
cells.push(`<a href="${esc(`${legacy}${latParam}=${c.lat}&${lngParam}=${c.lng}${opts.anchor || ""}`)}" class="map-pick-cell" title="${val}"></a>`);
}
}
}
return `<div class="map-pick-grid">${cells.join("")}</div>`;
};
const renderControls = (view, opts) => {
const nav = opts.nav;
if (!nav) return "";
const t = opts.text || {};
const el = (target, label, cls, title) => {
const value = target ? viewParam(target) : "";
if (nav.form) {
return `<button form="${esc(nav.form)}" formmethod="GET" formaction="${esc(nav.action)}" name="view" value="${value}" class="map-ctrl ${cls}" title="${esc(title)}">${label}</button>`;
}
return `<a href="${esc(nav.href(target))}" class="map-ctrl ${cls}" title="${esc(title)}">${label}</a>`;
};
const pan = (dx, dy) => {
const c = fromScreen(view, MAP_W / 2 + dx * MAP_W * 0.4, MAP_H / 2 + dy * MAP_H * 0.4);
return { zoom: view.zoom, lat: c.lat, lng: c.lng };
};
const zoomIn = view.zoom < MAX_ZOOM ? el({ zoom: view.zoom + 1, lat: view.lat, lng: view.lng }, "+", "map-ctrl-in", t.zoomIn || "+") : `<span class="map-ctrl map-ctrl-off">+</span>`;
const zoomOut = view.zoom > MIN_ZOOM ? el({ zoom: view.zoom - 1, lat: view.lat, lng: view.lng }, "−", "map-ctrl-out", t.zoomOut || "−") : `<span class="map-ctrl map-ctrl-off">−</span>`;
const fit = el(null, "⌖", "map-ctrl-fit", t.fit || "⌖");
return `<div class="map-controls map-controls-zoom">${zoomIn}${zoomOut}</div>` +
`<div class="map-controls map-controls-pan">` +
`<span></span>${el(pan(0, -1), "↑", "map-ctrl-up", t.pan || "↑")}<span></span>` +
`${el(pan(-1, 0), "←", "map-ctrl-left", t.pan || "←")}${fit}${el(pan(1, 0), "→", "map-ctrl-right", t.pan || "→")}` +
`<span></span>${el(pan(0, 1), "↓", "map-ctrl-down", t.pan || "↓")}<span></span>` +
`</div>`;
};
const renderMapHtml = (view, pins, opts = {}) => {
const detail = opts.thumb ? "low" : "normal";
const boxes = [];
const pinList = Array.isArray(pins) ? pins : [];
const grid = renderGrid(view, opts);
const hit = {};
const pinsSvg = renderPins(view, pinList, opts, boxes, hit) + renderPopup(hit.focus, opts.popup);
const labels = opts.thumb ? "" : renderLabels(view, boxes);
const extras = opts.thumb ? "" : renderScale(view) + renderAttribution();
const base = `<rect class="map-ocean" x="0" y="0" width="${MAP_W}" height="${MAP_H}"/>` + renderRaster(view, opts) + renderBase(view, detail);
const svgOpen = `<svg class="map-svg" viewBox="0 0 ${MAP_W} ${MAP_H}" xmlns="http://www.w3.org/2000/svg" role="img">`;
let html = `<div class="map-stage${opts.thumb ? " map-stage-thumb" : ""}" id="${esc(opts.id || "map")}">`;
if (grid || opts.nav) {
html += svgOpen + base + labels + extras + "</svg>";
html += grid;
html += `<svg class="map-svg map-pins-layer" viewBox="0 0 ${MAP_W} ${MAP_H}" xmlns="http://www.w3.org/2000/svg">${pinsSvg}</svg>`;
} else {
html += svgOpen + base + labels + pinsSvg + extras + "</svg>";
}
html += renderControls(view, opts);
html += "</div>";
return html;
};
const renderMapWithPins = (markers, mainIdx) => {
const pins = (Array.isArray(markers) ? markers : []).map((m, i) => ({ ...m, main: i === (mainIdx || 0) }));
const fit = fitView(pins);
return renderMapHtml(makeView(fit.lat, fit.lng, fit.zoom), pins, {});
};
const renderZoomedMapWithPins = (centerLat, centerLng, zoom, markers, mainIdx) => {
const maxZ = getMaxTileZoom();
if (!maxZ || zoom <= 2) return renderMapWithPins(markers, mainIdx);
const effectiveZ = Math.min(zoom, maxZ);
const scale = Math.pow(2, zoom - effectiveZ);
const n = Math.pow(2, effectiveZ);
const tileSize = 256;
const worldPx = n * tileSize;
const latRad = centerLat * Math.PI / 180;
const cx = (centerLng + 180) / 360 * worldPx;
const cy = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * worldPx;
const vw = MAP_W / scale;
const vh = MAP_H / scale;
const x0 = cx - vw / 2;
const y0 = cy - vh / 2;
const pinData = (Array.isArray(markers) ? markers : [])
.filter((m) => m && typeof m.lat === "number" && typeof m.lng === "number")
.map((m, i) => {
const latR = m.lat * Math.PI / 180;
const wx = (m.lng + 180) / 360 * worldPx;
const wy = (1 - Math.log(Math.tan(latR) + 1 / Math.cos(latR)) / Math.PI) / 2 * worldPx;
return { px: (wx - x0) * scale, py: (wy - y0) * scale, main: i === (mainIdx || 0) };
});
const hashInput = `z${zoom}_${Math.round(centerLat * 100)}_${Math.round(centerLng * 100)}_` + pinData.map(p => `${Math.round(p.px)},${Math.round(p.py)},${p.main}`).join(";");
const hash = crypto.createHash("md5").update(hashInput).digest("hex").slice(0, 12);
const outFile = path.join(CACHE_DIR, `map_${hash}.png`);
if (fs.existsSync(outFile)) return `map_${hash}.png`;
const txMin = Math.max(0, Math.floor(x0 / tileSize));
const txMax = Math.min(n - 1, Math.floor((x0 + vw) / tileSize));
const tyMin = Math.max(0, Math.floor(y0 / tileSize));
const tyMax = Math.min(n - 1, Math.floor((y0 + vh) / tileSize));
const tiles = [];
for (let tx = txMin; tx <= txMax; tx++) {
for (let ty = tyMin; ty <= tyMax; ty++) {
const tp = path.join(TILES_DIR, String(effectiveZ), `${tx}_${ty}.png`);
tiles.push({ tx, ty, tp, exists: fs.existsSync(tp) });
}
}
const script = `
from PIL import Image, ImageDraw
import sys, json, os
args = json.loads(sys.argv[1])
out_file = sys.argv[2]
tile_size = 256
tx_min = args['txMin']
ty_min = args['tyMin']
tx_max = args['txMax']
ty_max = args['tyMax']
x0 = args['x0']
y0 = args['y0']
vw = args['vw']
vh = args['vh']
scale = args['scale']
pins = args['pins']
tiles = args['tiles']
canvas_w = (tx_max - tx_min + 1) * tile_size
canvas_h = (ty_max - ty_min + 1) * tile_size
canvas = Image.new('RGB', (canvas_w, canvas_h), (170, 211, 223))
for t in tiles:
if t['exists']:
try:
tile = Image.open(t['tp']).convert('RGB')
canvas.paste(tile, ((t['tx'] - tx_min) * tile_size, (t['ty'] - ty_min) * tile_size))
except:
pass
crop_x = x0 - tx_min * tile_size
crop_y = y0 - ty_min * tile_size
cropped = canvas.crop((int(crop_x), int(crop_y), int(crop_x + vw), int(crop_y + vh)))
result = cropped.resize((1024, 1024), Image.LANCZOS)
draw = ImageDraw.Draw(result)
def draw_pin(draw, x, y, main):
sw = 7 if main else 5
sh = 34 if main else 26
head = 11 if main else 8
clr = '#e74c3c' if main else '#3498db'
dark = '#c0392b' if main else '#2980b9'
cy = y - sh
draw.polygon([(x, y + 2), (x - sw - 1, cy + sw), (x + sw + 1, cy + sw)], fill='white')
draw.polygon([(x, y), (x - sw, cy + sw), (x + sw, cy + sw)], fill=clr)
draw.ellipse([x - head - 2, cy - head - 2, x + head + 2, cy + head + 2], fill='white')
draw.ellipse([x - head, cy - head, x + head, cy + head], fill=clr, outline=dark, width=2)
draw.ellipse([x - head // 3, cy - head // 3, x + head // 3, cy + head // 3], fill='white')
for p in pins:
px, py, main = p['px'], p['py'], p.get('main', False)
if -40 <= px <= 1064 and -40 <= py <= 1064:
draw_pin(draw, px, py, main)
result.save(out_file, optimize=True)
`;
try {
fs.mkdirSync(CACHE_DIR, { recursive: true });
execFileSync("python3", [
"-c", script,
JSON.stringify({ txMin, tyMin, txMax, tyMax, x0, y0, vw, vh, scale, pins: pinData, tiles }),
outFile
], { timeout: 15000 });
} catch (e) {
return renderMapWithPins(markers, mainIdx);
}
return `map_${hash}.png`;
const pins = (Array.isArray(markers) ? markers : []).map((m, i) => ({ ...m, main: i === (mainIdx || 0) }));
return renderMapHtml(makeView(centerLat, centerLng, zoom), pins, {});
};
module.exports = { renderMapWithPins, renderZoomedMapWithPins, getViewportBounds, getMaxTileZoom, latLngToPx, pxToLatLng, MAP_W, MAP_H };
module.exports = {
MAP_W, MAP_H, MIN_ZOOM, MAX_ZOOM, TILE_MAX_ZOOM, TILE_PATH,
project, unproject, latLngToPx, pxToLatLng, makeView, toScreen, fromScreen, fitView, resolveView,
parseView, viewParam, parsePick, getViewportBounds, metersPerPx, clusterPins, niceScale, tileCoverage, popupPlacement,
renderMapHtml, renderMapWithPins, renderZoomedMapWithPins
};