/** * 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: *
* *
*/ 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(); } } }; }