Files
Francesco Picone 9c37c1ad7b Piantine staccate dai territori, editor a schermo intero, restyle
Le piantine non dipendono piu' da un territorio esistente: territorio_id e'
nullable, la chiave esterna e' nullOnDelete invece di cascadeOnDelete, e `nome`
fa da etichetta finche' il collegamento manca. Si disegna guardando le strade e
si decide dopo di chi e' il disegno; se il territorio non c'e', lo si crea
dall'editor con numero, zona, tipologia e confini — zona e tipologia comprese,
create sul posto quando mancano.

Il cambio di cascata chiude anche una perdita di dati silenziosa: ogni import
XML svuota `territori` in blocco e finora si portava via tutte le piantine.

Il disegno passa su /piantine/editor, a schermo intero con telaio proprio: su
una mappa larga un terzo di schermo un confine si sbaglia, e finisce stampato.
/piantine resta la gestione — area, generazione, collegamenti, storico.

Cartolina S-12 reimpaginata: pagina 1 il modulo con la Localita' (che ora e' il
nome della zona, non un campo duplicato) e i confini scritti nello spazio
centrale, con il corpo del testo che si adatta alla lunghezza; pagina 2 la
piantina sola al 98%. Aggiunta l'anteprima, che compone il PDF al volo senza
toccare pdf_path ne' lo storico.

Immagine: JPEG invece di PNG (0,9 MB contro 5 per territorio), composta alle
tessere dello zoom piu' alto e ridotta con Lanczos, nelle proporzioni della
pagina cosi' riempie il foglio invece di lasciare fasce bianche. Il tratto del
confine e' espresso in pixel dell'immagine finale: si vede il confine e si legge
la via che ci passa sotto. L'attribuzione OSM si dimensiona con l'immagine,
come la ODbL richiede.

Rimossa la rigenerazione massiva: job, stato in cache e interfaccia.

Sistema visivo ripassato su tutta l'applicazione: le scale gray e indigo di
Tailwind sono ridefinite nei token, cosi' le 37 viste si riallineano insieme
invece di essere riscritte una per una. Grigi caldi, un accento solo con quattro
significati, carattere servito dall'applicazione e non da Google Fonts, via il
gradiente della testata. Aggiunto il link "salta al contenuto", che mancava.

Corretto un guasto d'ambiente che bloccava TUTTI gli upload in sviluppo: dalla
1.26 il fastcgi_params di nginx passa HTTP_HOST come $host, che perde la porta,
e ogni URL firmato smetteva di validare — l'endpoint di upload di Livewire
rispondeva 401.

294 test verdi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 16:41:22 +02:00

427 lines
18 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?, 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}
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 = `<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="${tratto.toFixed(2)}" stroke-linejoin="round" stroke-opacity="${strokeOpacity}"/>
</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();
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 = `<svg width="${labelW}" height="${barraH}" xmlns="http://www.w3.org/2000/svg">
<rect width="${labelW}" height="${barraH}" fill="#ffffff" fill-opacity="0.78"/>
<text x="${labelW - corpo}" y="${Math.round(barraH * 0.72)}" text-anchor="end" font-family="DejaVu Sans, Arial, sans-serif" font-size="${corpo}" fill="#333333">${label}</text>
</svg>`;
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}`);
});