Unisce lavoro/daitarn-tmf-v-operatore: visore standard <tmf-viewer> (Refs #122)

This commit is contained in:
2026-10-28 22:06:43 +01:00
7 changed files with 84 additions and 348 deletions
@@ -38,13 +38,6 @@ function visionExecute() {
looping: false, // an in-line loop is running
gen: 0, // bumped by «Registra»: answers of older requests are dropped
// #115: zoom del riquadro immagine (stesso schema del wizard DXF, #113). zoom = px schermo per px immagine;
// fit = zoom di «Adatta»; w/h = dimensioni naturali; auto = la vista e' ad Adatta (segue il resize del riquadro)
z: { zoom: 1, fit: 1, w: 0, h: 0, auto: true },
zPanning: false, // #115: trascinamento in corso (cursore grabbing)
_zPan: null,
_zRO: null,
// #97: start on a frame already acquired and analysed; hook visibility and exit.
init() {
// #120: «Rimisura»/nuovo ciclo (task-result-reset): il pezzo va registrato di nuovo, anche senza camera
@@ -53,8 +46,6 @@ function visionExecute() {
this.preview = !!this.result;
this.kick();
});
// #121: l'overlay SVG si ridisegna a ogni cambio di zoom (spessore e croci costanti a video)
if (typeof this.$watch === 'function') this.$watch('z.zoom', () => this.drawOverlay());
if (!this.hasCamera) return;
document.addEventListener('visibilitychange', () => { if (!document.hidden) this.kick(); });
const leave = () => { this.leaving = true; };
@@ -132,7 +123,6 @@ function visionExecute() {
this.result = body;
this.preview = true;
this.saved = false;
this.$nextTick(() => this.drawOverlay());
return true;
} catch (e) {
if (gen === this.gen) this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
@@ -194,8 +184,6 @@ function visionExecute() {
this.result = body;
// #120: senza camera il file scelto e' salvato subito (nessun «Registra»): stesso evento
if (!this.hasCamera && body.vision_result_id) this._emitRecorded();
// Draw once Alpine has rendered the svg.
this.$nextTick(() => this.drawOverlay());
} catch (e) {
this.error = (cfg.labels && cfg.labels.requestFailed) || 'Error';
} finally {
@@ -209,13 +197,6 @@ function visionExecute() {
window.dispatchEvent(new CustomEvent('vision-recorded', { detail: { measurements: ms } }));
},
// #119: «Salva» = frame grezzo PNG dell'immagine mostrata, senza disegni (save_image.js)
saveImage() {
const cfg = window.__visionCfg || {};
if (!this.shownImage || typeof tmfSaveImage !== 'function') return Promise.resolve(false);
return tmfSaveImage(this.shownImage, cfg.taskName || 'immagine');
},
// Station camera (#72): the Flask proxy answers a PNG, which goes through
// acquire() like a picked file. Errors show as the execute ones do.
// #97: sends task_id (the task's camera wins over the station's); with a camera
@@ -297,114 +278,7 @@ function visionExecute() {
});
},
// #115: load dell'immagine (anche a ogni frame in linea): dimensioni naturali, fit solo se serve, poi overlay
onImgLoad() {
const img = this.$refs.img;
if (img && img.naturalWidth) {
const z = this.z;
const changed = z.w !== img.naturalWidth || z.h !== img.naturalHeight;
z.w = img.naturalWidth;
z.h = img.naturalHeight;
// dimensioni uguali e vista ingrandita: il frame nuovo resta ingrandito (in linea ogni frame ricarica l'immagine)
if (changed || z.auto) this.zFit();
this._zObserve();
}
this.drawOverlay();
},
// #115: osservatore di resize sul viewport: se la vista e' ad Adatta (o il riquadro era nascosto) rifa il fit
_zObserve() {
const vp = this.$refs.vp;
if (!vp || this._zRO || typeof ResizeObserver === 'undefined') return;
this._zRO = new ResizeObserver(() => { if (this.z.w > 0 && this.z.auto) this.zFit(); });
this._zRO.observe(vp);
},
// #115: area utile del viewport; offset meno i bordi (client* escludono le barre di scorrimento che spariranno dopo il fit, come #113)
_zArea() {
const vp = this.$refs.vp;
if (!vp) return { cw: 0, ch: 0 };
let cw = vp.clientWidth, ch = vp.clientHeight;
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);
}
return { cw, ch };
},
// #115: Adatta = tutta l'immagine nel riquadro (come il fit di #96)
zFit() {
const vp = this.$refs.vp, z = this.z;
if (!vp || !z.w || !z.h) return;
const a = this._zArea();
if (!(a.cw > 0) || !(a.ch > 0)) return; // riquadro nascosto o vuoto: niente, ci pensa l'osservatore
const f = Math.min(a.cw / z.w, a.ch / z.h);
z.fit = f;
z.zoom = f;
z.auto = true;
vp.scrollLeft = 0;
vp.scrollTop = 0;
},
// #115: zoom di un fattore tenendo fermo il punto (cx, cy) del viewport (default: il centro)
zoomBy(f, cx, cy) {
const vp = this.$refs.vp, z = this.z;
if (!vp || !z.w) return;
const old = z.zoom;
const nz = Math.min(Math.max(8, z.fit), Math.max(z.fit * 0.5, old * f));
if (nz === old) return;
if (cx == null) cx = vp.clientWidth / 2;
if (cy == null) cy = vp.clientHeight / 2;
const left = vp.scrollLeft, top = vp.scrollTop;
z.zoom = nz;
z.auto = false;
const apply = () => {
vp.scrollLeft = (left + cx) * nz / old - cx;
vp.scrollTop = (top + cy) * nz / old - cy;
};
if (typeof this.$nextTick === 'function') this.$nextTick(apply); else apply();
},
// #115: Ctrl+rotella = zoom sul punto del cursore; senza Ctrl la pagina scorre come sempre
zWheel(ev) {
if (!ev.ctrlKey) return;
ev.preventDefault();
const vp = this.$refs.vp;
let cx, cy;
if (vp && vp.getBoundingClientRect) {
const r = vp.getBoundingClientRect();
cx = ev.clientX - r.left;
cy = ev.clientY - r.top;
}
this.zoomBy(ev.deltaY < 0 ? 1.25 : 1 / 1.25, cx, cy);
},
zCanPan() { const z = this.z; return z.w > 0 && z.zoom > z.fit * 1.0001; },
zCursor() { return this.zCanPan() ? (this.zPanning ? 'grabbing' : 'grab') : 'default'; },
zPct() { return Math.round(this.z.zoom * 100) + '%'; },
// #115: dimensione del wrapper: naturale x zoom; prima del load riempie il riquadro
zSize() {
const z = this.z;
return z.w ? 'width:' + (z.w * z.zoom) + 'px;height:' + (z.h * z.zoom) + 'px' : 'width:100%;height:100%';
},
// #115: trascinamento = scorrimento del viewport (solo se ingrandito)
zPanDown(ev) {
const vp = this.$refs.vp;
if (!vp || !this.zCanPan() || (ev.pointerType === 'mouse' && ev.button)) return;
if (ev.preventDefault) ev.preventDefault();
this._zPan = { x: ev.clientX, y: ev.clientY, left: vp.scrollLeft, top: vp.scrollTop };
this.zPanning = true;
try { vp.setPointerCapture(ev.pointerId); } catch (e) { /* puntatore gia' rilasciato */ }
},
zPanMove(ev) {
const vp = this.$refs.vp, p = this._zPan;
if (!p || !vp) return;
vp.scrollLeft = p.left - (ev.clientX - p.x);
vp.scrollTop = p.top - (ev.clientY - p.y);
},
zPanEnd() { this._zPan = null; this.zPanning = false; },
// #122: zoom, pan, resize e Salva li gestisce <tmf-viewer> (static/js/image_viewer.js)
// #118: valore della cella: una quota non misurata (null) e' «NaN», mai Number(null) = 0
fmtValue(v, unit) {
@@ -414,19 +288,15 @@ function visionExecute() {
// #117: tolleranza a 3 decimali, trattino se manca (come x-text="...?.toFixed(3) || '—'" dei calibri, ma 0 resta 0.000)
fmtTol(v) { return v == null ? '\u2014' : Number(v).toFixed(3); },
// #116: etichette «nome valore unita» agganciate alla geometria dello strumento; posizione in % delle
// dimensioni naturali dell'immagine, cosi' seguono lo zoom (#115) senza ingrandirsi
get overlayLabels() {
// #119 #122: nome proposto da «Salva» (titolo del task) per <tmf-viewer save-name>
get saveName() { return (window.__visionCfg || {}).taskName || 'immagine'; },
// #116 #122: etichette «nome valore unita» per <tmf-viewer>: x/y in px logici dell'immagine
// (il componente le posiziona in % e le tiene a dimensione costante a ogni zoom)
get viewerLabels() {
if (typeof VisionOverlay === 'undefined' || !this.result) return [];
const img = this.$refs && this.$refs.img;
const w = this.z.w || (img && img.naturalWidth) || 0;
const h = this.z.h || (img && img.naturalHeight) || 0;
if (!w || !h) return [];
return VisionOverlay.labels(this.rows, this.result.overlay || []).map((l) => ({
key: l.key,
text: l.text,
cls: this.labelClass(l.status),
style: 'left:' + (l.x / w * 100) + '%;top:' + (l.y / h * 100) + '%;transform:translate(8px,-50%)',
key: l.key, x: l.x, y: l.y, text: l.text, cls: this.labelClass(l.status),
}));
},
@@ -445,15 +315,5 @@ function visionExecute() {
if (s === 'warning') return 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40';
return 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]';
},
// #121: overlay SVG (VisionOverlay.svg) nel wrapper dello zoom: viewBox = dimensioni naturali, quindi
// le coordinate (px immagine) valgono cosi' come sono; spessore e croci restano costanti a ogni zoom
drawOverlay() {
const img = this.$refs.img, sv = this.$refs.overlay;
if (!sv) return;
if (img && img.naturalWidth) sv.setAttribute('viewBox', '0 0 ' + img.naturalWidth + ' ' + img.naturalHeight);
const ok = typeof VisionOverlay !== 'undefined' && typeof VisionOverlay.svg === 'function';
sv.innerHTML = ok && this.result ? VisionOverlay.svg(this.result.overlay || [], { zoom: this.z.zoom }) : '';
},
};
}