++ Prima versione del designer piantine + Skill grafica

This commit is contained in:
2026-08-28 15:32:14 +02:00
parent 8aa29cc467
commit 7e4c8aa9b8
62 changed files with 16340 additions and 4 deletions

View File

@@ -0,0 +1,31 @@
# =============================================================================
# Renderer di piantine (servizio autonomo)
#
# Container separato dall'applicazione: TerManager2 gli parla solo via HTTP
# sulla rete interna. Nessun accesso al database ne' al volume storage.
# =============================================================================
FROM node:20-alpine
WORKDIR /srv
# Senza un font installato, librsvg rende l'attribuzione OSM come quadratini.
RUN apk add --no-cache font-dejavu fontconfig
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY server.js ./
# La cache delle tessere persiste in un volume: riavvii e redeploy non
# ributtano il carico su tile.openstreetmap.org.
RUN mkdir -p /tile-cache && chown node:node /tile-cache
ENV NODE_ENV=production
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:3000/healthz || exit 1
CMD ["node", "server.js"]

View File

@@ -0,0 +1,9 @@
{
"name": "termanager2-map-renderer",
"private": true,
"type": "module",
"description": "Renderer di piantine: tessere OpenStreetMap + confine del territorio, restituisce PNG ad alta definizione.",
"dependencies": {
"sharp": "^0.33.4"
}
}

335
docker/renderer/server.js Normal file
View File

@@ -0,0 +1,335 @@
/**
* 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('&', '&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);
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}`);
});