336 lines
13 KiB
JavaScript
336 lines
13 KiB
JavaScript
/**
|
|
* 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 = `<svg width="${canvasW}" height="${canvasH}" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M0,0H${canvasW}V${canvasH}H0Z ${polygonPath}" fill="#ffffff" fill-opacity="${dimOpacity}" fill-rule="evenodd"/>
|
|
<path d="${polygonPath}" fill="none" stroke="${strokeColor}" stroke-width="7" stroke-linejoin="round" stroke-opacity="0.9"/>
|
|
</svg>`;
|
|
|
|
// 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 = `<svg width="${labelW}" height="22" xmlns="http://www.w3.org/2000/svg">
|
|
<rect width="${labelW}" height="22" fill="#ffffff" fill-opacity="0.75"/>
|
|
<text x="${labelW - 8}" y="15" text-anchor="end" font-family="DejaVu Sans, Arial, sans-serif" font-size="12" fill="#333333">${label}</text>
|
|
</svg>`;
|
|
|
|
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}`);
|
|
});
|