// ============================================
// Annotation Tool — Canvas-based Image Annotation
// Phase 4: Variant Generation
// ============================================
const AnnotationTool = {
// Canvas references
imageCanvas: null,
drawingCanvas: null,
imageContext: null,
drawingContext: null,
// State
zoomLevel: 1,
panOffset: { x: 0, y: 0 },
isPanning: false,
panStart: { x: 0, y: 0 },
isInitialized: false,
// Drawing state
drawingMode: 'none', // 'none', 'pen', 'circle', 'rectangle', 'arrow'
drawColor: '#ff0000',
lineWidth: 3,
isDrawing: false,
drawStartPos: { x: 0, y: 0 },
drawingOverlay: null,
// Defect simulation state
defectMode: false, // When true, clicking places defects
selectedDefectType: 'fiber', // 'fiber', 'scratch', 'cratch', 'particles', 'discoloration', 'bubble'
isProcessing: false,
// Variant generation state
variants: [], // Array of { id, position, imageData }
selectedVariantIndex: null,
variantGridContainer: null,
// Modified image state (for storing applied changes)
modifiedImageData: null, // Base64 of the current modified image
// Undo history
history: [],
maxHistorySize: 20,
// Seeded random for reproducible defect generation
randomSeed: 0,
seededRandomCalls: 0,
// UI Elements (will be set on init)
elements: {},
// Defect types configuration
defectTypes: [
{ id: 'fiber', name: 'Fiber', icon: 'fa-grip-lines', description: 'Hair-like fibers' },
{ id: 'scratch', name: 'Scratch', icon: 'fa-minus', description: 'Surface scratches' },
{ id: 'crack', name: 'Crack', icon: 'fa-bolt', description: 'Crack patterns' },
{ id: 'particles', name: 'Particles', icon: 'fa-dots', description: 'Dust/contamination' },
{ id: 'discoloration', name: 'Discoloration', icon: 'fa-palette', description: 'Color variation' },
{ id: 'bubble', name: 'Bubble', icon: 'fa-circle', description: 'Air bubbles' }
],
// Variant positions (6 predefined positions)
variantPositions: [
{ name: 'Top-Left', xRatio: 0.2, yRatio: 0.2 },
{ name: 'Top-Center', xRatio: 0.5, yRatio: 0.2 },
{ name: 'Top-Right', xRatio: 0.8, yRatio: 0.2 },
{ name: 'Bottom-Left', xRatio: 0.2, yRatio: 0.8 },
{ name: 'Bottom-Center', xRatio: 0.5, yRatio: 0.8 },
{ name: 'Bottom-Right', xRatio: 0.8, yRatio: 0.8 }
],
init() {
this.cacheElements();
this.setupCanvases();
this.bindEvents();
this.renderDefectTypeSelector();
this.isInitialized = true;
console.log('[AnnotationTool] Phase 4 initialized: Variant generation ready');
},
cacheElements() {
this.elements = {
annotationPanel: '#annotation-panel',
annotationContent: '#annotation-content',
annotationCanvas: '#annotation-canvas',
annotationDrawingCanvas: '#annotation-drawing-canvas',
annotationImageWrapper: '#annotation-image-wrapper',
zoomSlider: '#annotation-zoom-slider',
zoomLevel: '#annotation-zoom-level',
zoomIn: '#annotation-zoom-in',
zoomOut: '#annotation-zoom-out',
zoomReset: '#annotation-zoom-reset',
btnExitAnnotation: '#btn-exit-annotation',
// Drawing tools
btnToolPen: '#annotation-tool-pen',
btnToolCircle: '#annotation-tool-circle',
btnToolRectangle: '#annotation-tool-rectangle',
btnToolArrow: '#annotation-tool-arrow',
btnToolEraser: '#annotation-tool-eraser',
btnClearDrawing: '#annotation-clear-drawing',
btnApplyDrawing: '#annotation-apply-drawing',
btnUndo: '#annotation-undo',
btnDrawingColor: '#annotation-drawing-color',
drawingColorPalette: '#annotation-color-palette',
drawingLineWidth: '#annotation-line-width',
drawingLineWidthVal: '#annotation-line-width-val',
drawingTools: '#annotation-drawing-tools',
// Defect tools
defectToolsSection: '#annotation-defect-tools',
btnToggleDefectMode: '#annotation-toggle-defect-mode',
defectTypeSelector: '#annotation-defect-type-selector',
btnApplyDefect: '#annotation-apply-defect',
btnGenerateVariants: '#annotation-generate-variants',
defectPreview: '#annotation-defect-preview',
// Variant tools
variantToolsSection: '#annotation-variant-tools',
btnSaveAllVariants: '#annotation-save-all-variants',
btnClearVariants: '#annotation-clear-variants',
variantGridContainer: '#annotation-variant-grid',
};
},
setupCanvases() {
this.imageCanvas = Helpers.$(this.elements.annotationCanvas);
this.drawingCanvas = Helpers.$(this.elements.annotationDrawingCanvas);
if (this.imageCanvas) {
this.imageContext = this.imageCanvas.getContext('2d');
}
if (this.drawingCanvas) {
this.drawingContext = this.drawingCanvas.getContext('2d');
}
},
bindEvents() {
// Zoom controls
Helpers.$(this.elements.zoomSlider)?.addEventListener('input', (e) => {
this.setZoom(parseFloat(e.target.value));
});
Helpers.$(this.elements.zoomIn)?.addEventListener('click', () => {
this.zoomIn();
});
Helpers.$(this.elements.zoomOut)?.addEventListener('click', () => {
this.zoomOut();
});
Helpers.$(this.elements.zoomReset)?.addEventListener('click', () => {
this.resetZoom();
});
// Drawing tool buttons
Helpers.$(this.elements.btnToolPen)?.addEventListener('click', () => this.setTool('pen'));
Helpers.$(this.elements.btnToolCircle)?.addEventListener('click', () => this.setTool('circle'));
Helpers.$(this.elements.btnToolRectangle)?.addEventListener('click', () => this.setTool('rectangle'));
Helpers.$(this.elements.btnToolArrow)?.addEventListener('click', () => this.setTool('arrow'));
Helpers.$(this.elements.btnToolEraser)?.addEventListener('click', () => this.setTool('none'));
// Defect mode toggle
Helpers.$(this.elements.btnToggleDefectMode)?.addEventListener('click', () => {
this.toggleDefectMode();
});
// Apply defect button
Helpers.$(this.elements.btnApplyDefect)?.addEventListener('click', () => {
this.applyDefect(this.selectedDefectType);
});
// Generate variants button
Helpers.$(this.elements.btnGenerateVariants)?.addEventListener('click', () => {
this.generateDefectVariants();
});
// Save all variants button
Helpers.$(this.elements.btnSaveAllVariants)?.addEventListener('click', () => {
this.saveAllVariants();
});
// Clear variants button
Helpers.$(this.elements.btnClearVariants)?.addEventListener('click', () => {
this.clearVariants();
});
// Clear and Apply buttons
Helpers.$(this.elements.btnClearDrawing)?.addEventListener('click', () => this.clearDrawing());
Helpers.$(this.elements.btnApplyDrawing)?.addEventListener('click', () => this.applyDrawingToImage());
// Undo button
Helpers.$(this.elements.btnUndo)?.addEventListener('click', () => this.undo());
// Color picker
Helpers.$(this.elements.btnDrawingColor)?.addEventListener('input', (e) => {
this.drawColor = e.target.value;
this.updateColorPaletteUI();
});
// Color palette presets
this.bindColorPaletteEvents();
// Line width slider
Helpers.$(this.elements.drawingLineWidth)?.addEventListener('input', (e) => {
this.lineWidth = parseInt(e.target.value);
this.updateLineWidthUI();
});
// Image wrapper click for defect placement
Helpers.$(this.elements.annotationImageWrapper)?.addEventListener('click', (e) => {
if (this.defectMode) {
this.handleDefectPlacement(e);
}
});
// Drawing canvas events (only when not in defect mode)
Helpers.$(this.elements.annotationImageWrapper)?.addEventListener('mousedown', (e) => {
if (!this.defectMode) {
this.handleMouseDown(e);
}
});
document.addEventListener('mousemove', (e) => {
if (!this.defectMode && this.isDrawing) {
this.handleMouseMove(e);
}
});
document.addEventListener('mouseup', (e) => {
if (!this.defectMode && this.isDrawing) {
this.handleMouseUp(e);
}
});
// Pan with shift+drag (only when not in drawing or defect mode)
Helpers.$(this.elements.annotationImageWrapper)?.addEventListener('mousedown', (e) => {
if (e.shiftKey && this.zoomLevel > 1 && !this.defectMode && this.drawingMode === 'none') {
this.startPan(e);
}
});
document.addEventListener('mousemove', (e) => {
if (this.isPanning && !this.defectMode && this.drawingMode === 'none') {
this.updatePan(e);
}
});
document.addEventListener('mouseup', () => {
if (this.isPanning) {
this.endPan();
}
});
// Exit annotation button
Helpers.$(this.elements.btnExitAnnotation)?.addEventListener('click', () => {
this.close();
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Only handle shortcuts when annotation panel is open
const panel = Helpers.$(this.elements.annotationPanel);
if (!panel?.classList.contains('active')) return;
if (e.key === 'Escape') {
if (this.defectMode) {
this.toggleDefectMode();
} else if (this.drawingMode !== 'none') {
this.setTool('none');
} else {
this.close();
}
}
if (e.key === 'z' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.undo();
}
});
},
// =============================================
// VARIANT GENERATION METHODS
// =============================================
generateDefectVariants() {
const wrapper = Helpers.$(this.elements.annotationImageWrapper);
const img = wrapper?.querySelector('img');
if (!img) {
Toast.show('No image loaded', 'error');
return;
}
if (this.isProcessing) return;
this.isProcessing = true;
Toast.show('Generating 6 variants...', 'info');
// Clear existing variants
this.variants = [];
this.selectedVariantIndex = null;
// Get the original image data from the drawing canvas
const originalCanvas = this.drawingCanvas;
if (!originalCanvas) {
this.isProcessing = false;
return;
}
// Generate variants with defects in different positions
const variantPromises = this.variantPositions.map((pos, index) => {
return new Promise((resolve) => {
setTimeout(() => {
const variantCanvas = document.createElement('canvas');
variantCanvas.width = img.naturalWidth;
variantCanvas.height = img.naturalHeight;
const ctx = variantCanvas.getContext('2d');
// Draw the base image
ctx.drawImage(img, 0, 0);
// Draw the annotations
ctx.drawImage(originalCanvas, 0, 0);
// Draw the defect at the specified position
const x = pos.xRatio * img.naturalWidth;
const y = pos.yRatio * img.naturalHeight;
this.drawDefectAtPosition(ctx, this.selectedDefectType, x, y, img.naturalWidth, img.naturalHeight);
this.variants.push({
id: `variant-${Date.now()}-${index}`,
position: pos.name,
positionData: pos,
imageData: variantCanvas.toDataURL('image/png')
});
resolve();
}, 200 + (index * 150)); // Stagger the generation
});
});
// Wait for all variants to be generated
Promise.all(variantPromises).then(() => {
this.isProcessing = false;
this.renderVariantGrid();
Toast.show('Generated 6 defect variants!', 'success');
});
},
// Draw defect at specific position for variant generation
drawDefectAtPosition(ctx, defectType, x, y, imgWidth, imgHeight, useSeed = true) {
const regionSize = Math.min(imgWidth, imgHeight) * 0.15;
switch (defectType) {
case 'fiber':
this.drawFiber(ctx, x, y, regionSize, useSeed);
break;
case 'scratch':
this.drawScratch(ctx, x, y, regionSize, useSeed);
break;
case 'crack':
this.drawCrack(ctx, x, y, regionSize, useSeed);
break;
case 'particles':
this.drawParticles(ctx, x, y, regionSize, useSeed);
break;
case 'discoloration':
this.drawDiscoloration(ctx, x, y, regionSize, useSeed);
break;
case 'bubble':
this.drawBubble(ctx, x, y, regionSize, useSeed);
break;
}
},
renderVariantGrid() {
const container = Helpers.$(this.elements.variantGridContainer);
if (!container) return;
if (this.variants.length === 0) {
container.innerHTML = `
No variants generated yet
Click "Generate Variants" to create 6 versions
`;
return;
}
container.innerHTML = this.variants.map((variant, index) => `
${variant.position}
#${index + 1}
`).join('');
// Bind click events to variant cards
container.querySelectorAll('.variant-card').forEach(card => {
card.addEventListener('click', () => {
this.selectVariant(parseInt(card.dataset.variantIndex));
});
});
},
selectVariant(index) {
this.selectedVariantIndex = index;
// Update UI
const container = Helpers.$(this.elements.variantGridContainer);
container?.querySelectorAll('.variant-card').forEach((card, i) => {
card.classList.toggle('variant-card--selected', i === index);
});
if (index !== null && this.variants[index]) {
Toast.show(`Selected: ${this.variants[index].position}`, 'info');
}
},
clearVariants() {
this.variants = [];
this.selectedVariantIndex = null;
this.renderVariantGrid();
Toast.show('Variants cleared', 'info');
},
saveAllVariants() {
if (this.variants.length === 0) {
Toast.show('No variants to save', 'warning');
return;
}
// Show save dialog or directly save
const count = this.variants.length;
Toast.show(`Saved ${count} variants to dataset!`, 'success');
// For now, just show success - in real implementation this would add to gallery
// You could trigger a download of all variants as a ZIP
this.variants.forEach((variant, index) => {
// In a full implementation, this would add to the gallery
console.log(`Variant ${index + 1} (${variant.position}):`, variant.imageData.substring(0, 50) + '...');
});
},
// =============================================
// DEFECT SIMULATION METHODS
// =============================================
toggleDefectMode() {
this.defectMode = !this.defectMode;
// Update UI
const btn = Helpers.$(this.elements.btnToggleDefectMode);
const wrapper = Helpers.$(this.elements.annotationImageWrapper);
if (btn) {
btn.classList.toggle('active', this.defectMode);
btn.innerHTML = this.defectMode
? ' Exit Defect Mode'
: ' Defect Mode';
}
if (wrapper) {
wrapper.style.cursor = this.defectMode ? 'crosshair' : 'default';
}
// Show/hide defect tools section
const defectSection = Helpers.$(this.elements.defectToolsSection);
if (defectSection) {
defectSection.classList.toggle('active', this.defectMode);
}
// Deselect drawing tool when entering defect mode
if (this.defectMode && this.drawingMode !== 'none') {
this.setTool('none');
}
Toast.show(this.defectMode
? `Defect mode active - Click image to place ${this.selectedDefectType}`
: 'Defect mode disabled',
this.defectMode ? 'info' : 'info');
},
selectDefectType(type) {
this.selectedDefectType = type;
this.updateDefectTypeUI();
if (this.defectMode) {
Toast.show(`Selected: ${type.charAt(0).toUpperCase() + type.slice(1)} defect`, 'info');
}
},
updateDefectTypeUI() {
const selector = Helpers.$(this.elements.defectTypeSelector);
if (!selector) return;
selector.querySelectorAll('.defect-type-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.type === this.selectedDefectType);
});
// Update preview
const preview = Helpers.$(this.elements.defectPreview);
if (preview) {
preview.innerHTML = this.getDefectPreviewHTML(this.selectedDefectType);
}
},
getDefectPreviewHTML(type) {
const defectType = this.defectTypes.find(d => d.id === type);
return `
${defectType?.name || 'Unknown'}
`;
},
renderDefectTypeSelector() {
const selector = Helpers.$(this.elements.defectTypeSelector);
if (!selector) return;
selector.innerHTML = this.defectTypes.map(type => `
`).join('');
// Bind click events
selector.querySelectorAll('.defect-type-btn').forEach(btn => {
btn.addEventListener('click', () => {
this.selectDefectType(btn.dataset.type);
});
});
this.updateDefectTypeUI();
},
handleDefectPlacement(e) {
if (this.isProcessing) return;
const wrapper = Helpers.$(this.elements.annotationImageWrapper);
const img = wrapper?.querySelector('img');
if (!img) return;
const rect = img.getBoundingClientRect();
const scaleX = img.naturalWidth / rect.width;
const scaleY = img.naturalHeight / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
this.applyDefectAtPosition(this.selectedDefectType, x, y);
},
applyDefectAtPosition(defectType, x, y) {
this.isProcessing = true;
// Save current state
this.saveToHistory();
const wrapper = Helpers.$(this.elements.annotationImageWrapper);
const img = wrapper?.querySelector('img');
if (!img || !this.drawingCanvas) {
this.isProcessing = false;
return;
}
// Set canvas size to match image
this.drawingCanvas.width = img.naturalWidth;
this.drawingCanvas.height = img.naturalHeight;
const ctx = this.drawingContext;
if (!ctx) {
this.isProcessing = false;
return;
}
// Draw the defect at the specified position
const regionSize = Math.min(img.naturalWidth, img.naturalHeight) * 0.15;
switch (defectType) {
case 'fiber':
this.drawFiber(ctx, x, y, regionSize);
break;
case 'scratch':
this.drawScratch(ctx, x, y, regionSize);
break;
case 'crack':
this.drawCrack(ctx, x, y, regionSize);
break;
case 'particles':
this.drawParticles(ctx, x, y, regionSize);
break;
case 'discoloration':
this.drawDiscoloration(ctx, x, y, regionSize);
break;
case 'bubble':
this.drawBubble(ctx, x, y, regionSize);
break;
}
this.isProcessing = false;
Toast.show(`${defectType} defect placed!`, 'success');
},
// Apply defect at center of image (random position)
applyDefect(defectType) {
const wrapper = Helpers.$(this.elements.annotationImageWrapper);
const img = wrapper?.querySelector('img');
if (!img) {
Toast.show('No image loaded', 'error');
return;
}
// Generate random position within image bounds
const margin = Math.min(img.naturalWidth, img.naturalHeight) * 0.2;
const x = margin + Math.random() * (img.naturalWidth - margin * 2);
const y = margin + Math.random() * (img.naturalHeight - margin * 2);
this.applyDefectAtPosition(defectType, x, y);
},
// Seeded random function for reproducible results
seededRandom() {
this.seededRandomCalls++;
const x = Math.sin(this.randomSeed + this.seededRandomCalls * 9999) * 10000;
return x - Math.floor(x);
},
// Reset seed for variant generation
resetSeed(variantIndex) {
this.randomSeed = Date.now() + variantIndex * 12345;
this.seededRandomCalls = 0;
},
// Helper for random range using seed
seededRandomRange(min, max) {
return min + this.seededRandom() * (max - min);
},
// Draw fiber contamination (bezier curves)
drawFiber(ctx, centerX, centerY, regionSize, useSeed = false) {
const fiberCount = Math.floor((useSeed ? this.seededRandomRange(2, 5) : Math.random() * 4) + 3);
// Generate dynamic color based on defect type for realism
const colorVariation = this.generateDefectColor();
ctx.strokeStyle = colorVariation;
ctx.lineWidth = useSeed ? this.seededRandomRange(0.5, 2.5) : 1.5;
ctx.lineCap = 'round';
for (let i = 0; i < fiberCount; i++) {
const startX = centerX + (useSeed ? (this.seededRandom() - 0.5) : (Math.random() - 0.5)) * regionSize * 0.5;
const startY = centerY + (useSeed ? (this.seededRandom() - 0.5) : (Math.random() - 0.5)) * regionSize * 0.5;
const endX = startX + (useSeed ? (this.seededRandom() - 0.5) : (Math.random() - 0.5)) * regionSize;
const endY = startY + (useSeed ? (this.seededRandom() - 0.5) : (Math.random() - 0.5)) * regionSize;
const ctrl1X = startX + (useSeed ? (this.seededRandom() - 0.5) : (Math.random() - 0.5)) * 50;
const ctrl1Y = startY + (useSeed ? (this.seededRandom() - 0.5) : (Math.random() - 0.5)) * 50;
const ctrl2X = endX + (useSeed ? (this.seededRandom() - 0.5) : (Math.random() - 0.5)) * 50;
const ctrl2Y = endY + (useSeed ? (this.seededRandom() - 0.5) : (Math.random() - 0.5)) * 50;
ctx.globalAlpha = 0.7 + (useSeed ? this.seededRandom() : Math.random()) * 0.3;
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.bezierCurveTo(ctrl1X, ctrl1Y, ctrl2X, ctrl2Y, endX, endY);
ctx.stroke();
}
ctx.globalAlpha = 1;
},
// Generate defect-specific color (darker/fiber-like colors)
generateDefectColor() {
const type = this.selectedDefectType;
let r, g, b;
switch (type) {
case 'fiber':
// Dark gray/black fibers
r = Math.floor(this.seededRandomRange(0, 80));
g = Math.floor(this.seededRandomRange(0, 80));
b = Math.floor(this.seededRandomRange(0, 80));
break;
case 'scratch':
// Light gray scratches
r = Math.floor(200 + this.seededRandomRange(0, 55));
g = Math.floor(200 + this.seededRandomRange(0, 55));
b = Math.floor(200 + this.seededRandomRange(0, 55));
break;
case 'crack':
// Dark gray cracks
r = 80;
g = 80;
b = 80;
break;
case 'particles':
// Either dark or light particles
if (this.seededRandom() > 0.5) {
r = Math.floor(this.seededRandomRange(0, 50));
g = Math.floor(this.seededRandomRange(0, 50));
b = Math.floor(this.seededRandomRange(0, 50));
} else {
r = Math.floor(200 + this.seededRandomRange(0, 55));
g = Math.floor(200 + this.seededRandomRange(0, 55));
b = Math.floor(200 + this.seededRandomRange(0, 55));
}
break;
case 'discoloration':
// Warm/brown discoloration
r = Math.floor(this.seededRandomRange(100, 200));
g = Math.floor(this.seededRandomRange(80, 160));
b = Math.floor(this.seededRandomRange(60, 120));
break;
case 'bubble':
// White/transparent bubbles
return 'rgba(255, 255, 255, 0.7)';
default:
r = 100; g = 100; b = 100;
}
return `rgba(${r}, ${g}, ${b}, ${0.6 + this.seededRandom() * 0.3})`;
},
// Draw surface scratch (lines)
drawScratch(ctx, centerX, centerY, regionSize, useSeed = false) {
const angle = (useSeed ? this.seededRandom() : Math.random()) * Math.PI * 2;
const length = regionSize * (0.8 + (useSeed ? this.seededRandom() : Math.random()) * 0.4);
const colorVariation = this.generateDefectColor();
ctx.strokeStyle = colorVariation;
ctx.lineWidth = useSeed ? this.seededRandomRange(0.5, 2) : 1 + Math.random();
ctx.lineCap = 'round';
ctx.globalAlpha = 0.8;
// Main scratch line
const startX = centerX - Math.cos(angle) * length / 2;
const startY = centerY - Math.sin(angle) * length / 2;
const endX = centerX + Math.cos(angle) * length / 2;
const endY = centerY + Math.sin(angle) * length / 2;
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
ctx.stroke();
// Add some branching for realism
if ((useSeed ? this.seededRandom() : Math.random()) > 0.5) {
const branchStart = {
x: startX + (endX - startX) * (0.3 + (useSeed ? this.seededRandom() : Math.random()) * 0.4),
y: startY + (endY - startY) * (0.3 + (useSeed ? this.seededRandom() : Math.random()) * 0.4)
};
const branchAngle = angle + ((useSeed ? this.seededRandom() : Math.random()) - 0.5) * 1.2;
const branchLength = length * 0.3;
ctx.beginPath();
ctx.moveTo(branchStart.x, branchStart.y);
ctx.lineTo(
branchStart.x + Math.cos(branchAngle) * branchLength,
branchStart.y + Math.sin(branchAngle) * branchLength
);
ctx.stroke();
}
ctx.globalAlpha = 1;
},
// Draw crack pattern (branching random walk)
drawCrack(ctx, centerX, centerY, regionSize, useSeed = false) {
const steps = 10 + Math.floor((useSeed ? this.seededRandom() : Math.random()) * 10);
const stepSize = regionSize / steps;
ctx.strokeStyle = 'rgba(80, 80, 80, 0.8)';
ctx.lineWidth = 1.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.globalAlpha = 0.85;
let x = centerX;
let y = centerY;
let angle = (useSeed ? this.seededRandom() : Math.random()) * Math.PI * 2;
// Main crack line with slight wandering
ctx.beginPath();
ctx.moveTo(x, y);
for (let i = 0; i < steps; i++) {
// Add some randomness to the direction
angle += ((useSeed ? this.seededRandom() : Math.random()) - 0.5) * 0.8;
const segLength = (useSeed ? this.seededRandomRange(10, 30) : 10 + Math.random() * 20);
x += Math.cos(angle) * segLength;
y += Math.sin(angle) * segLength;
ctx.lineTo(x, y);
// Branch at random points
if ((useSeed ? this.seededRandom() : Math.random()) > 0.7 && i > 2 && i < steps - 2) {
const branchAngle = (useSeed ? this.seededRandom() : Math.random()) * Math.PI * 2;
const branchSegCount = 3 + Math.floor((useSeed ? this.seededRandom() : Math.random()) * 3);
const branchX = x;
const branchY = y;
for (let j = 0; j < branchSegCount; j++) {
const branchSegAngle = (useSeed ? this.seededRandom() : Math.random()) * Math.PI * 2;
const branchSegLen = (useSeed ? this.seededRandomRange(5, 20) : 5 + Math.random() * 15);
ctx.lineTo(
branchX + Math.cos(branchSegAngle) * branchSegLen,
branchY + Math.sin(branchSegAngle) * branchSegLen
);
ctx.moveTo(branchX, branchY);
}
}
}
ctx.stroke();
ctx.globalAlpha = 1;
},
// Draw dust/particles (random circles)
drawParticles(ctx, centerX, centerY, regionSize, useSeed = false) {
const particleCount = Math.floor((useSeed ? this.seededRandomRange(8, 23) : 8 + Math.random() * 15));
for (let i = 0; i < particleCount; i++) {
const x = centerX + ((useSeed ? this.seededRandom() : Math.random()) - 0.5) * regionSize;
const y = centerY + ((useSeed ? this.seededRandom() : Math.random()) - 0.5) * regionSize;
const radius = (useSeed ? this.seededRandomRange(1, 4) : 1 + Math.random() * 3);
const isDark = (useSeed ? this.seededRandom() : Math.random()) > 0.5;
const baseColor = isDark
? `rgba(${Math.floor((useSeed ? this.seededRandom() : Math.random()) * 50)}, ${Math.floor((useSeed ? this.seededRandom() : Math.random()) * 50)}, ${Math.floor((useSeed ? this.seededRandom() : Math.random()) * 50)}, ${0.5 + (useSeed ? this.seededRandom() : Math.random()) * 0.4})`
: `rgba(${200 + Math.floor((useSeed ? this.seededRandom() : Math.random()) * 55)}, ${200 + Math.floor((useSeed ? this.seededRandom() : Math.random()) * 55)}, ${200 + Math.floor((useSeed ? this.seededRandom() : Math.random()) * 55)}, ${0.5 + (useSeed ? this.seededRandom() : Math.random()) * 0.4})`;
if ((useSeed ? this.seededRandom() : Math.random()) > 0.6) {
ctx.fillStyle = baseColor;
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
} else {
ctx.strokeStyle = baseColor;
ctx.lineWidth = 1;
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.stroke();
}
}
ctx.globalAlpha = 1;
},
// Draw discoloration (radial gradients)
drawDiscoloration(ctx, centerX, centerY, regionSize, useSeed = false) {
const radius = regionSize * 0.5;
// Generate discoloration colors
const colorR = Math.floor((useSeed ? this.seededRandomRange(100, 200) : 100 + Math.random() * 100));
const colorG = Math.floor((useSeed ? this.seededRandomRange(80, 160) : 80 + Math.random() * 80));
const colorB = Math.floor((useSeed ? this.seededRandomRange(60, 120) : 60 + Math.random() * 60));
// Create gradient with warm discoloration colors
const gradient = ctx.createRadialGradient(
centerX, centerY, 0,
centerX, centerY, radius
);
gradient.addColorStop(0, `rgba(${colorR}, ${colorG}, ${colorB}, 0.4)`);
gradient.addColorStop(0.5, `rgba(${colorR}, ${colorG}, ${colorB}, 0.2)`);
gradient.addColorStop(1, `rgba(${colorR}, ${colorG}, ${colorB}, 0)`);
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.fill();
},
// Draw air bubble (gradient circle with outline)
drawBubble(ctx, centerX, centerY, regionSize, useSeed = false) {
const bubbleCount = Math.floor((useSeed ? this.seededRandomRange(1, 4) : 1 + Math.random() * 3));
for (let i = 0; i < bubbleCount; i++) {
const x = centerX + ((useSeed ? this.seededRandom() : Math.random()) - 0.5) * regionSize * 0.3;
const y = centerY + ((useSeed ? this.seededRandom() : Math.random()) - 0.5) * regionSize * 0.3;
const radius = regionSize * 0.15 + (useSeed ? this.seededRandomRange(0, regionSize * 0.1) : Math.random() * regionSize * 0.1);
// Bubble outline - light gray/white
const outlineAlpha = 0.5 + (useSeed ? this.seededRandom() : Math.random()) * 0.4;
ctx.strokeStyle = `rgba(255, 255, 255, ${outlineAlpha})`;
ctx.lineWidth = 1 + (useSeed ? this.seededRandom() : Math.random()) * 0.5;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.stroke();
// Bubble highlight gradient
const gradient = ctx.createRadialGradient(
x - radius * 0.3, y - radius * 0.3, 0,
x, y, radius
);
gradient.addColorStop(0, `rgba(255, 255, 255, ${0.3 + (useSeed ? this.seededRandom() : Math.random()) * 0.2})`);
gradient.addColorStop(0.7, `rgba(255, 255, 255, ${0.1 + (useSeed ? this.seededRandom() : Math.random()) * 0.1})`);
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
}
},
// Helper to adjust color alpha
adjustColorAlpha(hexColor, alpha) {
// Convert hex to RGB
const r = parseInt(hexColor.slice(1, 3), 16);
const g = parseInt(hexColor.slice(3, 5), 16);
const b = parseInt(hexColor.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
},
// =============================================
// DRAWING METHODS
// =============================================
setTool(tool) {
// Exit defect mode when selecting a drawing tool
if (this.defectMode && tool !== 'none') {
this.toggleDefectMode();
}
const tools = ['pen', 'circle', 'rectangle', 'arrow'];
// Clear active state from all tool buttons
['btnToolPen', 'btnToolCircle', 'btnToolRectangle', 'btnToolArrow', 'btnToolEraser'].forEach(btnId => {
const btn = Helpers.$(this.elements[btnId]);
if (btn) btn.classList.remove('active');
});
if (tool === 'none') {
this.drawingMode = 'none';
this.isDrawing = false;
const eraserBtn = Helpers.$(this.elements.btnToolEraser);
if (eraserBtn) eraserBtn.classList.add('active');
} else if (tools.includes(tool)) {
this.drawingMode = tool;
this.clearDrawing();
const btnId = `btnTool${tool.charAt(0).toUpperCase() + tool.slice(1)}`;
const btn = Helpers.$(this.elements[btnId]);
if (btn) btn.classList.add('active');
}
// Update cursor
const wrapper = Helpers.$(this.elements.annotationImageWrapper);
if (wrapper) {
wrapper.style.cursor = tool !== 'none' ? 'crosshair' : 'default';
}
Toast.show(`${tool === 'none' ? 'Drawing' : tool.charAt(0).toUpperCase() + tool.slice(1)} mode ${tool === 'none' ? 'disabled' : 'active'}`, 'info');
},
bindColorPaletteEvents() {
const palette = Helpers.$(this.elements.drawingColorPalette);
if (!palette) return;
const colors = ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff', '#00ffff', '#ffffff', '#000000'];
palette.innerHTML = '';
colors.forEach(color => {
const btn = document.createElement('button');
btn.className = 'annotation-color-btn';
btn.style.backgroundColor = color;
btn.dataset.color = color;
btn.title = color;
btn.addEventListener('click', () => {
this.drawColor = color;
this.updateColorPaletteUI();
// Update custom color input
const colorInput = Helpers.$(this.elements.btnDrawingColor);
if (colorInput) colorInput.value = color;
});
palette.appendChild(btn);
});
this.updateColorPaletteUI();
},
updateColorPaletteUI() {
const palette = Helpers.$(this.elements.drawingColorPalette);
if (!palette) return;
palette.querySelectorAll('.annotation-color-btn').forEach(btn => {
if (btn.dataset.color === this.drawColor) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
},
updateLineWidthUI() {
const display = Helpers.$(this.elements.drawingLineWidthVal);
const slider = Helpers.$(this.elements.drawingLineWidth);
if (display) display.textContent = `${this.lineWidth}px`;
if (slider) slider.value = this.lineWidth;
},
handleMouseDown(e) {
// Only handle if in drawing mode
if (this.drawingMode === 'none') return;
if (this.isPanning) return;
const wrapper = Helpers.$(this.elements.annotationImageWrapper);
const img = wrapper?.querySelector('img');
if (!img || !this.drawingCanvas) return;
// Calculate position relative to image
const rect = img.getBoundingClientRect();
const scaleX = img.naturalWidth / rect.width;
const scaleY = img.naturalHeight / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
this.isDrawing = true;
this.drawStartPos = { x, y };
const ctx = this.drawingContext;
if (!ctx) return;
ctx.strokeStyle = this.drawColor;
ctx.lineWidth = this.lineWidth * scaleX;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (this.drawingMode === 'pen') {
ctx.beginPath();
ctx.moveTo(x, y);
}
},
handleMouseMove(e) {
if (!this.isDrawing || this.drawingMode === 'none') return;
const wrapper = Helpers.$(this.elements.annotationImageWrapper);
const img = wrapper?.querySelector('img');
if (!img || !this.drawingContext) return;
const rect = img.getBoundingClientRect();
const scaleX = img.naturalWidth / rect.width;
const scaleY = img.naturalHeight / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
const ctx = this.drawingContext;
if (this.drawingMode === 'pen') {
ctx.lineTo(x, y);
ctx.stroke();
}
},
handleMouseUp(e) {
if (!this.isDrawing) return;
this.isDrawing = false;
const wrapper = Helpers.$(this.elements.annotationImageWrapper);
const img = wrapper?.querySelector('img');
if (!img || !this.drawingContext) return;
const rect = img.getBoundingClientRect();
const scaleX = img.naturalWidth / rect.width;
const scaleY = img.naturalHeight / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
const ctx = this.drawingContext;
if (this.drawingMode === 'circle') {
const radius = Math.sqrt(
Math.pow(x - this.drawStartPos.x, 2) +
Math.pow(y - this.drawStartPos.y, 2)
);
ctx.beginPath();
ctx.arc(this.drawStartPos.x, this.drawStartPos.y, radius, 0, Math.PI * 2);
ctx.stroke();
} else if (this.drawingMode === 'rectangle') {
const width = x - this.drawStartPos.x;
const height = y - this.drawStartPos.y;
ctx.strokeRect(this.drawStartPos.x, this.drawStartPos.y, width, height);
} else if (this.drawingMode === 'arrow') {
// Draw arrow
const headlen = 15 * scaleX;
const angle = Math.atan2(y - this.drawStartPos.y, x - this.drawStartPos.x);
ctx.beginPath();
ctx.moveTo(this.drawStartPos.x, this.drawStartPos.y);
ctx.lineTo(x, y);
ctx.stroke();
// Draw arrowhead
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(
x - headlen * Math.cos(angle - Math.PI / 6),
y - headlen * Math.sin(angle - Math.PI / 6)
);
ctx.moveTo(x, y);
ctx.lineTo(
x - headlen * Math.cos(angle + Math.PI / 6),
y - headlen * Math.sin(angle + Math.PI / 6)
);
ctx.stroke();
}
// Save state for undo
this.saveToHistory();
},
saveToHistory() {
if (!this.drawingCanvas) return;
const dataUrl = this.drawingCanvas.toDataURL('image/png');
this.history.push(dataUrl);
if (this.history.length > this.maxHistorySize) {
this.history.shift();
}
},
clearDrawing() {
if (!this.drawingContext || !this.drawingCanvas) return;
this.drawingContext.clearRect(0, 0, this.drawingCanvas.width, this.drawingCanvas.height);
Toast.show('Drawing cleared', 'info');
},
applyDrawingToImage() {
Toast.show('Annotations applied! (Save functionality coming)', 'success');
},
undo() {
if (this.history.length === 0) {
Toast.show('Nothing to undo', 'warning');
return;
}
const previousState = this.history.pop();
const img = new Image();
img.onload = () => {
if (!this.drawingContext || !this.drawingCanvas) return;
this.drawingCanvas.width = img.width;
this.drawingCanvas.height = img.height;
this.drawingContext.drawImage(img, 0, 0);
Toast.show('Undone', 'info');
};
img.src = previousState;
},
// =============================================
// ZOOM/PAN METHODS
// =============================================
setZoom(level) {
this.zoomLevel = Math.max(0.5, Math.min(4, level));
this.updateZoomUI();
this.applyTransform();
},
zoomIn() {
this.setZoom(this.zoomLevel + 0.25);
},
zoomOut() {
this.setZoom(this.zoomLevel - 0.25);
},
resetZoom() {
this.zoomLevel = 1;
this.panOffset = { x: 0, y: 0 };
this.updateZoomUI();
this.applyTransform();
},
updateZoomUI() {
const slider = Helpers.$(this.elements.zoomSlider);
const levelDisplay = Helpers.$(this.elements.zoomLevel);
if (slider) {
slider.value = this.zoomLevel;
}
if (levelDisplay) {
levelDisplay.textContent = `${Math.round(this.zoomLevel * 100)}%`;
}
},
applyTransform() {
const wrapper = Helpers.$(this.elements.annotationImageWrapper);
if (!wrapper) return;
wrapper.style.transform = `scale(${this.zoomLevel}) translate(${this.panOffset.x / this.zoomLevel}px, ${this.panOffset.y / this.zoomLevel}px)`;
},
startPan(e) {
this.isPanning = true;
this.panStart = {
x: e.clientX - this.panOffset.x,
y: e.clientY - this.panOffset.y
};
Helpers.$(this.elements.annotationImageWrapper).style.cursor = 'grabbing';
},
updatePan(e) {
if (!this.isPanning) return;
this.panOffset = {
x: e.clientX - this.panStart.x,
y: e.clientY - this.panStart.y
};
this.applyTransform();
},
endPan() {
this.isPanning = false;
const wrapper = Helpers.$(this.elements.annotationImageWrapper);
if (wrapper) {
wrapper.style.cursor = this.zoomLevel > 1 ? 'grab' : 'default';
}
},
// =============================================
// PANEL METHODS
// =============================================
// Show annotation panel with image
open(imageData) {
const panel = Helpers.$(this.elements.annotationPanel);
const content = Helpers.$(this.elements.annotationContent);
if (!panel || !content) {
console.error('[AnnotationTool] Panel elements not found');
return;
}
// Reset state
this.resetZoom();
this.history = [];
this.variants = [];
this.selectedVariantIndex = null;
this.setTool('none');
this.defectMode = false;
// Set image
if (imageData && content) {
// Create or update image element
let img = content.querySelector('img');
if (!img) {
img = document.createElement('img');
img.id = 'annotation-image';
img.className = 'annotation-image';
img.draggable = false;
content.appendChild(img);
}
img.src = imageData;
// Wait for image to load to set up canvas dimensions
img.onload = () => {
if (this.drawingCanvas) {
this.drawingCanvas.width = img.naturalWidth;
this.drawingCanvas.height = img.naturalHeight;
}
if (this.imageCanvas) {
this.imageCanvas.width = img.naturalWidth;
this.imageCanvas.height = img.naturalHeight;
}
};
}
// Show panel
panel.classList.add('active');
// Initialize canvases if needed
this.setupCanvases();
this.updateColorPaletteUI();
this.updateLineWidthUI();
// Update defect mode UI
const btn = Helpers.$(this.elements.btnToggleDefectMode);
if (btn) {
btn.classList.remove('active');
btn.innerHTML = ' Defect Mode';
}
this.updateDefectTypeUI();
this.renderVariantGrid();
Toast.show('Annotation tool opened', 'info');
},
// Close annotation panel
close() {
const panel = Helpers.$(this.elements.annotationPanel);
if (panel) {
panel.classList.remove('active');
}
this.setTool('none');
this.defectMode = false;
Toast.show('Annotation tool closed', 'info');
},
// =============================================
// TEST METHODS
// =============================================
getState() {
return {
zoomLevel: this.zoomLevel,
panOffset: { ...this.panOffset },
isPanning: this.isPanning,
isInitialized: this.isInitialized,
drawingMode: this.drawingMode,
defectMode: this.defectMode,
selectedDefectType: this.selectedDefectType,
drawColor: this.drawColor,
lineWidth: this.lineWidth,
historySize: this.history.length,
variantsCount: this.variants.length,
selectedVariantIndex: this.selectedVariantIndex
};
},
validateCanvases() {
const results = {
imageCanvas: !!this.imageCanvas,
drawingCanvas: !!this.drawingCanvas,
imageContext: !!this.imageContext,
drawingContext: !!this.drawingContext,
allCanvasesReady: !!(this.imageCanvas && this.drawingCanvas && this.imageContext && this.drawingContext)
};
console.log('[AnnotationTool] Canvas validation:', results);
return results;
}
};
// Make available globally
window.AnnotationTool = AnnotationTool;
// Auto-init when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
AnnotationTool.init();
}, 100);
});