/**
* 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 = ``;
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}`);
});