/** * 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?, aspect?, strokeWidth?, format?, ... } * -> image/png | image/jpeg * 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 // Quanto si compone piu' grande del risultato voluto, prima di ridurre. const SUPERCAMPIONAMENTO = 2; // ─── 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); // 19 e' l'ultimo livello che OSM serve: partire da 18 buttava via il piu' // dettagliato proprio sui territori piccoli, dove serve di piu'. const maxZoom = Math.min(Math.max(Number(body.maxZoom ?? 19), 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'; // Proporzioni volute (larghezza/altezza). Assente: quelle naturali del // territorio. const aspect = body.aspect ? Math.min(Math.max(Number(body.aspect), 0.2), 5) : null; // Spessore del confine in pixel dell'immagine finale. const strokeWidth = Math.min(Math.max(Number(body.strokeWidth ?? 3), 1), 30); const strokeOpacity = Math.min(Math.max(Number(body.strokeOpacity ?? 0.85), 0.2), 1); const attribution = body.attribution === false ? null : '© OpenStreetMap contributors'; // JPEG per quello che finisce stampato (una mappa e' un'immagine // fotografica: il PNG la porta a qualche megabyte senza guadagno visibile // sulla carta), PNG quando serve il pixel esatto. const format = body.format === 'png' ? 'png' : 'jpeg'; const quality = Math.min(Math.max(Number(body.quality ?? 86), 40), 100); const codifica = (pipeline) => format === 'png' ? pipeline.png() : pipeline.jpeg({ quality, mozjpeg: true, chromaSubsampling: '4:4:4' }); // 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 * SUPERCAMPIONAMENTO pixel. // // Il fattore non e' spreco: le etichette delle strade OSM sono disegnate // dentro la tessera a una dimensione fissa, quindi salire di uno zoom // significa averle piu' grandi e piu' definite. Componendo a quella scala e // riducendo alla fine si porta quella nitidezza dentro un'immagine della // dimensione voluta — che e' esattamente quello che serve per la stampa, // dove i pixel oltre i 600 dpi non arrivano sulla carta ma il testo sfocato // si vede. Ridurre costa anche meno byte: il rumore di ricampionamento // sparisce e il JPEG comprime meglio di quanto farebbe sull'originale. let zoom = maxZoom; const tettoComposizione = maxSide * SUPERCAMPIONAMENTO; 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 <= tettoComposizione) { 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; let cropMinX = Math.max(0, Math.floor(pxMinX - margin)); let cropMinY = Math.max(0, Math.floor(pxMinY - margin)); let cropMaxX = Math.min(worldSize, Math.ceil(pxMaxX + margin)); let cropMaxY = Math.min(worldSize, Math.ceil(pxMaxY + margin)); // Porta il ritaglio alle proporzioni chieste, allargando il lato corto. // // Serve perche' l'immagine finisce su una pagina di forma fissa: un // territorio quasi quadrato su una cartolina orizzontale, tenendo le sue // proporzioni, lascerebbe due fasce bianche larghe un quinto di pagina. // Allargare mostra piu' dintorni — che a chi percorre il territorio // servono — invece di stampare carta vuota. Il confine resta comunque al // centro, perche' si cresce in parti uguali sui due lati. if (aspect) { const larghezza = cropMaxX - cropMinX; const altezza = cropMaxY - cropMinY; if (larghezza / altezza < aspect) { const voluta = altezza * aspect; const cresce = (voluta - larghezza) / 2; cropMinX = Math.max(0, Math.floor(cropMinX - cresce)); cropMaxX = Math.min(worldSize, Math.ceil(cropMaxX + cresce)); } else { const voluta = larghezza / aspect; const cresce = (voluta - altezza) / 2; cropMinY = Math.max(0, Math.floor(cropMinY - cresce)); cropMaxY = Math.min(worldSize, Math.ceil(cropMaxY + cresce)); } } 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 ritaglioW = cropMaxX - cropMinX; const ritaglioH = cropMaxY - cropMinY; 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. // // Lo spessore e' espresso in pixel dell'immagine FINALE e riportato qui // alla scala di composizione: il disegno viene fatto grande e poi ridotto, // quindi un numero fisso qui darebbe un tratto piu' o meno grosso a seconda // di quanto si e' ridotto. E il tratto conta: deve dire dove passa il // confine senza cancellare la via che ci corre sotto — chi percorre il // territorio quella via la deve leggere. Semitrasparente per lo stesso // motivo: si vede il segno e si vede cosa copre. const fattoreScala = Math.max(1, Math.max(ritaglioW, ritaglioH) / maxSide); const tratto = Math.max(1, strokeWidth * fattoreScala); 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(); let riduzione = sharp(composto, { limitInputPixels: false }) .extract({ left: cropMinX - originX, top: cropMinY - originY, width: ritaglioW, height: ritaglioH, }); // Lanczos: e' il kernel che tiene i bordi delle scritte, dove un bilineare // le impasta. if (Math.max(ritaglioW, ritaglioH) > maxSide) { riduzione = riduzione.resize({ width: ritaglioW >= ritaglioH ? maxSide : null, height: ritaglioH > ritaglioW ? maxSide : null, kernel: 'lanczos3', fit: 'inside', }); } const cropped = await riduzione.png().toBuffer(); const { width: outW, height: outH } = await sharp(cropped).metadata(); if (!attribution) { return { buffer: await codifica(sharp(cropped)).toBuffer(), format }; } // L'attribuzione si dimensiona sull'immagine finale, non su una misura // fissa. La licenza ODbL la vuole leggibile, e 22 px su un'immagine da // 3400 px sono meno di un millimetro sulla carta: presente nel file, // invisibile a chi tiene in mano la cartolina. Legata alla larghezza resta // della stessa misura apparente a ogni risoluzione. const label = svgEscape(attribution); const corpo = Math.max(12, Math.round(outW / 145)); const barraH = Math.round(corpo * 1.9); const labelW = Math.min(outW, Math.round(label.length * corpo * 0.55 + corpo * 2)); const attributionSvg = ` ${label} `; const finale = sharp(cropped) .composite([{ input: Buffer.from(attributionSvg), left: outW - labelW, top: outH - barraH }]); return { buffer: await codifica(finale).toBuffer(), format }; } // ─── 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 { buffer, format } = await render(body); res.writeHead(200, { 'Content-Type': format === 'png' ? 'image/png' : 'image/jpeg', 'Content-Length': buffer.length, }); res.end(buffer); 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}`); });