- 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>
346 lines
11 KiB
JavaScript
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'));
|
|
}
|