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 }) : '';
},
};
}
@@ -730,60 +730,36 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">
<div x-show="imagePreviewUrl">
<!-- #102: anteprima dell'immagine -->
<!-- #113: barra zoom come quella della Prova (#104), per l'anteprima 'img'; x-show sul contenitore (sul viewport :style lo pesterebbe) -->
<div class="relative" x-show="!imageBroken">
<div x-show="!imageBroken && views.img.w" class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded px-1 py-0.5"
style="background: rgba(0,0,0,.55); color: #fff;">
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vZoomBy('img', 1 / 1.25)" title="{{ _('Riduci') }}">&minus;</button>
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vZoomBy('img', 1.25)" title="{{ _('Ingrandisci') }}">+</button>
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vFit('img')">{{ _('Adatta') }}</button>
{# #119: salva il frame grezzo mostrato (PNG, senza disegni) #}
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="tmfSaveImage(imagePreviewUrl, 'immagine')" title="{{ _('Salva immagine') }}">{{ _('Salva') }}</button>
<span class="text-xs px-1" x-text="vPct('img')"></span>
</div>
<!-- #113: viewport fisso 240px; il wrapper (margin auto) e' largo w*zoom: centrato se piu' piccolo, scorrevole se piu' grande -->
<div x-ref="vpImg" @wheel="vWheel('img', $event)"
@pointerdown="vPanDown('img', $event)" @pointermove="vPanMove('img', $event)"
@pointerup="vPanEnd()" @pointercancel="vPanEnd()"
class="rounded border border-[var(--border-color)]"
:style="'overflow:auto;touch-action:none;user-select:none;display:flex;height:240px;background:var(--bg-primary);cursor:' + vCursor('img')">
<div :style="'margin:auto;flex:none;' + (views.img.w ? ('width:' + vW('img') + 'px;height:' + vH('img') + 'px') : 'width:100%;height:240px')">{# #113: tutto in :style, Alpine con stringa sostituisce l'attributo style #}
<img :src="imagePreviewUrl" alt="" draggable="false"
@load="onImagePreviewLoad($event.target)" @error="onImagePreviewError()"
class="block" style="width: 100%; height: 100%; object-fit: contain; user-select: none;">
</div>
</div>
{# #122: <tmf-viewer> standard (zoom, Adatta, Salva, pan) al posto della barra e del viewport scritti a mano (#113); altezza fissa 240px #}
<div x-show="!imageBroken">{# #122: x-show sul contenitore, mai sul <tmf-viewer> #}
<tmf-viewer :src="imagePreviewUrl" save-name="immagine"
label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
@viewer-load="onImagePreviewLoad($event.detail)"
class="rounded border border-[var(--border-color)]"
style="height: 240px; background: var(--bg-primary);"></tmf-viewer>
{# #122: sonda nascosta: il componente non propaga l'errore di caricamento, la sonda si'; se il formato non e' mostrabile compare l'avviso #}
<img :src="imagePreviewUrl" alt="" hidden @error="onImagePreviewError()">
</div>
<p x-show="imageBroken" class="text-xs text-amber-600">{{ _('Anteprima non disponibile per questo formato') }}</p>
<p x-show="imagePreviewSummary" class="mt-1 text-xs text-[var(--text-muted)]" x-text="imagePreviewSummary"></p>
</div>
<div x-show="dxfPreview" class="sm:col-start-2">{# #104: resta a destra anche senza anteprima immagine #}
<!-- #113: barra zoom come quella della Prova (#104), per l'anteprima 'dxf' -->
<div class="relative">
<div class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded px-1 py-0.5"
style="background: rgba(0,0,0,.55); color: #fff;">
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vZoomBy('dxf', 1 / 1.25)" title="{{ _('Riduci') }}">&minus;</button>
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vZoomBy('dxf', 1.25)" title="{{ _('Ingrandisci') }}">+</button>
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="vFit('dxf')">{{ _('Adatta') }}</button>
<span class="text-xs px-1" x-text="vPct('dxf')"></span>
</div>
<!-- #113: stesso viewport dell'immagine; svg 100% del wrapper, viewBox in mm -->
<div x-ref="vpDxf" @wheel="vWheel('dxf', $event)"
@pointerdown="vPanDown('dxf', $event)" @pointermove="vPanMove('dxf', $event)"
@pointerup="vPanEnd()" @pointercancel="vPanEnd()"
class="rounded border border-[var(--border-color)]"
:style="'overflow:auto;touch-action:none;user-select:none;display:flex;height:240px;background:var(--bg-primary);cursor:' + vCursor('dxf')">
<div :style="'margin:auto;flex:none;width:' + vW('dxf') + 'px;height:' + vH('dxf') + 'px'">
<svg x-bind:view-box.camel="dxfPreviewViewBox()" preserveAspectRatio="xMidYMid meet"
class="block rounded" style="width: 100%; height: 100%;">
<!-- #101: un solo path (x-for/template non funziona nel namespace SVG); view-box.camel perche' il parser HTML abbassa viewBox -->
<path :d="dxfPreviewD()" fill="none" stroke="var(--color-primary, #2563eb)"
stroke-width="1.5" vector-effect="non-scaling-stroke"
stroke-linejoin="round"></path>
</svg>
</div>
</div>
</div>
{# #122: <tmf-viewer> in modo contenuto: lo <svg> (viewBox in mm) va nello stage; width/height = misura del viewBox, ora anche Salva (PNG) #}
<tmf-viewer save-name="dxf"
:width="dxfPreviewViewBox().split(' ')[2]" :height="dxfPreviewViewBox().split(' ')[3]"
label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
class="rounded border border-[var(--border-color)]"
style="height: 240px; background: var(--bg-primary);">
<svg x-bind:view-box.camel="dxfPreviewViewBox()" preserveAspectRatio="xMidYMid meet"
class="block rounded" style="width: 100%; height: 100%;">
<!-- #101: un solo path (x-for/template non funziona nel namespace SVG); view-box.camel perche' il parser HTML abbassa viewBox -->
<path :d="dxfPreviewD()" fill="none" stroke="var(--color-primary, #2563eb)"
stroke-width="1.5" vector-effect="non-scaling-stroke"
stroke-linejoin="round"></path>
</svg>
</tmf-viewer>
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="dxfPreviewSummary"></p>
</div>
</div>
@@ -814,31 +790,14 @@
<!-- #104: risultato della Prova su due colonne: immagine con zoom a sinistra, dati ed errori a destra -->
<div x-show="tested" class="grid grid-cols-1 sm:grid-cols-2 gap-3 items-start">{# #104: risultati a destra gia' da 640px, come le altre griglie del wizard #}
<div>
<!-- #104: barra zoom in sovrimpressione, fuori dal contenuto scorrevole -->
<div class="relative">
<div class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded px-1 py-0.5"
style="background: rgba(0,0,0,.55); color: #fff;">
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(1 / 1.25)" title="{{ _('Riduci') }}">&minus;</button>
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(1.25)" title="{{ _('Ingrandisci') }}">+</button>
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="fit()">{{ _('Adatta') }}</button>
{# #119: salva il frame grezzo della Prova (PNG, senza overlay) #}
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="tmfSaveImage(imageSrc, 'prova')" title="{{ _('Salva immagine') }}">{{ _('Salva') }}</button>
<span class="text-xs px-1" x-text="zoomPct"></span>
</div>
<!-- #104: pan col puntatore (mouse e dito), cursore grab; altezza che segue l'immagine finche' zoom <= Adatta -->
<div x-ref="viewport" @wheel="onWheel($event)"
@pointerdown="onPanDown($event)" @pointermove="onPanMove($event)"
@pointerup="onPanEnd()" @pointercancel="onPanEnd()"
class="rounded border border-[var(--border-color)]"
:style="'overflow:auto;touch-action:none;user-select:none;background:var(--bg-primary);height:' + vpHeight + 'px;cursor:' + panCursor">
<div class="relative" :style="zoomW ? ('width:' + zoomW + 'px;height:' + zoomH + 'px') : 'width:100%'">
<img x-ref="img" :src="imageSrc" alt="" class="block" draggable="false" style="width: 100%; height: 100%; user-select: none;"
@load="onResultImgLoad()">
{# #121: overlay SVG (tratto a spessore costante a ogni zoom) al posto del canvas; il viewBox lo imposta drawOverlay #}
<svg x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none" preserveAspectRatio="none"></svg>
</div>
</div>
</div>
{# #122: <tmf-viewer fit-height> al posto di barra, viewport, pan e overlay della Prova (#104 #121); overlay dalle forme del risultato #}
<tmf-viewer fit-height :src="imageSrc" save-name="prova"
x-effect="$el.overlay = (result && result.result && result.result.overlay) || []"
label-fit="{{ _('Adatta') }}" label-save="{{ _('Salva') }}"
title-in="{{ _('Ingrandisci') }}" title-out="{{ _('Riduci') }}" title-save="{{ _('Salva immagine') }}"
class="rounded border border-[var(--border-color)]"
style="background: var(--bg-primary);"></tmf-viewer>
</div>
</div>
<div class="space-y-2">
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
@@ -1523,6 +1482,7 @@
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
<script src="{{ url_for('static', filename='js/vision_overlay.js') }}"></script>{# #121 #}
<script src="{{ url_for('static', filename='js/save_image.js') }}"></script>{# #119 #}
<script src="{{ url_for('static', filename='js/image_viewer.js') }}"></script>{# #122: <tmf-viewer>, dopo vision_overlay.js e save_image.js #}
<script src="{{ url_for('static', filename='js/dxf_recipe_wizard.js') }}"></script>
<script src="{{ url_for('static', filename='js/task_acquisition.js') }}"></script>{# #97 #}
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
+31 -139
View File
@@ -1,5 +1,8 @@
/**
* #113: zoom, Adatta e pan sulle anteprime del wizard (immagine e DXF), con plain node.
* #122: lo zoom delle tre viste del wizard (immagine, DXF, Prova) e' nel componente <tmf-viewer>.
* Qui si prova cio' che resta nel wizard (dimensioni dal detail di viewer-load, anteprima DXF)
* e che le funzioni di zoom/pan/fit/overlay non ci siano piu'.
* (Il nome del file resta quello di #113 per non rompere i riferimenti.)
*
* Run from src/frontend/flask_app: node tests/js/test_dxf_zoom.js
*/
@@ -18,109 +21,35 @@ function check(label, cond, extra) {
if (cond) console.log('ok - ' + label);
else { failures++; console.log('FAIL - ' + label + (extra ? ' :: ' + extra : '')); }
}
const near = (a, b) => Math.abs(a - b) < 1e-9 * Math.max(1, Math.abs(b));
// Wizard con viewport finti e $nextTick sincrono
function make(cw) {
const w = dxfRecipeWizard(1, () => 'tok', {});
const vp = () => ({ clientWidth: cw, clientHeight: 238, scrollLeft: 5, scrollTop: 5 });
w.$refs = { vpImg: vp(), vpDxf: vp() };
w.$nextTick = fn => fn();
return w;
}
const w = dxfRecipeWizard(1, () => 'tok', {});
w.$nextTick = fn => fn();
// img: viewport 400 x 238 utili, immagine 2856 x 2848
let w = make(400);
w.vSetContent('img', 2856, 2848);
check('#113 img: Adatta = clientHeight/2848', near(w.views.img.zoom, 238 / 2848) && near(w.views.img.fit, 238 / 2848), String(w.views.img.zoom));
check('#113 img: scroll azzerato dopo Adatta', w.$refs.vpImg.scrollLeft === 0 && w.$refs.vpImg.scrollTop === 0);
check('#113 img: percentuale = zoom*100', w.vPct('img') === Math.round(238 / 2848 * 100) + ' %', w.vPct('img'));
const z0 = w.views.img.zoom;
w.vZoomBy('img', 1.25);
check('#113 img: zoomBy moltiplica', near(w.views.img.zoom, z0 * 1.25));
check('#113 img: dopo zoom si puo trascinare', w.vCanPan('img') === true && w.vCursor('img') === 'grab');
w.vZoomBy('img', 1e6);
check('#113 img: limite alto 8', w.views.img.zoom === 8);
w.vZoomBy('img', 1e-9);
check('#113 img: limite basso 0.05', w.views.img.zoom === 0.05);
w.vFit('img');
check('#113 img: Adatta torna al fit', near(w.views.img.zoom, z0) && w.vCanPan('img') === false);
// clientHeight 0 (non misurabile): ripiego 240
w = make(400); w.$refs.vpImg.clientHeight = 0; w.vSetContent('img', 2856, 2848);
check('#113 img: clientHeight 0 usa 240', near(w.views.img.fit, 240 / 2848));
w = make(400); w.vSetContent('img', 2856, 2848);
check('#113 img: l\'altra vista non si muove', w.views.dxf.w === 0 && w.views.dxf.zoom === 1);
check('#113 img: wrapper w*zoom', near(w.vW('img'), 2856 * z0) && near(w.vH('img'), 2848 * z0));
// #122: via tutto lo zoom/pan/fit/overlay del wizard
const gone = ['views', 'viewPanKey', 'zoom', 'fitZoom', 'panning', 'resPx', 'vZoomBy', 'vFit', 'vWheel', 'vPanDown',
'vPanMove', 'vPanEnd', 'vCursor', 'vPct', 'vW', 'vH', 'vReset', 'vSetContent', 'zoomBy', 'fit', 'onWheel',
'onPanDown', 'onPanMove', 'onPanEnd', 'onResultImgLoad', 'drawOverlay', 'zoomPct', 'zoomW', 'zoomH', 'vpHeight'];
for (const k of gone) check('#122 tolto dal wizard: ' + k, !(k in w));
check('#122 sorgente senza $refs di zoom', !/\$refs\./.test(src));
// dxf: viewBox 20 x 10 mm, viewport 400 x 238 -> fit = min(20, 23.8) = 20
w = make(400);
w.vSetContent('dxf', 20, 10);
check('#113 dxf: fit = min(vpW/w, clientHeight/h)', near(w.views.dxf.fit, 20), String(w.views.dxf.fit));
check('#113 dxf: dopo Adatta 100 %', w.vPct('dxf') === '100 %', w.vPct('dxf'));
w.vZoomBy('dxf', 2);
check('#113 dxf: zoom 2x = 200 %', w.vPct('dxf') === '200 %', w.vPct('dxf'));
w.vZoomBy('dxf', 1e9);
check('#113 dxf: si ferma a fit*50', near(w.views.dxf.zoom, 20 * 50) && w.vPct('dxf') === '5000 %', String(w.views.dxf.zoom));
w.vZoomBy('dxf', 1e-9);
check('#113 dxf: si ferma a fit*0.5', near(w.views.dxf.zoom, 10) && w.vPct('dxf') === '50 %', String(w.views.dxf.zoom));
// vFit con viewport largo 0 (nascosto): stato invariato
w = make(0);
w.vSetContent('img', 1000, 500);
check('#113 clientWidth 0: nessun fit, stato invariato', w.views.img.zoom === 1 && w.views.img.fit === 1 && w.views.img.w === 1000);
w.$refs.vpImg.clientWidth = 500;
w.vFit('img');
check('#113 viewport visibile: il fit parte', near(w.views.img.fit, 238 / 500), String(w.views.img.fit));
// Reset: nuova immagine / DXF tolto
w.vReset('img');
check('#113 reset: contenuto e zoom azzerati', w.views.img.w === 0 && w.views.img.zoom === 1 && w.vCanPan('img') === false);
w.vZoomBy('img', 2);
check('#113 zoomBy senza contenuto: ignorato', w.views.img.zoom === 1);
// Integrazione: onImagePreviewLoad e _setImage
// onImagePreviewLoad riceve il detail di viewer-load e alimenta il riepilogo
global.URL.createObjectURL = () => 'blob:x';
global.URL.revokeObjectURL = () => {};
w = make(400);
w._setImage({ name: 'a.bmp' }, 'file');
w.onImagePreviewLoad({ naturalWidth: 800, naturalHeight: 240 });
check('#113 onImagePreviewLoad: Adatta all\'arrivo', near(w.views.img.zoom, 0.5) && w.views.img.w === 800, String(w.views.img.zoom));
w._setImage({ name: 'b.bmp' }, 'file');
check('#113 _setImage: vista azzerata', w.views.img.w === 0 && w.views.img.zoom === 1);
w.onImagePreviewLoad({ naturalWidth: 800, naturalHeight: 240 });
w.onImagePreviewLoad({ width: 800, height: 240 });
check('#122 viewer-load: dimensioni nel riepilogo', w.imagePx.w === 800 && w.imagePx.h === 240 && w.imageBroken === false);
check('#122 riepilogo «W × H px»', /800.*240 px/.test(w.imagePreviewSummary), w.imagePreviewSummary);
w.onImagePreviewLoad({ width: 0, height: 0 });
check('#122 viewer-load senza dimensioni: imagePx null', w.imagePx === null);
w.onImagePreviewLoad({ width: 800, height: 240 });
w.onImagePreviewError();
check('#113 errore <img>: vista azzerata', w.views.img.w === 0);
check('#122 errore immagine: avviso e dimensioni azzerate', w.imageBroken === true && w.imagePx === null);
w._setImage({ name: 'b.bmp' }, 'file');
check('#122 nuova immagine: avviso tolto', w.imageBroken === false && w.imagePx === null);
w._setImage(null);
check('#122 immagine tolta: src vuoto', w.imageSrc === '' && w.imagePreviewUrl === '');
// Wheel: solo con Ctrl
w = make(400);
w.vSetContent('dxf', 20, 10);
let prevented = false;
w.vWheel('dxf', { ctrlKey: false, deltaY: -1, preventDefault() { prevented = true; } });
check('#113 rotella senza Ctrl: niente zoom', w.views.dxf.zoom === 20 && !prevented);
w.vWheel('dxf', { ctrlKey: true, deltaY: -1, preventDefault() { prevented = true; } });
check('#113 Ctrl+rotella: zoom avanti', near(w.views.dxf.zoom, 25) && prevented);
// Pan
w = make(400);
w.vSetContent('dxf', 20, 10);
w.vZoomBy('dxf', 2);
const vp = w.$refs.vpDxf;
vp.scrollLeft = 100; vp.scrollTop = 50; vp.setPointerCapture = () => {};
w.vPanDown('dxf', { pointerType: 'mouse', button: 0, clientX: 200, clientY: 100, pointerId: 1 });
w.vPanMove('dxf', { clientX: 150, clientY: 90 });
check('#113 pan: scroll di quanto si muove il puntatore', vp.scrollLeft === 150 && vp.scrollTop === 60, vp.scrollLeft + ',' + vp.scrollTop);
check('#113 pan: cursore grabbing', w.vCursor('dxf') === 'grabbing');
w.vPanEnd();
check('#113 pan finito', w.viewPanKey === '' && w._viewPan === null);
// #113: Adatta da ingrandito, con barre di scorrimento presenti: area = offset* meno i bordi, non client*
global.getComputedStyle = () => ({ borderLeftWidth: '1px', borderRightWidth: '1px', borderTopWidth: '1px', borderBottomWidth: '1px' });
w = make(432); w.$refs.vpImg.clientHeight = 228; w.$refs.vpImg.offsetWidth = 444; w.$refs.vpImg.offsetHeight = 240;
w.vSetContent('img', 2464, 2056);
check('#113 Adatta con barre: area senza barre (238/2056)', near(w.views.img.fit, 238 / 2056), String(w.views.img.fit));
delete global.getComputedStyle;
// Integrazione DXF: loadDxfPreview imposta la misura del viewBox e fa Adatta; file tolto azzera
// Anteprima DXF: il viewBox resta quello per il <tmf-viewer> (width/height del template)
(async () => {
const PREVIEW = {
contorni: [[[0, 0], [20, 0], [20, 10]]],
@@ -129,54 +58,17 @@ delete global.getComputedStyle;
};
global.FormData = class { append() {} };
global.fetch = async () => ({ ok: true, json: async () => PREVIEW });
const wd = make(400);
const wd = dxfRecipeWizard(1, () => 'tok', {});
wd.$nextTick = fn => fn();
wd.dxfFile = { name: 'a.dxf' };
await wd.loadDxfPreview();
const vb = wd.dxfPreviewViewBox().split(' ').map(Number);
check('#113 loadDxfPreview: misura = viewBox', near(wd.views.dxf.w, vb[2]) && near(wd.views.dxf.h, vb[3]), JSON.stringify(wd.views.dxf));
check('#113 loadDxfPreview: Adatta = 100 %', wd.vPct('dxf') === '100 %' && wd.views.dxf.fit > 1);
check('#122 DXF: viewBox con margine 5 % (w 22 x h 12)', vb.length === 4 && Math.abs(vb[2] - 22) < 1e-6 && Math.abs(vb[3] - 12) < 1e-6, vb.join(' '));
check('#122 DXF: anteprima caricata', wd.dxfPreview && wd.dxfPreviewD().startsWith('M 0 0'));
wd.dxfFile = null;
await wd.loadDxfPreview();
check('#113 DXF tolto: vista azzerata', wd.views.dxf.w === 0 && wd.views.dxf.zoom === 1);
// #121: overlay SVG al posto del canvas; ridisegno a ogni cambio di zoom (zoomBy, fit)
{
const calls = [];
global.window = { VisionOverlay: { svg: (shapes, o) => { calls.push({ n: shapes.length, zoom: o.zoom }); return '<path d="M0 0"/>'; } } };
const attrs = {};
const sv = { innerHTML: '', setAttribute: (k, v) => { attrs[k] = v; } };
const mk = () => {
const x = make(400);
x.$refs = { viewport: { clientWidth: 400, clientHeight: 300, scrollLeft: 0, scrollTop: 0 },
img: { naturalWidth: 800, naturalHeight: 600 }, overlay: sv };
x.result = { result: { overlay: [{ type: 'contour', role: 'found', rows: [1], cols: [2] }] } };
return x;
};
const wo = mk();
wo.drawOverlay();
check('#121 viewBox = dimensioni naturali', attrs.viewBox === '0 0 800 600', attrs.viewBox);
check('#121 innerHTML = VisionOverlay.svg', sv.innerHTML === '<path d="M0 0"/>' && calls[0].n === 1);
wo.zoomBy(2);
check('#121 zoomBy ridisegna col nuovo zoom', calls[calls.length - 1].zoom === wo.zoom && wo.zoom === 2, JSON.stringify(calls));
wo.fit();
check('#121 fit ridisegna col nuovo zoom', calls[calls.length - 1].zoom === wo.zoom, JSON.stringify(calls));
// senza modulo o senza svg: vuoto, nessun errore
global.window = {};
sv.innerHTML = 'x';
let err = null;
try { mk().drawOverlay(); } catch (e) { err = e; }
check('#121 senza VisionOverlay: vuoto senza errori', err === null && sv.innerHTML === '', String(err));
global.window = { VisionOverlay: {} };
sv.innerHTML = 'x';
try { mk().drawOverlay(); } catch (e) { err = e; }
check('#121 senza svg(): vuoto senza errori', err === null && sv.innerHTML === '', String(err));
// immagine non ancora caricata: non tocca nulla
const wn = mk(); wn.$refs.img.naturalWidth = 0; sv.innerHTML = 'y';
wn.drawOverlay();
check('#121 immagine non caricata: nessun disegno', sv.innerHTML === 'y');
delete global.window;
}
check('#122 DXF tolto: anteprima azzerata', wd.dxfPreview === null && wd.dxfPreviewViewBox() === '0 0 1 1');
if (failures) { console.log(failures + ' test falliti'); process.exit(1); }
console.log('tutti i test #113 ok');
console.log('tutti i test #122 ok');
})();
@@ -354,19 +354,13 @@ class TestPages:
assert "@dxf-recipe-saved" in html
assert 'x-model="mmPx"' in html # #98: campo mm/px a mano
assert "dxfPreviewD()" in html and "view-box.camel" in html # #101: anteprima SVG del DXF
assert "imageSource === 'file'" in html and "zoomBy(" in html and "fit()" in html # #104: mm/px solo da file, zoom e Fit
assert 'onPanDown($event)' in html # #104: pan col puntatore
assert "imageSource === 'file'" in html # #104: mm/px solo da file
assert "imagePreviewUrl" in html and "imagePreviewSummary" in html # #102: anteprima immagine
# #113: zoom, Adatta e pan anche sulle due anteprime (immagine e DXF), per chiave
assert 'x-ref="vpImg"' in html and 'x-ref="vpDxf"' in html
assert "vFit('img')" in html and "vFit('dxf')" in html
assert "vZoomBy('img', 1.25)" in html and "vZoomBy('dxf', 1.25)" in html
assert "vPanDown('img', $event)" in html and "vWheel('dxf', $event)" in html
# #113: Alpine :style a stringa sostituisce l'attributo style: niente style statico sullo stesso tag
assert 'style="margin:auto;flex:none;"' not in html
assert html.count("margin:auto;flex:none;") == 2 and html.count(":style=\"'margin:auto;flex:none;") == 2
# #113: x-show e :style (display:flex) si pestano: x-ref="vpImg" non sta sul tag con x-show
tag = html[html.rindex("<div", 0, html.index('x-ref="vpImg"')):html.index('x-ref="vpImg"')]
assert "x-show" not in tag
# #122: le tre viste (immagine, DXF, Prova) sono <tmf-viewer>; zoom, Adatta, pan e Salva stanno nel componente
assert html.count("<tmf-viewer") == 3 and "js/image_viewer.js" in html
assert '@viewer-load="onImagePreviewLoad($event.detail)"' in html # #122: dimensioni per il riepilogo
assert "fit-height" in html and 'save-name="prova"' in html # #122: la Prova segue l'immagine
for old in ('x-ref="vpImg"', 'x-ref="vpDxf"', "vZoomBy(", "vFit(", "vPanDown(", "onPanDown(", "zoomBy(", 'x-ref="viewport"'):
assert old not in html, old # #122: zoom a mano tolto dal wizard
assert "existing ? msgs.editLabel : msgs.createLabel" in html and "Modifica ricetta da DXF" in html # #105: etichetta dinamica
assert "Pezzo scuro su fondo chiaro" in html or "Dark part on light" in html
@@ -1,52 +1,64 @@
"""Controllo del template del wizard DXF: Salva in due barre zoom e overlay SVG (#119, #121).
"""Controllo del template del wizard DXF: le tre viste sono <tmf-viewer> (#122, prima #113 #119 #121).
Legge task_editor.html come testo (come test_vision_panel_fit.py), senza renderlo.
"""
import re
from pathlib import Path
TEMPLATE = Path(__file__).resolve().parent.parent / "templates" / "maker" / "task_editor.html"
def _src() -> str:
return TEMPLATE.read_text(encoding="utf-8")
# senza commenti Jinja e HTML, che citano <tmf-viewer> e i vecchi nomi
text = TEMPLATE.read_text(encoding="utf-8")
text = re.sub(r"\{#.*?#\}", "", text, flags=re.S)
return re.sub(r"<!--.*?-->", "", text, flags=re.S)
def _bar_of(src: str, marker: str) -> str:
"""Testo della barra zoom che contiene il marker (dal div della barra al primo </div>)."""
def _tag_of(src: str, marker: str) -> str:
"""Tag di apertura <tmf-viewer ...> che contiene il marker."""
i = src.index(marker)
start = src.rfind('absolute top-2 right-2', 0, i)
return src[start:src.index("</div>", i)]
start = src.rfind("<tmf-viewer", 0, i)
return src[start:src.index(">", i) + 1]
def test_prova_overlay_is_svg_not_canvas():
# #121: l'overlay della Prova e' un <svg> con preserveAspectRatio none, non piu' un canvas
def test_three_viewers_image_dxf_prova():
# #122: tre <tmf-viewer>: immagine (src), DXF (modo contenuto, con <svg>), Prova (fit-height)
src = _src()
i = src.index('x-ref="overlay"')
tag = src[src.rfind("<", 0, i):src.index(">", i) + 1]
assert tag.startswith("<svg")
assert 'preserveAspectRatio="none"' in tag
assert "pointer-events-none" in tag
assert "<canvas" not in src[i - 200:i + 200]
assert src.count("<tmf-viewer") == 3
img = _tag_of(src, 'save-name="immagine"')
assert ':src="imagePreviewUrl"' in img
dxf = _tag_of(src, 'save-name="dxf"')
assert ":src" not in dxf and ":width=" in dxf and ":height=" in dxf
prova = _tag_of(src, 'save-name="prova"')
assert "fit-height" in prova and ':src="imageSrc"' in prova
assert "result.result.overlay" in prova # overlay delle forme del risultato
# il DXF contiene lo <svg> da disegnare, dentro il componente
i = src.index('save-name="dxf"')
assert src.index("<svg", i) < src.index("</tmf-viewer>", i)
assert "dxfPreviewD()" in src[i:src.index("</tmf-viewer>", i)]
def test_save_button_in_image_and_prova_bars_only():
# #119: Salva nella barra dell'immagine e in quella della Prova, non in quella del DXF
def test_old_viewports_gone():
# #122: niente piu' viewport, barre e overlay scritti a mano
src = _src()
img_bar = _bar_of(src, "vFit('img')")
prova_bar = _bar_of(src, 'zoomBy(1 / 1.25)')
dxf_bar = _bar_of(src, "vFit('dxf')")
assert "tmfSaveImage(imagePreviewUrl, 'immagine')" in img_bar
assert "tmfSaveImage(imageSrc, 'prova')" in prova_bar
assert "tmfSaveImage" not in dxf_bar
assert src.count("tmfSaveImage(") == 2
# il pulsante sta dopo Adatta e prima della percentuale
assert img_bar.index("vFit('img')") < img_bar.index("tmfSaveImage") < img_bar.index("vPct('img')")
assert prova_bar.index("fit()") < prova_bar.index("tmfSaveImage") < prova_bar.index("zoomPct")
for old in ('x-ref="vpImg"', 'x-ref="vpDxf"', 'x-ref="overlay"', 'x-ref="viewport"', "tmfSaveImage(",
"vZoomBy", "vFit(", "zoomPct", "vPct("):
assert old not in src, old
def test_no_style_string_on_viewers():
# #122: un :style a stringa sul <tmf-viewer> cancella lo style statico; x-show non sta sul componente
src = _src()
for m in re.finditer(r"<tmf-viewer[^>]*>", src):
tag = m.group(0)
assert ":style" not in tag and "x-show" not in tag, tag
def test_scripts_loaded_before_wizard():
# #119 #121: vision_overlay.js e save_image.js prima di dxf_recipe_wizard.js
# #119 #121 #122: vision_overlay.js e save_image.js prima di image_viewer.js, che sta prima di dxf_recipe_wizard.js
src = _src()
wiz = src.index("js/dxf_recipe_wizard.js")
assert src.index("js/vision_overlay.js") < wiz
assert src.index("js/save_image.js") < wiz
viewer = src.index("js/image_viewer.js")
assert src.index("js/vision_overlay.js") < viewer
assert src.index("js/save_image.js") < viewer
assert viewer < src.index("js/dxf_recipe_wizard.js")