/** * 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(); } } }; } /** * Standalone function to render an image with annotations overlay. * For use in read-only previews (e.g. task cards) without Alpine.js. * * @param {HTMLCanvasElement} canvas - the canvas element to draw on * @param {string} imageUrl - URL of the image to load * @param {Object|string|null} annotationsJson - annotations data from the editor * @param {Object} [options] - optional settings * @param {number} [options.maxHeight=192] - max canvas height in pixels */ function renderAnnotationPreview(canvas, imageUrl, annotationsJson, options) { if (!canvas || !imageUrl) return; var opts = options || {}; var maxHeight = opts.maxHeight || 192; var ctx = canvas.getContext('2d'); var annotations = null; if (annotationsJson) { try { annotations = (typeof annotationsJson === 'string') ? JSON.parse(annotationsJson) : annotationsJson; } catch (e) { console.error('renderAnnotationPreview: invalid annotations JSON'); } } var isPdf = imageUrl.toLowerCase().replace(/\?.*$/, '').endsWith('.pdf'); if (isPdf && typeof pdfjsLib !== 'undefined') { pdfjsLib.getDocument(imageUrl).promise.then(function (pdf) { return pdf.getPage(1); }).then(function (page) { var viewport = page.getViewport({ scale: 2 }); var offCanvas = document.createElement('canvas'); offCanvas.width = viewport.width; offCanvas.height = viewport.height; var offCtx = offCanvas.getContext('2d'); page.render({ canvasContext: offCtx, viewport: viewport }).promise.then(function () { _drawPreview(canvas, ctx, offCanvas, viewport.width, viewport.height, annotations, maxHeight); }); }).catch(function (err) { console.error('renderAnnotationPreview: PDF load failed:', err); }); } else { var img = new Image(); img.crossOrigin = 'anonymous'; img.onload = function () { _drawPreview(canvas, ctx, img, img.width, img.height, annotations, maxHeight); }; img.onerror = function () { console.error('renderAnnotationPreview: image load failed:', imageUrl); }; img.src = imageUrl; } } /** * Internal: draw image + annotations on canvas, scaled to fit. */ function _drawPreview(canvas, ctx, source, srcW, srcH, annotations, maxHeight) { var container = canvas.parentElement; var containerWidth = container ? container.clientWidth : 400; var scale = Math.min(containerWidth / srcW, maxHeight / srcH, 1); canvas.width = Math.round(srcW * scale); canvas.height = Math.round(srcH * scale); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(source, 0, 0, canvas.width, canvas.height); if (annotations && annotations.objects) { var annoScale = canvas.width / (annotations.width || srcW); _drawEditorAnnotations(ctx, annotations, annoScale); } else if (annotations && annotations.markers) { _drawLegacyAnnotations(ctx, annotations, scale); } } /** * Draw annotations in the editor format (objects: marker/arrow/area). */ function _drawEditorAnnotations(ctx, data, scale) { var objects = data.objects || []; // Calculate annotation scale: annotations were placed on a canvas of // data.width x data.height, but we're drawing on a scaled canvas. // The image was scaled to fit data.width x data.height in the editor, // and now we scale again for preview. Use overall scale factor. var annoScale = scale; for (var i = 0; i < objects.length; i++) { var obj = objects[i]; if (obj.type === 'marker') { _drawPreviewMarker(ctx, obj, annoScale); } else if (obj.type === 'arrow') { _drawPreviewArrow(ctx, obj, annoScale); } else if (obj.type === 'area') { _drawPreviewArea(ctx, obj, annoScale); } } } function _drawPreviewMarker(ctx, obj, scale) { var x = obj.left * scale; var y = obj.top * scale; var color = obj.fill || '#2563EB'; var radius = 14 * Math.min(scale, 1); ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fillStyle = color; ctx.globalAlpha = 0.85; ctx.fill(); ctx.globalAlpha = 1; ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2; ctx.stroke(); if (obj.markerNumber) { ctx.fillStyle = '#ffffff'; ctx.font = 'bold ' + Math.max(10, Math.round(12 * Math.min(scale, 1))) + 'px Inter, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(obj.markerNumber.toString(), x, y); } } function _drawPreviewArrow(ctx, obj, scale) { var x1 = (obj.x1 != null ? obj.x1 : obj.left) * scale; var y1 = (obj.y1 != null ? obj.y1 : obj.top) * scale; var x2 = (obj.x2 != null ? obj.x2 : (obj.left + (obj.width || 100))) * scale; var y2 = (obj.y2 != null ? obj.y2 : (obj.top + (obj.height || 50))) * scale; var color = obj.stroke || '#DC2626'; var lineWidth = obj.strokeWidth || 2; var lineDash = obj.lineDash || []; ctx.setLineDash(lineDash); ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.stroke(); ctx.setLineDash([]); // Arrowhead var angle = Math.atan2(y2 - y1, x2 - x1); var headLen = 8 + lineWidth * 2; ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo(x2 - headLen * Math.cos(angle - Math.PI / 6), y2 - headLen * Math.sin(angle - Math.PI / 6)); ctx.lineTo(x2 - headLen * Math.cos(angle + Math.PI / 6), y2 - headLen * Math.sin(angle + Math.PI / 6)); ctx.closePath(); ctx.fillStyle = color; ctx.fill(); } function _drawPreviewArea(ctx, obj, scale) { var x = obj.left * scale; var y = obj.top * scale; var w = (obj.width || 150) * (obj.scaleX || 1) * scale; var h = (obj.height || 100) * (obj.scaleY || 1) * scale; var color = obj.stroke || '#2563EB'; var lineWidth = obj.strokeWidth || 2; var lineDash = obj.lineDash || [5, 3]; ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.setLineDash(lineDash); ctx.strokeRect(x, y, w, h); ctx.setLineDash([]); } /** * Draw annotations in the legacy format (markers: point/rectangle). */ function _drawLegacyAnnotations(ctx, data, scale) { var markers = data.markers || []; for (var i = 0; i < markers.length; i++) { var m = markers[i]; if (m.type === 'point') { _drawPreviewMarker(ctx, { left: m.x, top: m.y, markerNumber: m.marker_number, fill: '#64748B' }, scale); } else if (m.type === 'rectangle') { _drawPreviewArea(ctx, { left: m.x, top: m.y, width: m.width, height: m.height, stroke: '#64748B' }, scale); } } }