Offline Drone Grid Annotator
:root{
--accent:#0b74de;
--bg:#f6f7fb;
--card:#fff;
--muted:#666;
--touch:48px;
}
html,body{height:100%;margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,"Helvetica Neue",Arial;}
body{background:var(--bg);display:flex;flex-direction:column;gap:12px;padding:12px;}
.topbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}
.card{background:var(--card);border-radius:10px;padding:10px;box-shadow:0 6px 18px rgba(15,20,30,0.06);}
.controls{display:flex;gap:8px;align-items:center;}
label{font-size:13px;color:var(--muted);}
input[type="number"]{width:80px;padding:6px;border-radius:6px;border:1px solid #ddd;}
button{background:var(--accent);color:#fff;border:0;padding:8px 12px;border-radius:8px;cursor:pointer;}
button.ghost{background:transparent;color:var(--accent);border:1px solid rgba(11,116,222,0.18);}
.canvas-wrap{display:flex;gap:12px;flex:1;min-height:320px;}
#canvas{background:#222;max-width:100%;height:auto;border-radius:8px;display:block;touch-action:none;}
.side{width:300px;min-width:220px;max-height:70vh;overflow:auto;}
.field{margin:8px 0;}
.field label{display:block;margin-bottom:6px;}
select,input[type="text"],textarea{width:100%;padding:8px;border-radius:6px;border:1px solid #ddd;}
textarea{min-height:80px;resize:vertical;}
.small{font-size:12px;color:var(--muted);}
.cell-badge{position:absolute;background:var(--accent);color:#fff;border-radius:6px;padding:4px 6px;font-size:12px;pointer-events:none;}
footer{font-size:12px;color:var(--muted);margin-top:6px;}
/* Modal */
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,0.35);display:flex;align-items:center;justify-content:center;}
.modal{background:#fff;padding:12px;border-radius:12px;min-width:320px;max-width:92vw;}
.row{display:flex;gap:8px;}
.touch-large{min-height:var(--touch);display:flex;align-items:center;justify-content:center;}
.legend{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px;}
.legend .item{background:#fff;padding:6px;border-radius:6px;border:1px solid #eee;font-size:12px;}
@media (max-width:900px){
.canvas-wrap{flex-direction:column;}
.side{width:100%}
}
Annotations stored locally
— choose —
Sand
Loam
Clay
Gravel
— choose —
None
Sparse
Moderate
Dense
— choose —
Yes
No
12345
Project
Tap/click cell to edit
Long-press to show cell coords
Export JSON/CSV for backup
Edit Cell
/*
Offline Drone Grid Annotator
- localStorage used for simplicity (works offline)
- grid stored as rows x cols
- annotations keyed by "r_c" (row_col)
*/
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let img = new Image();
let imageLoaded = false;
const fileInput = document.getElementById('fileInput');
const rowsInput = document.getElementById('rows');
const colsInput = document.getElementById('cols');
const applyGridBtn = document.getElementById('applyGrid');
const selectedCellLabel = document.getElementById('selectedCellLabel');
const saveCellBtn = document.getElementById('saveCell');
const deleteCellBtn = document.getElementById('deleteCell');
const clearAnnotationsBtn = document.getElementById('clearAnnotations');
const exportJSONBtn = document.getElementById('exportJSON');
const exportCSVBtn = document.getElementById('exportCSV');
const downloadAnnotatedBtn = document.getElementById('downloadAnnotated');
const soilEl = document.getElementById('soilType');
const vegEl = document.getElementById('vegType');
const waterEl = document.getElementById('water');
const riskEl = document.getElementById('risk');
const notesEl = document.getElementById('notes');
const projectNameEl = document.getElementById('projectName');
const saveProjectBtn = document.getElementById('saveProject');
const loadProjectBtn = document.getElementById('loadProject');
const cellMarker = document.getElementById('cellMarker');
let rows = parseInt(rowsInput.value,10) || 10;
let cols = parseInt(colsInput.value,10) || 10;
let annotations = {}; // { "r_c": {soil,veg,water,risk,notes}}
let selected = null; // {r,c}
const STORAGE_KEY = 'drone_grid_annotator_v1';
// helpers
function saveToStorage(){
const payload = { rows, cols, annotations, projectName: projectNameEl.value || '' , timestamp: Date.now() };
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
}
function loadFromStorage(){
const raw = localStorage.getItem(STORAGE_KEY);
if(!raw) return false;
try{
const p = JSON.parse(raw);
rows = p.rows || rows;
cols = p.cols || cols;
annotations = p.annotations || {};
projectNameEl.value = p.projectName || '';
rowsInput.value = rows;
colsInput.value = cols;
return true;
}catch(e){ console.warn(e); return false; }
}
function exportJSON(){
const payload = { rows, cols, annotations, projectName: projectNameEl.value || '', exportedAt: new Date().toISOString() };
const blob = new Blob([JSON.stringify(payload, null, 2)], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = (projectNameEl.value || 'annotations') + '.json';
a.click();
}
function exportCSV(){
// columns: row,col,soil,veg,water,risk,notes
let lines = [['row','col','soil','vegetation','water','risk','notes']];
for(const key in annotations){
const [r,c] = key.split('_').map(x=>parseInt(x,10));
const obj = annotations[key];
lines.push([r,c,escapeCSV(obj.soil||''),escapeCSV(obj.veg||''),escapeCSV(obj.water||''),escapeCSV(obj.risk||''),escapeCSV(obj.notes||'')]);
}
const csv = lines.map(l=>l.map(x=>x===null?'':x).join(',')).join('\n');
const blob = new Blob([csv], {type:'text/csv'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = (projectNameEl.value || 'annotations') + '.csv';
a.click();
}
function escapeCSV(s){ if(typeof s !== 'string') s = String(s||''); return s.replace(/"/g,'""'); }
function downloadAnnotatedImage(){
if(!imageLoaded) return alert('Load an image first');
drawCanvas({ drawCells: true, drawAnnotations: true });
const dataURL = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = dataURL;
a.download = (projectNameEl.value || 'annotated') + '.png';
a.click();
// redraw w/o annotations overlay (so UI remains unchanged)
drawCanvas({ drawCells: true, drawAnnotations: true });
}
function setSelected(r,c){
selected = {r,c};
selectedCellLabel.innerText = `Row ${r+1}, Col ${c+1}`;
const key = `${r}_${c}`;
const obj = annotations[key] || {};
soilEl.value = obj.soil || '';
vegEl.value = obj.veg || '';
waterEl.value = obj.water || '';
riskEl.value = obj.risk || '';
notesEl.value = obj.notes || '';
showCellMarker(r,c);
}
function clearSelected(){
selected = null;
selectedCellLabel.innerText = 'None';
soilEl.value = '';
vegEl.value = '';
waterEl.value = '';
riskEl.value = '';
notesEl.value = '';
hideCellMarker();
}
function showCellMarker(r,c){
if(!imageLoaded) return;
const [cellW, cellH] = getCellSize();
const display = getDisplaySize();
const scale = display.scale;
const x = display.offsetX + c * cellW * scale;
const y = display.offsetY + r * cellH * scale;
cellMarker.style.left = (x + 6) + 'px';
cellMarker.style.top = (y + 6) + 'px';
cellMarker.style.display = 'block';
cellMarker.innerText = `${r+1},${c+1}`;
}
function hideCellMarker(){ cellMarker.style.display = 'none'; }
function getCellSize(){
if(!imageLoaded) return [0,0];
return [img.width / cols, img.height / rows];
}
function getDisplaySize(){
// returns scale factor to account for canvas display vs natural image size
const dispW = canvas.width;
const dispH = canvas.height;
const scaleX = dispW / img.width;
const scaleY = dispH / img.height;
const scale = Math.min(scaleX, scaleY);
// compute offset (center)
const drawnW = img.width * scale;
const drawnH = img.height * scale;
const offsetX = (dispW - drawnW) / 2;
const offsetY = (dispH - drawnH) / 2;
return { scale, offsetX, offsetY, drawnW, drawnH };
}
function windowToCanvas(x,y){
const rect = canvas.getBoundingClientRect();
return { x: x - rect.left, y: y - rect.top };
}
function posToCell(px,py){
// px,py are coordinates inside canvas (display coords)
const d = getDisplaySize();
const cx = px - d.offsetX;
const cy = py - d.offsetY;
if(cx < 0 || cy d.drawnW || cy > d.drawnH) return null;
const imgX = cx / d.scale;
const imgY = cy / d.scale;
const [cellW, cellH] = getCellSize();
const col = Math.min(cols - 1, Math.floor(imgX / cellW));
const row = Math.min(rows - 1, Math.floor(imgY / cellH));
return {row, col};
}
function drawCanvas({drawCells=true, drawAnnotations=true} = {}){
if(!imageLoaded) {
// blank canvas
ctx.clearRect(0,0,canvas.width,canvas.height);
ctx.fillStyle = '#222';
ctx.fillRect(0,0,canvas.width,canvas.height);
return;
}
// clear
ctx.clearRect(0,0,canvas.width,canvas.height);
// compute scale to fit canvas while preserving aspect ratio
const maxW = canvas.width;
const maxH = canvas.height;
const scale = Math.min(maxW / img.width, maxH / img.height);
const drawW = img.width * scale;
const drawH = img.height * scale;
const offsetX = (canvas.width - drawW) / 2;
const offsetY = (canvas.height - drawH) / 2;
ctx.drawImage(img, offsetX, offsetY, drawW, drawH);
if(drawCells){
ctx.save();
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
// draw grid
ctx.beginPath();
for(let r=0;r<=rows;r++){
const y = offsetY + r * (drawH/rows);
ctx.moveTo(offsetX, y);
ctx.lineTo(offsetX + drawW, y);
}
for(let c=0;cparseInt(n,10));
const obj = annotations[key];
// choose color by risk or presence
ctx.save();
ctx.globalAlpha = 0.9;
const cx = offsetX + c*cellWpx + 4;
const cy = offsetY + r*cellHpx + 4;
ctx.fillStyle = getColorForAnnotation(obj);
const radius = Math.min(cellWpx, cellHpx) * 0.08 + 10;
ctx.beginPath();
ctx.arc(cx + radius/2, cy + radius/2, radius, 0, Math.PI*2);
ctx.fill();
ctx.fillStyle = "#fff";
ctx.font = `${Math.min(14, radius)}px sans-serif`;
ctx.fillText((obj.risk||'').toString(), cx + radius/2 - 4, cy + radius/2 + 4);
ctx.restore();
}
}
}
function getColorForAnnotation(obj){
const r = parseInt(obj.risk||0,10);
if(r>=4) return 'rgba(220,30,40,0.9)';
if(r>=3) return 'rgba(255,140,0,0.95)';
if(r>=1) return 'rgba(60,160,60,0.95)';
return 'rgba(10,120,200,0.85)';
}
// events: file load
fileInput.addEventListener('change', (e)=>{
const f = e.target.files && e.target.files[0];
if(!f) return;
const url = URL.createObjectURL(f);
img = new Image();
img.onload = ()=>{
imageLoaded = true;
// set canvas size to a comfortable editing size but keep aspect ratio
const maxW = Math.min(window.innerWidth - 360, 1400) || 1000;
const maxH = Math.min(window.innerHeight - 220, 900) || 700;
// pick canvas size that fits both
const scale = Math.min(maxW / img.width, maxH / img.height, 1);
canvas.width = Math.round(img.width * Math.max(scale, 0.6));
canvas.height = Math.round(img.height * Math.max(scale, 0.6));
drawCanvas({drawCells:true, drawAnnotations:true});
};
img.src = url;
});
// set grid
applyGridBtn.addEventListener('click', ()=>{
rows = Math.max(1, parseInt(rowsInput.value,10) || 10);
cols = Math.max(1, parseInt(colsInput.value,10) || 10);
saveToStorage();
drawCanvas({drawCells:true, drawAnnotations:true});
});
// save cell
saveCellBtn.addEventListener('click', ()=>{
if(!selected) return alert('Select a cell first');
const key = `${selected.r}_${selected.c}`;
annotations[key] = {
soil: soilEl.value,
veg: vegEl.value,
water: waterEl.value,
risk: riskEl.value,
notes: notesEl.value
};
saveToStorage();
drawCanvas({drawCells:true, drawAnnotations:true});
alert('Saved');
});
// delete cell
deleteCellBtn.addEventListener('click', ()=>{
if(!selected) return alert('Select a cell first');
const key = `${selected.r}_${selected.c}`;
delete annotations[key];
saveToStorage();
clearSelected();
drawCanvas({drawCells:true, drawAnnotations:true});
});
// clear all
clearAnnotationsBtn.addEventListener('click', ()=>{
if(confirm('Clear all annotations? This cannot be undone.')) {
annotations = {};
saveToStorage();
clearSelected();
drawCanvas({drawCells:true, drawAnnotations:true});
}
});
exportJSONBtn.addEventListener('click', exportJSON);
exportCSVBtn.addEventListener('click', exportCSV);
downloadAnnotatedBtn.addEventListener('click', downloadAnnotatedImage);
saveProjectBtn.addEventListener('click', ()=>{
saveToStorage();
alert('Project saved locally');
});
loadProjectBtn.addEventListener('click', ()=>{
if(loadFromStorage()){
alert('Project loaded from local storage');
drawCanvas({drawCells:true, drawAnnotations:true});
} else {
alert('No saved project in local storage.');
}
});
// canvas interaction (mouse/touch)
let lastTouchTime = 0;
canvas.addEventListener('pointerdown', (ev)=>{
if(!imageLoaded) return;
ev.preventDefault();
const p = windowToCanvas(ev.clientX, ev.clientY);
const cell = posToCell(p.x, p.y);
if(!cell) { clearSelected(); return; }
setSelected(cell.row, cell.col);
// on long-press show modal? we'll do simple double-tap to open modal alternative
// For now, on long press (>500ms) show modal:
const start = Date.now();
let canceled = false;
const onUp = ()=>{
const t = Date.now() - start;
if(t > 600 && !canceled){
openEditModal(selected.r, selected.c);
}
canvas.removeEventListener('pointerup', onUp);
canvas.removeEventListener('pointercancel', onCancel);
};
const onCancel = ()=>{ canceled=true; canvas.removeEventListener('pointerup', onUp); canvas.removeEventListener('pointercancel', onCancel); };
canvas.addEventListener('pointerup', onUp);
canvas.addEventListener('pointercancel', onCancel);
drawCanvas({drawCells:true, drawAnnotations:true});
});
function openEditModal(r,c){
// build modal clone
const tpl = document.getElementById('modalTemplate');
const node = tpl.content.cloneNode(true);
const backdrop = node.querySelector('.modal-backdrop');
const modal = backdrop.querySelector('.modal');
const closeBtn = node.getElementById('closeModal');
const m_save = node.getElementById('m_save');
const m_delete = node.getElementById('m_delete');
// fill selects with same options
const m_soil = node.getElementById('m_soil');
const m_veg = node.getElementById('m_veg');
const m_w = node.getElementById('m_w');
const m_r = node.getElementById('m_r');
const m_notes = node.getElementById('m_notes');
const modalLabel = node.getElementById('modalCellLabel');
// populate options (clone from side inputs)
copySelectOptions(soilEl, m_soil);
copySelectOptions(vegEl, m_veg);
copySelectOptions(waterEl, m_w);
copySelectOptions(riskEl, m_r);
const key = `${r}_${c}`;
const obj = annotations[key] || {};
m_soil.value = obj.soil || '';
m_veg.value = obj.veg || '';
m_w.value = obj.water || '';
m_r.value = obj.risk || '';
m_notes.value = obj.notes || '';
modalLabel.innerText = `Row ${r+1}, Col ${c+1}`;
document.body.appendChild(node);
closeBtn.addEventListener('click', ()=>{
backdrop.remove();
});
m_save.addEventListener('click', ()=>{
annotations[key] = { soil: m_soil.value, veg: m_veg.value, water: m_w.value, risk: m_r.value, notes: m_notes.value };
saveToStorage();
backdrop.remove();
drawCanvas({drawCells:true, drawAnnotations:true});
setSelected(r,c);
});
m_delete.addEventListener('click', ()=>{
if(confirm('Delete annotation for this cell?')){
delete annotations[key];
saveToStorage();
backdrop.remove();
drawCanvas({drawCells:true, drawAnnotations:true});
clearSelected();
}
});
}
function copySelectOptions(from, to){
to.innerHTML = '';
Array.from(from.options).forEach(opt=>{
const o = document.createElement('option');
o.value = opt.value;
o.text = opt.text;
to.appendChild(o);
});
}
// redraw on resize
window.addEventListener('resize', ()=>{
if(!imageLoaded) return;
// recompute canvas size to remain usable in window
const maxW = Math.min(window.innerWidth - 360, 1400) || 1000;
const maxH = Math.min(window.innerHeight - 220, 900) || 700;
const scale = Math.min(maxW / img.width, maxH / img.height, 1);
canvas.width = Math.round(img.width * Math.max(scale, 0.6));
canvas.height = Math.round(img.height * Math.max(scale, 0.6));
drawCanvas({drawCells:true, drawAnnotations:true});
});
// initial load if storage exists
loadFromStorage();
drawCanvas({drawCells:true, drawAnnotations:true});