++ Prima versione del designer piantine + Skill grafica
This commit is contained in:
@@ -52,6 +52,24 @@ server {
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
fastcgi_split_path_info ^(.+\.php)(/.*)$;
|
||||
include fastcgi_params;
|
||||
|
||||
# Restores the port, which nginx's own fastcgi_params drops.
|
||||
#
|
||||
# Since nginx 1.26 the packaged fastcgi_params sets HTTP_HOST to $host
|
||||
# instead of $http_host. $host carries the hostname only: a request to
|
||||
# localhost:8090 reaches PHP as "localhost". Laravel then rebuilds the
|
||||
# request URL without the port, while URL::forceRootUrl() (see
|
||||
# AppServiceProvider) generates links WITH it — so every signed URL
|
||||
# fails validation. Concretely: all Livewire file uploads answer 401,
|
||||
# because the upload endpoint aborts on an invalid signature.
|
||||
#
|
||||
# Only bites when APP_URL carries a non-standard port, i.e. local
|
||||
# development. In production APP_URL has no port and the two forms
|
||||
# agree. Forwarding the raw header is safe here because trustHosts()
|
||||
# in bootstrap/app.php already rejects any Host that is not APP_URL's,
|
||||
# localhost or 127.0.0.1.
|
||||
fastcgi_param HTTP_HOST $http_host;
|
||||
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
fastcgi_param PATH_INFO $fastcgi_path_info;
|
||||
fastcgi_param DOCUMENT_ROOT $realpath_root;
|
||||
|
||||
31
docker/renderer/Dockerfile
Normal file
31
docker/renderer/Dockerfile
Normal 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"]
|
||||
9
docker/renderer/package.json
Normal file
9
docker/renderer/package.json
Normal 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
335
docker/renderer/server.js
Normal 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('&', '&')
|
||||
.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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user