Wizard DXF: anteprima immagine, anteprima DXF e Prova usano <tmf-viewer>

Refs #122

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 21:58:27 +01:00
parent 43ef22079d
commit 269bbc03ca
5 changed files with 120 additions and 443 deletions
@@ -39,11 +39,7 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
result: null, // corpo B3 {result, saved, recipe_dir, outputs}
imageSource: '', // #104: '' | 'file' | 'camera'; il campo mm/px vale solo per 'file'
tested: false, // #104: true dopo una Prova (anche fallita): mostra la sezione del risultato
zoom: 1, // #104: scala dell'immagine di prova (1 = pixel nativi)
fitZoom: 1, // #104: zoom di Adatta (sotto o pari a questo il viewport segue l'immagine)
panning: false, // #104: trascinamento in corso
_pan: null, // #104: {x, y, left, top} al pointerdown
resPx: null, // #104: {w, h} in pixel dell'immagine di prova (per la dimensione del wrapper)
// #122: zoom, Adatta e pan delle tre viste (immagine, DXF, Prova) ora stanno nel componente <tmf-viewer>
runPxPerMm: null, // #104: scala usata nell'ultima Prova (null = calibrazione stazione)
runBandMm: null, // #104: banda usata nell'ultima Prova
runMinPointsPct: null, // #104: soglia usata nell'ultima Prova
@@ -56,11 +52,6 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
_srcSeq: 0, // #105: una risposta di /source arrivata dopo una lettura piu' recente si scarta
_dxfPickSeq: 0, // #105: sale a ogni scelta manuale del DXF; i file della ricetta arrivati dopo si scartano
_imgPickSeq: 0, // #105: idem per l'immagine (_setImage)
// #113: zoom delle due anteprime (img = immagine caricata/acquisita, dxf = disegno); w,h = misura del contenuto (px immagine; mm del viewBox per il DXF)
views: { img: { zoom: 1, fit: 1, w: 0, h: 0 }, dxf: { zoom: 1, fit: 1, w: 0, h: 0 } },
viewPanKey: '', // #113: anteprima in trascinamento ('' = nessuna)
_viewPan: null, // #113: {k, x, y, left, top} al pointerdown
_viewRO: {}, // #113: ResizeObserver per chiave, rifanno Adatta se il viewport cambia larghezza
// #105: all'avvio legge se il task ha gia' una ricetta da DXF (serve per l'etichetta del bottone)
init() {
@@ -204,25 +195,22 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
this.imageSrc = file ? URL.createObjectURL(file) : '';
this.imagePx = null;
this.imageBroken = false;
this.vReset('img'); // #113: nuova immagine, vista da rifare
this.result = null;
this.tested = false; // #104
this.resPx = null; // #104
this.imageSource = file ? (source || '') : ''; // #104
this._imgPickSeq++; // #105: una scelta piu' recente batte i file della ricetta ancora in arrivo
},
// #102: l'<img> dell'anteprima e' caricata: legge i pixel; se non si carica il formato non e' mostrabile
onImagePreviewLoad(el) {
// #102: il <tmf-viewer> ha caricato l'immagine (evento viewer-load): legge i pixel dal detail
// #122: lo zoom e' del componente; qui restano solo le dimensioni per il riepilogo
onImagePreviewLoad(d) {
this.imageBroken = false;
this.imagePx = el && el.naturalWidth ? { w: el.naturalWidth, h: el.naturalHeight } : null;
if (this.imagePx) this.vSetContent('img', this.imagePx.w, this.imagePx.h); // #113: Adatta al caricamento
else this.vReset('img');
this.imagePx = d && d.width ? { w: d.width, h: d.height } : null;
},
// #102: se il formato non e' mostrabile (sonda <img hidden> del template) compare l'avviso
onImagePreviewError() {
this.imageBroken = !!this.imageSrc;
this.imagePx = null;
this.vReset('img'); // #113
},
// #102: nome usato dal template (stesso valore dell'URL gia' usato per l'overlay dei risultati)
get imagePreviewUrl() { return this.imageSrc; },
@@ -255,7 +243,6 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
async loadDxfPreview() {
const seq = ++this._dxfPreviewSeq;
this.dxfPreview = null;
this.vReset('dxf'); // #113: nuova anteprima in arrivo
this.dxfPreviewError = '';
this.dxfPreviewBusy = false;
if (!this.dxfFile) return; // file tolto: tutto azzerato
@@ -271,8 +258,6 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
if (!resp.ok || body.error) this.dxfPreviewError = this._errorText(body);
else {
this.dxfPreview = body;
const vb = this.dxfPreviewViewBox().split(' ').map(Number); // #113: misura del viewBox (mm) per lo zoom
this.vSetContent('dxf', vb[2], vb[3]);
}
} catch (e) {
if (seq !== this._dxfPreviewSeq) return;
@@ -373,7 +358,6 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
this.error = this._errorText(body);
} else {
this.result = body;
this.$nextTick(() => this.drawOverlay());
if (save && body.saved) {
this.notice = this.msgs.saved || 'Saved';
// Il componente visionRecipe ricarica cartella, file e uscite delle quote
@@ -391,7 +375,6 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
this.busy = false;
// #104: la sezione del risultato compare anche con Prova fallita (immagine a sinistra, errore a destra)
this.tested = true;
this.$nextTick(() => this.fit());
},
// Le quattro uscite del confronto, nell'ordine fisso
@@ -424,173 +407,9 @@ function dxfRecipeWizard(taskId, csrf, msgs) {
return `${mm} mm/px (${this.imageSource === 'file' ? (this.msgs.scaleManual || 'manual') : (this.msgs.scaleStation || 'station calibration')})`;
},
// #104: zoom dell'immagine di prova. Il wrapper e' largo naturalWidth*zoom; il canvas resta in pixel immagine e lo scala il CSS
get zoomPct() { return Math.round(this.zoom * 100) + ' %'; },
get zoomW() { return this.resPx ? this.resPx.w * this.zoom : 0; },
get zoomH() { return this.resPx ? this.resPx.h * this.zoom : 0; },
// #104: altezza massima del viewport = min(70vh, 520px)
_maxVpH() { return Math.min(0.7 * (typeof window !== 'undefined' && window.innerHeight ? window.innerHeight : 800), 520); },
// #104: con zoom <= Adatta il viewport e' alto come l'immagine scalata (min 200px), altrimenti al massimo
get vpHeight() {
const max = this._maxVpH();
if (!this.resPx || this.zoom > this.fitZoom * 1.0001) return max;
return Math.min(max, Math.max(200, this.zoomH));
},
// #104: l'immagine e' piu' grande dello spazio: si puo' trascinare
get canPan() { return !!this.resPx && this.zoom > this.fitZoom * 1.0001; },
get panCursor() { return this.canPan ? (this.panning ? 'grabbing' : 'grab') : 'default'; },
// #104: pan col puntatore (mouse e touch): scroll del viewport di quanto si muove il puntatore
onPanDown(ev) {
const vp = this.$refs.viewport;
if (!vp || (ev.pointerType === 'mouse' && ev.button)) return;
// #104: senza preventDefault Chromium manda pointercancel al primo movimento del mouse e il pan si ferma
if (ev.preventDefault) ev.preventDefault();
this._pan = { x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop };
this.panning = true;
try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
},
onPanMove(ev) {
const vp = this.$refs.viewport;
if (!this._pan || !vp) return;
vp.scrollLeft = this._pan.left - (ev.clientX - this._pan.x);
vp.scrollTop = this._pan.top - (ev.clientY - this._pan.y);
},
onPanEnd() { this._pan = null; this.panning = false; },
zoomBy(f) {
const vp = this.$refs.viewport;
const old = this.zoom;
const z = Math.min(8, Math.max(0.05, old * f));
if (z === old) return;
// centro visibile invariato: coordinate immagine del centro prima, scroll dopo il nuovo layout
let cx = 0, cy = 0;
if (vp) { cx = (vp.scrollLeft + vp.clientWidth / 2) / old; cy = (vp.scrollTop + vp.clientHeight / 2) / old; }
this.zoom = z;
this.drawOverlay(); // #121: ridisegno al nuovo zoom
if (vp) this.$nextTick(() => { vp.scrollLeft = cx * z - vp.clientWidth / 2; vp.scrollTop = cy * z - vp.clientHeight / 2; });
},
// #104: Fit = tutta l'immagine nel viewport (altezza massima, non quella corrente che segue l'immagine)
fit() {
const vp = this.$refs.viewport, img = this.$refs.img;
if (!vp || !img || !img.naturalWidth || !vp.clientWidth) return;
const z = Math.min(8, Math.max(0.05, Math.min(vp.clientWidth / img.naturalWidth, this._maxVpH() / img.naturalHeight)));
this.zoom = z;
this.fitZoom = z;
this.drawOverlay(); // #121: ridisegno al nuovo zoom
vp.scrollLeft = 0; vp.scrollTop = 0;
},
// #113: zoom per chiave ('img' | 'dxf'), stessa logica di #104 ma con stato in views[k]. Viewport fisso di 240px.
_vpEl(k) { return this.$refs ? this.$refs[k === 'img' ? 'vpImg' : 'vpDxf'] : null; },
_vLimits(k) {
const v = this.views[k];
// img: pixel nativi 5 %..800 %; dxf: da meta' a 50 volte Adatta
return k === 'img' ? [0.05, 8] : [v.fit * 0.5, v.fit * 50];
},
vPct(k) {
const v = this.views[k];
return Math.round((k === 'img' ? v.zoom : (v.fit ? v.zoom / v.fit : 1)) * 100) + ' %';
},
vW(k) { const v = this.views[k]; return v.w * v.zoom; },
vH(k) { const v = this.views[k]; return v.h * v.zoom; },
vCanPan(k) { const v = this.views[k]; return v.w > 0 && v.zoom > v.fit * 1.0001; },
vCursor(k) { return this.vCanPan(k) ? (this.viewPanKey === k ? 'grabbing' : 'grab') : 'default'; },
// #113: nessun contenuto: zoom di nuovo a 1 e misura azzerata (il wrapper sparisce)
vReset(k) { this.views[k] = { zoom: 1, fit: 1, w: 0, h: 0 }; },
// #113: nuovo contenuto: vista da Adatta (appena il viewport ha una larghezza) e osservatore di resize
vSetContent(k, w, h) {
if (!(w > 0) || !(h > 0)) { this.vReset(k); return; }
this.views[k] = { zoom: 1, fit: 1, w, h };
const run = () => { this._vObserve(k); this.vFit(k); };
if (typeof this.$nextTick === 'function') this.$nextTick(run); else run();
},
// #113: se il viewport era nascosto (larghezza 0) o cambia larghezza, rifa Adatta finche' la vista e' ad Adatta
_vObserve(k) {
const vp = this._vpEl(k);
if (!vp || this._viewRO[k] || typeof ResizeObserver === 'undefined') return;
this._viewRO[k] = new ResizeObserver(() => {
const v = this.views[k];
if (v.w > 0 && Math.abs(v.zoom - v.fit) < 1e-9 * Math.max(1, v.fit)) this.vFit(k);
});
this._viewRO[k].observe(vp);
},
vFit(k) {
const vp = this._vpEl(k), v = this.views[k];
if (!vp || !v.w || !v.h || !vp.clientWidth) return; // nascosto o vuoto: niente
// altezza utile del viewport (senza bordo), ripiego 240; con 240 secco il contenuto sborda e compare lo scroll
let cw = vp.clientWidth, ch = vp.clientHeight || 240;
// #113: Adatta premuto da ingrandito: client* escludono le barre di scorrimento che spariranno dopo il fit;
// l'area vera e' offset* meno i bordi (nei test senza DOM offsetWidth manca e restano client*)
if (vp.offsetWidth && typeof getComputedStyle === 'function') {
const cs = getComputedStyle(vp);
cw = vp.offsetWidth - (parseFloat(cs.borderLeftWidth) + parseFloat(cs.borderRightWidth) || 0);
ch = vp.offsetHeight - (parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth) || 0) || 240;
}
let z = Math.min(cw / v.w, ch / v.h);
if (k === 'img') z = Math.min(8, Math.max(0.05, z));
v.zoom = z;
v.fit = z;
vp.scrollLeft = 0; vp.scrollTop = 0;
},
vZoomBy(k, f) {
const vp = this._vpEl(k), v = this.views[k];
if (!v.w) return;
const old = v.zoom, lim = this._vLimits(k);
const z = Math.min(lim[1], Math.max(lim[0], old * f));
if (z === old) return;
// centro visibile invariato, come zoomBy (#104)
let cx = 0, cy = 0;
if (vp) { cx = (vp.scrollLeft + vp.clientWidth / 2) / old; cy = (vp.scrollTop + vp.clientHeight / 2) / old; }
v.zoom = z;
if (vp && typeof this.$nextTick === 'function') this.$nextTick(() => { vp.scrollLeft = cx * z - vp.clientWidth / 2; vp.scrollTop = cy * z - vp.clientHeight / 2; });
},
vWheel(k, ev) {
if (!ev.ctrlKey) return;
ev.preventDefault();
this.vZoomBy(k, ev.deltaY < 0 ? 1.25 : 1 / 1.25);
},
vPanDown(k, ev) {
const vp = this._vpEl(k);
if (!vp || (ev.pointerType === 'mouse' && ev.button)) return;
if (ev.preventDefault) ev.preventDefault();
this._viewPan = { k, x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop };
this.viewPanKey = k;
try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
},
vPanMove(k, ev) {
const vp = this._vpEl(k), p = this._viewPan;
if (!p || p.k !== k || !vp) return;
vp.scrollLeft = p.left - (ev.clientX - p.x);
vp.scrollTop = p.top - (ev.clientY - p.y);
},
vPanEnd() { this._viewPan = null; this.viewPanKey = ''; },
// #104: Ctrl+rotella = zoom
onWheel(ev) {
if (!ev.ctrlKey) return;
ev.preventDefault();
this.zoomBy(ev.deltaY < 0 ? 1.25 : 1 / 1.25);
},
// #104: load dell'immagine di prova: dimensioni, overlay e adattamento allo spazio
onResultImgLoad() {
const img = this.$refs.img;
this.resPx = img && img.naturalWidth ? { w: img.naturalWidth, h: img.naturalHeight } : null;
this.drawOverlay();
this.$nextTick(() => this.fit());
},
fmt(v) {
return (typeof v === 'number') ? v.toLocaleString(document.documentElement.lang || 'it',
{ maximumFractionDigits: 3 }) : '-';
},
// #121: overlay SVG in pixel immagine (viewBox = dimensioni naturali); tratto a spessore costante a ogni zoom.
// Senza VisionOverlay.svg resta vuoto, senza errori.
drawOverlay() {
const img = this.$refs.img, sv = this.$refs.overlay;
if (!img || !sv || !img.naturalWidth) return;
sv.setAttribute('viewBox', '0 0 ' + img.naturalWidth + ' ' + img.naturalHeight);
const shapes = (this.result && this.result.result && this.result.result.overlay) || [];
const VO = (typeof window !== 'undefined' && window.VisionOverlay) || null;
sv.innerHTML = (VO && typeof VO.svg === 'function') ? VO.svg(shapes, { zoom: this.zoom }) : '';
},
};
}