Files
TieMeasureFlow/client/static/js/annotation-viewer.js
T
Adriano a386986c17 feat: FASE 3 - Flusso MeasurementTec (selezione ricetta, esecuzione misure, riepilogo)
Implementazione completa del flusso operativo per il ruolo MeasurementTec:

Blueprint measure.py:
- select_recipe: selezione ricetta con ricerca e barcode
- task_list: lista task con conteggi subtask e allegati
- task_execute: esecuzione misure con numpad, calibro USB, feedback real-time
- task_complete: riepilogo con statistiche pass/fail e export CSV
- API AJAX: lookup-barcode, save-traceability, save-measurement
- Autorizzazione role_required("MeasurementTec") su tutte le route

Componenti riutilizzabili:
- numpad.html/js/css: tastierino numerico touch-friendly con keyboard support
- caliper_status.html + caliper.js: integrazione calibro USB via Web Serial API
- barcode_scanner.html + barcode.js: scansione barcode con html5-qrcode
- measurement_feedback.html: feedback visivo pass/warning/fail in tempo reale
- next_measurement.html: indicatore prossima misurazione
- annotation-viewer.js: visualizzatore canvas con marker su disegni tecnici
- csv-export.js: export CSV con locale italiano (delimitatore ;, decimale ,)

Sicurezza:
- Decoratore role_required(*roles) per autorizzazione basata su ruoli
- CSRF token su tutti i POST AJAX
- |tojson per prevenire XSS su annotations_json
- Validazione input lato client e server

i18n: 23+ nuove chiavi tradotte IT/EN per tutti i template FASE 3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 08:40:58 +01:00

274 lines
7.6 KiB
JavaScript

/**
* Annotation Viewer - Display-only viewer for image annotations
*
* Alpine.js component for viewing measurement markers on technical drawings.
* Supports point markers and rectangle markers with highlighting.
*
* Annotation JSON Format:
* {
* "markers": [
* {
* "id": 1,
* "marker_number": 1,
* "subtask_id": 42,
* "x": 150,
* "y": 200,
* "type": "point",
* "label": "D1"
* },
* {
* "id": 2,
* "marker_number": 2,
* "subtask_id": 43,
* "x": 300,
* "y": 150,
* "width": 100,
* "height": 50,
* "type": "rectangle",
* "label": "L1"
* }
* ],
* "imageWidth": 800,
* "imageHeight": 600
* }
*
* Usage:
* <div x-data="annotationViewer()"
* x-init="imageUrl = '/path/to/image.jpg'; annotations = {...}; init()">
* <canvas x-ref="annotationCanvas" @click="handleClick($event)"></canvas>
* </div>
*/
function annotationViewer() {
return {
// Canvas elements
canvas: null,
ctx: null,
// Data
annotations: null, // { markers: [...], imageWidth, imageHeight }
imageUrl: null, // URL dell'immagine da visualizzare
activeMarker: null, // marker_number del marker correntemente selezionato
// State
imageLoaded: false,
scale: 1, // scala di ridimensionamento immagine
/**
* Inizializzazione component
*/
init() {
this.canvas = this.$refs.annotationCanvas;
if (!this.canvas) {
console.error('AnnotationViewer: canvas ref not found');
return;
}
this.ctx = this.canvas.getContext('2d');
// Load image if URL is provided
if (this.imageUrl) {
this.loadImage();
}
// Responsive resize
window.addEventListener('resize', () => {
if (this.imageLoaded) {
this.loadImage();
}
});
},
/**
* Carica e renderizza l'immagine con scaling
*/
loadImage() {
const img = new Image();
img.onload = () => {
// Calculate scale to fit container
const container = this.canvas.parentElement;
if (!container) {
console.error('AnnotationViewer: parent container not found');
return;
}
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight || 500; // fallback height
this.scale = Math.min(
containerWidth / img.width,
containerHeight / img.height,
1 // non ingrandire oltre dimensione originale
);
this.canvas.width = img.width * this.scale;
this.canvas.height = img.height * this.scale;
// Draw image
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(img, 0, 0, this.canvas.width, this.canvas.height);
this.imageLoaded = true;
// Draw annotations on top
this.drawAnnotations();
};
img.onerror = () => {
console.error('AnnotationViewer: failed to load image:', this.imageUrl);
};
img.src = this.imageUrl;
},
/**
* Disegna tutti i markers sulle annotazioni
*/
drawAnnotations() {
if (!this.annotations || !this.annotations.markers) return;
for (const marker of this.annotations.markers) {
const isActive = this.activeMarker === marker.marker_number;
this.drawMarker(marker, isActive);
}
},
/**
* Disegna un singolo marker
* @param {Object} marker - marker data
* @param {boolean} isActive - se il marker è attualmente selezionato
*/
drawMarker(marker, isActive) {
const x = marker.x * this.scale;
const y = marker.y * this.scale;
const color = isActive ? '#2563EB' : '#64748B'; // primary : steel
const radius = isActive ? 16 : 12;
if (marker.type === 'point') {
// Cerchio numerato
this.ctx.beginPath();
this.ctx.arc(x, y, radius, 0, Math.PI * 2);
this.ctx.fillStyle = color;
this.ctx.globalAlpha = isActive ? 0.9 : 0.7;
this.ctx.fill();
this.ctx.globalAlpha = 1;
// Bordo bianco
this.ctx.strokeStyle = '#FFFFFF';
this.ctx.lineWidth = 2;
this.ctx.stroke();
// Numero
this.ctx.fillStyle = '#FFFFFF';
this.ctx.font = `bold ${isActive ? 14 : 11}px Inter, sans-serif`;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(marker.marker_number.toString(), x, y);
} else if (marker.type === 'rectangle') {
const w = (marker.width || 50) * this.scale;
const h = (marker.height || 30) * this.scale;
// Rettangolo
this.ctx.strokeStyle = color;
this.ctx.lineWidth = isActive ? 3 : 2;
this.ctx.setLineDash(isActive ? [] : [5, 3]);
this.ctx.strokeRect(x, y, w, h);
this.ctx.setLineDash([]);
// Label badge
const badgeWidth = 30;
const badgeHeight = 18;
this.ctx.fillStyle = color;
this.ctx.globalAlpha = 0.8;
this.ctx.fillRect(x, y - 20, badgeWidth, badgeHeight);
this.ctx.globalAlpha = 1;
// Numero nella badge
this.ctx.fillStyle = '#FFFFFF';
this.ctx.font = 'bold 11px Inter, sans-serif';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(marker.marker_number.toString(), x + badgeWidth / 2, y - 11);
}
},
/**
* Evidenzia un marker specifico
* @param {number} markerNumber - numero del marker da evidenziare
*/
setActiveMarker(markerNumber) {
this.activeMarker = markerNumber;
if (this.imageLoaded) {
// Ridisegna tutto
this.loadImage();
}
},
/**
* Gestisce click sul canvas per selezionare markers
* @param {MouseEvent} e - evento click
*/
handleClick(e) {
if (!this.annotations || !this.annotations.markers) return;
const rect = this.canvas.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const clickY = e.clientY - rect.top;
// Check click su ogni marker (reverse order per priorità visuale)
for (let i = this.annotations.markers.length - 1; i >= 0; i--) {
const marker = this.annotations.markers[i];
const mx = marker.x * this.scale;
const my = marker.y * this.scale;
let hit = false;
if (marker.type === 'point') {
const dist = Math.sqrt((clickX - mx) ** 2 + (clickY - my) ** 2);
hit = dist < 20;
} else if (marker.type === 'rectangle') {
const w = (marker.width || 50) * this.scale;
const h = (marker.height || 30) * this.scale;
hit = clickX >= mx && clickX <= mx + w &&
clickY >= my && clickY <= my + h;
}
if (hit) {
// Dispatch event per parent component
this.$dispatch('marker-click', {
marker_number: marker.marker_number,
subtask_id: marker.subtask_id,
marker: marker
});
// Auto-evidenzia
this.setActiveMarker(marker.marker_number);
return;
}
}
},
/**
* Pulisce il canvas
*/
clear() {
if (this.ctx && this.canvas) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.imageLoaded = false;
}
},
/**
* Aggiorna le annotazioni e ridisegna
* @param {Object} newAnnotations - nuovo oggetto annotations
*/
updateAnnotations(newAnnotations) {
this.annotations = newAnnotations;
if (this.imageLoaded) {
this.loadImage();
}
}
};
}