map_renderer.js 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. const { execFileSync } = require("child_process");
  2. const path = require("path");
  3. const fs = require("fs");
  4. const crypto = require("crypto");
  5. const BASE_MAP = path.join(__dirname, "..", "client", "assets", "images", "worldmap-z2.png");
  6. const CACHE_DIR = path.join(__dirname, "cache");
  7. const TILES_DIR = path.join(__dirname, "tiles");
  8. const MAP_W = 1024;
  9. const MAP_H = 1024;
  10. const latLngToPx = (lat, lng) => {
  11. const latRad = lat * Math.PI / 180;
  12. const x = Math.round((lng + 180) / 360 * MAP_W);
  13. const y = Math.round((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * MAP_H);
  14. return { x: Math.max(12, Math.min(MAP_W - 12, x)), y: Math.max(12, Math.min(MAP_H - 12, y)) };
  15. };
  16. const pxToLatLng = (px, py) => {
  17. const lng = px / MAP_W * 360 - 180;
  18. const n = Math.PI - 2 * Math.PI * py / MAP_H;
  19. const lat = 180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
  20. return { lat: Math.round(lat * 100) / 100, lng: Math.round(lng * 100) / 100 };
  21. };
  22. const getMaxTileZoom = () => {
  23. try {
  24. const dirs = fs.readdirSync(TILES_DIR).filter(d => /^\d+$/.test(d) && fs.existsSync(path.join(TILES_DIR, d, '0_0.png')));
  25. return dirs.length ? Math.max(...dirs.map(Number)) : 0;
  26. } catch (_) { return 0; }
  27. };
  28. const getViewportBounds = (centerLat, centerLng, zoom) => {
  29. const effectiveZ = Math.min(zoom, getMaxTileZoom());
  30. const n = Math.pow(2, effectiveZ);
  31. const tileSize = 256;
  32. const worldPx = n * tileSize;
  33. const scale = Math.pow(2, zoom - effectiveZ);
  34. const vw = MAP_W / scale;
  35. const vh = MAP_H / scale;
  36. const latRad = centerLat * Math.PI / 180;
  37. const cx = (centerLng + 180) / 360 * worldPx;
  38. const cy = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * worldPx;
  39. const x0 = cx - vw / 2;
  40. const y0 = cy - vh / 2;
  41. const x1 = cx + vw / 2;
  42. const y1 = cy + vh / 2;
  43. const pxToLng = (px) => px / worldPx * 360 - 180;
  44. 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))); };
  45. return {
  46. latMin: Math.max(-85, pxToLat(Math.min(y1, worldPx - 1))),
  47. latMax: Math.min(85, pxToLat(Math.max(y0, 0))),
  48. lngMin: Math.max(-180, pxToLng(Math.max(x0, 0))),
  49. lngMax: Math.min(180, pxToLng(Math.min(x1, worldPx - 1)))
  50. };
  51. };
  52. const renderMapWithPins = (markers, mainIdx) => {
  53. const pins = (Array.isArray(markers) ? markers : [])
  54. .filter((m) => m && typeof m.lat === "number" && typeof m.lng === "number")
  55. .map((m, i) => ({ ...latLngToPx(m.lat, m.lng), main: i === (mainIdx || 0) }));
  56. const hash = crypto.createHash("md5")
  57. .update(pins.map((p) => `${p.x},${p.y},${p.main}`).join(";"))
  58. .digest("hex")
  59. .slice(0, 12);
  60. const outFile = path.join(CACHE_DIR, `map_${hash}.png`);
  61. if (fs.existsSync(outFile)) return `map_${hash}.png`;
  62. const script = `
  63. from PIL import Image, ImageDraw
  64. import sys, json
  65. pins = json.loads(sys.argv[1])
  66. im = Image.open(sys.argv[2]).copy()
  67. draw = ImageDraw.Draw(im)
  68. for p in pins:
  69. x, y, main = p['x'], p['y'], p.get('main', False)
  70. sw = 3 if main else 2
  71. sh = 18 if main else 13
  72. clr = '#e74c3c' if main else '#3498db'
  73. dark = '#c0392b' if main else '#2980b9'
  74. draw.polygon([(x, y + 2), (x - sw, y - sh + sw * 2), (x + sw, y - sh + sw * 2)], fill=clr)
  75. draw.ellipse([x - sw - 1, y - sh - sw, x + sw + 1, y - sh + sw], fill=dark, outline='white', width=1)
  76. im.save(sys.argv[3], optimize=True)
  77. `;
  78. try {
  79. fs.mkdirSync(CACHE_DIR, { recursive: true });
  80. execFileSync("python3", [
  81. "-c", script,
  82. JSON.stringify(pins),
  83. BASE_MAP,
  84. outFile
  85. ], { timeout: 10000 });
  86. } catch (e) {
  87. return null;
  88. }
  89. return `map_${hash}.png`;
  90. };
  91. const renderZoomedMapWithPins = (centerLat, centerLng, zoom, markers, mainIdx) => {
  92. const maxZ = getMaxTileZoom();
  93. if (!maxZ || zoom <= 2) return renderMapWithPins(markers, mainIdx);
  94. const effectiveZ = Math.min(zoom, maxZ);
  95. const scale = Math.pow(2, zoom - effectiveZ);
  96. const n = Math.pow(2, effectiveZ);
  97. const tileSize = 256;
  98. const worldPx = n * tileSize;
  99. const latRad = centerLat * Math.PI / 180;
  100. const cx = (centerLng + 180) / 360 * worldPx;
  101. const cy = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * worldPx;
  102. const vw = MAP_W / scale;
  103. const vh = MAP_H / scale;
  104. const x0 = cx - vw / 2;
  105. const y0 = cy - vh / 2;
  106. const pinData = (Array.isArray(markers) ? markers : [])
  107. .filter((m) => m && typeof m.lat === "number" && typeof m.lng === "number")
  108. .map((m, i) => {
  109. const latR = m.lat * Math.PI / 180;
  110. const wx = (m.lng + 180) / 360 * worldPx;
  111. const wy = (1 - Math.log(Math.tan(latR) + 1 / Math.cos(latR)) / Math.PI) / 2 * worldPx;
  112. return { px: (wx - x0) * scale, py: (wy - y0) * scale, main: i === (mainIdx || 0) };
  113. });
  114. 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(";");
  115. const hash = crypto.createHash("md5").update(hashInput).digest("hex").slice(0, 12);
  116. const outFile = path.join(CACHE_DIR, `map_${hash}.png`);
  117. if (fs.existsSync(outFile)) return `map_${hash}.png`;
  118. const txMin = Math.max(0, Math.floor(x0 / tileSize));
  119. const txMax = Math.min(n - 1, Math.floor((x0 + vw) / tileSize));
  120. const tyMin = Math.max(0, Math.floor(y0 / tileSize));
  121. const tyMax = Math.min(n - 1, Math.floor((y0 + vh) / tileSize));
  122. const tiles = [];
  123. for (let tx = txMin; tx <= txMax; tx++) {
  124. for (let ty = tyMin; ty <= tyMax; ty++) {
  125. const tp = path.join(TILES_DIR, String(effectiveZ), `${tx}_${ty}.png`);
  126. tiles.push({ tx, ty, tp, exists: fs.existsSync(tp) });
  127. }
  128. }
  129. const script = `
  130. from PIL import Image, ImageDraw
  131. import sys, json, os
  132. args = json.loads(sys.argv[1])
  133. out_file = sys.argv[2]
  134. tile_size = 256
  135. tx_min = args['txMin']
  136. ty_min = args['tyMin']
  137. tx_max = args['txMax']
  138. ty_max = args['tyMax']
  139. x0 = args['x0']
  140. y0 = args['y0']
  141. vw = args['vw']
  142. vh = args['vh']
  143. scale = args['scale']
  144. pins = args['pins']
  145. tiles = args['tiles']
  146. canvas_w = (tx_max - tx_min + 1) * tile_size
  147. canvas_h = (ty_max - ty_min + 1) * tile_size
  148. canvas = Image.new('RGB', (canvas_w, canvas_h), (170, 211, 223))
  149. for t in tiles:
  150. if t['exists']:
  151. try:
  152. tile = Image.open(t['tp']).convert('RGB')
  153. canvas.paste(tile, ((t['tx'] - tx_min) * tile_size, (t['ty'] - ty_min) * tile_size))
  154. except:
  155. pass
  156. crop_x = x0 - tx_min * tile_size
  157. crop_y = y0 - ty_min * tile_size
  158. cropped = canvas.crop((int(crop_x), int(crop_y), int(crop_x + vw), int(crop_y + vh)))
  159. result = cropped.resize((1024, 1024), Image.LANCZOS)
  160. draw = ImageDraw.Draw(result)
  161. for p in pins:
  162. px, py, main = p['px'], p['py'], p.get('main', False)
  163. if -20 <= px <= 1044 and -20 <= py <= 1044:
  164. sw = 3 if main else 2
  165. sh = 18 if main else 13
  166. clr = '#e74c3c' if main else '#3498db'
  167. dark = '#c0392b' if main else '#2980b9'
  168. draw.polygon([(px, py + 2), (px - sw, py - sh + sw * 2), (px + sw, py - sh + sw * 2)], fill=clr)
  169. draw.ellipse([px - sw - 1, py - sh - sw, px + sw + 1, py - sh + sw], fill=dark, outline='white', width=1)
  170. result.save(out_file, optimize=True)
  171. `;
  172. try {
  173. fs.mkdirSync(CACHE_DIR, { recursive: true });
  174. execFileSync("python3", [
  175. "-c", script,
  176. JSON.stringify({ txMin, tyMin, txMax, tyMax, x0, y0, vw, vh, scale, pins: pinData, tiles }),
  177. outFile
  178. ], { timeout: 15000 });
  179. } catch (e) {
  180. return renderMapWithPins(markers, mainIdx);
  181. }
  182. return `map_${hash}.png`;
  183. };
  184. module.exports = { renderMapWithPins, renderZoomedMapWithPins, getViewportBounds, getMaxTileZoom, latLngToPx, pxToLatLng, MAP_W, MAP_H };