// Freehand polygon selection tool
// User taps to place points, then adjusts nodes before confirming.
const CLOSE_THRESHOLD = 30; // px distance to first point to auto-close
const NODE_RADIUS = 14; // px, visual + hit area
/**
* Show the polygon selection overlay on an image.
* @param {string} imageURL - object URL of the image to select from
* @returns {Promise<{polygon: {x,y}[], imageBlob: Blob} | null>}
* polygon coords are normalized 0–1. Returns null if cancelled.
*/
export function showPolygonSelector(imageURL, initialPoints) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'poly-overlay';
overlay.innerHTML = `
Tap around the item
`;
document.body.appendChild(overlay);
const img = document.getElementById('poly-img');
const canvas = document.getElementById('poly-canvas');
const ctx = canvas.getContext('2d');
const hint = document.getElementById('poly-hint');
const undoBtn = overlay.querySelector('.poly-undo-btn');
const closeBtn = overlay.querySelector('.poly-close-btn');
let points = []; // {x, y} in canvas pixel coords
let closed = false;
let draggingIdx = -1;
let imgRect = null;
function cleanup() {
overlay.remove();
}
// Wait for image to load to size canvas
img.onload = () => {
imgRect = img.getBoundingClientRect();
canvas.width = imgRect.width;
canvas.height = imgRect.height;
canvas.style.width = imgRect.width + 'px';
canvas.style.height = imgRect.height + 'px';
// Pre-populate with initial points if provided
if (initialPoints && initialPoints.length >= 3) {
points = initialPoints.map(p => ({
x: p.x * canvas.width,
y: p.y * canvas.height,
}));
closePolygon();
}
draw();
};
// If already loaded (cached)
if (img.complete && img.naturalWidth) {
setTimeout(() => img.onload(), 0);
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (points.length === 0) return;
// Draw filled polygon (semi-transparent)
if (closed && points.length >= 3) {
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.closePath();
ctx.fillStyle = 'rgba(240, 180, 41, 0.15)';
ctx.fill();
}
// Draw lines
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
if (closed) ctx.closePath();
ctx.strokeStyle = '#f0b429';
ctx.lineWidth = 3;
ctx.stroke();
// Draw nodes
points.forEach((p, i) => {
ctx.beginPath();
ctx.arc(p.x, p.y, NODE_RADIUS, 0, Math.PI * 2);
ctx.fillStyle = i === 0 ? '#e8738a' : '#f0b429';
ctx.fill();
ctx.strokeStyle = '#2d1b4e';
ctx.lineWidth = 2;
ctx.stroke();
});
}
function canvasCoords(e) {
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
}
function distPt(a, b) {
return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
}
function findNodeAt(pos) {
for (let i = points.length - 1; i >= 0; i--) {
if (distPt(pos, points[i]) < NODE_RADIUS + 10) return i;
}
return -1;
}
function closePolygon() {
if (points.length < 3) return;
closed = true;
hint.textContent = 'Drag nodes to adjust';
closeBtn.style.display = 'none';
document.getElementById('poly-actions').style.display = '';
document.getElementById('poly-drawing-actions').style.display = 'none';
draw();
}
// Pointer events on canvas
let pointerDownPos = null;
let pointerMoved = false;
canvas.addEventListener('pointerdown', (e) => {
e.preventDefault();
const pos = canvasCoords(e);
pointerDownPos = pos;
pointerMoved = false;
if (closed) {
// Try to drag a node
draggingIdx = findNodeAt(pos);
if (draggingIdx >= 0) {
canvas.setPointerCapture(e.pointerId);
}
return;
}
// Check if near first point -> close
if (points.length >= 3 && distPt(pos, points[0]) < CLOSE_THRESHOLD) {
closePolygon();
return;
}
draggingIdx = -1;
});
canvas.addEventListener('pointermove', (e) => {
e.preventDefault();
const pos = canvasCoords(e);
if (pointerDownPos && distPt(pos, pointerDownPos) > 5) {
pointerMoved = true;
}
if (closed && draggingIdx >= 0) {
// Clamp to canvas
points[draggingIdx].x = Math.max(0, Math.min(canvas.width, pos.x));
points[draggingIdx].y = Math.max(0, Math.min(canvas.height, pos.y));
draw();
}
});
canvas.addEventListener('pointerup', (e) => {
e.preventDefault();
const pos = canvasCoords(e);
if (closed) {
draggingIdx = -1;
return;
}
// Only add point if it was a tap (not a drag)
if (!pointerMoved) {
points.push(pos);
undoBtn.disabled = false;
if (points.length >= 3) closeBtn.style.display = '';
draw();
}
pointerDownPos = null;
pointerMoved = false;
});
// Undo
undoBtn.addEventListener('click', () => {
if (closed) {
// Reopen
closed = false;
hint.textContent = 'Tap to add more points';
document.getElementById('poly-actions').style.display = 'none';
document.getElementById('poly-drawing-actions').style.display = '';
if (points.length >= 3) closeBtn.style.display = '';
} else {
points.pop();
}
undoBtn.disabled = points.length === 0;
if (points.length < 3) closeBtn.style.display = 'none';
draw();
});
// Close shape button
closeBtn.addEventListener('click', closePolygon);
// Confirm
document.getElementById('poly-confirm').addEventListener('click', async () => {
if (!closed || points.length < 3) return;
// Normalize coordinates
const polygon = points.map(p => ({
x: p.x / canvas.width,
y: p.y / canvas.height,
}));
// Crop and mask the image
const croppedBlob = await cropToPolygon(img, polygon);
cleanup();
resolve({ polygon, imageBlob: croppedBlob });
});
// Reset
document.getElementById('poly-reset').addEventListener('click', () => {
points = [];
closed = false;
draggingIdx = -1;
hint.textContent = 'Tap around the item';
undoBtn.disabled = true;
closeBtn.style.display = 'none';
document.getElementById('poly-actions').style.display = 'none';
document.getElementById('poly-drawing-actions').style.display = '';
draw();
});
// Cancel
const cancelHandler = () => { cleanup(); resolve(null); };
document.getElementById('poly-cancel').addEventListener('click', cancelHandler);
document.getElementById('poly-cancel2').addEventListener('click', cancelHandler);
});
}
/**
* Crop image to polygon bounding box with pixels outside the polygon made transparent.
*/
async function cropToPolygon(imgEl, polygon) {
// Draw original image at full resolution
const bitmap = await createImageBitmap(imgEl);
const fullW = bitmap.width;
const fullH = bitmap.height;
// Convert normalized polygon to pixel coords
const pxPoly = polygon.map(p => ({ x: p.x * fullW, y: p.y * fullH }));
// Bounding box
let minX = fullW, minY = fullH, maxX = 0, maxY = 0;
for (const p of pxPoly) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
}
// Add small padding
const pad = 4;
minX = Math.max(0, Math.floor(minX) - pad);
minY = Math.max(0, Math.floor(minY) - pad);
maxX = Math.min(fullW, Math.ceil(maxX) + pad);
maxY = Math.min(fullH, Math.ceil(maxY) + pad);
const cropW = maxX - minX;
const cropH = maxY - minY;
// Draw cropped region
const canvas = new OffscreenCanvas(cropW, cropH);
const ctx = canvas.getContext('2d');
// Clip to polygon
ctx.beginPath();
const first = pxPoly[0];
ctx.moveTo(first.x - minX, first.y - minY);
for (let i = 1; i < pxPoly.length; i++) {
ctx.lineTo(pxPoly[i].x - minX, pxPoly[i].y - minY);
}
ctx.closePath();
ctx.clip();
// Draw image
ctx.drawImage(bitmap, minX, minY, cropW, cropH, 0, 0, cropW, cropH);
return canvas.convertToBlob({ type: 'image/png' });
}