/* * canvas-boundary.js * Boundary-line editor rendered on a overlaid on cabinet/shelf images. * Handles: * - Parsing boundary JSON from tree nodes * - Drawing segment fills, labels, user boundary lines, and AI suggestion * overlays (dashed lines per plugin, or all-plugins combined) * - Pointer drag to move existing boundary lines * - Ctrl+Alt+Click to add a new boundary line (and create a new child entity) * - Mouse hover to highlight the corresponding tree row (seg-hover) * - Snap-to-AI-guide when releasing a drag near a plugin boundary * * Reads: S, _bnd (state.js); req, toast, render (api.js / init.js) * Writes: _bnd (state.js) * Provides: parseBounds(), parseBndPluginResults(), SEG_FILLS, SEG_STROKES, * setupDetailCanvas(), drawBnd(), clearSegHover() */ // ── Boundary parsing helpers ───────────────────────────────────────────────── function parseBounds(json) { if (!json) return []; try { return JSON.parse(json) || []; } catch { return []; } } function parseBndPluginResults(json) { if (!json) return {}; try { const v = JSON.parse(json); if (Array.isArray(v) || !v || typeof v !== 'object') return {}; return v; } catch { return {}; } } const SEG_FILLS = ['rgba(59,130,246,.14)','rgba(16,185,129,.14)','rgba(245,158,11,.14)','rgba(239,68,68,.14)','rgba(168,85,247,.14)']; const SEG_STROKES = ['#3b82f6','#10b981','#f59e0b','#ef4444','#a855f7']; // ── Canvas setup ───────────────────────────────────────────────────────────── function setupDetailCanvas() { const wrap = document.getElementById('bnd-wrap'); const img = document.getElementById('bnd-img'); const canvas = document.getElementById('bnd-canvas'); if (!wrap || !img || !canvas || !S.selected) return; const {type, id} = S.selected; const node = findNode(id); if (!node || (type !== 'cabinet' && type !== 'shelf')) return; const axis = type === 'cabinet' ? 'y' : 'x'; const boundaries = parseBounds(type === 'cabinet' ? node.shelf_boundaries : node.book_boundaries); const pluginResults = parseBndPluginResults(type === 'cabinet' ? node.ai_shelf_boundaries : node.ai_book_boundaries); const pluginIds = Object.keys(pluginResults); const segments = type === 'cabinet' ? node.shelves.map((s,i) => ({id:s.id, label:s.name||`Shelf ${i+1}`})) : node.books.map((b,i) => ({id:b.id, label:b.title||`Book ${i+1}`})); const hasChildren = type === 'cabinet' ? node.shelves.length > 0 : node.books.length > 0; const prevSel = (_bnd?.nodeId === id) ? _bnd.selectedPlugin : (hasChildren ? null : pluginIds[0] ?? null); _bnd = {wrap, img, canvas, axis, boundaries:[...boundaries], pluginResults, selectedPlugin: prevSel, segments, nodeId:id, nodeType:type}; function sizeAndDraw() { canvas.width = img.offsetWidth; canvas.height = img.offsetHeight; drawBnd(); } if (img.complete && img.offsetWidth > 0) sizeAndDraw(); else img.addEventListener('load', sizeAndDraw); canvas.addEventListener('pointerdown', bndPointerDown); canvas.addEventListener('pointermove', bndPointerMove); canvas.addEventListener('pointerup', bndPointerUp); canvas.addEventListener('click', bndClick); canvas.addEventListener('mousemove', bndHover); canvas.addEventListener('mouseleave', () => clearSegHover()); } // ── Draw ───────────────────────────────────────────────────────────────────── function drawBnd(dragIdx = -1, dragVal = null) { if (!_bnd || S._cropMode) return; const {canvas, axis, boundaries, segments} = _bnd; const W = canvas.width, H = canvas.height; if (!W || !H) return; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, W, H); // Build working boundary list with optional live drag value const full = [0, ...boundaries, 1]; if (dragIdx >= 0 && dragIdx < boundaries.length) { const lo = full[dragIdx] + 0.005; const hi = full[dragIdx + 2] - 0.005; full[dragIdx + 1] = Math.max(lo, Math.min(hi, dragVal)); } // Draw segments for (let i = 0; i < full.length - 1; i++) { const a = full[i], b = full[i + 1]; const ci = i % SEG_FILLS.length; ctx.fillStyle = SEG_FILLS[ci]; if (axis === 'y') ctx.fillRect(0, a*H, W, (b-a)*H); else ctx.fillRect(a*W, 0, (b-a)*W, H); // Label const seg = segments[i]; if (seg) { ctx.font = '11px system-ui,sans-serif'; ctx.fillStyle = 'rgba(0,0,0,.5)'; const lbl = seg.label.slice(0, 24); if (axis === 'y') { ctx.fillText(lbl, 4, a*H + 14); } else { ctx.save(); ctx.translate(a*W + 12, 14); ctx.rotate(Math.PI/2); ctx.fillText(lbl, 0, 0); ctx.restore(); } } } // Draw interior user boundary lines ctx.setLineDash([5, 3]); ctx.lineWidth = 2; for (let i = 0; i < boundaries.length; i++) { const val = (dragIdx === i && dragVal !== null) ? full[i+1] : boundaries[i]; ctx.strokeStyle = '#1e3a5f'; ctx.beginPath(); if (axis === 'y') { ctx.moveTo(0, val*H); ctx.lineTo(W, val*H); } else { ctx.moveTo(val*W, 0); ctx.lineTo(val*W, H); } ctx.stroke(); } // Draw plugin boundary suggestions (dashed, non-interactive) const {pluginResults, selectedPlugin} = _bnd; const pluginIds = Object.keys(pluginResults); if (selectedPlugin && pluginIds.length) { ctx.setLineDash([3, 6]); ctx.lineWidth = 1.5; const drawPluginBounds = (bounds, color) => { ctx.strokeStyle = color; for (const ab of (bounds || [])) { ctx.beginPath(); if (axis === 'y') { ctx.moveTo(0, ab*H); ctx.lineTo(W, ab*H); } else { ctx.moveTo(ab*W, 0); ctx.lineTo(ab*W, H); } ctx.stroke(); } }; if (selectedPlugin === 'all') { pluginIds.forEach((pid, i) => drawPluginBounds(pluginResults[pid], SEG_STROKES[i % SEG_STROKES.length])); } else if (pluginResults[selectedPlugin]) { drawPluginBounds(pluginResults[selectedPlugin], 'rgba(234,88,12,0.8)'); } } ctx.setLineDash([]); } // ── Drag machinery ─────────────────────────────────────────────────────────── let _dragIdx = -1, _dragging = false; function fracFromEvt(e) { const r = _bnd.canvas.getBoundingClientRect(); const x = (e.clientX - r.left) / r.width; const y = (e.clientY - r.top) / r.height; return _bnd.axis === 'y' ? y : x; } function nearestBnd(frac) { const {boundaries, canvas, axis} = _bnd; const r = canvas.getBoundingClientRect(); const dim = axis === 'y' ? r.height : r.width; const thresh = (window._grabPx ?? 14) / dim; let best = -1, bestD = thresh; boundaries.forEach((b,i) => { const d=Math.abs(b-frac); if(d { const d = Math.abs(ab - frac); if (d < bestD) { bestD = d; best = ab; } }); return best; } function bndPointerDown(e) { if (!_bnd || S._cropMode) return; const frac = fracFromEvt(e); const idx = nearestBnd(frac); if (idx >= 0) { _dragIdx = idx; _dragging = true; _bnd.canvas.setPointerCapture(e.pointerId); e.stopPropagation(); } } function bndPointerMove(e) { if (!_bnd || S._cropMode) return; const frac = fracFromEvt(e); const near = nearestBnd(frac); _bnd.canvas.style.cursor = (near >= 0 || _dragging) ? (_bnd.axis==='y' ? 'ns-resize' : 'ew-resize') : 'default'; if (_dragging && _dragIdx >= 0) drawBnd(_dragIdx, frac); } async function bndPointerUp(e) { if (!_dragging || !_bnd || S._cropMode) return; const frac = fracFromEvt(e); _dragging = false; const {boundaries, nodeId, nodeType} = _bnd; const full = [0, ...boundaries, 1]; const clamped = Math.max(full[_dragIdx]+0.005, Math.min(full[_dragIdx+2]-0.005, frac)); boundaries[_dragIdx] = Math.round(snapToAi(clamped) * 10000) / 10000; _bnd.boundaries = [...boundaries]; _dragIdx = -1; drawBnd(); const url = nodeType==='cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`; try { await req('PATCH', url, {boundaries}); const node = findNode(nodeId); if (node) { if (nodeType==='cabinet') node.shelf_boundaries = JSON.stringify(boundaries); else node.book_boundaries = JSON.stringify(boundaries); } } catch(err) { toast('Save failed: ' + err.message); } } async function bndClick(e) { if (!_bnd || _dragging || S._cropMode) return; if (!e.ctrlKey || !e.altKey) return; e.preventDefault(); const frac = snapToAi(fracFromEvt(e)); const {boundaries, nodeId, nodeType} = _bnd; const newBounds = [...boundaries, frac].sort((a,b)=>a-b); _bnd.boundaries = newBounds; const url = nodeType==='cabinet' ? `/api/cabinets/${nodeId}/boundaries` : `/api/shelves/${nodeId}/boundaries`; try { await req('PATCH', url, {boundaries: newBounds}); if (nodeType === 'cabinet') { const s = await req('POST', `/api/cabinets/${nodeId}/shelves`, null); S.tree.forEach(r=>r.cabinets.forEach(c=>{ if(c.id===nodeId){ c.shelf_boundaries=JSON.stringify(newBounds); c.shelves.push({...s,books:[]}); }})); } else { const b = await req('POST', `/api/shelves/${nodeId}/books`); S.tree.forEach(r=>r.cabinets.forEach(c=>c.shelves.forEach(s=>{ if(s.id===nodeId){ s.book_boundaries=JSON.stringify(newBounds); s.books.push(b); }}))); } render(); } catch(err) { toast('Error: ' + err.message); } } function bndHover(e) { if (!_bnd || S._cropMode) return; const frac = fracFromEvt(e); const {boundaries, segments} = _bnd; const full = [0, ...boundaries, 1]; let segIdx = -1; for (let i = 0; i < full.length-1; i++) { if(frac>=full[i]&&frac=0 && segments[segIdx]) { document.querySelector(`.node[data-id="${segments[segIdx].id}"] .nrow`)?.classList.add('seg-hover'); } } function clearSegHover() { document.querySelectorAll('.seg-hover').forEach(el=>el.classList.remove('seg-hover')); }