Alonso Gonzalez

Geographer

Protocol for Coastal CleanUp
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%} }
Selected Cell
None
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
Tips: Use Files or Photos to load images on iPad. Export JSON to move data between devices.
/* 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});