Pannello visione operatore: barra senza input file con camera, Salva, Registra conta le quote, svg al posto del canvas, NaN

Refs #118 #119 #120 #121

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-28 21:20:21 +01:00
parent 4485803122
commit 58baed19e2
6 changed files with 287 additions and 51 deletions
@@ -69,7 +69,8 @@
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
@numpad-rejected.window="onNumpadRejected($event.detail)"
@numpad-error.window="onNumpadError($event.detail)"
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)">
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)"
@vision-recorded.window="onVisionRecorded($event.detail)">
{# ================================================================
HEADER — Compact task info bar
@@ -539,13 +540,14 @@
<div class="flex-1 min-w-0 min-h-0 flex flex-col gap-2">
<div class="flex items-center gap-2 flex-wrap">
{# On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
{% if not camera_enabled %}
{# #119: l'input file solo senza camera. On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
<span x-text="result ? '{{ _('Riprova') }}' : '{{ _('Acquisisci immagine') }}'"></span>
<input type="file" accept="image/*" capture="environment" class="hidden"
@change="onPick($event)" :disabled="busy">
</label>
{% if camera_enabled %}
{% else %}
{# #97: camera del task (o della stazione). Impulsiva: «Acquisisci» rifa grab+check; in linea: «Pausa»/«Riprendi» sul ciclo #}
{% if camera_mode == 'in_linea' %}
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="togglePause()"
@@ -558,7 +560,7 @@
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()"
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
:disabled="busy || grabbing">
<span>{{ _('Acquisisci') }}</span>
<span>{{ _('Acquisisci da camera') }}</span> {# #119 #}
</button>
{% endif %}
{# #97: salva l'ULTIMO frame mostrato (execute); fino ad allora e' solo un'anteprima #}
@@ -576,7 +578,7 @@
<div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
{# #96: riquadro flex-1 min-h-0; #115: dentro, barra zoom in overlay e viewport scorrevole con il wrapper (naturale x zoom) che contiene img e canvas; il canvas ha la stessa risoluzione naturale dell'immagine, quindi l'overlay resta allineato #}
{# #96: riquadro flex-1 min-h-0; #115: dentro, barra zoom in overlay e viewport scorrevole con il wrapper (naturale x zoom) che contiene img e svg; #121: l'svg ha il viewBox delle dimensioni naturali dell'immagine, quindi l'overlay resta allineato #}
<div x-show="shownImage" class="relative flex-1 min-h-0 border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
{# #115: x-show sul contenitore della barra, mai sul viewport (con :style stringa Alpine lo pesterebbe) #}
<div x-show="z.w" class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded px-1 py-0.5"
@@ -584,6 +586,8 @@
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(0.8)" 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="zFit()">{{ _('Adatta') }}</button>
{# #119: salva il frame grezzo PNG (senza disegni); la barra compare solo con un'immagine (x-show="z.w") #}
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="saveImage()" title="{{ _('Salva immagine') }}">{{ _('Salva') }}</button>
<span class="text-xs px-1" x-text="zPct()"></span>
</div>
{# #115: viewport = tutto il riquadro; solo :style stringa (uno style statico verrebbe cancellato da Alpine) #}
@@ -593,8 +597,9 @@
:style="'overflow:auto;touch-action:none;user-select:none;display:flex;cursor:' + zCursor()">
<div class="relative" :style="'margin:auto;flex:none;' + zSize()">
<img x-ref="img" :src="shownImage" @load="onImgLoad()" alt="" draggable="false" class="absolute inset-0 w-full h-full object-contain">
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full object-contain pointer-events-none"></canvas>
{# #116: etichette quota in HTML sopra il canvas (posizione in %, dimensione costante a ogni zoom); solo :style stringa #}
{# #121: overlay SVG al posto del canvas: viewBox = dimensioni naturali (le imposta drawOverlay), tratto costante a ogni zoom #}
<svg x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none" preserveAspectRatio="none"></svg>
{# #116: etichette quota in HTML sopra l'svg (posizione in %, dimensione costante a ogni zoom); solo :style stringa #}
<template x-for="l in overlayLabels" :key="l.key">
<div class="absolute pointer-events-none whitespace-nowrap text-xs font-bold px-1.5 py-0.5 rounded shadow"
:class="l.cls" :style="l.style" x-text="l.text"></div>
@@ -619,7 +624,8 @@
<tbody>
<tr>
<td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></td>
<td class="py-1 pr-2 font-mono text-right" x-text="Number(r.value).toFixed(3) + ' ' + r.unit"></td>
{# #118: quota non misurata (value null) = «NaN», esito fail #}
<td class="py-1 pr-2 font-mono text-right" x-text="fmtValue(r.value, r.unit)"></td>
<td class="py-1"><span class="inline-block px-2 py-0.5 rounded text-xs font-bold" :class="statusClass(r.status)" x-text="r.status"></span></td>
</tr>
<tr class="border-b border-[var(--border-color)]">
@@ -1209,12 +1215,15 @@
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
taskId: {{ task.id|tojson }},
taskType: {{ (task.task_type or '')|tojson }},
taskName: {{ (task.title or '')|tojson }}, {# #119: nome proposto per «Salva» #}
subtasks: window.__taskSubtasks,
labels: { requestFailed: {{ _('Richiesta non riuscita')|tojson }} },
};
</script>
{# #116: disegno overlay ed etichette quote; prima di vision_execute.js, che lo usa se presente #}
<script src="{{ url_for('static', filename='js/vision_overlay.js') }}"></script>
{# #119: «Salva» immagine (frame grezzo PNG) #}
<script src="{{ url_for('static', filename='js/save_image.js') }}"></script>
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
{# #53 #54 #55: result of check / note tasks (urls and words for the component) #}
<script>
@@ -1256,6 +1265,7 @@ function taskExecute() {
// ---- Measurement state ----
currentIndex: 0,
measurements: [], // [{subtask_id, value, pass_fail, deviation}, ...]
visionRecorded: false, // #120: task visione: il pezzo e' stato registrato («Registra»), le quote sono in measurements
saving: false,
// The single reentrancy guard for handleMeasurement (#13): true for the
// whole time a reading is being processed, including the 1s feedback
@@ -1362,6 +1372,8 @@ function taskExecute() {
},
get missingQuotesLabel() {
// #120: task visione non registrato: le quote arrivano tutte col «Registra», non una a una
if (this.isVisionTask && !this.visionRecorded) return {{ _('Registra il pezzo')|tojson }};
const n = this.missingQuotes;
return n === 1
? {{ _('Manca 1 quota')|tojson }}
@@ -1375,6 +1387,8 @@ function taskExecute() {
ciclo misura», and the quotes it exists to collect would be optional. */
get canLeaveTask() {
if (this.blockedByTolerance) return false;
// #120: un task visione si lascia solo dopo «Registra» (anche dxf_compare, prima sempre attivo)
if (this.isVisionTask && !this.visionRecorded) return false;
if (this.isMeasureTask && this.subtasks.length > 0) {
return this.cycleConfirmed;
}
@@ -1385,6 +1399,7 @@ function taskExecute() {
if (this.blockedByTolerance) {
return {{ _("Serve l'autorizzazione del capoturno")|tojson }};
}
if (this.isVisionTask && !this.visionRecorded) return this.missingQuotesLabel; // #120
if (!this.isComplete) return this.missingQuotesLabel;
return {{ _('Chiudi prima il ciclo di misura')|tojson }};
},
@@ -1468,9 +1483,10 @@ function taskExecute() {
subtask_id: m.subtask_id,
value: m.value,
pass_fail: m.pass_fail,
deviation: m.value - nominal,
deviation: m.value == null ? null : m.value - nominal, // #118: quota non misurata = NaN, nessuna deviazione
};
});
if (this.isVisionTask) this.visionRecorded = true; // #120: misure ripristinate = pezzo gia' registrato
const next = this.subtasks.findIndex(
s => !this.measurements.some(m => m.subtask_id === s.id),
);
@@ -1951,6 +1967,23 @@ function taskExecute() {
this.redirectIn = this.measurementTaskUrl ? 5 : 0;
},
/* #120: «Registra» del pannello visione: le misure salvate dal server diventano le misure del
ciclo (conteggio quote, «Fine ciclo misura»/«Completato»). value null = quota non misurata. */
onVisionRecorded(detail) {
const ms = (detail && detail.measurements) || [];
this.measurements = ms.map(m => {
const st = this.subtasks.find(s => s.id === m.subtask_id);
const nominal = st ? (st.nominal || 0) : 0;
return {
subtask_id: m.subtask_id,
value: m.value == null ? null : m.value,
pass_fail: m.pass_fail,
deviation: m.value == null ? null : m.value - nominal,
};
});
this.visionRecorded = true;
},
/* Clear the screen for another pass over the same quotes.
Local only: everything already measured is on the server, and the next
reading is saved beside it rather than replacing it. */
@@ -1958,6 +1991,7 @@ function taskExecute() {
this.cycleConfirmed = false;
this.showCompletionOverlay = false;
this.measurements = [];
this.visionRecorded = false; // #120: nuovo ciclo = pezzo da registrare di nuovo
this.currentIndex = 0;
this.currentValue = null;
this.errorMessage = '';