9e1bb20b36
- Canvas now sizes to match the scaled image dimensions (zero empty space) instead of using a fixed aspect ratio, fixing coordinate mismatch between editor and viewer - Annotations load only after background image is ready via _pendingAnnotations pattern, preventing placement at wrong coordinates - Arrow endpoints (arrowX1/Y1/X2/Y2) update on object:modified using transform delta, so moved arrows serialize at correct position - Coordinate scaling on load: coordScale = currentImageScale / savedImageScale handles annotations saved at different screen widths Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
531 lines
16 KiB
JavaScript
531 lines
16 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 (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) return;
|
|
|
|
// Editor format (objects array from annotation-editor.js)
|
|
if (this.annotations.objects) {
|
|
var previewScale = this.scale;
|
|
var imgScale = this.annotations.imageScale;
|
|
// v2+: use imageScale for correct canvas→image→preview mapping
|
|
// v1 fallback: annoScale = previewWidth / editorWidth
|
|
var annoScale = imgScale
|
|
? (previewScale / imgScale)
|
|
: (this.canvas.width / (this.annotations.width || this.canvas.width));
|
|
_drawEditorAnnotations(this.ctx, this.annotations, annoScale);
|
|
return;
|
|
}
|
|
|
|
// Legacy format (markers array)
|
|
if (!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 imgScale = annotations.imageScale;
|
|
// v2+: use imageScale for correct canvas→image→preview mapping
|
|
// v1 fallback: annoScale = previewWidth / editorWidth
|
|
var annoScale = imgScale
|
|
? (scale / imgScale)
|
|
: (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);
|
|
}
|
|
}
|
|
}
|