Overlay SVG e helper salva immagine per il pannello visione
Refs #121 #119 #118 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* save_image.js - #119: salva il frame grezzo mostrato nel pannello visione come PNG, senza disegni.
|
||||
*
|
||||
* Cosa fa: tmfSaveImage(src, baseName) scarica l'immagine `src` (blob: o http), la converte in PNG se serve
|
||||
* e la salva con nome <baseName>_<AAAAMMGG-hhmmss>.png: «Salva con nome» (showSaveFilePicker)
|
||||
* dove c'e', altrimenti <a download>. Ritorna una Promise<boolean> (false se annullato o fallito).
|
||||
* tmfSaveImageName(baseName, date) e' la funzione pura del nome, esportata per i test.
|
||||
* Come si usa: <script src=".../save_image.js"></script> poi @click="tmfSaveImage(url, 'task_14')".
|
||||
* Dipende da: fetch, canvas, createImageBitmap e window del browser, toccati solo dentro le funzioni
|
||||
* (il modulo si carica anche in node, con require, senza questi globali).
|
||||
* Idea di conversione da image_to_png.js (#107).
|
||||
*/
|
||||
|
||||
// #119: nome file proposto <baseName>_<AAAAMMGG-hhmmss>.png; baseName ripulito dai caratteri non validi
|
||||
function tmfSaveImageName(baseName, date) {
|
||||
var base = String(baseName === undefined || baseName === null ? '' : baseName)
|
||||
.replace(/[\/:*?"<>|\u0000-\u001f]/g, '_') // caratteri vietati nei nomi file Windows
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/^[._]+|[._]+$/g, ''); // niente punti o underscore ai bordi
|
||||
if (!base) base = 'immagine';
|
||||
var d = date instanceof Date && !isNaN(date.getTime()) ? date : new Date();
|
||||
var p = function (n) { return (n < 10 ? '0' : '') + n; };
|
||||
var stamp = '' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' +
|
||||
p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
|
||||
return base + '_' + stamp + '.png';
|
||||
}
|
||||
|
||||
// #119: ridisegna su canvas un'immagine non PNG (es. JPEG, BMP) e ritorna il blob PNG
|
||||
async function tmfBlobToPng(blob) {
|
||||
var bitmap = await createImageBitmap(blob);
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
canvas.getContext('2d').drawImage(bitmap, 0, 0);
|
||||
if (bitmap.close) bitmap.close();
|
||||
var png = await new Promise(function (resolve) { canvas.toBlob(resolve, 'image/png'); });
|
||||
if (!png) throw new Error('conversione PNG fallita');
|
||||
return png;
|
||||
}
|
||||
|
||||
// #119: scarica `src`, lo porta a PNG e lo salva; true = salvato, false = annullato dall'utente o errore
|
||||
async function tmfSaveImage(src, baseName) {
|
||||
try {
|
||||
var resp = await fetch(src);
|
||||
if (!resp.ok) return false;
|
||||
var blob = await resp.blob();
|
||||
if (blob.type !== 'image/png') blob = await tmfBlobToPng(blob);
|
||||
var name = tmfSaveImageName(baseName, new Date());
|
||||
if (typeof window !== 'undefined' && typeof window.showSaveFilePicker === 'function') {
|
||||
var handle = await window.showSaveFilePicker({
|
||||
suggestedName: name,
|
||||
types: [{ description: 'PNG', accept: { 'image/png': ['.png'] } }],
|
||||
});
|
||||
var w = await handle.createWritable();
|
||||
await w.write(blob);
|
||||
await w.close();
|
||||
return true;
|
||||
}
|
||||
// #119: ripiego senza «Salva con nome» (browser non Chromium, contesto non sicuro): <a download>
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err && err.name === 'AbortError') return false; // #119: annullato dall'utente, non e' un errore
|
||||
if (typeof console !== 'undefined') console.error('tmfSaveImage:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// #119: globale nel browser, export per il test node
|
||||
if (typeof window !== 'undefined') { window.tmfSaveImage = tmfSaveImage; window.tmfSaveImageName = tmfSaveImageName; }
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = { tmfSaveImage, tmfSaveImageName };
|
||||
@@ -7,7 +7,8 @@
|
||||
* con require.
|
||||
*
|
||||
* API: draw(g, overlay, lw) -> {drawn, skipped}; anchor(item) -> {x, y} | null;
|
||||
* labels(rows, overlay) -> [{key, x, y, text, status}].
|
||||
* labels(rows, overlay) -> [{key, x, y, text, status}];
|
||||
* svg(overlay, opts) -> stringa di figli SVG (#121, vedi sotto).
|
||||
* Convenzioni (vs-tools): row = y, col = x; angoli in gradi positivi antiorari a video.
|
||||
* Porting da TieVision-Studio frontend/src/comune/overlay.js (verticiRect2, forma)
|
||||
* e frontend/src/editor/misure.js (ancoraOverlay).
|
||||
@@ -129,6 +130,82 @@ var VisionOverlay = (function () {
|
||||
return res;
|
||||
}
|
||||
|
||||
// #121: numero per il markup SVG, al piu' 2 decimali (niente stringhe dei dati nel markup)
|
||||
function n2(v) { return String(Math.round(v * 100) / 100); }
|
||||
function pos(v, dflt) { return num(v) && v > 0 ? v : dflt; }
|
||||
|
||||
// #121: path SVG di un arco, porting di percorsoArco dello Studio; extent > 0 = antiorario a video
|
||||
function arcPath(o) {
|
||||
var ext = Math.max(-359.99, Math.min(359.99, o.extent_deg)); // 360 pieni darebbero start == end
|
||||
var a0 = rad(o.start_deg);
|
||||
var a1 = rad(o.start_deg + ext);
|
||||
var x0 = o.col + o.radius * Math.cos(a0), y0 = o.row - o.radius * Math.sin(a0);
|
||||
var x1 = o.col + o.radius * Math.cos(a1), y1 = o.row - o.radius * Math.sin(a1);
|
||||
var large = Math.abs(ext) > 180 ? 1 : 0;
|
||||
var sweep = ext > 0 ? 0 : 1; // y verso il basso: antiorario a video = sweep-flag 0
|
||||
return 'M ' + n2(x0) + ' ' + n2(y0) + ' A ' + n2(o.radius) + ' ' + n2(o.radius) +
|
||||
' 0 ' + large + ' ' + sweep + ' ' + n2(x1) + ' ' + n2(y1);
|
||||
}
|
||||
|
||||
// #121: [tag, attributi geometrici] di una forma, o null se tipo ignoto o dati malformati (stessi criteri di drawShape)
|
||||
function shapeSvg(o, cross) {
|
||||
var i, pts;
|
||||
switch (o && o.type) {
|
||||
case 'rect2':
|
||||
if (!allNum(o, 'row', 'col', 'phi_deg', 'length1', 'length2')) return null;
|
||||
pts = rect2Vertices(o).map(function (v) { return n2(v[0]) + ',' + n2(v[1]); }).join(' ');
|
||||
return ['polygon', 'points="' + pts + '"'];
|
||||
case 'segment':
|
||||
case 'line':
|
||||
if (!allNum(o, 'row1', 'col1', 'row2', 'col2')) return null;
|
||||
return ['line', 'x1="' + n2(o.col1) + '" y1="' + n2(o.row1) + '" x2="' + n2(o.col2) + '" y2="' + n2(o.row2) + '"'];
|
||||
case 'circle':
|
||||
if (!allNum(o, 'row', 'col', 'radius')) return null;
|
||||
return ['circle', 'cx="' + n2(o.col) + '" cy="' + n2(o.row) + '" r="' + n2(o.radius) + '"'];
|
||||
case 'arc':
|
||||
if (!allNum(o, 'row', 'col', 'radius', 'start_deg', 'extent_deg')) return null;
|
||||
return ['path', 'd="' + arcPath(o) + '"'];
|
||||
case 'points': {
|
||||
if (!validSeries(o)) return null;
|
||||
var d = [];
|
||||
for (i = 0; i < o.rows.length; i++) {
|
||||
var x = o.cols[i], y = o.rows[i];
|
||||
d.push('M ' + n2(x - cross) + ' ' + n2(y) + ' L ' + n2(x + cross) + ' ' + n2(y) +
|
||||
' M ' + n2(x) + ' ' + n2(y - cross) + ' L ' + n2(x) + ' ' + n2(y + cross));
|
||||
}
|
||||
return ['path', 'd="' + d.join(' ') + '"'];
|
||||
}
|
||||
case 'contour':
|
||||
if (!validSeries(o)) return null;
|
||||
pts = o.rows.map(function (r, k) { return n2(o.cols[k]) + ',' + n2(r); }).join(' ');
|
||||
return ['polyline', 'points="' + pts + '"']; // aperta, come draw
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// #121: overlay -> figli SVG (senza <svg> esterno) in px immagine. Tratto a spessore costante a video
|
||||
// (vector-effect non-scaling-stroke), croci dei points a semi-braccio costante a video (cross / zoom).
|
||||
function svg(overlay, opts) {
|
||||
opts = opts || {};
|
||||
var zoom = pos(opts.zoom, 1), lw = pos(opts.lw, 2), cross = pos(opts.cross, 6);
|
||||
var items = Array.isArray(overlay) ? overlay : [];
|
||||
var out = [];
|
||||
for (var k = 0; k < items.length; k++) {
|
||||
var o = items[k];
|
||||
var sh = null;
|
||||
try { sh = shapeSvg(o, cross / zoom); } catch (e) { sh = null; } // un elemento rotto non ferma gli altri
|
||||
if (!sh) continue;
|
||||
var role = o.role;
|
||||
var isRoi = role === 'roi';
|
||||
// solo colori fissi e numeri: nessuna stringa dei dati finisce nel markup
|
||||
out.push('<' + sh[0] + ' ' + sh[1] + ' fill="none" stroke="' + (COLORS[role] || COLOR_OTHER) +
|
||||
'" stroke-width="' + n2(isRoi ? lw * 0.75 : lw) + '"' +
|
||||
(isRoi ? ' stroke-dasharray="6 4"' : '') + ' vector-effect="non-scaling-stroke"/>');
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
// #116: punto di ancoraggio dell'etichetta (px immagine), come ancoraOverlay dello Studio
|
||||
function anchor(o) {
|
||||
switch (o && o.type) {
|
||||
@@ -172,6 +249,8 @@ var VisionOverlay = (function () {
|
||||
var pt = null;
|
||||
for (var i = 0; i < order.length && !pt; i++) pt = anchor(order[i]);
|
||||
if (!pt) return;
|
||||
// #118: valore null/undefined/vuoto = quota non misurata (Number(null) e' 0): nessuna etichetta
|
||||
if (r.value === null || r.value === undefined || r.value === '' || typeof r.value === 'boolean') return;
|
||||
var v = Number(r.value);
|
||||
if (!isFinite(v)) return;
|
||||
out.push({
|
||||
@@ -185,7 +264,7 @@ var VisionOverlay = (function () {
|
||||
return out;
|
||||
}
|
||||
|
||||
return { draw: draw, anchor: anchor, labels: labels };
|
||||
return { draw: draw, anchor: anchor, labels: labels, svg: svg };
|
||||
})();
|
||||
|
||||
if (typeof window !== 'undefined') window.VisionOverlay = VisionOverlay;
|
||||
|
||||
Reference in New Issue
Block a user