Files
bookshelf/static/js/canvas-boundary.js
Petr Polezhaev b94f222c96 Add per-request AI logging, DB batch queue, WS entity updates, and UI polish
- log_thread.py: thread-safe ContextVar bridge so executor threads can log
  individual LLM calls and archive searches back to the event loop
- ai_log.py: init_thread_logging(), notify_entity_update(); WS now pushes
  entity_update messages when book data changes after any plugin or batch run
- batch.py: replace batch_pending.json with batch_queue SQLite table;
  run_batch_consumer() reads queue dynamically so new books can be added
  while batch is running; add_to_queue() deduplicates
- migrate.py: fix _migrate_v1 (clear-on-startup bug); add _migrate_v2 for
  batch_queue table
- _client.py / archive.py / identification.py: wrap each LLM API call and
  archive search with log_thread start/finish entries
- api.py: POST /api/batch returns {already_running, added}; notify_entity_update
  after identify pipeline
- models.default.yaml: strengthen ai_identify confidence-scoring instructions;
  warn against placeholder data
- detail-render.js: book log entries show clickable ID + spine thumbnail;
  book spine/title images open full-screen popup
- events.js: batch-start handles already_running+added; open-img-popup action
- init.js: entity_update WS handler; image popup close listeners
- overlays.css / index.html: full-screen image popup overlay
- eslint.config.js: add new globals; fix no-redeclare/no-unused-vars for
  multi-file global architecture; all lint errors resolved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 12:10:54 +03:00

346 lines
11 KiB
JavaScript

/*
* canvas-boundary.js
* Boundary-line editor rendered on a <canvas> 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'));
}