/** * 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 (o la prima pagina PDF) con scaling */ loadImage() { var isPdf = this.imageUrl.toLowerCase().replace(/\?.*$/, '').endsWith('.pdf'); if (isPdf && typeof pdfjsLib !== 'undefined') { this._loadPdf(); } else { this._loadRasterImage(); } }, /** * Render first page of a PDF via PDF.js */ _loadPdf() { const self = this; pdfjsLib.getDocument(this.imageUrl).promise.then(function (pdf) { return pdf.getPage(1); }).then(function (page) { const pdfScale = 2; // render at 2x for clarity const viewport = page.getViewport({ scale: pdfScale }); const offCanvas = document.createElement('canvas'); offCanvas.width = viewport.width; offCanvas.height = viewport.height; const offCtx = offCanvas.getContext('2d'); page.render({ canvasContext: offCtx, viewport: viewport }).promise.then(function () { // Use rendered PDF page as if it were an image self._drawImageOnCanvas(offCanvas, viewport.width, viewport.height); }); }).catch(function (err) { console.error('AnnotationViewer: failed to load PDF:', err); }); }, /** * Load a raster image (PNG, JPG, etc.) */ _loadRasterImage() { const self = this; const img = new Image(); img.onload = function () { self._drawImageOnCanvas(img, img.width, img.height); }; img.onerror = function () { console.error('AnnotationViewer: failed to load image:', self.imageUrl); }; img.src = this.imageUrl; }, /** * Draw an image source (Image or Canvas) scaled to fit the container */ _drawImageOnCanvas(source, srcWidth, srcHeight) { const container = this.canvas.parentElement; if (!container) { console.error('AnnotationViewer: parent container not found'); return; } const containerWidth = container.clientWidth; const containerHeight = container.clientHeight || 500; this.scale = Math.min( containerWidth / srcWidth, containerHeight / srcHeight, 1 ); this.canvas.width = srcWidth * this.scale; this.canvas.height = srcHeight * this.scale; this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.ctx.drawImage(source, 0, 0, this.canvas.width, this.canvas.height); this.imageLoaded = true; this.drawAnnotations(); }, /** * 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(); } } }; }