From 6a9d1f5292e040d4dd08f6b65567df0514aaf65c Mon Sep 17 00:00:00 2001 From: Filip Vrubel <114007566+FilipVrubel@users.noreply.github.com> Date: Tue, 19 May 2026 13:20:24 +0200 Subject: [PATCH 01/20] feat: show annotations from job to user --- plugins/analyze-dev/analyzeDropdown.mjs | 187 ++++++++++++++------- plugins/analyze-dev/include.json | 3 +- plugins/analyze-dev/jobResultsOverlay.mjs | 194 ++++++++++++++++++++++ 3 files changed, 327 insertions(+), 57 deletions(-) create mode 100644 plugins/analyze-dev/jobResultsOverlay.mjs diff --git a/plugins/analyze-dev/analyzeDropdown.mjs b/plugins/analyze-dev/analyzeDropdown.mjs index f3e2f835..33a2a4fa 100644 --- a/plugins/analyze-dev/analyzeDropdown.mjs +++ b/plugins/analyze-dev/analyzeDropdown.mjs @@ -10,6 +10,9 @@ addPlugin('analyze-dev', class extends XOpatPlugin { } pluginReady() { + this._overlay = new JobResultsOverlay(); + this._empaiaConvertor = null; + const tOr = (key, fallback) => { const translated = $.t(key); return (translated && translated !== key) ? translated : fallback; @@ -181,7 +184,8 @@ addPlugin('analyze-dev', class extends XOpatPlugin { async _resolveCaseId() { const slideId = VIEWER.scalebar?.getReferencedTiledImage()?.source?.getEmpaiaId(); if (slideId) { - const api = EmpationAPI.V3.get(); + const api = singletonModule('empation-api')?.V3; + if (!api) return null; const cases = await api.cases.list(); for (const c of cases.items) { const slides = await api.cases.slides(c.id); @@ -192,6 +196,83 @@ addPlugin('analyze-dev', class extends XOpatPlugin { return this.getOption('caseId') || this.params.caseId || plugin('empaia')?.scopeAPI?.activeCaseId || null; } + async _fetchAndRenderResults(finalJob, appId, viewerId) { + if (!this._empaiaConvertor) { + try { + const annotationsModule = OSDAnnotations.instance(); + if (!OSDAnnotations.Convertor.CONVERTERS['empaia']) { + EmpationAPI.integrateWithAnnotations(annotationsModule); + } + const ConvertorClass = OSDAnnotations.Convertor.CONVERTERS['empaia']; + this._empaiaConvertor = new ConvertorClass(annotationsModule, {}); + console.log('[analyze] empaia convertor ready'); + } catch (e) { + console.warn('[analyze] empaia convertor not available', e); + return; + } + } + + try { + const ead = await window.EmpaiaStandaloneJobs?.getEAD?.(appId) || null; + if (!ead?.io) { + console.warn('[analyze] no EAD io definition — cannot identify annotation outputs'); + return; + } + + const annotationKeys = Object.entries(ead.io) + .filter(([, spec]) => spec.type === 'collection' && spec.items?.reference != null) + .map(([key]) => key); + + if (!annotationKeys.length) { + console.log('[analyze] no annotation output keys in EAD for job', finalJob.id); + return; + } + console.log('[analyze] annotation output keys:', annotationKeys); + + const scope = finalJob._scope; + if (!scope) { console.warn('[analyze] no scope on finalJob'); return; } + + const job = await scope.jobs.get(finalJob.id); + if (!job?.outputs) { + console.warn('[analyze] job has no outputs field', job); + return; + } + console.log('[analyze] job outputs:', job.outputs); + + const allShapes = []; + for (const key of annotationKeys) { + const collectionId = job.outputs[key]; + if (!collectionId) { + console.log('[analyze] no collection ID for output key', key); + continue; + } + try { + const result = await scope.collections.queryItems(collectionId, {}); + if (!result?.items?.length) { + console.log('[analyze] empty collection for key', key); + continue; + } + console.log('[analyze] fetched', result.items.length, 'annotations for key', key); + const decoded = await this._empaiaConvertor.decode({ items: result.items, presets: [] }); + if (decoded?.objects) allShapes.push(...decoded.objects.filter(Boolean)); + } catch (e) { + console.warn('[analyze] failed to fetch/decode annotations for key', key, e); + } + } + + if (!allShapes.length) { + console.log('[analyze] no shapes decoded from job', finalJob.id); + return; + } + + console.log('[analyze] rendering', allShapes.length, 'annotations from job', finalJob.id); + this._overlay.addJobResults(finalJob.id, allShapes, viewerId); + + } catch (e) { + console.error('[analyze] _fetchAndRenderResults failed', e); + } + } + _collapseDropdown(tab) { try { const btnId = `${tab.parentId}-b-${tab.id}`; @@ -202,20 +283,6 @@ addPlugin('analyze-dev', class extends XOpatPlugin { } catch(_) {} } - /** - * Poll obj.id until the empaia plugin assigns it asynchronously (after API POST). - * @param {object} obj annotation object reference from annotation-create event - * @param {number} timeout max wait in ms - * @returns {Promise} annotation ID or null on timeout - */ - async _waitForAnnotationId(obj, timeout = 5000) { - const start = Date.now(); - while (!obj.id && Date.now() - start < timeout) { - await new Promise(r => setTimeout(r, 50)); - } - return obj.id || null; - } - /** * Hide the FloatingWindow, activate rectangle drawing mode, wait for the user * to draw one annotation, then restore everything and return the annotation ID. @@ -226,12 +293,10 @@ addPlugin('analyze-dev', class extends XOpatPlugin { * @param {FloatingWindow} fw the apps FloatingWindow to hide during drawing * @returns {Promise} Empaia annotation ID */ - async _captureAnnotation(fw) { + async _captureAnnotation(fw, scope) { const annot = singletonModule('annotations'); - console.log('[analyze] _captureAnnotation start, annot:', annot, 'mode:', annot?.mode?.getId?.()); if (!annot) throw new Error('Annotations module not available'); - // Find rect factory by fabric structure in case its ID was registered incorrectly const rectFactory = annot.getAnnotationObjectFactory('rect') || Object.values(annot.objectFactories).find(f => f.fabricStructure?.() === 'rect'); if (!rectFactory) throw new Error('Rectangle annotation factory not available'); @@ -243,60 +308,61 @@ addPlugin('analyze-dev', class extends XOpatPlugin { annot.presets.left.objectFactory = rectFactory; annot.enableInteraction(true); - // Ensure the CUSTOM mode is registered before activating it annot.setModeUsed('CUSTOM'); - // setModeById fires mode-changed which may throw in third-party handlers, - // but the mode is assigned before the event fires so the catch is safe to ignore try { annot.setModeById('custom'); } catch (_) {} - console.log('[analyze] mode after switch:', annot.mode?.getId?.(), 'disabledInteraction:', annot.disabledInteraction); if (fw._rootEl) fw._rootEl.style.display = 'none'; - // Debug: monitor canvas-press to see if OSD is intercepting clicks - const debugPress = (e) => console.log('[analyze] canvas-press fired on VIEWER'); - const debugRelease = (e) => console.log('[analyze] canvas-release on annot'); - VIEWER.addHandler('canvas-press', debugPress); - annot.addHandler('canvas-release', debugRelease); - + const fabric = annot.fabric; let annotObj; try { - console.log('[analyze] _captureAnnotation: waiting for annotation-create event'); annotObj = await new Promise((resolve, reject) => { const onCreate = (ev) => { - console.log('[analyze] annotation-create fired, object:', ev.object); - annot.removeHandler('annotation-create', onCreate); + fabric.removeHandler('annotation-create', onCreate); document.removeEventListener('keydown', onEscape, true); resolve(ev.object); }; const onEscape = (e) => { if (e.key !== 'Escape') return; - console.log('[analyze] Escape pressed, cancelling annotation capture'); - annot.removeHandler('annotation-create', onCreate); + fabric.removeHandler('annotation-create', onCreate); document.removeEventListener('keydown', onEscape, true); reject(new Error('cancelled')); }; - annot.addHandler('annotation-create', onCreate); + fabric.addHandler('annotation-create', onCreate); document.addEventListener('keydown', onEscape, true); }); - console.log('[analyze] annotation-create resolved with object:', annotObj); } finally { - VIEWER.removeHandler('canvas-press', debugPress); - annot.removeHandler('canvas-release', debugRelease); - console.log('[analyze] _captureAnnotation finally block, annotObj:', annotObj); if (annot.presets.left) annot.presets.left.objectFactory = prevFactory; try { if (prevModeId !== undefined) annot.setModeById(prevModeId); } catch (_) {} if (!wasEnabled) annot.enableInteraction(false); - // Restore window on cancel/error immediately; success path restores after ID polling if (!annotObj && fw._rootEl) fw._rootEl.style.display = ''; } - console.log('[analyze] waiting for annotation ID...'); try { - const id = await this._waitForAnnotationId(annotObj); - console.log('[analyze] got annotation ID:', id); - if (!id) throw new Error('Annotation ID not assigned within timeout'); - return id; + const tileSource = VIEWER.scalebar.getReferencedTiledImage()?.source; + if (!tileSource) throw new Error('No active tiled image source'); + const slideId = tileSource.getEmpaiaId?.(); + if (!slideId) throw new Error('Could not get slide ID from tiled image source'); + const encoded = { + type: 'rectangle', + name: 'input_roi', + description: 'rect', + creator_type: 'scope', + creator_id: scope.id, + reference_type: 'wsi', + reference_id: slideId, + npp_created: Math.round(VIEWER.scalebar?.currentResolution?.() ?? 1), + upper_left: [Math.max(0, Math.round(annotObj.left)), Math.max(0, Math.round(annotObj.top))], + width: Math.round(annotObj.width), + height: Math.round(annotObj.height), + }; + console.log('[analyze] posting annotation to MDS:', encoded); + const created = await scope.annotations.create(encoded); + console.log('[analyze] annotation created in MDS, serverId=', created.id); + return created.id; + } catch (e) { + console.error('[analyze] _captureAnnotation failed:', e); + throw e; } finally { - console.log('[analyze] restoring FloatingWindow after ID polling'); if (fw._rootEl) fw._rootEl.style.display = ''; } } @@ -376,17 +442,17 @@ addPlugin('analyze-dev', class extends XOpatPlugin { let inputsForm = null; let inputsLoaded = false; - const onCapture = () => this._captureAnnotation(fw); - configBtn.addEventListener('click', async () => { inputsSection.classList.toggle('hidden'); if (!inputsLoaded && !inputsSection.classList.contains('hidden')) { try { - const api = EmpationAPI.V3.get(); + const api = singletonModule('empation-api')?.V3; + if (!api) throw new Error('EmpationAPI V3 is not available'); const caseId = await this._resolveCaseId(); if (!caseId) throw new Error('No active case found'); const examination = await api.examinations.create(caseId, appId); const scope = await api.getScopeFrom(examination); + const onCapture = () => this._captureAnnotation(fw, scope); inputsForm = await this._buildInputsForm(appId, scope, onCapture); inputsSection.innerHTML = ''; inputsSection.appendChild(inputsForm.container); @@ -411,11 +477,13 @@ addPlugin('analyze-dev', class extends XOpatPlugin { status.textContent = tOr('analyze.jobReady', 'Ready'); runBtn.addEventListener('click', async () => { + const viewerId = String(VIEWER.id); try { runBtn.disabled = true; status.textContent = tOr('analyze.jobStarting', 'Starting...'); const inputs = inputsForm?.getInputs?.() || {}; + const ead = inputsForm?.ead || null; console.log('[analyze] Running job with inputs:', inputs); const caseId = await this._resolveCaseId(); @@ -424,13 +492,17 @@ addPlugin('analyze-dev', class extends XOpatPlugin { appId, caseId, mode: 'STANDALONE', - inputs + inputs, + ead }); const isSuccess = res?.status === 'COMPLETED'; status.textContent = `${tOr('analyze.jobFinal', 'Status')}: ${res?.status || 'UNKNOWN'}`; status.className = isSuccess ? 'text-xs flex-1 text-success' : 'text-xs flex-1 text-error'; console.log('[analyze] Job final:', res); + if (isSuccess) { + await this._fetchAndRenderResults(res, appId, viewerId); + } } catch (err) { console.error('[analyze] Failed to run app job', err); status.textContent = `Error: ${err?.message || err}`; @@ -462,7 +534,7 @@ addPlugin('analyze-dev', class extends XOpatPlugin { console.log('[analyze] requiredInputs:', requiredInputs); if (requiredInputs.length === 0) { container.innerHTML = '
No inputs required
'; - return { container, getInputs: () => ({}) }; + return { container, getInputs: () => ({}), ead }; } const currentSlideId = VIEWER.scalebar?.getReferencedTiledImage()?.source?.getEmpaiaId() || ''; @@ -485,7 +557,7 @@ addPlugin('analyze-dev', class extends XOpatPlugin { return result; }; - return { container, getInputs }; + return { container, getInputs, ead }; } catch (e) { console.error('[analyze] Failed to build inputs form', e); container.innerHTML = `
Error: ${e.message}
`; @@ -532,8 +604,8 @@ addPlugin('analyze-dev', class extends XOpatPlugin { statusEl.textContent = id.slice(0, 8) + '\u2026'; } catch (e) { btn.textContent = 'Create annotation'; - if (e.message !== 'cancelled') { - statusEl.textContent = '\u26a0 ' + e.message; + if (e?.message !== 'cancelled') { + statusEl.textContent = '\u26a0 ' + (e?.message || String(e)); } } finally { btn.disabled = false; @@ -556,13 +628,16 @@ addPlugin('analyze-dev', class extends XOpatPlugin { fieldEl.type = 'number'; fieldEl.className = 'input input-xs input-bordered flex-1'; if (input.type === 'float') fieldEl.step = 'any'; + } else if (input.type === 'string') { + fieldEl = document.createElement('textarea'); + fieldEl.className = 'textarea textarea-xs textarea-bordered flex-1 font-mono text-xs'; + fieldEl.rows = 4; + fieldEl.placeholder = 'Enter text value\u2026'; } else { fieldEl = document.createElement('input'); fieldEl.type = 'text'; fieldEl.className = 'input input-xs input-bordered flex-1'; - if (!['string'].includes(input.type)) { - fieldEl.placeholder = `${input.type} ID`; - } + fieldEl.placeholder = `${input.type} ID`; } inputFields[input.key] = fieldEl; diff --git a/plugins/analyze-dev/include.json b/plugins/analyze-dev/include.json index cb88ca26..3f362a4c 100644 --- a/plugins/analyze-dev/include.json +++ b/plugins/analyze-dev/include.json @@ -5,6 +5,7 @@ "version": "1.0.0", "description": "Plugin for creating and running jobs", "icon": null, - "includes" : ["newAppForm.mjs", "analyzeDropdown.mjs"], + "includes": ["newAppForm.mjs", "jobResultsOverlay.mjs", "analyzeDropdown.mjs"], + "modules": ["annotations"], "permaLoad": true } \ No newline at end of file diff --git a/plugins/analyze-dev/jobResultsOverlay.mjs b/plugins/analyze-dev/jobResultsOverlay.mjs new file mode 100644 index 00000000..f9c83aeb --- /dev/null +++ b/plugins/analyze-dev/jobResultsOverlay.mjs @@ -0,0 +1,194 @@ +const COLOR_PALETTE = ['#e11d48', '#2563eb', '#16a34a', '#d97706', '#7c3aed', '#0891b2']; + +class JobResultsOverlay { + constructor() { + this._jobStore = new Map(); // jobId → {color, viewerId, fabricObjects} + this._viewerState = new Map(); // viewerId → {canvas, ctx, objects, viewer, handler, resizeObserver} + this._colorMap = new Map(); // fabricObj → color + this._colorIndex = 0; + } + + addJobResults(jobId, shapes, viewerId) { + if (!shapes || !shapes.length) { + console.log('[job-overlay] no shapes to render for job', jobId); + return; + } + + const color = COLOR_PALETTE[this._colorIndex % COLOR_PALETTE.length]; + this._colorIndex++; + + const fabricObjects = []; + for (const shape of shapes) { + if (!shape || !shape.factoryID) continue; + try { + shape.setCoords(); + fabricObjects.push(shape); + this._colorMap.set(shape, color); + } catch (e) { + console.warn('[job-overlay] failed to prepare shape', shape.factoryID, e); + } + } + + if (!fabricObjects.length) { + console.warn('[job-overlay] no renderable objects for job', jobId); + return; + } + + this._jobStore.set(jobId, { color, viewerId: String(viewerId), fabricObjects }); + console.log('[job-overlay] stored', fabricObjects.length, 'objects for job', jobId, 'color', color); + + this._ensureViewerState(String(viewerId)); + this._syncObjects(String(viewerId)); + this._redraw(String(viewerId)); + } + + destroy(viewerId) { + const state = this._viewerState.get(viewerId); + if (!state) return; + state.viewer.removeHandler('update-viewport', state.handler); + state.resizeObserver.disconnect(); + state.canvas.remove(); + this._viewerState.delete(viewerId); + } + + _ensureViewerState(viewerId) { + if (this._viewerState.has(viewerId)) return; + + const viewer = VIEWER_MANAGER.viewers.find(v => v && String(v.id) === String(viewerId)); + if (!viewer) { + console.warn('[job-overlay] viewer not found for id', viewerId); + return; + } + + const container = viewer.element; + const canvas = document.createElement('canvas'); + canvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:10;'; + canvas.width = container.clientWidth; + canvas.height = container.clientHeight; + container.appendChild(canvas); + const ctx = canvas.getContext('2d'); + + const handler = () => this._redraw(viewerId); + viewer.addHandler('update-viewport', handler); + + const resizeObserver = new ResizeObserver(() => { + canvas.width = container.clientWidth; + canvas.height = container.clientHeight; + this._redraw(viewerId); + }); + + this._viewerState.set(viewerId, { canvas, ctx, objects: [], viewer, handler, resizeObserver }); + console.log('[job-overlay] viewer state initialised for', viewerId); + resizeObserver.observe(container); + } + + _syncObjects(viewerId) { + const state = this._viewerState.get(viewerId); + if (!state) return; + state.objects = []; + for (const [, job] of this._jobStore) { + if (String(job.viewerId) !== String(viewerId)) continue; + for (const obj of job.fabricObjects) state.objects.push(obj); + } + } + + _redraw(viewerId) { + const state = this._viewerState.get(viewerId); + if (!state) return; + const { canvas, ctx, objects, viewer } = state; + ctx.clearRect(0, 0, canvas.width, canvas.height); + if (!objects.length) return; + + const tiledImage = viewer.scalebar?.getReferencedTiledImage?.() || viewer.world?.getItemAt?.(0); + if (!tiledImage) return; + + for (const obj of objects) { + this._drawShape(ctx, obj, this._colorMap.get(obj), tiledImage); + } + } + +_toScreen(tiledImage, x, y) { + return tiledImage.imageToViewerElementCoordinates(new OpenSeadragon.Point(x, y)); + } + + _drawShape(ctx, decoded, color, tiledImage) { + const { factoryID } = decoded; + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + + if (factoryID === 'rect') { + const tl = this._toScreen(tiledImage, decoded.left, decoded.top); + const br = this._toScreen(tiledImage, decoded.left + decoded.width, decoded.top + decoded.height); + ctx.beginPath(); + ctx.rect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + ctx.stroke(); + ctx.fillStyle = color; + ctx.globalAlpha = 0.2; + ctx.fill(); + + } else if (factoryID === 'ellipse') { + const center = this._toScreen(tiledImage, decoded.left + decoded.rx, decoded.top + decoded.ry); + const edgeX = this._toScreen(tiledImage, decoded.left + decoded.rx * 2, decoded.top + decoded.ry); + const edgeY = this._toScreen(tiledImage, decoded.left + decoded.rx, decoded.top + decoded.ry * 2); + const rx = Math.abs(edgeX.x - center.x); + const ry = Math.abs(edgeY.y - center.y); + ctx.beginPath(); + ctx.ellipse(center.x, center.y, Math.max(rx, 1), Math.max(ry, 1), 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.fillStyle = color; + ctx.globalAlpha = 0.2; + ctx.fill(); + + } else if (factoryID === 'polygon' || factoryID === 'multipolygon') { + const pts = decoded.points; + if (!pts || pts.length < 2) { ctx.restore(); return; } + ctx.beginPath(); + const first = this._toScreen(tiledImage, pts[0].x, pts[0].y); + ctx.moveTo(first.x, first.y); + for (let i = 1; i < pts.length; i++) { + if (pts[i].x >= 1e8 && pts[i].y >= 1e8) { + ctx.closePath(); + if (i + 1 < pts.length) { + const next = this._toScreen(tiledImage, pts[i + 1].x, pts[i + 1].y); + ctx.moveTo(next.x, next.y); + } + continue; + } + const s = this._toScreen(tiledImage, pts[i].x, pts[i].y); + ctx.lineTo(s.x, s.y); + } + ctx.closePath(); + ctx.stroke(); + ctx.fillStyle = color; + ctx.globalAlpha = 0.2; + ctx.fill(); + + } else if (factoryID === 'polyline' || factoryID === 'ruler' || factoryID === 'line') { + const pts = decoded.points; + if (!pts || pts.length < 2) { ctx.restore(); return; } + ctx.beginPath(); + const first = this._toScreen(tiledImage, pts[0].x, pts[0].y); + ctx.moveTo(first.x, first.y); + for (let i = 1; i < pts.length; i++) { + const s = this._toScreen(tiledImage, pts[i].x, pts[i].y); + ctx.lineTo(s.x, s.y); + } + ctx.stroke(); + + } else if (factoryID === 'point') { + const s = this._toScreen(tiledImage, decoded.left, decoded.top); + ctx.beginPath(); + ctx.arc(s.x, s.y, 4, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.globalAlpha = 1; + ctx.fill(); + ctx.stroke(); + } + + ctx.restore(); + } + +} + +window.JobResultsOverlay = JobResultsOverlay; From 2b9a1edf1aae710bbe11068864a140dd40264e48 Mon Sep 17 00:00:00 2001 From: Filip Vrubel <114007566+FilipVrubel@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:23:05 +0200 Subject: [PATCH 02/20] feat: render job results using OSDAnnotations layers --- plugins/analyze-dev/analyzeDropdown.mjs | 6 +- plugins/analyze-dev/jobResultsOverlay.mjs | 226 +++++++--------------- 2 files changed, 75 insertions(+), 157 deletions(-) diff --git a/plugins/analyze-dev/analyzeDropdown.mjs b/plugins/analyze-dev/analyzeDropdown.mjs index 33a2a4fa..638b56bd 100644 --- a/plugins/analyze-dev/analyzeDropdown.mjs +++ b/plugins/analyze-dev/analyzeDropdown.mjs @@ -12,6 +12,7 @@ addPlugin('analyze-dev', class extends XOpatPlugin { pluginReady() { this._overlay = new JobResultsOverlay(); this._empaiaConvertor = null; + UTILITIES.loadPlugin('gui_annotations'); const tOr = (key, fallback) => { const translated = $.t(key); @@ -266,7 +267,7 @@ addPlugin('analyze-dev', class extends XOpatPlugin { } console.log('[analyze] rendering', allShapes.length, 'annotations from job', finalJob.id); - this._overlay.addJobResults(finalJob.id, allShapes, viewerId); + await this._overlay.addJobResults(finalJob.id, allShapes, viewerId); } catch (e) { console.error('[analyze] _fetchAndRenderResults failed', e); @@ -477,7 +478,7 @@ addPlugin('analyze-dev', class extends XOpatPlugin { status.textContent = tOr('analyze.jobReady', 'Ready'); runBtn.addEventListener('click', async () => { - const viewerId = String(VIEWER.id); + const viewerId = String(VIEWER.uniqueId); try { runBtn.disabled = true; status.textContent = tOr('analyze.jobStarting', 'Starting...'); @@ -645,4 +646,5 @@ addPlugin('analyze-dev', class extends XOpatPlugin { return row; } + }); \ No newline at end of file diff --git a/plugins/analyze-dev/jobResultsOverlay.mjs b/plugins/analyze-dev/jobResultsOverlay.mjs index f9c83aeb..10e450e9 100644 --- a/plugins/analyze-dev/jobResultsOverlay.mjs +++ b/plugins/analyze-dev/jobResultsOverlay.mjs @@ -2,193 +2,109 @@ const COLOR_PALETTE = ['#e11d48', '#2563eb', '#16a34a', '#d97706', '#7c3aed', '# class JobResultsOverlay { constructor() { - this._jobStore = new Map(); // jobId → {color, viewerId, fabricObjects} - this._viewerState = new Map(); // viewerId → {canvas, ctx, objects, viewer, handler, resizeObserver} - this._colorMap = new Map(); // fabricObj → color + this._jobStore = new Map(); // jobId → { layerId, viewerId } this._colorIndex = 0; } - addJobResults(jobId, shapes, viewerId) { + async addJobResults(jobId, shapes, viewerId) { if (!shapes || !shapes.length) { console.log('[job-overlay] no shapes to render for job', jobId); return; } - const color = COLOR_PALETTE[this._colorIndex % COLOR_PALETTE.length]; - this._colorIndex++; - - const fabricObjects = []; - for (const shape of shapes) { - if (!shape || !shape.factoryID) continue; - try { - shape.setCoords(); - fabricObjects.push(shape); - this._colorMap.set(shape, color); - } catch (e) { - console.warn('[job-overlay] failed to prepare shape', shape.factoryID, e); - } + // Clear any existing layer/preset for this job before creating new ones. + if (this._jobStore.has(jobId)) { + await this.clearJob(jobId); } - if (!fabricObjects.length) { - console.warn('[job-overlay] no renderable objects for job', jobId); + const annot = OSDAnnotations.instance(); + if (!annot) { + console.warn('[job-overlay] OSDAnnotations not available'); return; } - this._jobStore.set(jobId, { color, viewerId: String(viewerId), fabricObjects }); - console.log('[job-overlay] stored', fabricObjects.length, 'objects for job', jobId, 'color', color); - - this._ensureViewerState(String(viewerId)); - this._syncObjects(String(viewerId)); - this._redraw(String(viewerId)); - } - - destroy(viewerId) { - const state = this._viewerState.get(viewerId); - if (!state) return; - state.viewer.removeHandler('update-viewport', state.handler); - state.resizeObserver.disconnect(); - state.canvas.remove(); - this._viewerState.delete(viewerId); - } - - _ensureViewerState(viewerId) { - if (this._viewerState.has(viewerId)) return; - - const viewer = VIEWER_MANAGER.viewers.find(v => v && String(v.id) === String(viewerId)); + const viewer = VIEWER_MANAGER.viewers.find(v => v && String(v.uniqueId) === String(viewerId)); if (!viewer) { console.warn('[job-overlay] viewer not found for id', viewerId); return; } - const container = viewer.element; - const canvas = document.createElement('canvas'); - canvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:10;'; - canvas.width = container.clientWidth; - canvas.height = container.clientHeight; - container.appendChild(canvas); - const ctx = canvas.getContext('2d'); - - const handler = () => this._redraw(viewerId); - viewer.addHandler('update-viewport', handler); - - const resizeObserver = new ResizeObserver(() => { - canvas.width = container.clientWidth; - canvas.height = container.clientHeight; - this._redraw(viewerId); - }); - - this._viewerState.set(viewerId, { canvas, ctx, objects: [], viewer, handler, resizeObserver }); - console.log('[job-overlay] viewer state initialised for', viewerId); - resizeObserver.observe(container); - } - - _syncObjects(viewerId) { - const state = this._viewerState.get(viewerId); - if (!state) return; - state.objects = []; - for (const [, job] of this._jobStore) { - if (String(job.viewerId) !== String(viewerId)) continue; - for (const obj of job.fabricObjects) state.objects.push(obj); + const fabric = annot.getFabric(viewer); + if (!fabric) { + console.warn('[job-overlay] fabric not available for viewer', viewerId); + return; } - } - _redraw(viewerId) { - const state = this._viewerState.get(viewerId); - if (!state) return; - const { canvas, ctx, objects, viewer } = state; - ctx.clearRect(0, 0, canvas.width, canvas.height); - if (!objects.length) return; + const color = COLOR_PALETTE[this._colorIndex % COLOR_PALETTE.length]; - const tiledImage = viewer.scalebar?.getReferencedTiledImage?.() || viewer.world?.getItemAt?.(0); - if (!tiledImage) return; + const layerId = `job-${jobId}`; + const layerName = `Job ${String(jobId).slice(0, 8)}`; + + try { + // createLayer() returns Promise; retrieve via getLayer() after awaiting. + await fabric.createLayer(layerId); + const layer = fabric.getLayer(layerId); + if (layer) layer.name = layerName; + + // Create a local preset so updateSingleAnnotationVisuals can apply the job colour. + const preset = annot.presets.addPreset(layerId, layerName, color); + + const valid = shapes.filter(Boolean); + for (const shape of valid) { + shape.layerID = layerId; + shape.presetID = preset.presetID; + // color must be set explicitly: commonAnnotationVisuals has no 'color' property, + // so updateRendering's typeof check would skip preset colour application otherwise. + shape.color = color; + } - for (const obj of objects) { - this._drawShape(ctx, obj, this._colorMap.get(obj), tiledImage); - } - } + await fabric.addAnnotationsBulk(valid, { + historyName: 'Load job results', + progress: true, + viewer: viewer, + }); + + this._jobStore.set(jobId, { layerId, viewerId: String(viewerId) }); + this._colorIndex++; + console.log('[job-overlay] added', valid.length, 'annotations for job', jobId); -_toScreen(tiledImage, x, y) { - return tiledImage.imageToViewerElementCoordinates(new OpenSeadragon.Point(x, y)); + } catch (e) { + console.error('[job-overlay] failed to add job results for job', jobId, e); + } } - _drawShape(ctx, decoded, color, tiledImage) { - const { factoryID } = decoded; - ctx.save(); - ctx.strokeStyle = color; - ctx.lineWidth = 2; - - if (factoryID === 'rect') { - const tl = this._toScreen(tiledImage, decoded.left, decoded.top); - const br = this._toScreen(tiledImage, decoded.left + decoded.width, decoded.top + decoded.height); - ctx.beginPath(); - ctx.rect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); - ctx.stroke(); - ctx.fillStyle = color; - ctx.globalAlpha = 0.2; - ctx.fill(); - - } else if (factoryID === 'ellipse') { - const center = this._toScreen(tiledImage, decoded.left + decoded.rx, decoded.top + decoded.ry); - const edgeX = this._toScreen(tiledImage, decoded.left + decoded.rx * 2, decoded.top + decoded.ry); - const edgeY = this._toScreen(tiledImage, decoded.left + decoded.rx, decoded.top + decoded.ry * 2); - const rx = Math.abs(edgeX.x - center.x); - const ry = Math.abs(edgeY.y - center.y); - ctx.beginPath(); - ctx.ellipse(center.x, center.y, Math.max(rx, 1), Math.max(ry, 1), 0, 0, Math.PI * 2); - ctx.stroke(); - ctx.fillStyle = color; - ctx.globalAlpha = 0.2; - ctx.fill(); - - } else if (factoryID === 'polygon' || factoryID === 'multipolygon') { - const pts = decoded.points; - if (!pts || pts.length < 2) { ctx.restore(); return; } - ctx.beginPath(); - const first = this._toScreen(tiledImage, pts[0].x, pts[0].y); - ctx.moveTo(first.x, first.y); - for (let i = 1; i < pts.length; i++) { - if (pts[i].x >= 1e8 && pts[i].y >= 1e8) { - ctx.closePath(); - if (i + 1 < pts.length) { - const next = this._toScreen(tiledImage, pts[i + 1].x, pts[i + 1].y); - ctx.moveTo(next.x, next.y); - } - continue; + async clearJob(jobId) { + const entry = this._jobStore.get(jobId); + if (!entry) return; + + const viewer = VIEWER_MANAGER.viewers.find(v => v && String(v.uniqueId) === String(entry.viewerId)); + if (viewer) { + const annot = OSDAnnotations.instance(); + const fabric = annot?.getFabric(viewer); + if (fabric) { + try { + await fabric.deleteLayer(entry.layerId); + } catch (e) { + console.warn('[job-overlay] failed to delete layer', entry.layerId, e); } - const s = this._toScreen(tiledImage, pts[i].x, pts[i].y); - ctx.lineTo(s.x, s.y); } - ctx.closePath(); - ctx.stroke(); - ctx.fillStyle = color; - ctx.globalAlpha = 0.2; - ctx.fill(); - - } else if (factoryID === 'polyline' || factoryID === 'ruler' || factoryID === 'line') { - const pts = decoded.points; - if (!pts || pts.length < 2) { ctx.restore(); return; } - ctx.beginPath(); - const first = this._toScreen(tiledImage, pts[0].x, pts[0].y); - ctx.moveTo(first.x, first.y); - for (let i = 1; i < pts.length; i++) { - const s = this._toScreen(tiledImage, pts[i].x, pts[i].y); - ctx.lineTo(s.x, s.y); + if (annot) { + try { + annot.presets.removePreset(entry.layerId); + } catch (e) { + console.warn('[job-overlay] failed to remove preset', entry.layerId, e); + } } - ctx.stroke(); - - } else if (factoryID === 'point') { - const s = this._toScreen(tiledImage, decoded.left, decoded.top); - ctx.beginPath(); - ctx.arc(s.x, s.y, 4, 0, Math.PI * 2); - ctx.fillStyle = color; - ctx.globalAlpha = 1; - ctx.fill(); - ctx.stroke(); } - ctx.restore(); + this._jobStore.delete(jobId); } + async destroy(viewerId) { + const toRemove = [...this._jobStore.keys()] + .filter(id => String(this._jobStore.get(id).viewerId) === String(viewerId)); + await Promise.all(toRemove.map(id => this.clearJob(id))); + } } window.JobResultsOverlay = JobResultsOverlay; From 16a1704bf3c4d271b5cc58de65dcd0aa4c7f85eb Mon Sep 17 00:00:00 2001 From: Filip Vrubel <114007566+FilipVrubel@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:25:14 +0200 Subject: [PATCH 03/20] feat: show output_values window and close apps window after job completes --- plugins/analyze-dev/analyzeDropdown.mjs | 94 +++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/plugins/analyze-dev/analyzeDropdown.mjs b/plugins/analyze-dev/analyzeDropdown.mjs index 638b56bd..ef59ea7c 100644 --- a/plugins/analyze-dev/analyzeDropdown.mjs +++ b/plugins/analyze-dev/analyzeDropdown.mjs @@ -274,6 +274,95 @@ addPlugin('analyze-dev', class extends XOpatPlugin { } } + async _fetchOutputValues(finalJob, appId) { + try { + const ead = await window.EmpaiaStandaloneJobs?.getEAD?.(appId) || null; + if (!ead?.io) { + console.log('[analyze] _fetchOutputValues: no EAD io, skipping'); + return []; + } + + const valueKeys = Object.entries(ead.io) + .filter(([, spec]) => spec.type === 'collection' && !spec.items?.reference) + .map(([key]) => key); + + if (!valueKeys.length) { + console.log('[analyze] no value output keys in EAD for job', finalJob.id); + return []; + } + console.log('[analyze] value output keys:', valueKeys); + + const scope = finalJob._scope; + if (!scope) { console.warn('[analyze] _fetchOutputValues: no scope on finalJob'); return []; } + + const job = await scope.jobs.get(finalJob.id); + if (!job?.outputs) { + console.warn('[analyze] _fetchOutputValues: job has no outputs field', job); + return []; + } + + const results = []; + for (const key of valueKeys) { + const collectionId = job.outputs[key]; + if (!collectionId) { + console.log('[analyze] no collection ID for value key', key); + continue; + } + try { + const result = await scope.collections.queryItems(collectionId, {}); + if (!result?.items?.length) { + console.log('[analyze] empty collection for value key', key); + continue; + } + console.log('[analyze] fetched', result.items.length, 'values for key', key); + results.push({ key, items: result.items }); + } catch (e) { + console.warn('[analyze] failed to fetch values for key', key, e); + } + } + return results; + } catch (e) { + console.error('[analyze] _fetchOutputValues failed', e); + return []; + } + } + + _showOutputValuesWindow(valueOutputs) { + const { FloatingWindow } = globalThis.UI; + const id = `${this.id}-output-values-window`; + const width = 360; + const height = 420; + const startLeft = Math.max(8, Math.round((window.innerWidth - width) / 2)); + const startTop = Math.max(8, Math.round((window.innerHeight - height) / 2)); + + const fw = new FloatingWindow({ id, title: 'Job Results', width, height, startLeft, startTop }); + fw.attachTo(document.body); + + const body = document.createElement('div'); + body.className = 'p-3 space-y-4 overflow-auto'; + body.style.height = '100%'; + + for (const { key, items } of valueOutputs) { + const section = document.createElement('div'); + section.className = 'mb-3'; + + const heading = document.createElement('div'); + heading.className = 'text-sm font-medium mb-1'; + heading.textContent = key; + section.appendChild(heading); + + const pre = document.createElement('pre'); + pre.className = 'text-xs font-mono opacity-80 whitespace-pre-wrap'; + pre.textContent = items.map((item, i) => `${i}: ${Number(item.value).toFixed(4)}`).join('\n'); + section.appendChild(pre); + + body.appendChild(section); + } + + fw.setBody(body); + fw.focus(); + } + _collapseDropdown(tab) { try { const btnId = `${tab.parentId}-b-${tab.id}`; @@ -503,6 +592,11 @@ addPlugin('analyze-dev', class extends XOpatPlugin { console.log('[analyze] Job final:', res); if (isSuccess) { await this._fetchAndRenderResults(res, appId, viewerId); + const valueOutputs = await this._fetchOutputValues(res, appId); + fw.close(); + if (valueOutputs.length > 0) { + this._showOutputValuesWindow(valueOutputs); + } } } catch (err) { console.error('[analyze] Failed to run app job', err); From d46041e496029eb3af8048b72a2a840a55aabc4b Mon Sep 17 00:00:00 2001 From: Filip Vrubel <114007566+FilipVrubel@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:47:09 +0200 Subject: [PATCH 04/20] feat: remove New App Form from analyze dropdown --- plugins/analyze-dev/analyzeDropdown.mjs | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/plugins/analyze-dev/analyzeDropdown.mjs b/plugins/analyze-dev/analyzeDropdown.mjs index ef59ea7c..3337d176 100644 --- a/plugins/analyze-dev/analyzeDropdown.mjs +++ b/plugins/analyze-dev/analyzeDropdown.mjs @@ -1,4 +1,3 @@ -import { NewAppForm } from "./newAppForm.mjs"; const { Dropdown } = globalThis.UI; addPlugin('analyze-dev', class extends XOpatPlugin { @@ -121,26 +120,6 @@ addPlugin('analyze-dev', class extends XOpatPlugin { }); } - tab.addItem({ - id: 'create-app', - label: tOr('analyze.createApp', 'Create New App'), - onClick: () => { - const form = new NewAppForm({ onSubmit: (data) => { - try { - this.params.onCreate?.(data); - } catch (err) { console.error(err); } - }}); - const win = form.showFloating({ title: tOr('analyze.createApp', 'Create New App'), width: 420, height: 360 }); - if (!win) { - const overlayId = `${this.id}-newapp-overlay`; - USER_INTERFACE.Dialogs.showCustom(overlayId, 'New App', `
`, '', { allowClose: true }); - const container = document.getElementById(overlayId)?.querySelector('.card-body'); - if (container) form.attachTo(container); - } - return false; - } - }); - tab.addItem({ id: 'apps-list', label: tOr('analyze.apps', 'Apps'), From 78b8a7071269d5b91e6face056557740f65bc9e1 Mon Sep 17 00:00:00 2001 From: Filip Vrubel <114007566+FilipVrubel@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:00:32 +0200 Subject: [PATCH 05/20] feat: filter no-op apps from analyze apps window --- plugins/analyze-dev/analyzeDropdown.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/analyze-dev/analyzeDropdown.mjs b/plugins/analyze-dev/analyzeDropdown.mjs index 3337d176..2080ccca 100644 --- a/plugins/analyze-dev/analyzeDropdown.mjs +++ b/plugins/analyze-dev/analyzeDropdown.mjs @@ -440,7 +440,11 @@ addPlugin('analyze-dev', class extends XOpatPlugin { let items = []; try { const resp = await window.EmpaiaStandaloneJobs?.getApps?.(); - items = Array.isArray(resp?.items) ? resp.items : []; + const all = Array.isArray(resp?.items) ? resp.items : []; + items = all.filter(app => { + const desc = (app?.store_description || '').toUpperCase(); + return !desc.includes('NO-OP') && !desc.includes('NO_OP'); + }); } catch (e) { console.warn('[analyze] failed to fetch apps, showing empty list', e); } From 4fea55b3732fec801ade48b828f086b9c3f47e91 Mon Sep 17 00:00:00 2001 From: Filip Vrubel <114007566+FilipVrubel@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:00:42 +0200 Subject: [PATCH 06/20] feat: add job status badge, ROI focus on click, select for script input --- plugins/analyze-dev/analyzeDropdown.mjs | 70 +++++++++++++++++++++---- 1 file changed, 61 insertions(+), 9 deletions(-) diff --git a/plugins/analyze-dev/analyzeDropdown.mjs b/plugins/analyze-dev/analyzeDropdown.mjs index 2080ccca..7891d61b 100644 --- a/plugins/analyze-dev/analyzeDropdown.mjs +++ b/plugins/analyze-dev/analyzeDropdown.mjs @@ -1,5 +1,9 @@ const { Dropdown } = globalThis.UI; +const STRING_SELECT_OPTIONS = { + script: ['stardist'], +}; + addPlugin('analyze-dev', class extends XOpatPlugin { constructor(id, params) { super(id); @@ -427,7 +431,7 @@ addPlugin('analyze-dev', class extends XOpatPlugin { console.log('[analyze] posting annotation to MDS:', encoded); const created = await scope.annotations.create(encoded); console.log('[analyze] annotation created in MDS, serverId=', created.id); - return created.id; + return { id: created.id, bounds: { left: annotObj.left, top: annotObj.top, width: annotObj.width, height: annotObj.height } }; } catch (e) { console.error('[analyze] _captureAnnotation failed:', e); throw e; @@ -551,12 +555,36 @@ addPlugin('analyze-dev', class extends XOpatPlugin { runBtn.addEventListener('click', async () => { const viewerId = String(VIEWER.uniqueId); + const bannerId = 'banner'; + const appLabel = app?.name_short || app?.name || 'Job'; + const focusOnBounds = (bounds) => { + const tiledImage = VIEWER.scalebar.getReferencedTiledImage(); + if (!tiledImage) return; + const rect = tiledImage.imageToViewportRectangle(bounds.left, bounds.top, bounds.width, bounds.height); + VIEWER.viewport.fitBounds(rect, false); + }; + const setJobBanner = (label, colorKey, bounds) => { + USER_INTERFACE.AppBar.addBadge(bannerId, { + label, + color: colorKey.toLowerCase(), + dot: colorKey === 'WARNING', + pulse: colorKey === 'WARNING', + title: bounds ? 'Click to focus ROI' : 'Click to dismiss', + onClick: () => { + if (bounds) focusOnBounds(bounds); + USER_INTERFACE.AppBar.removeBadge(bannerId); + }, + }); + }; + let annotBounds = null; try { runBtn.disabled = true; status.textContent = tOr('analyze.jobStarting', 'Starting...'); + setJobBanner(`${appLabel}: Pending`, 'WARNING', null); const inputs = inputsForm?.getInputs?.() || {}; const ead = inputsForm?.ead || null; + annotBounds = inputsForm?.getAnnotBounds?.() || null; console.log('[analyze] Running job with inputs:', inputs); const caseId = await this._resolveCaseId(); @@ -574,17 +602,21 @@ addPlugin('analyze-dev', class extends XOpatPlugin { status.className = isSuccess ? 'text-xs flex-1 text-success' : 'text-xs flex-1 text-error'; console.log('[analyze] Job final:', res); if (isSuccess) { + setJobBanner(`${appLabel}: Completed`, 'SUCCESS', annotBounds); await this._fetchAndRenderResults(res, appId, viewerId); const valueOutputs = await this._fetchOutputValues(res, appId); fw.close(); if (valueOutputs.length > 0) { this._showOutputValuesWindow(valueOutputs); } + } else { + setJobBanner(`${appLabel}: Failed`, 'ERROR', annotBounds); } } catch (err) { console.error('[analyze] Failed to run app job', err); status.textContent = `Error: ${err?.message || err}`; status.className = 'text-xs flex-1 text-error'; + setJobBanner(`${appLabel}: Failed`, 'ERROR', annotBounds); } finally { runBtn.disabled = false; } @@ -635,7 +667,14 @@ addPlugin('analyze-dev', class extends XOpatPlugin { return result; }; - return { container, getInputs, ead }; + const getAnnotBounds = () => { + for (const el of Object.values(inputFields)) { + if (el.bounds) return el.bounds; + } + return null; + }; + + return { container, getInputs, getAnnotBounds, ead }; } catch (e) { console.error('[analyze] Failed to build inputs form', e); container.innerHTML = `
Error: ${e.message}
`; @@ -676,10 +715,11 @@ addPlugin('analyze-dev', class extends XOpatPlugin { btn.textContent = 'Drawing\u2026'; statusEl.textContent = ''; try { - const id = await onCapture(); - valueHolder.value = id; + const result = await onCapture(); + valueHolder.value = result.id; + valueHolder.bounds = result.bounds; btn.textContent = 'Redraw'; - statusEl.textContent = id.slice(0, 8) + '\u2026'; + statusEl.textContent = result.id.slice(0, 8) + '\u2026'; } catch (e) { btn.textContent = 'Create annotation'; if (e?.message !== 'cancelled') { @@ -707,10 +747,22 @@ addPlugin('analyze-dev', class extends XOpatPlugin { fieldEl.className = 'input input-xs input-bordered flex-1'; if (input.type === 'float') fieldEl.step = 'any'; } else if (input.type === 'string') { - fieldEl = document.createElement('textarea'); - fieldEl.className = 'textarea textarea-xs textarea-bordered flex-1 font-mono text-xs'; - fieldEl.rows = 4; - fieldEl.placeholder = 'Enter text value\u2026'; + const selectOpts = STRING_SELECT_OPTIONS[input.key]; + if (selectOpts) { + fieldEl = document.createElement('select'); + fieldEl.className = 'select select-xs select-bordered flex-1'; + for (const opt of selectOpts) { + const option = document.createElement('option'); + option.value = opt; + option.textContent = opt; + fieldEl.appendChild(option); + } + } else { + fieldEl = document.createElement('textarea'); + fieldEl.className = 'textarea textarea-xs textarea-bordered flex-1 font-mono text-xs'; + fieldEl.rows = 4; + fieldEl.placeholder = 'Enter text value\u2026'; + } } else { fieldEl = document.createElement('input'); fieldEl.type = 'text'; From 3521796485903f68a36eb01b6e3daf50047ce35c Mon Sep 17 00:00:00 2001 From: Filip Vrubel <114007566+FilipVrubel@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:03:11 +0200 Subject: [PATCH 07/20] feat: show draw ROI modal on run, close apps window immediately --- plugins/analyze-dev/analyzeDropdown.mjs | 144 ++++++++++++++++-------- 1 file changed, 100 insertions(+), 44 deletions(-) diff --git a/plugins/analyze-dev/analyzeDropdown.mjs b/plugins/analyze-dev/analyzeDropdown.mjs index 7891d61b..405ad334 100644 --- a/plugins/analyze-dev/analyzeDropdown.mjs +++ b/plugins/analyze-dev/analyzeDropdown.mjs @@ -576,10 +576,40 @@ addPlugin('analyze-dev', class extends XOpatPlugin { }, }); }; + + if (!inputsLoaded) { + try { + const api = singletonModule('empation-api')?.V3; + if (!api) throw new Error('EmpationAPI V3 is not available'); + const caseId = await this._resolveCaseId(); + if (!caseId) throw new Error('No active case found'); + const examination = await api.examinations.create(caseId, appId); + const scope = await api.getScopeFrom(examination); + const onCapture = () => this._captureAnnotation(fw, scope); + inputsForm = await this._buildInputsForm(appId, scope, onCapture); + inputsLoaded = true; + } catch (e) { + console.error('[analyze] Failed to load inputs for run:', e); + } + } + + fw.close(); + + if (inputsForm?.captureAnnotation) { + if (!this.getOption('skipDrawROIModal')) { + const shouldDraw = await this._showDrawROIModal(); + if (!shouldDraw) return; + } + try { + await inputsForm.captureAnnotation(); + } catch (e) { + if (e?.message !== 'cancelled') console.error('[analyze] Annotation capture failed:', e); + return; + } + } + let annotBounds = null; try { - runBtn.disabled = true; - status.textContent = tOr('analyze.jobStarting', 'Starting...'); setJobBanner(`${appLabel}: Pending`, 'WARNING', null); const inputs = inputsForm?.getInputs?.() || {}; @@ -598,14 +628,11 @@ addPlugin('analyze-dev', class extends XOpatPlugin { }); const isSuccess = res?.status === 'COMPLETED'; - status.textContent = `${tOr('analyze.jobFinal', 'Status')}: ${res?.status || 'UNKNOWN'}`; - status.className = isSuccess ? 'text-xs flex-1 text-success' : 'text-xs flex-1 text-error'; console.log('[analyze] Job final:', res); if (isSuccess) { setJobBanner(`${appLabel}: Completed`, 'SUCCESS', annotBounds); await this._fetchAndRenderResults(res, appId, viewerId); const valueOutputs = await this._fetchOutputValues(res, appId); - fw.close(); if (valueOutputs.length > 0) { this._showOutputValuesWindow(valueOutputs); } @@ -614,11 +641,7 @@ addPlugin('analyze-dev', class extends XOpatPlugin { } } catch (err) { console.error('[analyze] Failed to run app job', err); - status.textContent = `Error: ${err?.message || err}`; - status.className = 'text-xs flex-1 text-error'; setJobBanner(`${appLabel}: Failed`, 'ERROR', annotBounds); - } finally { - runBtn.disabled = false; } }); @@ -629,6 +652,63 @@ addPlugin('analyze-dev', class extends XOpatPlugin { return wrap; } + _showDrawROIModal() { + const { FloatingWindow } = globalThis.UI; + return new Promise(resolve => { + let resolved = false; + const finish = (result) => { + if (resolved) return; + resolved = true; + resolve(result); + }; + + const width = 320, height = 190; + const modal = new FloatingWindow({ + id: `${this.id}-draw-roi-modal`, + title: 'Draw Region of Interest', + width, + height, + startLeft: Math.round((window.innerWidth - width) / 2), + startTop: Math.round((window.innerHeight - height) / 2), + onClose: () => finish(false), + }); + modal.attachTo(document.body); + + const body = document.createElement('div'); + body.className = 'p-4 flex flex-col gap-3'; + + const msg = document.createElement('p'); + msg.className = 'text-sm'; + msg.textContent = 'Draw a rectangular region on the slide to define the area of interest for analysis.'; + body.appendChild(msg); + + const checkRow = document.createElement('label'); + checkRow.className = 'flex items-center gap-2 text-xs cursor-pointer'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'checkbox checkbox-xs'; + const checkLabel = document.createElement('span'); + checkLabel.textContent = "Don't show again"; + checkRow.appendChild(checkbox); + checkRow.appendChild(checkLabel); + body.appendChild(checkRow); + + const drawBtn = document.createElement('button'); + drawBtn.type = 'button'; + drawBtn.className = 'btn btn-sm btn-primary w-full'; + drawBtn.textContent = 'Draw ROI'; + drawBtn.addEventListener('click', () => { + if (checkbox.checked) this.setOption('skipDrawROIModal', true); + finish(true); + modal.close(); + }); + body.appendChild(drawBtn); + + modal.setBody(body); + modal.focus(); + }); + } + async _buildInputsForm(appId, scope, onCapture, mode = 'STANDALONE') { const container = document.createElement('div'); container.className = 'space-y-2 mt-2'; @@ -674,7 +754,15 @@ addPlugin('analyze-dev', class extends XOpatPlugin { return null; }; - return { container, getInputs, getAnnotBounds, ead }; + const rectInput = requiredInputs.find(i => i.type === 'rectangle'); + const captureAnnotation = rectInput ? async () => { + const result = await onCapture(); + inputFields[rectInput.key].value = result.id; + inputFields[rectInput.key].bounds = result.bounds; + return result; + } : null; + + return { container, getInputs, getAnnotBounds, captureAnnotation, ead }; } catch (e) { console.error('[analyze] Failed to build inputs form', e); container.innerHTML = `
Error: ${e.message}
`; @@ -699,40 +787,8 @@ addPlugin('analyze-dev', class extends XOpatPlugin { row.appendChild(label); if (input.type === 'rectangle') { - const valueHolder = { value: '' }; - inputFields[input.key] = valueHolder; - - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'btn btn-xs btn-ghost'; - btn.textContent = 'Create annotation'; - - const statusEl = document.createElement('span'); - statusEl.className = 'text-xs opacity-70 ml-1'; - - btn.addEventListener('click', async () => { - btn.disabled = true; - btn.textContent = 'Drawing\u2026'; - statusEl.textContent = ''; - try { - const result = await onCapture(); - valueHolder.value = result.id; - valueHolder.bounds = result.bounds; - btn.textContent = 'Redraw'; - statusEl.textContent = result.id.slice(0, 8) + '\u2026'; - } catch (e) { - btn.textContent = 'Create annotation'; - if (e?.message !== 'cancelled') { - statusEl.textContent = '\u26a0 ' + (e?.message || String(e)); - } - } finally { - btn.disabled = false; - } - }); - - row.appendChild(btn); - row.appendChild(statusEl); - return row; + inputFields[input.key] = { value: '' }; + return null; } let fieldEl; From 7941d08e2f4c6e80588f7ff672f415a9db413ffe Mon Sep 17 00:00:00 2001 From: Filip Vrubel <114007566+FilipVrubel@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:38:53 +0200 Subject: [PATCH 08/20] feat: add JobHistory data layer --- plugins/analyze-dev/include.json | 2 +- plugins/analyze-dev/jobHistory.mjs | 66 ++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 plugins/analyze-dev/jobHistory.mjs diff --git a/plugins/analyze-dev/include.json b/plugins/analyze-dev/include.json index 5e83171c..175f43ba 100644 --- a/plugins/analyze-dev/include.json +++ b/plugins/analyze-dev/include.json @@ -5,7 +5,7 @@ "version": "1.0.0", "description": "Plugin for creating and running jobs", "icon": null, - "includes": ["newAppForm.mjs", "jobResultsOverlay.mjs", "analyzeDropdown.mjs"], + "includes": ["newAppForm.mjs", "jobResultsOverlay.mjs", "jobHistory.mjs", "analyzeDropdown.mjs"], "modules": ["annotations"], "permaLoad": true } diff --git a/plugins/analyze-dev/jobHistory.mjs b/plugins/analyze-dev/jobHistory.mjs new file mode 100644 index 00000000..b5de6c96 --- /dev/null +++ b/plugins/analyze-dev/jobHistory.mjs @@ -0,0 +1,66 @@ +function _fmtTs(ts) { + const d = new Date(ts); + return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')} ${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')}`; +} + +class JobHistory { + constructor({ plugin, overlay, onShow, onRerun }) { + this._plugin = plugin; + this._overlay = overlay; + this._onShow = onShow; + this._onRerun = onRerun; + this._modal = null; + this._modalBody = null; + } + + getHistory() { + try { + return this._plugin.getOption('jobHistory') || []; + } catch (_) { + return []; + } + } + + recordJob(entry) { + const history = this.getHistory(); + history.unshift(entry); + if (history.length > 50) history.splice(50); + this._plugin.setOption('jobHistory', history); + this._refreshModal(); + } + + updateJob(jobId, patch) { + const history = this.getHistory(); + const idx = history.findIndex(e => e.jobId === jobId); + if (idx !== -1) { + history[idx] = { ...history[idx], ...patch }; + this._plugin.setOption('jobHistory', history); + this._refreshModal(); + } + } + + showModal() { + // implemented in Task 2 + } + + _refreshModal() { + if (this._modal && this._modalBody) { + this._renderList(this._modalBody); + } + } + + _renderList(_container) { + // implemented in Task 2 + } + + _renderEntry(_entry) { + // implemented in Task 2 + return document.createElement('div'); + } + + _showEntryError(_card, _message) { + // implemented in Task 2 + } +} + +window.JobHistory = JobHistory; From c1ce95cc55c5ada3319efeb028e07b6d5a342ac2 Mon Sep 17 00:00:00 2001 From: Filip Vrubel <114007566+FilipVrubel@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:53:46 +0200 Subject: [PATCH 09/20] feat: add JobHistory modal UI --- plugins/analyze-dev/jobHistory.mjs | 173 +++++++++++++++++++++++++++-- 1 file changed, 165 insertions(+), 8 deletions(-) diff --git a/plugins/analyze-dev/jobHistory.mjs b/plugins/analyze-dev/jobHistory.mjs index b5de6c96..83d1e72f 100644 --- a/plugins/analyze-dev/jobHistory.mjs +++ b/plugins/analyze-dev/jobHistory.mjs @@ -40,7 +40,27 @@ class JobHistory { } showModal() { - // implemented in Task 2 + const { FloatingWindow } = globalThis.UI; + if (this._modal) { + this._modal.focus(); + return; + } + const width = 480, height = 500; + this._modal = new FloatingWindow({ + id: 'analyze-dev-job-history', + title: 'Job History', + width, + height, + startLeft: Math.round((window.innerWidth - width) / 2), + startTop: Math.round((window.innerHeight - height) / 2), + onClose: () => { this._modal = null; this._modalBody = null; }, + }); + this._modal.attachTo(document.body); + this._modalBody = document.createElement('div'); + this._modalBody.className = 'flex flex-col h-full overflow-hidden'; + this._renderList(this._modalBody); + this._modal.setBody(this._modalBody); + this._modal.focus(); } _refreshModal() { @@ -49,17 +69,154 @@ class JobHistory { } } - _renderList(_container) { - // implemented in Task 2 + _renderList(container) { + container.innerHTML = ''; + const history = this.getHistory(); + + const header = document.createElement('div'); + header.className = 'flex items-center justify-between px-3 py-2 border-b border-base-300 flex-shrink-0'; + const count = document.createElement('span'); + count.className = 'text-xs opacity-60'; + count.textContent = history.length + ? `${history.length} job${history.length === 1 ? '' : 's'} run` + : 'No jobs run yet.'; + header.appendChild(count); + if (history.length) { + const clearBtn = document.createElement('button'); + clearBtn.type = 'button'; + clearBtn.className = 'btn btn-xs btn-ghost'; + clearBtn.textContent = 'Clear all'; + clearBtn.addEventListener('click', () => { + this._plugin.setOption('jobHistory', []); + this._renderList(container); + }); + header.appendChild(clearBtn); + } + container.appendChild(header); + + if (!history.length) { + const empty = document.createElement('div'); + empty.className = 'flex-1 flex items-center justify-center text-sm opacity-50'; + empty.textContent = 'No jobs run yet.'; + container.appendChild(empty); + return; + } + + const list = document.createElement('div'); + list.className = 'flex-1 overflow-auto p-2'; + for (const entry of history) { + list.appendChild(this._renderEntry(entry)); + } + container.appendChild(list); } - _renderEntry(_entry) { - // implemented in Task 2 - return document.createElement('div'); + _renderEntry(entry) { + const isVisible = this._overlay._jobStore?.has(entry.jobId) ?? false; + + const card = document.createElement('div'); + card.className = 'p-2 rounded-box bg-base-200 mb-1' + (isVisible ? ' ring ring-primary ring-offset-1' : ''); + + const meta = document.createElement('div'); + meta.className = 'flex items-center gap-1 text-xs flex-wrap'; + + const dot = document.createElement('span'); + dot.className = 'w-2 h-2 rounded-full flex-shrink-0 ' + + (entry.status === 'COMPLETED' ? 'bg-success' : 'bg-error'); + meta.appendChild(dot); + + const name = document.createElement('span'); + name.className = 'font-medium'; + name.textContent = entry.name; + meta.appendChild(name); + + const appSpan = document.createElement('span'); + appSpan.className = 'opacity-50'; + appSpan.textContent = `· ${entry.appName || entry.appId?.slice(0, 8) || '?'}`; + meta.appendChild(appSpan); + + const tsSpan = document.createElement('span'); + tsSpan.className = 'opacity-50'; + tsSpan.textContent = `· ${_fmtTs(entry.timestamp)}`; + meta.appendChild(tsSpan); + + card.appendChild(meta); + + const actions = document.createElement('div'); + actions.className = 'flex gap-1 mt-1'; + + const toggleBtn = document.createElement('button'); + toggleBtn.type = 'button'; + toggleBtn.className = 'btn btn-xs ' + (isVisible ? 'btn-primary' : 'btn-ghost'); + toggleBtn.textContent = isVisible ? 'Hide' : 'Show'; + toggleBtn.addEventListener('click', async () => { + if (isVisible) { + await this._overlay.clearJob(entry.jobId); + this._renderList(this._modalBody); + } else { + toggleBtn.disabled = true; + toggleBtn.textContent = '…'; + let errorShown = false; + try { + await this._onShow(entry); + } catch (e) { + console.error('[job-history] show failed', e); + this._showEntryError(card, e?.message || 'Failed to load annotations'); + errorShown = true; + } finally { + toggleBtn.disabled = false; + toggleBtn.textContent = 'Show'; + if (!errorShown) this._renderList(this._modalBody); + } + } + }); + actions.appendChild(toggleBtn); + + const rerunBtn = document.createElement('button'); + rerunBtn.type = 'button'; + rerunBtn.className = 'btn btn-xs btn-ghost'; + rerunBtn.textContent = 'Rerun'; + rerunBtn.addEventListener('click', async () => { + rerunBtn.disabled = true; + rerunBtn.innerHTML = ''; + let errorShown = false; + try { + await this._onRerun(entry); + } catch (e) { + if (e?.message !== 'cancelled') { + console.error('[job-history] rerun failed', e); + this._showEntryError(card, e?.message || 'Rerun failed'); + errorShown = true; + } + } finally { + rerunBtn.disabled = false; + rerunBtn.textContent = 'Rerun'; + if (!errorShown) this._renderList(this._modalBody); + } + }); + actions.appendChild(rerunBtn); + + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'btn btn-xs btn-ghost text-error'; + deleteBtn.textContent = '×'; + deleteBtn.addEventListener('click', () => { + this._plugin.setOption('jobHistory', this.getHistory().filter(e => e.jobId !== entry.jobId)); + this._renderList(this._modalBody); + }); + actions.appendChild(deleteBtn); + + card.appendChild(actions); + return card; } - _showEntryError(_card, _message) { - // implemented in Task 2 + _showEntryError(card, message) { + const existing = card.querySelector('.entry-error'); + if (existing) existing.remove(); + const err = document.createElement('div'); + err.className = 'entry-error text-xs text-error mt-1'; + err.textContent = message; + card.appendChild(err); + setTimeout(() => { err.remove(); this._refreshModal(); }, 4000); } } From 4cc0fd57271637f73daaf5fa11865020b39a1a8c Mon Sep 17 00:00:00 2001 From: Filip Vrubel <114007566+FilipVrubel@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:58:08 +0200 Subject: [PATCH 10/20] feat: add job name input to app card --- plugins/analyze-dev/analyzeDropdown.mjs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/plugins/analyze-dev/analyzeDropdown.mjs b/plugins/analyze-dev/analyzeDropdown.mjs index 405ad334..bcca10ea 100644 --- a/plugins/analyze-dev/analyzeDropdown.mjs +++ b/plugins/analyze-dev/analyzeDropdown.mjs @@ -510,6 +510,13 @@ addPlugin('analyze-dev', class extends XOpatPlugin { wrap.appendChild(desc); } + // Job name input (optional) + const nameInput = document.createElement('input'); + nameInput.type = 'text'; + nameInput.className = 'input input-bordered input-sm w-full mb-2'; + nameInput.placeholder = 'Job name (optional)'; + wrap.appendChild(nameInput); + // Inputs section (hidden by default) const inputsSection = document.createElement('div'); inputsSection.className = 'mt-2 hidden'; @@ -557,6 +564,10 @@ addPlugin('analyze-dev', class extends XOpatPlugin { const viewerId = String(VIEWER.uniqueId); const bannerId = 'banner'; const appLabel = app?.name_short || app?.name || 'Job'; + const name = nameInput.value.trim() || (() => { + const now = new Date(); + return `${appLabel} – ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`; + })(); const focusOnBounds = (bounds) => { const tiledImage = VIEWER.scalebar.getReferencedTiledImage(); if (!tiledImage) return; From 41dc1e7e137b2b81017a7bb99837684280f53784 Mon Sep 17 00:00:00 2001 From: Filip Vrubel <114007566+FilipVrubel@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:06:20 +0200 Subject: [PATCH 11/20] feat: wire JobHistory into analyze dropdown --- plugins/analyze-dev/analyzeDropdown.mjs | 146 +++++++++++++++--------- 1 file changed, 89 insertions(+), 57 deletions(-) diff --git a/plugins/analyze-dev/analyzeDropdown.mjs b/plugins/analyze-dev/analyzeDropdown.mjs index bcca10ea..7c68f3b4 100644 --- a/plugins/analyze-dev/analyzeDropdown.mjs +++ b/plugins/analyze-dev/analyzeDropdown.mjs @@ -14,6 +14,74 @@ addPlugin('analyze-dev', class extends XOpatPlugin { pluginReady() { this._overlay = new JobResultsOverlay(); + this._jobHistory = new JobHistory({ + plugin: this, + overlay: this._overlay, + onShow: async (entry) => { + const api = singletonModule('empation-api')?.V3; + if (!api) throw new Error('EmpationAPI not available'); + const examination = await api.examinations.create(entry.caseId, entry.appId); + const scope = await api.getScopeFrom(examination); + const viewerId = String(VIEWER.uniqueId); + await this._fetchAndRenderResults( + { id: entry.jobId, _scope: scope }, + entry.appId, + viewerId + ); + }, + onRerun: async (entry) => { + const api = singletonModule('empation-api')?.V3; + if (!api) throw new Error('EmpationAPI not available'); + const examination = await api.examinations.create(entry.caseId, entry.appId); + const scope = await api.getScopeFrom(examination); + + let annotBounds = entry.bounds; + let inputs = { ...entry.inputs }; + + if (entry.hasRectInput) { + if (!this.getOption('skipDrawROIModal')) { + const shouldDraw = await this._showDrawROIModal(); + if (!shouldDraw) throw new Error('cancelled'); + } + const result = await this._captureAnnotation({ _rootEl: null }, scope); + const ead = await window.EmpaiaStandaloneJobs?.getEAD?.(entry.appId); + const rectKey = window.EmpaiaStandaloneJobs + ?.getRequiredInputs?.(ead, 'STANDALONE') + ?.find(i => i.type === 'rectangle')?.key; + if (rectKey) inputs[rectKey] = result.id; + annotBounds = result.bounds; + } + + const viewerId = String(VIEWER.uniqueId); + const res = await window.EmpaiaStandaloneJobs?.createAndRunJob?.({ + appId: entry.appId, + caseId: entry.caseId, + mode: 'STANDALONE', + inputs, + ead: await window.EmpaiaStandaloneJobs?.getEAD?.(entry.appId), + }); + + const status = res?.status === 'COMPLETED' ? 'COMPLETED' : 'FAILED'; + if (res?.id) this._jobHistory.recordJob({ + jobId: res.id, + appId: entry.appId, + appName: entry.appName, + caseId: entry.caseId, + name: `${entry.name} (rerun)`, + status, + timestamp: Date.now(), + inputs, + bounds: annotBounds, + hasRectInput: entry.hasRectInput, + }); + + if (status === 'COMPLETED') { + await this._fetchAndRenderResults(res, entry.appId, viewerId); + const valueOutputs = await this._fetchOutputValues(res, entry.appId); + if (valueOutputs.length > 0) this._showOutputValuesWindow(valueOutputs); + } + }, + }); this._empaiaConvertor = null; UTILITIES.loadPlugin('gui_annotations'); @@ -64,66 +132,16 @@ addPlugin('analyze-dev', class extends XOpatPlugin { if (tab._contentEl) tab._contentEl.classList.add('w-64'); tab.addItem({ - id: 'run-recent', + id: 'job-history', section: 'recent', - label: tOr('analyze.runRecent', 'Run Recent') + ' \u2192', - onClick: () => false, + label: 'Job History', + onClick: () => { + this._collapseDropdown(tab); + this._jobHistory.showModal(); + return false; + }, }); - // Use Dropdown as a standalone flyout panel — reuses its item rendering and styling - // without wiring up a trigger button. Only _contentEl is appended to the DOM. - const recentPanel = new Dropdown({ id: `${this.id}-recent-panel`, parentId: this.id }); - recentPanel.create(); - const panelEl = recentPanel._contentEl; - panelEl.style.display = 'none'; - panelEl.style.maxHeight = '70vh'; - panelEl.style.overflow = 'auto'; - document.body.appendChild(panelEl); - - let _hideTimer = null; - const cancelHide = () => { clearTimeout(_hideTimer); _hideTimer = null; }; - const scheduleHide = () => { cancelHide(); _hideTimer = setTimeout(() => { panelEl.style.display = 'none'; }, 250); }; - panelEl.addEventListener('mouseenter', cancelHide); - panelEl.addEventListener('mouseleave', scheduleHide); - - const content = tab._contentEl; - if (content) { - content.addEventListener('mouseover', (e) => { - const hit = e.target.closest?.('[data-item-id]'); - if (hit?.dataset?.itemId === 'run-recent') { - cancelHide(); - const jobs = this.recentJobs.length ? this.recentJobs : ['Recent Job 1', 'Recent Job 2', 'Recent Job 3']; - recentPanel.clear(); - jobs.forEach((job, idx) => { - const label = typeof job === 'string' ? job : job?.label; - recentPanel.addItem({ - id: `recent-job-${idx}`, - label, - onClick: () => { - if (typeof this.onJobClick === 'function') this.onJobClick({ index: idx, label }); - } - }); - }); - panelEl.style.display = ''; - requestAnimationFrame(() => { - const rect = hit.getBoundingClientRect(); - const pw = panelEl.offsetWidth || 160; - const ph = panelEl.offsetHeight || 0; - let left = rect.right - 1; - if (left + pw > window.innerWidth - 8) left = Math.max(8, rect.left - pw); - let top = Math.max(8, rect.top); - if (ph && top + ph > window.innerHeight - 8) top = Math.max(8, window.innerHeight - ph - 8); - panelEl.style.left = `${left}px`; - panelEl.style.top = `${top}px`; - }); - tab.hideRecent = () => { cancelHide(); panelEl.style.display = 'none'; }; - } - }); - content.addEventListener('mouseout', (e) => { - if (!panelEl.contains(e.relatedTarget)) scheduleHide(); - }); - } - tab.addItem({ id: 'apps-list', label: tOr('analyze.apps', 'Apps'), @@ -650,6 +668,20 @@ addPlugin('analyze-dev', class extends XOpatPlugin { } else { setJobBanner(`${appLabel}: Failed`, 'ERROR', annotBounds); } + if (res?.id) { + this._jobHistory.recordJob({ + jobId: res.id, + appId, + appName: appLabel, + caseId, + name, + status: isSuccess ? 'COMPLETED' : 'FAILED', + timestamp: Date.now(), + inputs, + bounds: annotBounds, + hasRectInput: !!inputsForm?.captureAnnotation, + }); + } } catch (err) { console.error('[analyze] Failed to run app job', err); setJobBanner(`${appLabel}: Failed`, 'ERROR', annotBounds); From 37ffac1b37fd2883bfa299a1a398319a975d3210 Mon Sep 17 00:00:00 2001 From: Filip Vrubel <114007566+FilipVrubel@users.noreply.github.com> Date: Mon, 29 Jun 2026 08:25:43 +0200 Subject: [PATCH 12/20] fix: extract _setJobBanner, close history modal on rerun, add rerun banners --- plugins/analyze-dev/analyzeDropdown.mjs | 48 +++++++++++++++---------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/plugins/analyze-dev/analyzeDropdown.mjs b/plugins/analyze-dev/analyzeDropdown.mjs index 7c68f3b4..253adec9 100644 --- a/plugins/analyze-dev/analyzeDropdown.mjs +++ b/plugins/analyze-dev/analyzeDropdown.mjs @@ -30,6 +30,8 @@ addPlugin('analyze-dev', class extends XOpatPlugin { ); }, onRerun: async (entry) => { + this._jobHistory._modal?.close(); + const api = singletonModule('empation-api')?.V3; if (!api) throw new Error('EmpationAPI not available'); const examination = await api.examinations.create(entry.caseId, entry.appId); @@ -52,7 +54,9 @@ addPlugin('analyze-dev', class extends XOpatPlugin { annotBounds = result.bounds; } + const appLabel = entry.appName || entry.appId || 'Job'; const viewerId = String(VIEWER.uniqueId); + this._setJobBanner(`${appLabel}: Pending`, 'WARNING', null); const res = await window.EmpaiaStandaloneJobs?.createAndRunJob?.({ appId: entry.appId, caseId: entry.caseId, @@ -76,9 +80,12 @@ addPlugin('analyze-dev', class extends XOpatPlugin { }); if (status === 'COMPLETED') { + this._setJobBanner(`${appLabel}: Completed`, 'SUCCESS', annotBounds); await this._fetchAndRenderResults(res, entry.appId, viewerId); const valueOutputs = await this._fetchOutputValues(res, entry.appId); if (valueOutputs.length > 0) this._showOutputValuesWindow(valueOutputs); + } else { + this._setJobBanner(`${appLabel}: Failed`, 'ERROR', annotBounds); } }, }); @@ -374,6 +381,27 @@ addPlugin('analyze-dev', class extends XOpatPlugin { } catch(_) {} } + _setJobBanner(label, colorKey, bounds) { + const bannerId = 'banner'; + USER_INTERFACE.AppBar.addBadge(bannerId, { + label, + color: colorKey.toLowerCase(), + dot: colorKey === 'WARNING', + pulse: colorKey === 'WARNING', + title: bounds ? 'Click to focus ROI' : 'Click to dismiss', + onClick: () => { + if (bounds) { + const tiledImage = VIEWER.scalebar.getReferencedTiledImage(); + if (tiledImage) { + const rect = tiledImage.imageToViewportRectangle(bounds.left, bounds.top, bounds.width, bounds.height); + VIEWER.viewport.fitBounds(rect, false); + } + } + USER_INTERFACE.AppBar.removeBadge(bannerId); + }, + }); + } + /** * Hide the FloatingWindow, activate rectangle drawing mode, wait for the user * to draw one annotation, then restore everything and return the annotation ID. @@ -586,25 +614,7 @@ addPlugin('analyze-dev', class extends XOpatPlugin { const now = new Date(); return `${appLabel} – ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`; })(); - const focusOnBounds = (bounds) => { - const tiledImage = VIEWER.scalebar.getReferencedTiledImage(); - if (!tiledImage) return; - const rect = tiledImage.imageToViewportRectangle(bounds.left, bounds.top, bounds.width, bounds.height); - VIEWER.viewport.fitBounds(rect, false); - }; - const setJobBanner = (label, colorKey, bounds) => { - USER_INTERFACE.AppBar.addBadge(bannerId, { - label, - color: colorKey.toLowerCase(), - dot: colorKey === 'WARNING', - pulse: colorKey === 'WARNING', - title: bounds ? 'Click to focus ROI' : 'Click to dismiss', - onClick: () => { - if (bounds) focusOnBounds(bounds); - USER_INTERFACE.AppBar.removeBadge(bannerId); - }, - }); - }; + const setJobBanner = (label, colorKey, bounds) => this._setJobBanner(label, colorKey, bounds); if (!inputsLoaded) { try { From af070bbe24d97410221befbe6f25096697161dd0 Mon Sep 17 00:00:00 2001 From: Filip Vrubel <114007566+FilipVrubel@users.noreply.github.com> Date: Mon, 29 Jun 2026 08:26:19 +0200 Subject: [PATCH 13/20] fix: serialize jobHistory as JSON string for xOpat setOption compatibility --- plugins/analyze-dev/jobHistory.mjs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins/analyze-dev/jobHistory.mjs b/plugins/analyze-dev/jobHistory.mjs index 83d1e72f..0e3f929d 100644 --- a/plugins/analyze-dev/jobHistory.mjs +++ b/plugins/analyze-dev/jobHistory.mjs @@ -15,7 +15,10 @@ class JobHistory { getHistory() { try { - return this._plugin.getOption('jobHistory') || []; + const raw = this._plugin.getOption('jobHistory'); + if (!raw) return []; + if (Array.isArray(raw)) return raw; + return JSON.parse(raw); } catch (_) { return []; } @@ -25,7 +28,7 @@ class JobHistory { const history = this.getHistory(); history.unshift(entry); if (history.length > 50) history.splice(50); - this._plugin.setOption('jobHistory', history); + this._plugin.setOption('jobHistory', JSON.stringify(history)); this._refreshModal(); } @@ -34,7 +37,7 @@ class JobHistory { const idx = history.findIndex(e => e.jobId === jobId); if (idx !== -1) { history[idx] = { ...history[idx], ...patch }; - this._plugin.setOption('jobHistory', history); + this._plugin.setOption('jobHistory', JSON.stringify(history)); this._refreshModal(); } } @@ -87,7 +90,7 @@ class JobHistory { clearBtn.className = 'btn btn-xs btn-ghost'; clearBtn.textContent = 'Clear all'; clearBtn.addEventListener('click', () => { - this._plugin.setOption('jobHistory', []); + this._plugin.setOption('jobHistory', '[]'); this._renderList(container); }); header.appendChild(clearBtn); From 0b613bd6244b9c1655aee814906fcffb2c4f79e0 Mon Sep 17 00:00:00 2001 From: Filip Vrubel <114007566+FilipVrubel@users.noreply.github.com> Date: Mon, 29 Jun 2026 08:30:19 +0200 Subject: [PATCH 14/20] fix: disable annotation drawing after ROI capture in rerun --- plugins/analyze-dev/analyzeDropdown.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/analyze-dev/analyzeDropdown.mjs b/plugins/analyze-dev/analyzeDropdown.mjs index 253adec9..f1e69187 100644 --- a/plugins/analyze-dev/analyzeDropdown.mjs +++ b/plugins/analyze-dev/analyzeDropdown.mjs @@ -46,6 +46,7 @@ addPlugin('analyze-dev', class extends XOpatPlugin { if (!shouldDraw) throw new Error('cancelled'); } const result = await this._captureAnnotation({ _rootEl: null }, scope); + singletonModule('annotations')?.enableInteraction(false); const ead = await window.EmpaiaStandaloneJobs?.getEAD?.(entry.appId); const rectKey = window.EmpaiaStandaloneJobs ?.getRequiredInputs?.(ead, 'STANDALONE') From 03c330ff6ef726e57d0ff4a66a3c97ba877856ea Mon Sep 17 00:00:00 2001 From: Filip Vrubel <114007566+FilipVrubel@users.noreply.github.com> Date: Mon, 29 Jun 2026 08:36:47 +0200 Subject: [PATCH 15/20] fix: guard _renderList calls against null _modalBody in _renderEntry --- plugins/analyze-dev/jobHistory.mjs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/analyze-dev/jobHistory.mjs b/plugins/analyze-dev/jobHistory.mjs index 0e3f929d..67758012 100644 --- a/plugins/analyze-dev/jobHistory.mjs +++ b/plugins/analyze-dev/jobHistory.mjs @@ -154,7 +154,7 @@ class JobHistory { toggleBtn.addEventListener('click', async () => { if (isVisible) { await this._overlay.clearJob(entry.jobId); - this._renderList(this._modalBody); + this._refreshModal(); } else { toggleBtn.disabled = true; toggleBtn.textContent = '…'; @@ -168,7 +168,7 @@ class JobHistory { } finally { toggleBtn.disabled = false; toggleBtn.textContent = 'Show'; - if (!errorShown) this._renderList(this._modalBody); + if (!errorShown) this._refreshModal(); } } }); @@ -193,7 +193,7 @@ class JobHistory { } finally { rerunBtn.disabled = false; rerunBtn.textContent = 'Rerun'; - if (!errorShown) this._renderList(this._modalBody); + if (!errorShown) this._refreshModal(); } }); actions.appendChild(rerunBtn); @@ -203,8 +203,8 @@ class JobHistory { deleteBtn.className = 'btn btn-xs btn-ghost text-error'; deleteBtn.textContent = '×'; deleteBtn.addEventListener('click', () => { - this._plugin.setOption('jobHistory', this.getHistory().filter(e => e.jobId !== entry.jobId)); - this._renderList(this._modalBody); + this._plugin.setOption('jobHistory', JSON.stringify(this.getHistory().filter(e => e.jobId !== entry.jobId))); + this._refreshModal(); }); actions.appendChild(deleteBtn); From a05f41980c17b8b021f12876281d80a40e17467c Mon Sep 17 00:00:00 2001 From: Filip Vrubel <114007566+FilipVrubel@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:03:21 +0200 Subject: [PATCH 16/20] Add job history search/filter and per-annotation visibility toggle --- plugins/analyze-dev/jobHistory.mjs | 107 ++++++++++++++++++++-- plugins/analyze-dev/jobResultsOverlay.mjs | 25 +++++ 2 files changed, 125 insertions(+), 7 deletions(-) diff --git a/plugins/analyze-dev/jobHistory.mjs b/plugins/analyze-dev/jobHistory.mjs index 67758012..a5b93108 100644 --- a/plugins/analyze-dev/jobHistory.mjs +++ b/plugins/analyze-dev/jobHistory.mjs @@ -11,6 +11,10 @@ class JobHistory { this._onRerun = onRerun; this._modal = null; this._modalBody = null; + this._searchQuery = ''; + this._appFilter = ''; + this._listEl = null; + this._countEl = null; } getHistory() { @@ -48,6 +52,8 @@ class JobHistory { this._modal.focus(); return; } + this._searchQuery = ''; + this._appFilter = ''; const width = 480, height = 500; this._modal = new FloatingWindow({ id: 'analyze-dev-job-history', @@ -74,16 +80,16 @@ class JobHistory { _renderList(container) { container.innerHTML = ''; + this._listEl = null; + this._countEl = null; const history = this.getHistory(); const header = document.createElement('div'); header.className = 'flex items-center justify-between px-3 py-2 border-b border-base-300 flex-shrink-0'; const count = document.createElement('span'); count.className = 'text-xs opacity-60'; - count.textContent = history.length - ? `${history.length} job${history.length === 1 ? '' : 's'} run` - : 'No jobs run yet.'; header.appendChild(count); + this._countEl = count; if (history.length) { const clearBtn = document.createElement('button'); clearBtn.type = 'button'; @@ -98,6 +104,7 @@ class JobHistory { container.appendChild(header); if (!history.length) { + count.textContent = 'No jobs run yet.'; const empty = document.createElement('div'); empty.className = 'flex-1 flex items-center justify-center text-sm opacity-50'; empty.textContent = 'No jobs run yet.'; @@ -105,16 +112,99 @@ class JobHistory { return; } + const filterBar = document.createElement('div'); + filterBar.className = 'flex items-center gap-2 px-3 py-2 border-b border-base-300 flex-shrink-0'; + + const searchInput = document.createElement('input'); + searchInput.type = 'text'; + searchInput.placeholder = 'Search by job name...'; + searchInput.className = 'input input-xs w-32'; + searchInput.value = this._searchQuery; + searchInput.addEventListener('input', (e) => { + this._searchQuery = e.target.value; + this._updateListBody(); + }); + filterBar.appendChild(searchInput); + + const appSelect = document.createElement('select'); + appSelect.className = 'select select-xs w-24'; + const allOption = document.createElement('option'); + allOption.value = ''; + allOption.textContent = 'All apps'; + appSelect.appendChild(allOption); + const appOptions = this._getAppOptions(history); + if (this._appFilter && !appOptions.includes(this._appFilter)) { + this._appFilter = ''; + } + for (const appName of appOptions) { + const opt = document.createElement('option'); + opt.value = appName; + opt.textContent = appName; + appSelect.appendChild(opt); + } + appSelect.value = this._appFilter; + appSelect.addEventListener('change', (e) => { + this._appFilter = e.target.value; + this._updateListBody(); + }); + filterBar.appendChild(appSelect); + + container.appendChild(filterBar); + const list = document.createElement('div'); list.className = 'flex-1 overflow-auto p-2'; + container.appendChild(list); + this._listEl = list; + + this._updateListBody(); + } + + _getAppOptions(history) { + const names = new Set(); for (const entry of history) { - list.appendChild(this._renderEntry(entry)); + names.add(entry.appName || entry.appId?.slice(0, 8) || '?'); + } + return [...names].sort((a, b) => a.localeCompare(b)); + } + + _getFilteredHistory() { + const history = this.getHistory(); + const query = this._searchQuery.trim().toLowerCase(); + return history.filter(entry => { + if (query && !entry.name?.toLowerCase().includes(query)) return false; + if (this._appFilter) { + const appLabel = entry.appName || entry.appId?.slice(0, 8) || '?'; + if (appLabel !== this._appFilter) return false; + } + return true; + }); + } + + _updateListBody() { + if (!this._listEl || !this._countEl) return; + const total = this.getHistory().length; + const filtered = this._getFilteredHistory(); + + this._countEl.textContent = (this._searchQuery || this._appFilter) + ? `${filtered.length} of ${total} job${total === 1 ? '' : 's'}` + : `${total} job${total === 1 ? '' : 's'} run`; + + this._listEl.innerHTML = ''; + if (!filtered.length) { + const empty = document.createElement('div'); + empty.className = 'flex items-center justify-center text-sm opacity-50 py-4'; + empty.textContent = 'No jobs match your search/filter.'; + this._listEl.appendChild(empty); + return; + } + for (const entry of filtered) { + this._listEl.appendChild(this._renderEntry(entry)); } - container.appendChild(list); } _renderEntry(entry) { - const isVisible = this._overlay._jobStore?.has(entry.jobId) ?? false; + const storeEntry = this._overlay._jobStore?.get(entry.jobId); + const isVisible = storeEntry ? (storeEntry.visible !== false) : false; const card = document.createElement('div'); card.className = 'p-2 rounded-box bg-base-200 mb-1' + (isVisible ? ' ring ring-primary ring-offset-1' : ''); @@ -153,7 +243,10 @@ class JobHistory { toggleBtn.textContent = isVisible ? 'Hide' : 'Show'; toggleBtn.addEventListener('click', async () => { if (isVisible) { - await this._overlay.clearJob(entry.jobId); + this._overlay.setJobVisible(entry.jobId, false); + this._refreshModal(); + } else if (storeEntry) { + this._overlay.setJobVisible(entry.jobId, true); this._refreshModal(); } else { toggleBtn.disabled = true; diff --git a/plugins/analyze-dev/jobResultsOverlay.mjs b/plugins/analyze-dev/jobResultsOverlay.mjs index 10e450e9..cfe004c9 100644 --- a/plugins/analyze-dev/jobResultsOverlay.mjs +++ b/plugins/analyze-dev/jobResultsOverlay.mjs @@ -73,6 +73,31 @@ class JobResultsOverlay { } } + setJobVisible(jobId, visible) { + const entry = this._jobStore.get(jobId); + if (!entry) return; + const viewer = VIEWER_MANAGER.viewers.find(v => v && String(v.uniqueId) === String(entry.viewerId)); + if (!viewer) return; + const annot = OSDAnnotations.instance(); + const fabric = annot?.getFabric(viewer); + if (!fabric) return; + fabric.setLayerVisibility(entry.layerId, visible); + // Belt-and-suspenders: sweep all canvas objects matching this job. + // Some annotations may have layerID reset to undefined during bulk-add + // if the layer lookup failed mid-batch; presetID is set independently + // and never cleared, so it reliably identifies every annotation. + for (const obj of fabric.canvas._objects || []) { + if (String(obj.layerID) === String(entry.layerId) || + String(obj.presetID) === String(entry.layerId)) { + obj.visible = !!visible; + obj.evented = !!visible; + obj.selectable = !!visible; + } + } + fabric.canvas.requestRenderAll?.(); + entry.visible = visible; + } + async clearJob(jobId) { const entry = this._jobStore.get(jobId); if (!entry) return; From 38fe52ee1c409f9f58faaff641570c5af106e5ea Mon Sep 17 00:00:00 2001 From: Filip Vrubel <114007566+FilipVrubel@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:27:51 +0200 Subject: [PATCH 17/20] Replace job history show/hide text button with eye icon, move Rerun/delete behind a More toggle --- plugins/analyze-dev/jobHistory.mjs | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/plugins/analyze-dev/jobHistory.mjs b/plugins/analyze-dev/jobHistory.mjs index a5b93108..a7b9769a 100644 --- a/plugins/analyze-dev/jobHistory.mjs +++ b/plugins/analyze-dev/jobHistory.mjs @@ -235,12 +235,13 @@ class JobHistory { card.appendChild(meta); const actions = document.createElement('div'); - actions.className = 'flex gap-1 mt-1'; + actions.className = 'flex gap-1 mt-1 items-center'; const toggleBtn = document.createElement('button'); toggleBtn.type = 'button'; - toggleBtn.className = 'btn btn-xs ' + (isVisible ? 'btn-primary' : 'btn-ghost'); - toggleBtn.textContent = isVisible ? 'Hide' : 'Show'; + toggleBtn.className = 'btn btn-xs btn-square ' + (isVisible ? 'btn-primary' : 'btn-ghost'); + toggleBtn.title = isVisible ? 'Hide annotations' : 'Show annotations'; + toggleBtn.innerHTML = ``; toggleBtn.addEventListener('click', async () => { if (isVisible) { this._overlay.setJobVisible(entry.jobId, false); @@ -250,7 +251,7 @@ class JobHistory { this._refreshModal(); } else { toggleBtn.disabled = true; - toggleBtn.textContent = '…'; + toggleBtn.innerHTML = ''; let errorShown = false; try { await this._onShow(entry); @@ -260,13 +261,25 @@ class JobHistory { errorShown = true; } finally { toggleBtn.disabled = false; - toggleBtn.textContent = 'Show'; if (!errorShown) this._refreshModal(); } } }); actions.appendChild(toggleBtn); + const moreActions = document.createElement('div'); + moreActions.className = 'flex gap-1 mt-1 hidden'; + + const moreBtn = document.createElement('button'); + moreBtn.type = 'button'; + moreBtn.className = 'btn btn-xs btn-square btn-ghost'; + moreBtn.title = 'More actions'; + moreBtn.innerHTML = ''; + moreBtn.addEventListener('click', () => { + moreActions.classList.toggle('hidden'); + }); + actions.appendChild(moreBtn); + const rerunBtn = document.createElement('button'); rerunBtn.type = 'button'; rerunBtn.className = 'btn btn-xs btn-ghost'; @@ -289,7 +302,7 @@ class JobHistory { if (!errorShown) this._refreshModal(); } }); - actions.appendChild(rerunBtn); + moreActions.appendChild(rerunBtn); const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; @@ -299,9 +312,10 @@ class JobHistory { this._plugin.setOption('jobHistory', JSON.stringify(this.getHistory().filter(e => e.jobId !== entry.jobId))); this._refreshModal(); }); - actions.appendChild(deleteBtn); + moreActions.appendChild(deleteBtn); card.appendChild(actions); + card.appendChild(moreActions); return card; } From 4471946cb0ff887131e67b8da98a405339eecd4b Mon Sep 17 00:00:00 2001 From: Filip Vrubel <114007566+FilipVrubel@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:37:42 +0200 Subject: [PATCH 18/20] Add on-demand inline job results panel to Job History --- plugins/analyze-dev/analyzeDropdown.mjs | 7 +++ plugins/analyze-dev/jobHistory.mjs | 74 +++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/plugins/analyze-dev/analyzeDropdown.mjs b/plugins/analyze-dev/analyzeDropdown.mjs index f1e69187..3bb9bbbb 100644 --- a/plugins/analyze-dev/analyzeDropdown.mjs +++ b/plugins/analyze-dev/analyzeDropdown.mjs @@ -89,6 +89,13 @@ addPlugin('analyze-dev', class extends XOpatPlugin { this._setJobBanner(`${appLabel}: Failed`, 'ERROR', annotBounds); } }, + onFetchResults: async (entry) => { + const api = singletonModule('empation-api')?.V3; + if (!api) throw new Error('EmpationAPI not available'); + const examination = await api.examinations.create(entry.caseId, entry.appId); + const scope = await api.getScopeFrom(examination); + return await this._fetchOutputValues({ id: entry.jobId, _scope: scope }, entry.appId); + }, }); this._empaiaConvertor = null; UTILITIES.loadPlugin('gui_annotations'); diff --git a/plugins/analyze-dev/jobHistory.mjs b/plugins/analyze-dev/jobHistory.mjs index a7b9769a..21fcf2b9 100644 --- a/plugins/analyze-dev/jobHistory.mjs +++ b/plugins/analyze-dev/jobHistory.mjs @@ -4,17 +4,19 @@ function _fmtTs(ts) { } class JobHistory { - constructor({ plugin, overlay, onShow, onRerun }) { + constructor({ plugin, overlay, onShow, onRerun, onFetchResults }) { this._plugin = plugin; this._overlay = overlay; this._onShow = onShow; this._onRerun = onRerun; + this._onFetchResults = onFetchResults; this._modal = null; this._modalBody = null; this._searchQuery = ''; this._appFilter = ''; this._listEl = null; this._countEl = null; + this._resultsCache = new Map(); } getHistory() { @@ -54,6 +56,7 @@ class JobHistory { } this._searchQuery = ''; this._appFilter = ''; + this._resultsCache = new Map(); const width = 480, height = 500; this._modal = new FloatingWindow({ id: 'analyze-dev-job-history', @@ -267,8 +270,16 @@ class JobHistory { }); actions.appendChild(toggleBtn); + const moreSection = document.createElement('div'); + moreSection.className = 'hidden'; + const moreActions = document.createElement('div'); - moreActions.className = 'flex gap-1 mt-1 hidden'; + moreActions.className = 'flex gap-1 mt-1'; + moreSection.appendChild(moreActions); + + const resultsPanel = document.createElement('div'); + resultsPanel.className = 'mt-1 hidden'; + moreSection.appendChild(resultsPanel); const moreBtn = document.createElement('button'); moreBtn.type = 'button'; @@ -276,7 +287,7 @@ class JobHistory { moreBtn.title = 'More actions'; moreBtn.innerHTML = ''; moreBtn.addEventListener('click', () => { - moreActions.classList.toggle('hidden'); + moreSection.classList.toggle('hidden'); }); actions.appendChild(moreBtn); @@ -304,6 +315,34 @@ class JobHistory { }); moreActions.appendChild(rerunBtn); + if (entry.status === 'COMPLETED') { + const resultsBtn = document.createElement('button'); + resultsBtn.type = 'button'; + resultsBtn.className = 'btn btn-xs btn-ghost'; + resultsBtn.textContent = 'Results'; + resultsBtn.addEventListener('click', async () => { + if (this._resultsCache.has(entry.jobId)) { + resultsPanel.classList.toggle('hidden'); + return; + } + resultsBtn.disabled = true; + resultsBtn.innerHTML = ''; + try { + const valueOutputs = await this._onFetchResults(entry); + this._resultsCache.set(entry.jobId, valueOutputs); + this._renderResultsPanel(resultsPanel, valueOutputs); + resultsPanel.classList.remove('hidden'); + } catch (e) { + console.error('[job-history] fetch results failed', e); + this._showEntryError(card, e?.message || 'Failed to fetch results'); + } finally { + resultsBtn.disabled = false; + resultsBtn.textContent = 'Results'; + } + }); + moreActions.appendChild(resultsBtn); + } + const deleteBtn = document.createElement('button'); deleteBtn.type = 'button'; deleteBtn.className = 'btn btn-xs btn-ghost text-error'; @@ -315,7 +354,7 @@ class JobHistory { moreActions.appendChild(deleteBtn); card.appendChild(actions); - card.appendChild(moreActions); + card.appendChild(moreSection); return card; } @@ -328,6 +367,33 @@ class JobHistory { card.appendChild(err); setTimeout(() => { err.remove(); this._refreshModal(); }, 4000); } + + _renderResultsPanel(container, valueOutputs) { + container.innerHTML = ''; + if (!valueOutputs.length) { + const empty = document.createElement('div'); + empty.className = 'text-xs opacity-50 py-1'; + empty.textContent = 'No output values for this job.'; + container.appendChild(empty); + return; + } + for (const { key, items } of valueOutputs) { + const section = document.createElement('div'); + section.className = 'mb-2'; + + const heading = document.createElement('div'); + heading.className = 'text-xs font-medium mb-1'; + heading.textContent = key; + section.appendChild(heading); + + const pre = document.createElement('pre'); + pre.className = 'text-xs font-mono opacity-80 whitespace-pre-wrap'; + pre.textContent = items.map((item, i) => `${i}: ${Number(item.value).toFixed(4)}`).join('\n'); + section.appendChild(pre); + + container.appendChild(section); + } + } } window.JobHistory = JobHistory; From ea9a28e60382e85ed66c5a34a7d0319d6b6d51c4 Mon Sep 17 00:00:00 2001 From: Filip Vrubel <114007566+FilipVrubel@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:41:40 +0200 Subject: [PATCH 19/20] feat: prefill Advanced settings and Job History rerun inputs from MPS config --- plugins/analyze-dev/analyzeDropdown.mjs | 143 ++++++++++++++++++------ 1 file changed, 110 insertions(+), 33 deletions(-) diff --git a/plugins/analyze-dev/analyzeDropdown.mjs b/plugins/analyze-dev/analyzeDropdown.mjs index 3bb9bbbb..bdeda111 100644 --- a/plugins/analyze-dev/analyzeDropdown.mjs +++ b/plugins/analyze-dev/analyzeDropdown.mjs @@ -36,25 +36,29 @@ addPlugin('analyze-dev', class extends XOpatPlugin { if (!api) throw new Error('EmpationAPI not available'); const examination = await api.examinations.create(entry.caseId, entry.appId); const scope = await api.getScopeFrom(examination); + const onCapture = () => this._captureAnnotation({ _rootEl: null }, scope); + + const inputsForm = await this._buildInputsForm(entry.appId, scope, onCapture, 'STANDALONE', entry.inputs); + const confirmed = await this._showRerunInputsModal(entry, inputsForm.container); + if (!confirmed) return; let annotBounds = entry.bounds; - let inputs = { ...entry.inputs }; - if (entry.hasRectInput) { + if (inputsForm.captureAnnotation) { if (!this.getOption('skipDrawROIModal')) { const shouldDraw = await this._showDrawROIModal(); - if (!shouldDraw) throw new Error('cancelled'); + if (!shouldDraw) return; + } + try { + const result = await inputsForm.captureAnnotation(); + annotBounds = result.bounds; + } catch (e) { + if (e?.message !== 'cancelled') console.error('[analyze] Annotation capture failed:', e); + return; } - const result = await this._captureAnnotation({ _rootEl: null }, scope); - singletonModule('annotations')?.enableInteraction(false); - const ead = await window.EmpaiaStandaloneJobs?.getEAD?.(entry.appId); - const rectKey = window.EmpaiaStandaloneJobs - ?.getRequiredInputs?.(ead, 'STANDALONE') - ?.find(i => i.type === 'rectangle')?.key; - if (rectKey) inputs[rectKey] = result.id; - annotBounds = result.bounds; } + const inputs = inputsForm.getInputs(); const appLabel = entry.appName || entry.appId || 'Job'; const viewerId = String(VIEWER.uniqueId); this._setJobBanner(`${appLabel}: Pending`, 'WARNING', null); @@ -63,7 +67,7 @@ addPlugin('analyze-dev', class extends XOpatPlugin { caseId: entry.caseId, mode: 'STANDALONE', inputs, - ead: await window.EmpaiaStandaloneJobs?.getEAD?.(entry.appId), + ead: inputsForm.ead, }); const status = res?.status === 'COMPLETED' ? 'COMPLETED' : 'FAILED'; @@ -535,6 +539,26 @@ addPlugin('analyze-dev', class extends XOpatPlugin { fw.focus(); } + async _openInputsForm(appId, fw) { + const api = singletonModule('empation-api')?.V3; + if (!api) throw new Error('EmpationAPI V3 is not available'); + const caseId = await this._resolveCaseId(); + if (!caseId) throw new Error('No active case found'); + const examination = await api.examinations.create(caseId, appId); + const scope = await api.getScopeFrom(examination); + const onCapture = () => this._captureAnnotation(fw, scope); + + let jobDefaults = {}; + try { + const eadInfo = await api.rationai?.ead?.get?.(appId); + jobDefaults = eadInfo?.job_defaults || {}; + } catch (e) { + console.warn('[analyze] Failed to fetch job defaults for', appId, e); + } + + return this._buildInputsForm(appId, scope, onCapture, 'STANDALONE', jobDefaults); + } + _createAppCard(app, idx, tOr, fw) { const appId = app?.id || app?.app_id; const wrap = document.createElement('div'); @@ -552,7 +576,7 @@ addPlugin('analyze-dev', class extends XOpatPlugin { const configBtn = document.createElement('button'); configBtn.type = 'button'; configBtn.className = 'btn btn-xs btn-ghost'; - configBtn.textContent = 'Configure'; + configBtn.textContent = tOr('analyze.advancedSettings', 'Advanced settings'); header.appendChild(configBtn); wrap.appendChild(header); @@ -584,14 +608,7 @@ addPlugin('analyze-dev', class extends XOpatPlugin { inputsSection.classList.toggle('hidden'); if (!inputsLoaded && !inputsSection.classList.contains('hidden')) { try { - const api = singletonModule('empation-api')?.V3; - if (!api) throw new Error('EmpationAPI V3 is not available'); - const caseId = await this._resolveCaseId(); - if (!caseId) throw new Error('No active case found'); - const examination = await api.examinations.create(caseId, appId); - const scope = await api.getScopeFrom(examination); - const onCapture = () => this._captureAnnotation(fw, scope); - inputsForm = await this._buildInputsForm(appId, scope, onCapture); + inputsForm = await this._openInputsForm(appId, fw); inputsSection.innerHTML = ''; inputsSection.appendChild(inputsForm.container); inputsLoaded = true; @@ -626,14 +643,7 @@ addPlugin('analyze-dev', class extends XOpatPlugin { if (!inputsLoaded) { try { - const api = singletonModule('empation-api')?.V3; - if (!api) throw new Error('EmpationAPI V3 is not available'); - const caseId = await this._resolveCaseId(); - if (!caseId) throw new Error('No active case found'); - const examination = await api.examinations.create(caseId, appId); - const scope = await api.getScopeFrom(examination); - const onCapture = () => this._captureAnnotation(fw, scope); - inputsForm = await this._buildInputsForm(appId, scope, onCapture); + inputsForm = await this._openInputsForm(appId, fw); inputsLoaded = true; } catch (e) { console.error('[analyze] Failed to load inputs for run:', e); @@ -770,7 +780,64 @@ addPlugin('analyze-dev', class extends XOpatPlugin { }); } - async _buildInputsForm(appId, scope, onCapture, mode = 'STANDALONE') { + _showRerunInputsModal(entry, container) { + const { FloatingWindow } = globalThis.UI; + return new Promise(resolve => { + let resolved = false; + const finish = (result) => { + if (resolved) return; + resolved = true; + resolve(result); + }; + + const width = 360, height = 420; + const modal = new FloatingWindow({ + id: `${this.id}-rerun-inputs-modal`, + title: 'Edit inputs before rerun', + width, + height, + startLeft: Math.round((window.innerWidth - width) / 2), + startTop: Math.round((window.innerHeight - height) / 2), + onClose: () => finish(false), + }); + modal.attachTo(document.body); + + const body = document.createElement('div'); + body.className = 'p-3 flex flex-col gap-3 overflow-auto'; + body.style.height = '100%'; + body.appendChild(container); + + const actions = document.createElement('div'); + actions.className = 'flex gap-2'; + + const runBtn = document.createElement('button'); + runBtn.type = 'button'; + runBtn.className = 'btn btn-sm btn-primary flex-1'; + runBtn.textContent = 'Rerun'; + runBtn.addEventListener('click', () => { + finish(true); + modal.close(); + }); + + const cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.className = 'btn btn-sm btn-ghost flex-1'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.addEventListener('click', () => { + finish(false); + modal.close(); + }); + + actions.appendChild(runBtn); + actions.appendChild(cancelBtn); + body.appendChild(actions); + + modal.setBody(body); + modal.focus(); + }); + } + + async _buildInputsForm(appId, scope, onCapture, mode = 'STANDALONE', initialValues = {}) { const container = document.createElement('div'); container.className = 'space-y-2 mt-2'; @@ -792,7 +859,7 @@ addPlugin('analyze-dev', class extends XOpatPlugin { const inputFields = {}; for (const input of requiredInputs) { - const row = this._createInputRow(input, currentSlideId, inputFields, onCapture); + const row = this._createInputRow(input, currentSlideId, inputFields, onCapture, initialValues); if (row) container.appendChild(row); } @@ -831,7 +898,7 @@ addPlugin('analyze-dev', class extends XOpatPlugin { } } - _createInputRow(input, currentSlideId, inputFields, onCapture) { + _createInputRow(input, currentSlideId, inputFields, onCapture, initialValues = {}) { console.log("input.type:", input.type) if (input.type === 'wsi') { // Auto-fill with current slide — no UI row needed @@ -858,27 +925,37 @@ addPlugin('analyze-dev', class extends XOpatPlugin { fieldEl = document.createElement('input'); fieldEl.type = 'checkbox'; fieldEl.className = 'checkbox checkbox-xs'; + const def = initialValues[input.key]; + if (def !== undefined) fieldEl.checked = def === true || def === 'true'; } else if (input.type === 'integer' || input.type === 'float') { fieldEl = document.createElement('input'); fieldEl.type = 'number'; fieldEl.className = 'input input-xs input-bordered flex-1'; if (input.type === 'float') fieldEl.step = 'any'; + const def = initialValues[input.key]; + if (def !== undefined) fieldEl.value = def; } else if (input.type === 'string') { const selectOpts = STRING_SELECT_OPTIONS[input.key]; + const def = initialValues[input.key]; if (selectOpts) { fieldEl = document.createElement('select'); fieldEl.className = 'select select-xs select-bordered flex-1'; - for (const opt of selectOpts) { + const opts = (def !== undefined && !selectOpts.includes(def)) + ? [...selectOpts, def] + : selectOpts; + for (const opt of opts) { const option = document.createElement('option'); option.value = opt; option.textContent = opt; fieldEl.appendChild(option); } + if (def !== undefined) fieldEl.value = def; } else { fieldEl = document.createElement('textarea'); fieldEl.className = 'textarea textarea-xs textarea-bordered flex-1 font-mono text-xs'; fieldEl.rows = 4; fieldEl.placeholder = 'Enter text value\u2026'; + if (def !== undefined) fieldEl.value = def; } } else { fieldEl = document.createElement('input'); From 6dcf8486abb5dff79c446ad7a1b7e4fbbad9c4ea Mon Sep 17 00:00:00 2001 From: Filip Vrubel <114007566+FilipVrubel@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:56:07 +0200 Subject: [PATCH 20/20] refactor(analyze-dev): build UI with Van.js components and drop debug logging --- plugins/analyze-dev/analyzeDropdown.mjs | 373 +++++--------- plugins/analyze-dev/jobHistory.mjs | 581 ++++++++++------------ plugins/analyze-dev/jobResultsOverlay.mjs | 2 - 3 files changed, 393 insertions(+), 563 deletions(-) diff --git a/plugins/analyze-dev/analyzeDropdown.mjs b/plugins/analyze-dev/analyzeDropdown.mjs index bdeda111..4608c1ab 100644 --- a/plugins/analyze-dev/analyzeDropdown.mjs +++ b/plugins/analyze-dev/analyzeDropdown.mjs @@ -1,4 +1,5 @@ const { Dropdown } = globalThis.UI; +const { div, span, p, label, input, select, option, textarea, button } = globalThis.van.tags; const STRING_SELECT_OPTIONS = { script: ['stardist'], @@ -226,7 +227,6 @@ addPlugin('analyze-dev', class extends XOpatPlugin { } const ConvertorClass = OSDAnnotations.Convertor.CONVERTERS['empaia']; this._empaiaConvertor = new ConvertorClass(annotationsModule, {}); - console.log('[analyze] empaia convertor ready'); } catch (e) { console.warn('[analyze] empaia convertor not available', e); return; @@ -245,10 +245,8 @@ addPlugin('analyze-dev', class extends XOpatPlugin { .map(([key]) => key); if (!annotationKeys.length) { - console.log('[analyze] no annotation output keys in EAD for job', finalJob.id); return; } - console.log('[analyze] annotation output keys:', annotationKeys); const scope = finalJob._scope; if (!scope) { console.warn('[analyze] no scope on finalJob'); return; } @@ -258,22 +256,18 @@ addPlugin('analyze-dev', class extends XOpatPlugin { console.warn('[analyze] job has no outputs field', job); return; } - console.log('[analyze] job outputs:', job.outputs); const allShapes = []; for (const key of annotationKeys) { const collectionId = job.outputs[key]; if (!collectionId) { - console.log('[analyze] no collection ID for output key', key); continue; } try { const result = await scope.collections.queryItems(collectionId, {}); if (!result?.items?.length) { - console.log('[analyze] empty collection for key', key); continue; } - console.log('[analyze] fetched', result.items.length, 'annotations for key', key); const decoded = await this._empaiaConvertor.decode({ items: result.items, presets: [] }); if (decoded?.objects) allShapes.push(...decoded.objects.filter(Boolean)); } catch (e) { @@ -282,11 +276,9 @@ addPlugin('analyze-dev', class extends XOpatPlugin { } if (!allShapes.length) { - console.log('[analyze] no shapes decoded from job', finalJob.id); return; } - console.log('[analyze] rendering', allShapes.length, 'annotations from job', finalJob.id); await this._overlay.addJobResults(finalJob.id, allShapes, viewerId); } catch (e) { @@ -298,7 +290,6 @@ addPlugin('analyze-dev', class extends XOpatPlugin { try { const ead = await window.EmpaiaStandaloneJobs?.getEAD?.(appId) || null; if (!ead?.io) { - console.log('[analyze] _fetchOutputValues: no EAD io, skipping'); return []; } @@ -307,10 +298,8 @@ addPlugin('analyze-dev', class extends XOpatPlugin { .map(([key]) => key); if (!valueKeys.length) { - console.log('[analyze] no value output keys in EAD for job', finalJob.id); return []; } - console.log('[analyze] value output keys:', valueKeys); const scope = finalJob._scope; if (!scope) { console.warn('[analyze] _fetchOutputValues: no scope on finalJob'); return []; } @@ -325,16 +314,13 @@ addPlugin('analyze-dev', class extends XOpatPlugin { for (const key of valueKeys) { const collectionId = job.outputs[key]; if (!collectionId) { - console.log('[analyze] no collection ID for value key', key); continue; } try { const result = await scope.collections.queryItems(collectionId, {}); if (!result?.items?.length) { - console.log('[analyze] empty collection for value key', key); continue; } - console.log('[analyze] fetched', result.items.length, 'values for key', key); results.push({ key, items: result.items }); } catch (e) { console.warn('[analyze] failed to fetch values for key', key, e); @@ -358,28 +344,9 @@ addPlugin('analyze-dev', class extends XOpatPlugin { const fw = new FloatingWindow({ id, title: 'Job Results', width, height, startLeft, startTop }); fw.attachTo(document.body); - const body = document.createElement('div'); - body.className = 'p-3 space-y-4 overflow-auto'; - body.style.height = '100%'; - - for (const { key, items } of valueOutputs) { - const section = document.createElement('div'); - section.className = 'mb-3'; - - const heading = document.createElement('div'); - heading.className = 'text-sm font-medium mb-1'; - heading.textContent = key; - section.appendChild(heading); - - const pre = document.createElement('pre'); - pre.className = 'text-xs font-mono opacity-80 whitespace-pre-wrap'; - pre.textContent = items.map((item, i) => `${i}: ${Number(item.value).toFixed(4)}`).join('\n'); - section.appendChild(pre); - - body.appendChild(section); - } - - fw.setBody(body); + fw.setBody(div({ class: 'p-3 space-y-4 overflow-auto h-full' }, + valueOutputs.map(output => window.renderJobOutputValues(output)), + )); fw.focus(); } @@ -486,9 +453,7 @@ addPlugin('analyze-dev', class extends XOpatPlugin { width: Math.round(annotObj.width), height: Math.round(annotObj.height), }; - console.log('[analyze] posting annotation to MDS:', encoded); const created = await scope.annotations.create(encoded); - console.log('[analyze] annotation created in MDS, serverId=', created.id); return { id: created.id, bounds: { left: annotObj.left, top: annotObj.top, width: annotObj.width, height: annotObj.height } }; } catch (e) { console.error('[analyze] _captureAnnotation failed:', e); @@ -520,22 +485,11 @@ addPlugin('analyze-dev', class extends XOpatPlugin { }); fw.attachTo(document.body); - const container = document.createElement('div'); - container.className = 'p-2 space-y-3'; - - for (const [idx, app] of items.entries()) { - const card = this._createAppCard(app, idx, tOr, fw); - container.appendChild(card); - } - - if (!items.length) { - const empty = document.createElement('div'); - empty.className = 'p-2 text-sm opacity-70'; - empty.textContent = tOr('analyze.noApps', 'No apps available.'); - container.appendChild(empty); - } - - fw.setBody(container); + fw.setBody(div({ class: 'p-2 space-y-3' }, + items.length + ? items.map((app, idx) => this._createAppCard(app, idx, tOr, fw)) + : div({ class: 'p-2 text-sm opacity-70' }, tOr('analyze.noApps', 'No apps available.')), + )); fw.focus(); } @@ -561,77 +515,31 @@ addPlugin('analyze-dev', class extends XOpatPlugin { _createAppCard(app, idx, tOr, fw) { const appId = app?.id || app?.app_id; - const wrap = document.createElement('div'); - wrap.className = 'p-3 rounded-box bg-base-200 border border-base-300'; - - // Header with title and configure button - const header = document.createElement('div'); - header.className = 'flex items-center justify-between'; - - const title = document.createElement('span'); - title.className = 'font-medium'; - title.textContent = app?.name_short || app?.name || `App ${idx + 1}`; - header.appendChild(title); - - const configBtn = document.createElement('button'); - configBtn.type = 'button'; - configBtn.className = 'btn btn-xs btn-ghost'; - configBtn.textContent = tOr('analyze.advancedSettings', 'Advanced settings'); - header.appendChild(configBtn); - wrap.appendChild(header); - - // Description - if (app?.store_description) { - const desc = document.createElement('div'); - desc.className = 'text-xs opacity-70 mt-1'; - desc.textContent = app.store_description; - wrap.appendChild(desc); - } - - // Job name input (optional) - const nameInput = document.createElement('input'); - nameInput.type = 'text'; - nameInput.className = 'input input-bordered input-sm w-full mb-2'; - nameInput.placeholder = 'Job name (optional)'; - wrap.appendChild(nameInput); - - // Inputs section (hidden by default) - const inputsSection = document.createElement('div'); - inputsSection.className = 'mt-2 hidden'; - inputsSection.innerHTML = '
Loading inputs...
'; - wrap.appendChild(inputsSection); + const nameInput = input({ + type: 'text', + class: 'input input-bordered input-sm w-full mb-2', + placeholder: 'Job name (optional)', + }); + // Inputs section (hidden by default), loaded on first open + const settingsOpen = van.state(false); + const inputsContent = van.state(div({ class: 'text-xs opacity-50' }, 'Loading inputs...')); let inputsForm = null; let inputsLoaded = false; - configBtn.addEventListener('click', async () => { - inputsSection.classList.toggle('hidden'); - if (!inputsLoaded && !inputsSection.classList.contains('hidden')) { - try { - inputsForm = await this._openInputsForm(appId, fw); - inputsSection.innerHTML = ''; - inputsSection.appendChild(inputsForm.container); - inputsLoaded = true; - } catch (e) { - inputsSection.innerHTML = `
Failed to load inputs: ${e?.message || String(e)}
`; - } + const toggleSettings = async () => { + settingsOpen.val = !settingsOpen.val; + if (inputsLoaded || !settingsOpen.val) return; + try { + inputsForm = await this._openInputsForm(appId, fw); + inputsContent.val = inputsForm.container; + inputsLoaded = true; + } catch (e) { + inputsContent.val = div({ class: 'text-xs text-error' }, `Failed to load inputs: ${e?.message || String(e)}`); } - }); - - // Actions row - const actions = document.createElement('div'); - actions.className = 'flex items-center gap-2 mt-2'; - - const runBtn = document.createElement('button'); - runBtn.type = 'button'; - runBtn.className = 'btn btn-sm btn-primary'; - runBtn.textContent = tOr('analyze.run', 'Run'); - - const status = document.createElement('span'); - status.className = 'text-xs flex-1'; - status.textContent = tOr('analyze.jobReady', 'Ready'); + }; - runBtn.addEventListener('click', async () => { + const runJob = async () => { const viewerId = String(VIEWER.uniqueId); const bannerId = 'banner'; const appLabel = app?.name_short || app?.name || 'Job'; @@ -672,7 +580,6 @@ addPlugin('analyze-dev', class extends XOpatPlugin { const inputs = inputsForm?.getInputs?.() || {}; const ead = inputsForm?.ead || null; annotBounds = inputsForm?.getAnnotBounds?.() || null; - console.log('[analyze] Running job with inputs:', inputs); const caseId = await this._resolveCaseId(); if (!caseId) throw new Error('No active case found'); @@ -685,7 +592,6 @@ addPlugin('analyze-dev', class extends XOpatPlugin { }); const isSuccess = res?.status === 'COMPLETED'; - console.log('[analyze] Job final:', res); if (isSuccess) { setJobBanner(`${appLabel}: Completed`, 'SUCCESS', annotBounds); await this._fetchAndRenderResults(res, appId, viewerId); @@ -714,13 +620,22 @@ addPlugin('analyze-dev', class extends XOpatPlugin { console.error('[analyze] Failed to run app job', err); setJobBanner(`${appLabel}: Failed`, 'ERROR', annotBounds); } - }); - - actions.appendChild(runBtn); - actions.appendChild(status); - wrap.appendChild(actions); + }; - return wrap; + return div({ class: 'p-3 rounded-box bg-base-200 border border-base-300' }, + div({ class: 'flex items-center justify-between' }, + span({ class: 'font-medium' }, app?.name_short || app?.name || `App ${idx + 1}`), + button({ type: 'button', class: 'btn btn-xs btn-ghost', onclick: toggleSettings }, + tOr('analyze.advancedSettings', 'Advanced settings')), + ), + app?.store_description ? div({ class: 'text-xs opacity-70 mt-1' }, app.store_description) : null, + nameInput, + div({ class: () => settingsOpen.val ? 'mt-2' : 'mt-2 hidden' }, () => inputsContent.val), + div({ class: 'flex items-center gap-2 mt-2' }, + button({ type: 'button', class: 'btn btn-sm btn-primary', onclick: runJob }, tOr('analyze.run', 'Run')), + span({ class: 'text-xs flex-1' }, tOr('analyze.jobReady', 'Ready')), + ), + ); } _showDrawROIModal() { @@ -745,35 +660,23 @@ addPlugin('analyze-dev', class extends XOpatPlugin { }); modal.attachTo(document.body); - const body = document.createElement('div'); - body.className = 'p-4 flex flex-col gap-3'; - - const msg = document.createElement('p'); - msg.className = 'text-sm'; - msg.textContent = 'Draw a rectangular region on the slide to define the area of interest for analysis.'; - body.appendChild(msg); - - const checkRow = document.createElement('label'); - checkRow.className = 'flex items-center gap-2 text-xs cursor-pointer'; - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.className = 'checkbox checkbox-xs'; - const checkLabel = document.createElement('span'); - checkLabel.textContent = "Don't show again"; - checkRow.appendChild(checkbox); - checkRow.appendChild(checkLabel); - body.appendChild(checkRow); - - const drawBtn = document.createElement('button'); - drawBtn.type = 'button'; - drawBtn.className = 'btn btn-sm btn-primary w-full'; - drawBtn.textContent = 'Draw ROI'; - drawBtn.addEventListener('click', () => { - if (checkbox.checked) this.setOption('skipDrawROIModal', true); - finish(true); - modal.close(); - }); - body.appendChild(drawBtn); + const dontShowAgain = input({ type: 'checkbox', class: 'checkbox checkbox-xs' }); + const body = div({ class: 'p-4 flex flex-col gap-3' }, + p({ class: 'text-sm' }, 'Draw a rectangular region on the slide to define the area of interest for analysis.'), + label({ class: 'flex items-center gap-2 text-xs cursor-pointer' }, + dontShowAgain, + span("Don't show again"), + ), + button({ + type: 'button', + class: 'btn btn-sm btn-primary w-full', + onclick: () => { + if (dontShowAgain.checked) this.setOption('skipDrawROIModal', true); + finish(true); + modal.close(); + }, + }, 'Draw ROI'), + ); modal.setBody(body); modal.focus(); @@ -802,35 +705,17 @@ addPlugin('analyze-dev', class extends XOpatPlugin { }); modal.attachTo(document.body); - const body = document.createElement('div'); - body.className = 'p-3 flex flex-col gap-3 overflow-auto'; - body.style.height = '100%'; - body.appendChild(container); - - const actions = document.createElement('div'); - actions.className = 'flex gap-2'; - - const runBtn = document.createElement('button'); - runBtn.type = 'button'; - runBtn.className = 'btn btn-sm btn-primary flex-1'; - runBtn.textContent = 'Rerun'; - runBtn.addEventListener('click', () => { - finish(true); + const choose = (result) => { + finish(result); modal.close(); - }); - - const cancelBtn = document.createElement('button'); - cancelBtn.type = 'button'; - cancelBtn.className = 'btn btn-sm btn-ghost flex-1'; - cancelBtn.textContent = 'Cancel'; - cancelBtn.addEventListener('click', () => { - finish(false); - modal.close(); - }); - - actions.appendChild(runBtn); - actions.appendChild(cancelBtn); - body.appendChild(actions); + }; + const body = div({ class: 'p-3 flex flex-col gap-3 overflow-auto h-full' }, + container, + div({ class: 'flex gap-2' }, + button({ type: 'button', class: 'btn btn-sm btn-primary flex-1', onclick: () => choose(true) }, 'Rerun'), + button({ type: 'button', class: 'btn btn-sm btn-ghost flex-1', onclick: () => choose(false) }, 'Cancel'), + ), + ); modal.setBody(body); modal.focus(); @@ -838,30 +723,24 @@ addPlugin('analyze-dev', class extends XOpatPlugin { } async _buildInputsForm(appId, scope, onCapture, mode = 'STANDALONE', initialValues = {}) { - const container = document.createElement('div'); - container.className = 'space-y-2 mt-2'; + const formContainer = (...children) => div({ class: 'space-y-2 mt-2' }, ...children); + const note = (text) => formContainer(div({ class: 'text-xs opacity-50' }, text)); try { const ead = await window.EmpaiaStandaloneJobs?.getEAD?.(appId, scope); if (!ead) { - container.innerHTML = '
No EAD available
'; - return { container, getInputs: () => ({}) }; + return { container: note('No EAD available'), getInputs: () => ({}) }; } const requiredInputs = window.EmpaiaStandaloneJobs?.getRequiredInputs?.(ead, mode) || []; - console.log('[analyze] requiredInputs:', requiredInputs); if (requiredInputs.length === 0) { - container.innerHTML = '
No inputs required
'; - return { container, getInputs: () => ({}), ead }; + return { container: note('No inputs required'), getInputs: () => ({}), ead }; } const currentSlideId = VIEWER.scalebar?.getReferencedTiledImage()?.source?.getEmpaiaId() || ''; const inputFields = {}; - - for (const input of requiredInputs) { - const row = this._createInputRow(input, currentSlideId, inputFields, onCapture, initialValues); - if (row) container.appendChild(row); - } + const container = formContainer(requiredInputs.map(spec => + this._createInputRow(spec, currentSlideId, inputFields, initialValues))); const getInputs = () => { const result = {}; @@ -893,81 +772,65 @@ addPlugin('analyze-dev', class extends XOpatPlugin { return { container, getInputs, getAnnotBounds, captureAnnotation, ead }; } catch (e) { console.error('[analyze] Failed to build inputs form', e); - container.innerHTML = `
Error: ${e.message}
`; - return { container, getInputs: () => ({}) }; + return { + container: formContainer(div({ class: 'text-xs text-error' }, `Error: ${e.message}`)), + getInputs: () => ({}), + }; } } - _createInputRow(input, currentSlideId, inputFields, onCapture, initialValues = {}) { - console.log("input.type:", input.type) - if (input.type === 'wsi') { - // Auto-fill with current slide — no UI row needed - inputFields[input.key] = { value: currentSlideId }; + /** + * Build the field for one EAD input and register it in inputFields. + * Inputs filled automatically (wsi, rectangle) get a value holder and no row. + */ + _createInputRow(spec, currentSlideId, inputFields, initialValues = {}) { + if (spec.type === 'wsi') { + inputFields[spec.key] = { value: currentSlideId }; return null; } - - const row = document.createElement('div'); - row.className = 'flex items-center gap-2'; - - const label = document.createElement('label'); - label.className = 'text-xs font-medium min-w-20'; - label.textContent = `${input.key} (${input.type})`; - row.appendChild(label); - - if (input.type === 'rectangle') { - inputFields[input.key] = { value: '' }; + if (spec.type === 'rectangle') { + inputFields[spec.key] = { value: '' }; return null; } + const def = initialValues[spec.key]; + const initial = def !== undefined ? { value: def } : {}; let fieldEl; - if (input.type === 'bool') { - fieldEl = document.createElement('input'); - fieldEl.type = 'checkbox'; - fieldEl.className = 'checkbox checkbox-xs'; - const def = initialValues[input.key]; - if (def !== undefined) fieldEl.checked = def === true || def === 'true'; - } else if (input.type === 'integer' || input.type === 'float') { - fieldEl = document.createElement('input'); - fieldEl.type = 'number'; - fieldEl.className = 'input input-xs input-bordered flex-1'; - if (input.type === 'float') fieldEl.step = 'any'; - const def = initialValues[input.key]; - if (def !== undefined) fieldEl.value = def; - } else if (input.type === 'string') { - const selectOpts = STRING_SELECT_OPTIONS[input.key]; - const def = initialValues[input.key]; + if (spec.type === 'bool') { + fieldEl = input({ type: 'checkbox', class: 'checkbox checkbox-xs', checked: def === true || def === 'true' }); + } else if (spec.type === 'integer' || spec.type === 'float') { + fieldEl = input({ + type: 'number', + class: 'input input-xs input-bordered flex-1', + ...(spec.type === 'float' ? { step: 'any' } : {}), + ...initial, + }); + } else if (spec.type === 'string') { + const selectOpts = STRING_SELECT_OPTIONS[spec.key]; if (selectOpts) { - fieldEl = document.createElement('select'); - fieldEl.className = 'select select-xs select-bordered flex-1'; - const opts = (def !== undefined && !selectOpts.includes(def)) - ? [...selectOpts, def] - : selectOpts; - for (const opt of opts) { - const option = document.createElement('option'); - option.value = opt; - option.textContent = opt; - fieldEl.appendChild(option); - } + const opts = (def !== undefined && !selectOpts.includes(def)) ? [...selectOpts, def] : selectOpts; + fieldEl = select({ class: 'select select-xs select-bordered flex-1' }, + opts.map(opt => option({ value: opt }, opt))); + // options must exist before the value can select one if (def !== undefined) fieldEl.value = def; } else { - fieldEl = document.createElement('textarea'); - fieldEl.className = 'textarea textarea-xs textarea-bordered flex-1 font-mono text-xs'; - fieldEl.rows = 4; - fieldEl.placeholder = 'Enter text value\u2026'; - if (def !== undefined) fieldEl.value = def; + fieldEl = textarea({ + class: 'textarea textarea-xs textarea-bordered flex-1 font-mono text-xs', + rows: 4, + placeholder: 'Enter text value…', + ...initial, + }); } } else { - fieldEl = document.createElement('input'); - fieldEl.type = 'text'; - fieldEl.className = 'input input-xs input-bordered flex-1'; - fieldEl.placeholder = `${input.type} ID`; + fieldEl = input({ type: 'text', class: 'input input-xs input-bordered flex-1', placeholder: `${spec.type} ID` }); } - inputFields[input.key] = fieldEl; - row.appendChild(fieldEl); - - return row; + inputFields[spec.key] = fieldEl; + return div({ class: 'flex items-center gap-2' }, + label({ class: 'text-xs font-medium min-w-20' }, `${spec.key} (${spec.type})`), + fieldEl, + ); } }); \ No newline at end of file diff --git a/plugins/analyze-dev/jobHistory.mjs b/plugins/analyze-dev/jobHistory.mjs index 21fcf2b9..8e5a2718 100644 --- a/plugins/analyze-dev/jobHistory.mjs +++ b/plugins/analyze-dev/jobHistory.mjs @@ -1,8 +1,16 @@ +const { div, span, button, input, select, option, pre, i } = globalThis.van.tags; + function _fmtTs(ts) { const d = new Date(ts); return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')} ${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')}`; } +function _appLabel(entry) { + return entry.appName || entry.appId?.slice(0, 8) || '?'; +} + +const spinner = () => span({ class: 'loading loading-spinner loading-xs' }); + class JobHistory { constructor({ plugin, overlay, onShow, onRerun, onFetchResults }) { this._plugin = plugin; @@ -11,12 +19,13 @@ class JobHistory { this._onRerun = onRerun; this._onFetchResults = onFetchResults; this._modal = null; - this._modalBody = null; - this._searchQuery = ''; - this._appFilter = ''; - this._listEl = null; - this._countEl = null; this._resultsCache = new Map(); + // Bumped on every history or overlay visibility change; the panel re-renders from it. + this.revision = van.state(0); + this.searchQuery = van.state(''); + this.appFilter = van.state(''); + // jobId -> message, kept outside the cards so it survives a re-render + this.entryErrors = van.state({}); } getHistory() { @@ -34,8 +43,7 @@ class JobHistory { const history = this.getHistory(); history.unshift(entry); if (history.length > 50) history.splice(50); - this._plugin.setOption('jobHistory', JSON.stringify(history)); - this._refreshModal(); + this._saveHistory(history); } updateJob(jobId, patch) { @@ -43,19 +51,73 @@ class JobHistory { const idx = history.findIndex(e => e.jobId === jobId); if (idx !== -1) { history[idx] = { ...history[idx], ...patch }; - this._plugin.setOption('jobHistory', JSON.stringify(history)); - this._refreshModal(); + this._saveHistory(history); } } + deleteJob(jobId) { + this._saveHistory(this.getHistory().filter(e => e.jobId !== jobId)); + } + + clearHistory() { + this._saveHistory([]); + } + + _saveHistory(history) { + this._plugin.setOption('jobHistory', JSON.stringify(history)); + this.refresh(); + } + + refresh() { + this.revision.val++; + } + + getAppOptions(history) { + const names = new Set(history.map(_appLabel)); + return [...names].sort((a, b) => a.localeCompare(b)); + } + + getFilteredHistory(history) { + const query = this.searchQuery.val.trim().toLowerCase(); + const appFilter = this.appFilter.val; + return history.filter(entry => { + if (query && !entry.name?.toLowerCase().includes(query)) return false; + if (appFilter && _appLabel(entry) !== appFilter) return false; + return true; + }); + } + + isJobVisible(jobId) { + const storeEntry = this._overlay._jobStore?.get(jobId); + return storeEntry ? storeEntry.visible !== false : false; + } + + isJobLoaded(jobId) { + return !!this._overlay._jobStore?.get(jobId); + } + + setJobVisible(jobId, visible) { + this._overlay.setJobVisible(jobId, visible); + this.refresh(); + } + + showEntryError(jobId, message) { + this.entryErrors.val = { ...this.entryErrors.val, [jobId]: message }; + setTimeout(() => { + if (this.entryErrors.val[jobId] !== message) return; + const { [jobId]: _, ...rest } = this.entryErrors.val; + this.entryErrors.val = rest; + }, 4000); + } + showModal() { const { FloatingWindow } = globalThis.UI; if (this._modal) { this._modal.focus(); return; } - this._searchQuery = ''; - this._appFilter = ''; + this.searchQuery.val = ''; + this.appFilter.val = ''; this._resultsCache = new Map(); const width = 480, height = 500; this._modal = new FloatingWindow({ @@ -65,335 +127,242 @@ class JobHistory { height, startLeft: Math.round((window.innerWidth - width) / 2), startTop: Math.round((window.innerHeight - height) / 2), - onClose: () => { this._modal = null; this._modalBody = null; }, + onClose: () => { this._modal = null; }, }); this._modal.attachTo(document.body); - this._modalBody = document.createElement('div'); - this._modalBody.className = 'flex flex-col h-full overflow-hidden'; - this._renderList(this._modalBody); - this._modal.setBody(this._modalBody); + this._modal.setBody(new JobHistoryPanel({ history: this })); this._modal.focus(); } +} - _refreshModal() { - if (this._modal && this._modalBody) { - this._renderList(this._modalBody); - } +/** + * Body of the job history window: header with count, search/app filter, and the card list. + * The search input is rendered once so typing does not lose focus; only the count, the app + * options and the list are reactive. + */ +class JobHistoryPanel extends UI.BaseComponent { + constructor(options = undefined, ...children) { + options = super(options, ...children).options; + this.history = options.history; + this.classMap.base = 'flex flex-col h-full overflow-hidden'; + this.refreshClassState(); } - _renderList(container) { - container.innerHTML = ''; - this._listEl = null; - this._countEl = null; - const history = this.getHistory(); + create() { + const history = this.history; + const entries = () => { + history.revision.val; + return history.getHistory(); + }; + + return div({ ...this.commonProperties }, + div({ class: 'flex items-center justify-between px-3 py-2 border-b border-base-300 flex-shrink-0' }, + () => span({ class: 'text-xs opacity-60' }, this._countLabel(entries())), + () => entries().length + ? button({ type: 'button', class: 'btn btn-xs btn-ghost', onclick: () => history.clearHistory() }, 'Clear all') + : span(), + ), + () => entries().length ? this._renderFilterBar() : span({ class: 'hidden' }), + () => this._renderList(entries()), + ); + } - const header = document.createElement('div'); - header.className = 'flex items-center justify-between px-3 py-2 border-b border-base-300 flex-shrink-0'; - const count = document.createElement('span'); - count.className = 'text-xs opacity-60'; - header.appendChild(count); - this._countEl = count; - if (history.length) { - const clearBtn = document.createElement('button'); - clearBtn.type = 'button'; - clearBtn.className = 'btn btn-xs btn-ghost'; - clearBtn.textContent = 'Clear all'; - clearBtn.addEventListener('click', () => { - this._plugin.setOption('jobHistory', '[]'); - this._renderList(container); - }); - header.appendChild(clearBtn); - } - container.appendChild(header); - - if (!history.length) { - count.textContent = 'No jobs run yet.'; - const empty = document.createElement('div'); - empty.className = 'flex-1 flex items-center justify-center text-sm opacity-50'; - empty.textContent = 'No jobs run yet.'; - container.appendChild(empty); - return; + _countLabel(all) { + const history = this.history; + const total = all.length; + if (!total) return 'No jobs run yet.'; + if (history.searchQuery.val || history.appFilter.val) { + return `${history.getFilteredHistory(all).length} of ${total} job${total === 1 ? '' : 's'}`; } + return `${total} job${total === 1 ? '' : 's'} run`; + } - const filterBar = document.createElement('div'); - filterBar.className = 'flex items-center gap-2 px-3 py-2 border-b border-base-300 flex-shrink-0'; - - const searchInput = document.createElement('input'); - searchInput.type = 'text'; - searchInput.placeholder = 'Search by job name...'; - searchInput.className = 'input input-xs w-32'; - searchInput.value = this._searchQuery; - searchInput.addEventListener('input', (e) => { - this._searchQuery = e.target.value; - this._updateListBody(); - }); - filterBar.appendChild(searchInput); - - const appSelect = document.createElement('select'); - appSelect.className = 'select select-xs w-24'; - const allOption = document.createElement('option'); - allOption.value = ''; - allOption.textContent = 'All apps'; - appSelect.appendChild(allOption); - const appOptions = this._getAppOptions(history); - if (this._appFilter && !appOptions.includes(this._appFilter)) { - this._appFilter = ''; - } - for (const appName of appOptions) { - const opt = document.createElement('option'); - opt.value = appName; - opt.textContent = appName; - appSelect.appendChild(opt); + _renderFilterBar() { + const history = this.history; + const appOptions = history.getAppOptions(history.getHistory()); + if (history.appFilter.rawVal && !appOptions.includes(history.appFilter.rawVal)) { + history.appFilter.val = ''; } - appSelect.value = this._appFilter; - appSelect.addEventListener('change', (e) => { - this._appFilter = e.target.value; - this._updateListBody(); - }); - filterBar.appendChild(appSelect); + const appSelect = select({ + class: 'select select-xs w-24', + onchange: e => { history.appFilter.val = e.target.value; }, + }, + option({ value: '' }, 'All apps'), + ...appOptions.map(name => option({ value: name }, name)), + ); + appSelect.value = history.appFilter.rawVal; + + return div({ class: 'flex items-center gap-2 px-3 py-2 border-b border-base-300 flex-shrink-0' }, + input({ + type: 'text', + class: 'input input-xs w-32', + placeholder: 'Search by job name...', + value: history.searchQuery.rawVal, + oninput: e => { history.searchQuery.val = e.target.value; }, + }), + appSelect, + ); + } - container.appendChild(filterBar); + _renderList(all) { + if (!all.length) { + return div({ class: 'flex-1 flex items-center justify-center text-sm opacity-50' }, 'No jobs run yet.'); + } + const filtered = this.history.getFilteredHistory(all); + return div({ class: 'flex-1 overflow-auto p-2' }, + filtered.length + ? filtered.map(entry => new JobHistoryCard({ history: this.history, entry }).create()) + : div({ class: 'flex items-center justify-center text-sm opacity-50 py-4' }, 'No jobs match your search/filter.'), + ); + } +} - const list = document.createElement('div'); - list.className = 'flex-1 overflow-auto p-2'; - container.appendChild(list); - this._listEl = list; +/** One history entry: status, name, show/hide toggle and the collapsible action row. */ +class JobHistoryCard extends UI.BaseComponent { + constructor(options = undefined, ...children) { + options = super(options, ...children).options; + this.history = options.history; + this.entry = options.entry; + this.moreOpen = van.state(false); + this.resultsOpen = van.state(false); + this.busy = van.state(''); + const visible = this.history.isJobVisible(this.entry.jobId); + this.classMap.base = 'p-2 rounded-box bg-base-200 mb-1'; + this.classMap.ring = visible ? 'ring ring-primary ring-offset-1' : ''; + this.refreshClassState(); + } - this._updateListBody(); + create() { + const { entry, history } = this; + return div({ ...this.commonProperties }, + div({ class: 'flex items-center gap-1 text-xs flex-wrap' }, + span({ class: 'w-2 h-2 rounded-full flex-shrink-0 ' + (entry.status === 'COMPLETED' ? 'bg-success' : 'bg-error') }), + span({ class: 'font-medium' }, entry.name), + span({ class: 'opacity-50' }, `· ${_appLabel(entry)}`), + span({ class: 'opacity-50' }, `· ${_fmtTs(entry.timestamp)}`), + ), + div({ class: 'flex gap-1 mt-1 items-center' }, + this._renderToggleButton(), + button({ + type: 'button', + class: 'btn btn-xs btn-square btn-ghost', + title: 'More actions', + onclick: () => { this.moreOpen.val = !this.moreOpen.val; }, + }, i({ class: 'ph-light ph-dots-three' })), + ), + div({ class: () => this.moreOpen.val ? '' : 'hidden' }, + div({ class: 'flex gap-1 mt-1' }, + this._renderActionButton('rerun', 'Rerun', () => this._rerun()), + entry.status === 'COMPLETED' + ? this._renderActionButton('results', 'Results', () => this._toggleResults()) + : null, + button({ + type: 'button', + class: 'btn btn-xs btn-ghost text-error', + onclick: () => history.deleteJob(entry.jobId), + }, '×'), + ), + () => this.resultsOpen.val + ? this._renderResults(history._resultsCache.get(entry.jobId) || []) + : span({ class: 'hidden' }), + ), + () => { + const message = history.entryErrors.val[entry.jobId]; + return message ? div({ class: 'text-xs text-error mt-1' }, message) : span({ class: 'hidden' }); + }, + ); } - _getAppOptions(history) { - const names = new Set(); - for (const entry of history) { - names.add(entry.appName || entry.appId?.slice(0, 8) || '?'); - } - return [...names].sort((a, b) => a.localeCompare(b)); + _renderToggleButton() { + const visible = this.history.isJobVisible(this.entry.jobId); + return button({ + type: 'button', + class: 'btn btn-xs btn-square ' + (visible ? 'btn-primary' : 'btn-ghost'), + title: visible ? 'Hide annotations' : 'Show annotations', + disabled: () => this.busy.val === 'toggle', + onclick: () => this._toggleVisibility(visible), + }, () => this.busy.val === 'toggle' + ? spinner() + : i({ class: `ph-light ${visible ? 'ph-eye' : 'ph-eye-slash'}` })); } - _getFilteredHistory() { - const history = this.getHistory(); - const query = this._searchQuery.trim().toLowerCase(); - return history.filter(entry => { - if (query && !entry.name?.toLowerCase().includes(query)) return false; - if (this._appFilter) { - const appLabel = entry.appName || entry.appId?.slice(0, 8) || '?'; - if (appLabel !== this._appFilter) return false; - } - return true; - }); + _renderActionButton(key, label, onclick) { + return button({ + type: 'button', + class: 'btn btn-xs btn-ghost', + disabled: () => this.busy.val === key, + onclick, + }, () => this.busy.val === key ? spinner() : span(label)); } - _updateListBody() { - if (!this._listEl || !this._countEl) return; - const total = this.getHistory().length; - const filtered = this._getFilteredHistory(); - - this._countEl.textContent = (this._searchQuery || this._appFilter) - ? `${filtered.length} of ${total} job${total === 1 ? '' : 's'}` - : `${total} job${total === 1 ? '' : 's'} run`; - - this._listEl.innerHTML = ''; - if (!filtered.length) { - const empty = document.createElement('div'); - empty.className = 'flex items-center justify-center text-sm opacity-50 py-4'; - empty.textContent = 'No jobs match your search/filter.'; - this._listEl.appendChild(empty); + async _toggleVisibility(visible) { + const { entry, history } = this; + if (visible || history.isJobLoaded(entry.jobId)) { + history.setJobVisible(entry.jobId, !visible); return; } - for (const entry of filtered) { - this._listEl.appendChild(this._renderEntry(entry)); + this.busy.val = 'toggle'; + try { + await history._onShow(entry); + history.refresh(); + } catch (e) { + console.error('[job-history] show failed', e); + history.showEntryError(entry.jobId, e?.message || 'Failed to load annotations'); + } finally { + this.busy.val = ''; } } - _renderEntry(entry) { - const storeEntry = this._overlay._jobStore?.get(entry.jobId); - const isVisible = storeEntry ? (storeEntry.visible !== false) : false; - - const card = document.createElement('div'); - card.className = 'p-2 rounded-box bg-base-200 mb-1' + (isVisible ? ' ring ring-primary ring-offset-1' : ''); - - const meta = document.createElement('div'); - meta.className = 'flex items-center gap-1 text-xs flex-wrap'; - - const dot = document.createElement('span'); - dot.className = 'w-2 h-2 rounded-full flex-shrink-0 ' + - (entry.status === 'COMPLETED' ? 'bg-success' : 'bg-error'); - meta.appendChild(dot); - - const name = document.createElement('span'); - name.className = 'font-medium'; - name.textContent = entry.name; - meta.appendChild(name); - - const appSpan = document.createElement('span'); - appSpan.className = 'opacity-50'; - appSpan.textContent = `· ${entry.appName || entry.appId?.slice(0, 8) || '?'}`; - meta.appendChild(appSpan); - - const tsSpan = document.createElement('span'); - tsSpan.className = 'opacity-50'; - tsSpan.textContent = `· ${_fmtTs(entry.timestamp)}`; - meta.appendChild(tsSpan); - - card.appendChild(meta); - - const actions = document.createElement('div'); - actions.className = 'flex gap-1 mt-1 items-center'; - - const toggleBtn = document.createElement('button'); - toggleBtn.type = 'button'; - toggleBtn.className = 'btn btn-xs btn-square ' + (isVisible ? 'btn-primary' : 'btn-ghost'); - toggleBtn.title = isVisible ? 'Hide annotations' : 'Show annotations'; - toggleBtn.innerHTML = ``; - toggleBtn.addEventListener('click', async () => { - if (isVisible) { - this._overlay.setJobVisible(entry.jobId, false); - this._refreshModal(); - } else if (storeEntry) { - this._overlay.setJobVisible(entry.jobId, true); - this._refreshModal(); - } else { - toggleBtn.disabled = true; - toggleBtn.innerHTML = ''; - let errorShown = false; - try { - await this._onShow(entry); - } catch (e) { - console.error('[job-history] show failed', e); - this._showEntryError(card, e?.message || 'Failed to load annotations'); - errorShown = true; - } finally { - toggleBtn.disabled = false; - if (!errorShown) this._refreshModal(); - } - } - }); - actions.appendChild(toggleBtn); - - const moreSection = document.createElement('div'); - moreSection.className = 'hidden'; - - const moreActions = document.createElement('div'); - moreActions.className = 'flex gap-1 mt-1'; - moreSection.appendChild(moreActions); - - const resultsPanel = document.createElement('div'); - resultsPanel.className = 'mt-1 hidden'; - moreSection.appendChild(resultsPanel); - - const moreBtn = document.createElement('button'); - moreBtn.type = 'button'; - moreBtn.className = 'btn btn-xs btn-square btn-ghost'; - moreBtn.title = 'More actions'; - moreBtn.innerHTML = ''; - moreBtn.addEventListener('click', () => { - moreSection.classList.toggle('hidden'); - }); - actions.appendChild(moreBtn); - - const rerunBtn = document.createElement('button'); - rerunBtn.type = 'button'; - rerunBtn.className = 'btn btn-xs btn-ghost'; - rerunBtn.textContent = 'Rerun'; - rerunBtn.addEventListener('click', async () => { - rerunBtn.disabled = true; - rerunBtn.innerHTML = ''; - let errorShown = false; - try { - await this._onRerun(entry); - } catch (e) { - if (e?.message !== 'cancelled') { - console.error('[job-history] rerun failed', e); - this._showEntryError(card, e?.message || 'Rerun failed'); - errorShown = true; - } - } finally { - rerunBtn.disabled = false; - rerunBtn.textContent = 'Rerun'; - if (!errorShown) this._refreshModal(); + async _rerun() { + const { entry, history } = this; + this.busy.val = 'rerun'; + try { + await history._onRerun(entry); + history.refresh(); + } catch (e) { + if (e?.message !== 'cancelled') { + console.error('[job-history] rerun failed', e); + history.showEntryError(entry.jobId, e?.message || 'Rerun failed'); } - }); - moreActions.appendChild(rerunBtn); - - if (entry.status === 'COMPLETED') { - const resultsBtn = document.createElement('button'); - resultsBtn.type = 'button'; - resultsBtn.className = 'btn btn-xs btn-ghost'; - resultsBtn.textContent = 'Results'; - resultsBtn.addEventListener('click', async () => { - if (this._resultsCache.has(entry.jobId)) { - resultsPanel.classList.toggle('hidden'); - return; - } - resultsBtn.disabled = true; - resultsBtn.innerHTML = ''; - try { - const valueOutputs = await this._onFetchResults(entry); - this._resultsCache.set(entry.jobId, valueOutputs); - this._renderResultsPanel(resultsPanel, valueOutputs); - resultsPanel.classList.remove('hidden'); - } catch (e) { - console.error('[job-history] fetch results failed', e); - this._showEntryError(card, e?.message || 'Failed to fetch results'); - } finally { - resultsBtn.disabled = false; - resultsBtn.textContent = 'Results'; - } - }); - moreActions.appendChild(resultsBtn); + } finally { + this.busy.val = ''; } - - const deleteBtn = document.createElement('button'); - deleteBtn.type = 'button'; - deleteBtn.className = 'btn btn-xs btn-ghost text-error'; - deleteBtn.textContent = '×'; - deleteBtn.addEventListener('click', () => { - this._plugin.setOption('jobHistory', JSON.stringify(this.getHistory().filter(e => e.jobId !== entry.jobId))); - this._refreshModal(); - }); - moreActions.appendChild(deleteBtn); - - card.appendChild(actions); - card.appendChild(moreSection); - return card; - } - - _showEntryError(card, message) { - const existing = card.querySelector('.entry-error'); - if (existing) existing.remove(); - const err = document.createElement('div'); - err.className = 'entry-error text-xs text-error mt-1'; - err.textContent = message; - card.appendChild(err); - setTimeout(() => { err.remove(); this._refreshModal(); }, 4000); } - _renderResultsPanel(container, valueOutputs) { - container.innerHTML = ''; - if (!valueOutputs.length) { - const empty = document.createElement('div'); - empty.className = 'text-xs opacity-50 py-1'; - empty.textContent = 'No output values for this job.'; - container.appendChild(empty); + async _toggleResults() { + const { entry, history } = this; + if (history._resultsCache.has(entry.jobId)) { + this.resultsOpen.val = !this.resultsOpen.val; return; } - for (const { key, items } of valueOutputs) { - const section = document.createElement('div'); - section.className = 'mb-2'; - - const heading = document.createElement('div'); - heading.className = 'text-xs font-medium mb-1'; - heading.textContent = key; - section.appendChild(heading); - - const pre = document.createElement('pre'); - pre.className = 'text-xs font-mono opacity-80 whitespace-pre-wrap'; - pre.textContent = items.map((item, i) => `${i}: ${Number(item.value).toFixed(4)}`).join('\n'); - section.appendChild(pre); + this.busy.val = 'results'; + try { + history._resultsCache.set(entry.jobId, await history._onFetchResults(entry)); + this.resultsOpen.val = true; + } catch (e) { + console.error('[job-history] fetch results failed', e); + history.showEntryError(entry.jobId, e?.message || 'Failed to fetch results'); + } finally { + this.busy.val = ''; + } + } - container.appendChild(section); + _renderResults(valueOutputs) { + if (!valueOutputs.length) { + return div({ class: 'mt-1 text-xs opacity-50 py-1' }, 'No output values for this job.'); } + return div({ class: 'mt-1' }, valueOutputs.map(output => renderOutputValues(output, 'text-xs'))); } } +/** One output collection as a heading and an index: value list. */ +function renderOutputValues({ key, items }, headingSize = 'text-sm') { + return div({ class: 'mb-2' }, + div({ class: `${headingSize} font-medium mb-1` }, key), + pre({ class: 'text-xs font-mono opacity-80 whitespace-pre-wrap' }, + items.map((item, idx) => `${idx}: ${Number(item.value).toFixed(4)}`).join('\n')), + ); +} + window.JobHistory = JobHistory; +window.renderJobOutputValues = renderOutputValues; diff --git a/plugins/analyze-dev/jobResultsOverlay.mjs b/plugins/analyze-dev/jobResultsOverlay.mjs index cfe004c9..af538b3e 100644 --- a/plugins/analyze-dev/jobResultsOverlay.mjs +++ b/plugins/analyze-dev/jobResultsOverlay.mjs @@ -8,7 +8,6 @@ class JobResultsOverlay { async addJobResults(jobId, shapes, viewerId) { if (!shapes || !shapes.length) { - console.log('[job-overlay] no shapes to render for job', jobId); return; } @@ -66,7 +65,6 @@ class JobResultsOverlay { this._jobStore.set(jobId, { layerId, viewerId: String(viewerId) }); this._colorIndex++; - console.log('[job-overlay] added', valid.length, 'annotations for job', jobId); } catch (e) { console.error('[job-overlay] failed to add job results for job', jobId, e);