/* * 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() */ /* exported parseBounds, parseBndPluginResults, setupDetailCanvas, drawBnd */ // ── 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 < bestD) { bestD = d; best = i; } }); return best; } function snapToAi(frac) { if (!_bnd?.selectedPlugin) return frac; const { pluginResults, selectedPlugin } = _bnd; const snapBounds = selectedPlugin === 'all' ? Object.values(pluginResults).flat() : pluginResults[selectedPlugin] || []; if (!snapBounds.length) return frac; const r = _bnd.canvas.getBoundingClientRect(); const dim = _bnd.axis === 'y' ? r.height : r.width; const thresh = (window._grabPx ?? 14) / dim; let best = frac, bestD = thresh; snapBounds.forEach((ab) => { 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 < full[i + 1]) { segIdx = i; break; } } clearSegHover(); if (segIdx >= 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')); }