/** * Renderer di piantine per TerManager2. * * Servizio autonomo: riceve un poligono (coordinate GeoJSON, lng/lat) e * restituisce un PNG ad alta definizione composto dalle tessere OpenStreetMap, * con il confine del territorio evidenziato e l'esterno attenuato. * * Non tocca il database ne' i file dell'applicazione: parla solo HTTP. * * POST /render { geometry, marginRatio?, maxSide?, maxZoom?, ... } -> image/png * GET /healthz -> 200 "ok" * * Le tessere vengono scaricate da tile.openstreetmap.org nel rispetto della * tile usage policy: User-Agent identificante, cache su disco persistente, * concorrenza limitata. L'attribuzione OSM viene impressa sull'immagine, * come richiesto dalla licenza ODbL/CC-BY-SA. */ import http from 'node:http'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import sharp from 'sharp'; const PORT = Number(process.env.PORT || 3000); const TILE_URL = process.env.TILE_URL || 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; const CACHE_DIR = process.env.TILE_CACHE_DIR || '/tile-cache'; const USER_AGENT = process.env.TILE_USER_AGENT || 'TerManager2-MapRenderer/1.0 (rendering di piantine di territorio; contatto: admin@pyconetwork.it)'; const TILE_SIZE = 256; const FETCH_CONCURRENCY = 6; const MAX_TILES = 1600; // valvola: nessuna richiesta legittima arriva qui // ─── Proiezione Web Mercator ──────────────────────────────────────────────── function project(lng, lat, zoom) { const scale = TILE_SIZE * 2 ** zoom; const clampedLat = Math.max(-85.05112878, Math.min(85.05112878, lat)); const x = ((lng + 180) / 360) * scale; const sinLat = Math.sin((clampedLat * Math.PI) / 180); const y = (0.5 - Math.log((1 + sinLat) / (1 - sinLat)) / (4 * Math.PI)) * scale; return [x, y]; } /** * Normalizza Polygon/MultiPolygon GeoJSON in un elenco di anelli * [[lng,lat], ...]. Il primo anello di ogni poligono e' il perimetro; gli * eventuali successivi (buchi) vengono mantenuti: il fill-rule evenodd * dell'overlay li gestisce da solo. */ function extractRings(geometry) { if (!geometry || !geometry.type || !Array.isArray(geometry.coordinates)) { throw new Error('geometry mancante o non valida'); } let polygons; if (geometry.type === 'Polygon') { polygons = [geometry.coordinates]; } else if (geometry.type === 'MultiPolygon') { polygons = geometry.coordinates; } else { throw new Error(`tipo di geometria non supportato: ${geometry.type}`); } const rings = polygons.flat().map((ring) => ring.map(([lng, lat]) => { if (typeof lng !== 'number' || typeof lat !== 'number') { throw new Error('coordinate non numeriche nella geometry'); } return [lng, lat]; }) ); if (rings.length === 0 || rings[0].length < 3) { throw new Error('il poligono deve avere almeno 3 vertici'); } return rings; } // ─── Tessere ──────────────────────────────────────────────────────────────── async function fetchTile(z, x, y) { const cachePath = path.join(CACHE_DIR, String(z), String(x), `${y}.png`); try { return await readFile(cachePath); } catch { // non in cache: si scarica } const url = TILE_URL.replace('{z}', z).replace('{x}', x).replace('{y}', y); let lastError; for (let attempt = 1; attempt <= 2; attempt++) { try { const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT } }); if (!res.ok) { throw new Error(`HTTP ${res.status} per ${url}`); } const buffer = Buffer.from(await res.arrayBuffer()); // La cache e' un'ottimizzazione: se il disco non collabora si // prosegue lo stesso con la tessera appena scaricata. try { await mkdir(path.dirname(cachePath), { recursive: true }); await writeFile(cachePath, buffer); } catch { // ignorata di proposito } return buffer; } catch (err) { lastError = err; if (attempt < 2) { await new Promise((r) => setTimeout(r, 500)); } } } throw lastError; } async function fetchTiles(jobs) { const results = new Array(jobs.length); let next = 0; async function worker() { while (next < jobs.length) { const index = next++; const { z, x, y } = jobs[index]; results[index] = await fetchTile(z, x, y); } } await Promise.all( Array.from({ length: Math.min(FETCH_CONCURRENCY, jobs.length) }, worker) ); return results; } // ─── Rendering ────────────────────────────────────────────────────────────── function svgEscape(text) { return String(text) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>'); } async function render(body) { const rings = extractRings(body.geometry); const marginRatio = Math.min(Math.max(Number(body.marginRatio ?? 0.15), 0), 1); const maxSide = Math.min(Math.max(Number(body.maxSide ?? 4096), 512), 8192); const maxZoom = Math.min(Math.max(Number(body.maxZoom ?? 18), 10), 19); const dimOpacity = Math.min(Math.max(Number(body.dimOpacity ?? 0.55), 0), 0.9); const strokeColor = /^#[0-9a-fA-F]{6}$/.test(body.strokeColor ?? '') ? body.strokeColor : '#d81b1b'; const attribution = body.attribution === false ? null : '© OpenStreetMap contributors'; // Riquadro geografico del poligono. let minLng = Infinity, minLat = Infinity, maxLng = -Infinity, maxLat = -Infinity; for (const ring of rings) { for (const [lng, lat] of ring) { minLng = Math.min(minLng, lng); maxLng = Math.max(maxLng, lng); minLat = Math.min(minLat, lat); maxLat = Math.max(maxLat, lat); } } // Zoom piu' alto (max maxZoom) al quale il riquadro, margine compreso, // sta dentro maxSide pixel: massima definizione utile senza esplodere. let zoom = maxZoom; for (; zoom > 1; zoom--) { const [x0, y0] = project(minLng, maxLat, zoom); const [x1, y1] = project(maxLng, minLat, zoom); const longest = Math.max(x1 - x0, y1 - y0) * (1 + marginRatio * 2); if (longest <= maxSide) { break; } } const [pxMinX, pxMinY] = project(minLng, maxLat, zoom); const [pxMaxX, pxMaxY] = project(maxLng, minLat, zoom); const margin = Math.round(Math.max(pxMaxX - pxMinX, pxMaxY - pxMinY) * marginRatio); const worldSize = TILE_SIZE * 2 ** zoom; const cropMinX = Math.max(0, Math.floor(pxMinX - margin)); const cropMinY = Math.max(0, Math.floor(pxMinY - margin)); const cropMaxX = Math.min(worldSize, Math.ceil(pxMaxX + margin)); const cropMaxY = Math.min(worldSize, Math.ceil(pxMaxY + margin)); const tileMinX = Math.floor(cropMinX / TILE_SIZE); const tileMinY = Math.floor(cropMinY / TILE_SIZE); const tileMaxX = Math.floor((cropMaxX - 1) / TILE_SIZE); const tileMaxY = Math.floor((cropMaxY - 1) / TILE_SIZE); const tileCount = (tileMaxX - tileMinX + 1) * (tileMaxY - tileMinY + 1); if (tileCount > MAX_TILES) { const err = new Error(`area troppo estesa: ${tileCount} tessere allo zoom ${zoom}`); err.statusCode = 413; throw err; } const jobs = []; for (let ty = tileMinY; ty <= tileMaxY; ty++) { for (let tx = tileMinX; tx <= tileMaxX; tx++) { jobs.push({ z: zoom, x: tx, y: ty }); } } const buffers = await fetchTiles(jobs); const canvasW = (tileMaxX - tileMinX + 1) * TILE_SIZE; const canvasH = (tileMaxY - tileMinY + 1) * TILE_SIZE; const originX = tileMinX * TILE_SIZE; const originY = tileMinY * TILE_SIZE; const tileLayers = buffers.map((buffer, i) => ({ input: buffer, left: (jobs[i].x - tileMinX) * TILE_SIZE, top: (jobs[i].y - tileMinY) * TILE_SIZE, })); // Percorso SVG del poligono nelle coordinate della tela. const ringPaths = rings.map((ring) => { const points = ring.map(([lng, lat]) => { const [px, py] = project(lng, lat, zoom); return `${(px - originX).toFixed(1)},${(py - originY).toFixed(1)}`; }); return `M${points.join('L')}Z`; }); const polygonPath = ringPaths.join(' '); // Velo bianco su tutta la tela con il poligono come buco (evenodd): // l'esterno sbiadisce, l'interno resta pieno. Sopra, il confine marcato. const overlay = ` `; // Composizione e ritaglio in due passaggi: nella pipeline di sharp // extract() verrebbe applicato alla tela PRIMA di composite(), e le // tessere non ci starebbero piu'. const composto = await sharp({ create: { width: canvasW, height: canvasH, channels: 3, background: '#ffffff' }, limitInputPixels: false, }) .composite([...tileLayers, { input: Buffer.from(overlay), left: 0, top: 0 }]) .flatten() .png() .toBuffer(); const cropped = await sharp(composto, { limitInputPixels: false }) .extract({ left: cropMinX - originX, top: cropMinY - originY, width: cropMaxX - cropMinX, height: cropMaxY - cropMinY, }) .toBuffer(); if (!attribution) { return sharp(cropped).png().toBuffer(); } const outW = cropMaxX - cropMinX; const outH = cropMaxY - cropMinY; const label = svgEscape(attribution); const labelW = Math.min(outW, label.length * 8 + 16); const attributionSvg = ` ${label} `; return sharp(cropped) .composite([{ input: Buffer.from(attributionSvg), left: outW - labelW, top: outH - 22 }]) .png() .toBuffer(); } // ─── HTTP ─────────────────────────────────────────────────────────────────── function readBody(req, limitBytes = 4 * 1024 * 1024) { return new Promise((resolve, reject) => { const chunks = []; let size = 0; req.on('data', (chunk) => { size += chunk.length; if (size > limitBytes) { reject(Object.assign(new Error('corpo della richiesta troppo grande'), { statusCode: 413 })); req.destroy(); return; } chunks.push(chunk); }); req.on('end', () => resolve(Buffer.concat(chunks))); req.on('error', reject); }); } const server = http.createServer(async (req, res) => { try { if (req.method === 'GET' && req.url === '/healthz') { res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('ok'); return; } if (req.method === 'POST' && req.url === '/render') { const raw = await readBody(req); let body; try { body = JSON.parse(raw.toString('utf8')); } catch { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'JSON non valido' })); return; } const png = await render(body); res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': png.length }); res.end(png); return; } res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'non trovato' })); } catch (err) { const status = err.statusCode || 500; console.error(`[render] ${err.message}`); res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: err.message })); } }); server.listen(PORT, () => { console.log(`map-renderer in ascolto sulla porta ${PORT}, cache tessere in ${CACHE_DIR}`); });