RodSizer_web / frontend /analysis.html
Caesium135's picture
Claude Opus 4.8
Sync analysis UI from RodSizer; refresh index docs (keep session + HF notice)
911d85d
Raw History Blame Contribute Delete
89.6 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Analysis - RodSizer</title>
<!-- Favicon -->
<link rel="icon" type="image/png" href="/static/assets/loading_screen.png">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 1800px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
/* --- INFO MODAL --- */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 30px;
border-radius: 12px;
max-width: 600px;
width: 90%;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
position: relative;
}
.modal-close {
position: absolute;
top: 15px;
right: 15px;
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: #888;
}
.modal-section {
margin-bottom: 20px;
}
.modal-section h3 {
margin-top: 0;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.modal-body {
font-size: 14px;
line-height: 1.6;
color: #333;
}
.modal-body ul {
margin: 10px 0;
padding-left: 20px;
}
.modal-body li {
margin-bottom: 8px;
}
.nav {
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav a {
text-decoration: none;
color: #007bff;
font-weight: bold;
}
.container {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 20px;
align-items: start;
height: calc(100vh - 120px);
}
.panel {
background: white;
padding: 15px;
border-radius: 10px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
height: 100%;
overflow: auto;
display: flex;
flex-direction: column;
}
.image-container {
width: 100%;
text-align: center;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.image-panel-header {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
.image-panel-header h2 {
margin: 0;
text-align: center;
}
.binary-mask-controls {
display: flex;
align-items: center;
gap: 8px;
justify-self: start;
min-height: 28px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
.binary-mask-controls.visible {
opacity: 1;
pointer-events: auto;
}
.binary-mask-controls label {
font-size: 11px;
color: #555;
font-weight: 600;
white-space: nowrap;
}
.binary-mask-controls input[type="range"] {
width: 84px;
}
.binary-mask-controls input[type="range"]:disabled {
opacity: 0.55;
cursor: wait;
}
.binary-mask-apply-btn {
border: 1px solid #007bff;
background: #007bff;
color: white;
border-radius: 999px;
padding: 3px 10px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.binary-mask-apply-btn:disabled {
opacity: 0.5;
cursor: default;
}
.binary-mask-value {
min-width: 18px;
text-align: center;
font-size: 11px;
color: #007bff;
font-weight: 700;
}
.binary-mask-status {
min-width: 56px;
font-size: 11px;
color: #666;
}
.image-container img {
max-width: 100%;
max-height: calc(100vh - 220px);
height: auto;
object-fit: contain;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 20px;
}
.stat-card {
background: #f8f9fa;
padding: 10px;
border-radius: 5px;
text-align: center;
}
.stat-value {
font-size: 18px;
font-weight: bold;
color: #007bff;
}
.stat-label {
color: #666;
font-size: 12px;
}
.charts-container {
margin-top: 20px;
}
.chart-wrapper {
height: 200px;
margin-bottom: 20px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 0;
/* Reset margin */
font-size: 13px;
}
th,
td {
padding: 8px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f8f9fa;
position: sticky;
top: 0;
z-index: 1;
}
th.sortable {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
th.sortable:hover {
background-color: #dbe7ff;
}
/* Always-visible sort affordance: a dim arrow on every sortable column,
highlighted (bold, accent colour) on the active sort column. */
th .sort-ind {
font-size: 11px;
margin-left: 3px;
color: #b9c2cc;
font-weight: 700;
}
th .sort-ind.active {
color: #1e5eff;
}
.table-hint {
font-size: 11px;
color: #6c757d;
margin: 0 0 6px 0;
line-height: 1.4;
}
/* Compact the selection table so all columns fit without sideways scroll. */
#dataTable th,
#dataTable td {
padding: 5px 6px;
font-size: 13px;
}
#dataTable th.actions-col,
#dataTable td.actions-col {
padding: 0 2px;
width: 22px;
text-align: center;
}
.row-menu-btn {
border: none;
background: transparent;
color: #adb5bd;
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 2px 4px;
border-radius: 4px;
}
#dataTable tbody tr:hover .row-menu-btn {
color: #1e5eff;
}
.row-menu-btn:hover {
background: #e9ecef;
color: #1e5eff;
}
.row-menu {
position: fixed;
z-index: 1000;
background: #fff;
border: 1px solid #cfd6dd;
border-radius: 8px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
padding: 4px;
min-width: 188px;
}
.row-menu-title {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #8a949e;
padding: 4px 8px 2px;
}
.row-menu button {
display: block;
width: 100%;
text-align: left;
border: none;
background: transparent;
padding: 7px 8px;
font-size: 12px;
border-radius: 5px;
cursor: pointer;
color: #212529;
}
.row-menu button:hover {
background: #eef3ff;
}
.btn {
background-color: #28a745;
color: white;
padding: 12px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
text-decoration: none;
display: inline-block;
margin-top: 10px;
width: 100%;
text-align: center;
box-sizing: border-box;
font-size: 16px;
font-weight: bold;
}
.btn:hover {
background-color: #218838;
}
.btn-primary {
background-color: #007bff;
}
.btn-primary:hover {
background-color: #0056b3;
}
.loading {
text-align: center;
padding: 50px;
font-size: 18px;
color: #666;
}
.hidden {
display: none;
}
.scrollable-table {
flex: 1;
overflow-y: auto;
margin-bottom: 20px;
border: 1px solid #eee;
margin-bottom: 20px;
border: 1px solid #eee;
}
.zoom-btn {
position: absolute;
top: 10px;
right: 10px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #ccc;
padding: 5px 10px;
cursor: pointer;
z-index: 10;
border-radius: 4px;
font-size: 14px;
display: flex;
align-items: center;
gap: 5px;
}
.image-toggle-btn {
position: absolute;
top: 10px;
right: 100px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #ccc;
padding: 5px 10px;
cursor: pointer;
z-index: 10;
border-radius: 4px;
font-size: 13px;
}
.zoom-btn.active {
background: #007bff;
color: white;
border-color: #0056b3;
}
.label-toggle-btn {
position: absolute;
top: 10px;
left: 10px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #ccc;
padding: 5px 10px;
cursor: pointer;
z-index: 10;
border-radius: 4px;
font-size: 13px;
}
.label-toggle-btn.active {
background: #007bff;
color: white;
border-color: #0056b3;
}
.mini-btn {
border: 1px solid #c0392b;
background: #fff;
color: #c0392b;
border-radius: 5px;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
}
.mini-btn:hover {
background: #c0392b;
color: #fff;
}
.image-wrapper {
position: relative;
overflow: hidden;
width: 100%;
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
}
#processedImage {
transition: transform 0.1s ease-out;
max-width: 100%;
max-height: calc(100vh - 220px);
height: auto;
object-fit: contain;
display: block;
}
#particleOverlayCanvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 15;
}
/* Scale bar calibration */
.calibration-panel {
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 5px;
padding: 8px;
margin-bottom: 10px;
font-size: 12px;
}
.calibration-panel h4 {
margin: 0 0 6px 0;
font-size: 13px;
color: #856404;
}
.calibration-panel .cal-option {
margin-bottom: 8px;
padding: 6px;
background: rgba(255,255,255,0.7);
border-radius: 4px;
}
.calibration-panel label {
font-weight: bold;
font-size: 12px;
}
.calibration-panel input[type="number"] {
width: 80px;
padding: 3px 6px;
border: 1px solid #ccc;
border-radius: 3px;
font-size: 12px;
}
.calibration-panel button {
padding: 4px 10px;
font-size: 11px;
border: 1px solid #007bff;
background: #007bff;
color: white;
border-radius: 3px;
cursor: pointer;
}
.calibration-panel button:hover {
background: #0056b3;
}
.calibration-panel button.cal-secondary {
background: #6c757d;
border-color: #6c757d;
}
.calibration-panel button.cal-active {
background: #28a745;
border-color: #28a745;
}
/* Canvas overlay for line drawing */
#scaleCanvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: crosshair;
z-index: 20;
}
</style>
</head>
<body>
<div class="nav">
<a href="#" id="backLink" onclick="goBack()">← Back to Folder</a>
<h2 id="pageTitle" style="margin: 0; position: absolute; left: 50%; transform: translateX(-50%);">Analysis</h2>
<button onclick="openInstructions()"
style="background: none; border: 1px solid #007bff; color: #007bff; padding: 5px 15px; border-radius: 4px; cursor: pointer;">Instructions</button>
</div>
<!-- Instructions Modal -->
<div id="instrModal" class="modal-overlay" onclick="closeInstructions(event)">
<div class="modal-content" onclick="event.stopPropagation()">
<button class="modal-close" onclick="closeInstructions(event)">×</button>
<div class="modal-section">
<h3>Analysis Instructions</h3>
<div class="modal-body">
<p><strong>1. Review the image:</strong> the centre panel shows detected particles as boxes.
Toggle <em>Show Binary Mask</em> to inspect segmentation, <em>Zoom</em> to look closer, and
<em>Show IDs</em> to label particles (hidden by default so they don't cover small rods).</p>
<p><strong>2. Check the scale:</strong> the calibration source is shown at the top right. If it is
wrong or uncalibrated, click <em>Re-scale</em> and draw a line along the scale bar, or use the
<em>Mask</em> slider to fine-tune how aggressively touching particles are separated.</p>
<p><strong>3. Curate the selection</strong> (right panel <em>Ledger</em>) — exclude clumps, dirt,
or anything that isn't a clean rod:</p>
<ul>
<li><strong>Click a particle on the image</strong> to deselect/reselect it (it flashes).</li>
<li><strong>Sort</strong> any column (e.g. Area ↓) to bring big clumps to the top.</li>
<li><strong>⋮ on a row</strong> bulk-selects/deselects everything above or below it.</li>
<li><strong>Deselect outliers</strong> auto-removes statistical outliers (Tukey 1.5×IQR).</li>
</ul>
<p><strong>4. Update:</strong> press <strong>"Update Particles"</strong> to generate statistics and
histograms from the kept particles.</p>
<p><strong>5. Save &amp; export:</strong></p>
<ul>
<li><strong>Download Excel / PDF:</strong> raw data and a report for this image.</li>
<li><strong>Add to Folder:</strong> confirms these results for the aggregate folder report.</li>
</ul>
</div>
</div>
</div>
</div>
<div id="loading" class="loading">
<div class="spinner"></div>
<p>Processing Image...</p>
</div>
<div id="content" class="container" style="display: none;">
<!-- Column 1: Images (Grouped) -->
<div class="panel">
<div class="image-panel-header">
<div id="binaryMaskControls" class="binary-mask-controls">
<label for="binaryMaskSlider">Mask</label>
<input type="range" id="binaryMaskSlider" min="-6" max="6" step="1" value="0">
<span id="binaryMaskValue" class="binary-mask-value">0</span>
<button id="binaryMaskApplyBtn" class="binary-mask-apply-btn" disabled>Apply</button>
<span id="binaryMaskStatus" class="binary-mask-status"></span>
</div>
<h2 id="imagePanelTitle">Detected Particles</h2>
<div></div>
</div>
<p id="imagePanelHint" style="text-align: center; margin: -4px 0 12px 0; font-size: 13px; color: #666;">
Click on particles to deselect
</p>
<div class="image-container" style="position: relative; flex: 1; overflow: auto;">
<div class="image-wrapper" id="zoomWrapper">
<button class="label-toggle-btn active" onclick="toggleLabels()" id="labelToggleBtn">Show IDs</button>
<button class="image-toggle-btn" onclick="toggleImageView()" id="imageToggleBtn">Show Binary Mask</button>
<button class="zoom-btn" onclick="toggleZoom()" id="zoomToggle">
<span style="font-size: 16px;">🔍</span> Zoom
</button>
<canvas id="particleOverlayCanvas"></canvas>
<canvas id="scaleCanvas" style="display: none;"></canvas>
<img id="processedImage" src="" alt="Detected Particles">
</div>
</div>
</div>
<!-- Column 2: Analysis Selection -->
<div class="panel">
<h2>Selection</h2>
<div
style="background: #e9ecef; padding: 10px; border-radius: 5px; margin-bottom: 10px; text-align: center; font-size: 12px;">
<strong>Calibration:</strong> <span id="calibrationMethod">-</span> <br>
<strong>Pixel Size:</strong> <span id="pixelSize">-</span><span id="pixelSizeUnit"> nm/px</span>
</div>
<div style="margin-bottom: 10px; text-align: center;">
<button id="toggleCalibrationBtn" class="cal-secondary" onclick="toggleCalibrationPanel()"
style="padding: 6px 14px; font-size: 12px;">Re-scale</button>
</div>
<!-- Scale Bar Calibration Panel -->
<div id="calibrationPanel" class="calibration-panel" style="display: none;">
<h4 id="calibrationPanelTitle">Scale Calibration</h4>
<p id="calibrationPanelNote" style="margin: 0 0 8px 0; color: #6b5a00;">You can recalibrate this image at any time.</p>
<div class="cal-option">
<label>Manual</label>
<p style="margin: 4px 0; color: #555;">Draw a line along the scale bar in the image, then enter its length.</p>
<button id="drawLineBtn" onclick="startDrawLine()">Draw Line on Image</button>
<span id="lineStatus" style="margin-left: 6px; color: #888;"></span>
<div style="margin-top: 6px;">
<input type="number" id="scaleNmInput" placeholder="nm" min="1" step="1">
<span style="font-size: 11px;">nm</span>
<button onclick="applyManualScale()" style="margin-left: 4px;">Set Scale</button>
</div>
</div>
</div>
<p class="table-hint">Click a column header to sort ↑/↓. Use <strong>⋮</strong> on a row for bulk select/deselect above &amp; below.</p>
<div style="margin: 0 0 8px 0; display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
<button type="button" id="deselectOutliersBtn" class="mini-btn" onclick="deselectOutliers()"
title="Deselect particles whose length, width or aspect ratio are statistical outliers (Tukey 1.5×IQR) among the currently selected set">Deselect outliers</button>
<span id="outlierStatus" style="font-size: 11px; color: #6c757d;"></span>
</div>
<div class="scrollable-table">
<table id="dataTable">
<thead>
<tr>
<th><input type="checkbox" id="selectAll" checked onclick="toggleAll(this)" title="Select / deselect all"></th>
<th class="sortable" onclick="sortBy('id')"><span>ID</span><span class="sort-ind" id="ind-id"></span></th>
<th class="sortable" onclick="sortBy('length')"><span id="lengthHeaderLabel">L (nm)</span><span class="sort-ind" id="ind-length"></span></th>
<th class="sortable" onclick="sortBy('width')"><span id="widthHeaderLabel">W (nm)</span><span class="sort-ind" id="ind-width"></span></th>
<th class="sortable" onclick="sortBy('ar')"><span>AR</span><span class="sort-ind" id="ind-ar"></span></th>
<th class="sortable" onclick="sortBy('area')"><span id="areaHeaderLabel">Area (nm²)</span><span class="sort-ind" id="ind-area"></span></th>
<th class="actions-col"></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<!-- Floating per-row bulk-selection menu (positioned in JS) -->
<div id="rowMenu" class="row-menu hidden">
<div class="row-menu-title">Bulk from this row</div>
<button type="button" data-act="sel-above">☑ Select this &amp; above</button>
<button type="button" data-act="desel-above">☐ Deselect this &amp; above</button>
<button type="button" data-act="sel-below">☑ Select this &amp; below</button>
<button type="button" data-act="desel-below">☐ Deselect this &amp; below</button>
</div>
<button id="analyzeBtn" class="btn btn-primary" onclick="calculateAnalysis()">Update Particles ↓</button>
</div>
</div>
<!-- Bottom Section: Analysis Results -->
<div id="analysisResults" class="panel hidden" style="margin-top: 20px;">
<div
style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #eee; padding-bottom: 10px; margin-bottom: 20px;">
<h2 style="margin: 0;">Analysis Statistics</h2>
<button class="btn" style="width: auto; padding: 8px 16px; margin: 0; background-color: #6c757d;"
onclick="scrollToSelection()">↑ Edit Selection</button>
</div>
<div class="stats-grid" style="grid-template-columns: repeat(4, 1fr);"> <!-- 4 Columns for stats now -->
<div class="stat-card">
<div class="stat-value" id="count">-</div>
<div class="stat-label">Count</div>
</div>
<div class="stat-card">
<div class="stat-value" id="meanLength">-</div>
<div class="stat-label" id="meanLengthLabel">Mean L (nm)</div>
</div>
<div class="stat-card">
<div class="stat-value" id="meanWidth">-</div>
<div class="stat-label" id="meanWidthLabel">Mean W (nm)</div>
</div>
<div class="stat-card">
<div class="stat-value" id="meanAR">-</div>
<div class="stat-label">Mean AR</div>
</div>
</div>
<div class="charts-container" style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<div class="chart-wrapper">
<canvas id="lengthChart"></canvas>
</div>
<div class="chart-wrapper">
<canvas id="widthChart"></canvas>
</div>
</div>
<div style="text-align: center; margin-top: 20px; display: flex; gap: 10px; justify-content: center;">
<a id="downloadExcel" href="#" class="btn" style="width: auto; padding: 12px 40px;">Download Excel
Report</a>
<button onclick="downloadPDF()" class="btn"
style="width: auto; padding: 12px 40px; background-color: #6c757d;">Download PDF Report</button>
<button id="addToFolderBtn" onclick="addToFolderAnalysis()" class="btn"
style="width: auto; padding: 12px 40px; background-color: #28a745; display: none;">Add to Folder
Analysis</button>
</div>
</div>
<script>
// Session isolation (public web build). A random per-browser id scopes
// all storage on the server; auto-appended to every relative API call.
const SESSION_ID = localStorage.getItem('rodsizer_session_id') || (() => {
const id = crypto.randomUUID();
localStorage.setItem('rodsizer_session_id', id);
return id;
})();
const _nativeFetch = window.fetch.bind(window);
window.fetch = (url, opts) => {
if (typeof url === 'string' && url.startsWith('/') && !url.startsWith('//')) {
url += (url.includes('?') ? '&' : '?') + 'session_id=' + SESSION_ID;
}
return _nativeFetch(url, opts);
};
const urlParams = new URLSearchParams(window.location.search);
const imageId = urlParams.get('id');
const folderParam = urlParams.get('folder');
if (folderParam) {
const backLink = document.getElementById('backLink');
backLink.href = `/?folder=${encodeURIComponent(folderParam)}`;
backLink.innerText = `← Back to ${folderParam}`;
// Show Add to Folder button
document.getElementById('addToFolderBtn').style.display = 'inline-block';
}
if (!imageId) {
window.location.href = '/';
}
let lengthChartInstance = null;
let widthChartInstance = null;
let currentData = [];
let currentFilename = "";
let currentIsPlaceholderCalibration = false;
let currentCalibrationInfo = null;
let calibrationPanelOpen = false;
let currentPixelSize = 1;
let currentImageMode = 'processed';
let currentImageUrls = { processed: '', binary: '' };
let currentBinaryMaskTune = 0;
let appliedBinaryMaskTune = 0;
let currentBinaryPreviewUrl = null;
let isBinaryPreviewUpdating = false;
let isApplyingBinaryMask = false;
let pendingBinaryPreviewTune = null;
async function processImage(options = {}) {
try {
const {
manualPixelSize = null,
requestedBarLengthNm = null,
forceReprocess = false,
binaryMaskTune = appliedBinaryMaskTune
} = options;
const resolvedManualPixelSize = manualPixelSize !== null && manualPixelSize !== undefined
? manualPixelSize
: getActiveManualPixelSize();
let url = `/process/${imageId}`;
const params = new URLSearchParams();
params.set('binary_mask_tune', String(binaryMaskTune));
if (resolvedManualPixelSize !== null && resolvedManualPixelSize !== undefined) {
params.set('manual_pixel_size', String(resolvedManualPixelSize));
}
if (requestedBarLengthNm !== null && requestedBarLengthNm !== undefined) {
params.set('requested_bar_length_nm', String(requestedBarLengthNm));
}
if (forceReprocess) {
params.set('force_reprocess', 'true');
}
url += `?${params.toString()}`;
// document.getElementById('loading').style.display = 'block';
// Don't show full screen blocking loading for instant fetch
const response = await fetch(url, { method: 'POST' });
const data = await response.json();
if (response.ok) {
displayResults(data);
} else {
throw new Error(data.detail || 'Server returned error');
}
} catch (error) {
console.error('Error:', error);
alert('Error processing image: ' + error.message);
throw error;
}
}
function displayResults(data) {
document.getElementById('loading').style.display = 'none';
document.getElementById('content').style.display = 'grid';
const keepCurrentImageMode = currentImageMode;
currentData = data.data || [];
currentFilename = data.filename || imageId;
currentIsPlaceholderCalibration = Boolean(data.calibration_info && data.calibration_info.is_placeholder);
currentCalibrationInfo = data.calibration_info || null;
currentPixelSize = data.pixel_size_nm || 1;
currentBinaryMaskTune = Number.isFinite(data.binary_mask_tune) ? Number(data.binary_mask_tune) : currentBinaryMaskTune;
appliedBinaryMaskTune = currentBinaryMaskTune;
currentBinaryPreviewUrl = null;
currentImageUrls = {
processed: data.image_url,
binary: data.binary_url
};
updateBinaryMaskControlsUI();
// Images
// Add timestamp to prevent caching
currentImageMode = keepCurrentImageMode || 'processed';
updateDisplayedImage();
// Download Link
// Download Link
const downloadBtn = document.getElementById('downloadExcel');
// Remove old listeners
downloadBtn.onclick = null;
downloadBtn.removeAttribute('href');
downloadBtn.onclick = downloadFilteredReport;
downloadBtn.style.cursor = 'pointer';
// Calibration
// Calibration
if (currentIsPlaceholderCalibration) {
document.getElementById('pixelSize').textContent = 'N/A';
document.getElementById('pixelSizeUnit').textContent = '';
} else if (data.pixel_size_nm) {
document.getElementById('pixelSize').textContent = data.pixel_size_nm.toFixed(4);
document.getElementById('pixelSizeUnit').textContent = ' nm/px';
} else {
document.getElementById('pixelSize').textContent = '-';
document.getElementById('pixelSizeUnit').textContent = ' nm/px';
}
// Update calibration info
let calibText = data.calibration_info ? data.calibration_info.method : 'Unknown';
if (data.calibration_info && data.calibration_info.description) {
calibText = data.calibration_info.description.replace(/^Calibration:\s*/, '');
} else if (data.calibration_info && data.calibration_info.source_file) {
calibText = `Linked: ${data.calibration_info.source_file}`;
}
document.getElementById('calibrationMethod').textContent = calibText;
updateMeasurementUnits();
// Show calibration panel if needed
showCalibrationPanel(data.calibration_info);
// Table Population — selection is restored from cache inside the
// renderer, the chosen sort order is applied, and particle IDs are
// left untouched (sorting never renumbers rows).
renderSelectionTable();
renderParticleOverlay();
// Automatically Calculate to show graphs immediately
calculateAnalysis(true);
}
function updateDisplayedImage() {
const image = document.getElementById('processedImage');
const timestamp = new Date().getTime();
const isBinary = currentImageMode === 'binary';
const nextBinaryUrl = currentBinaryPreviewUrl || currentImageUrls.binary;
const nextUrl = isBinary ? nextBinaryUrl : currentImageUrls.processed;
const maskControls = document.getElementById('binaryMaskControls');
document.getElementById('imagePanelTitle').textContent = isBinary ? 'Binary Mask' : 'Detected Particles';
document.getElementById('imagePanelHint').textContent = isBinary
? 'Use the small mask slider to separate or reconnect lightly fused regions'
: 'Click on particles to deselect';
document.getElementById('imageToggleBtn').textContent = isBinary ? 'Show Processed Image' : 'Show Binary Mask';
image.alt = isBinary ? 'Binary Mask' : 'Detected Particles';
maskControls.classList.toggle('visible', isBinary);
image.onload = () => {
syncParticleOverlayToImage();
renderParticleOverlay();
};
image.src = `${nextUrl}?t=${timestamp}&session_id=${SESSION_ID}`;
}
function toggleImageView() {
currentImageMode = currentImageMode === 'processed' ? 'binary' : 'processed';
updateDisplayedImage();
}
function getActiveManualPixelSize() {
if (currentCalibrationInfo && currentCalibrationInfo.method === 'manual' && currentPixelSize > 0) {
return currentPixelSize;
}
return null;
}
function updateBinaryMaskControlsUI() {
const slider = document.getElementById('binaryMaskSlider');
const value = document.getElementById('binaryMaskValue');
const applyBtn = document.getElementById('binaryMaskApplyBtn');
slider.value = String(currentBinaryMaskTune);
value.textContent = currentBinaryMaskTune > 0 ? `+${currentBinaryMaskTune}` : String(currentBinaryMaskTune);
applyBtn.disabled = isApplyingBinaryMask || isBinaryPreviewUpdating || currentBinaryMaskTune === appliedBinaryMaskTune;
}
function handleBinaryMaskSliderInput() {
const slider = document.getElementById('binaryMaskSlider');
const nextTune = parseInt(slider.value, 10) || 0;
currentBinaryMaskTune = nextTune;
updateBinaryMaskControlsUI();
if (currentBinaryMaskTune === appliedBinaryMaskTune) {
currentBinaryPreviewUrl = null;
document.getElementById('binaryMaskStatus').textContent = '';
if (currentImageMode === 'binary') {
updateDisplayedImage();
}
}
}
function handleBinaryMaskSliderChange() {
if (currentBinaryMaskTune === appliedBinaryMaskTune) {
return;
}
previewBinaryMask(currentBinaryMaskTune);
}
async function previewBinaryMask(tuneToPreview) {
if (isBinaryPreviewUpdating || isApplyingBinaryMask) {
pendingBinaryPreviewTune = tuneToPreview;
return;
}
const slider = document.getElementById('binaryMaskSlider');
const status = document.getElementById('binaryMaskStatus');
isBinaryPreviewUpdating = true;
slider.disabled = true;
status.textContent = 'Previewing...';
updateBinaryMaskControlsUI();
try {
const params = new URLSearchParams();
params.set('binary_mask_tune', String(tuneToPreview));
const activeManualPixelSize = getActiveManualPixelSize();
if (activeManualPixelSize !== null && activeManualPixelSize !== undefined) {
params.set('manual_pixel_size', String(activeManualPixelSize));
}
const response = await fetch(`/process/${imageId}/binary_preview?${params.toString()}`, { method: 'POST' });
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Failed to preview binary mask');
}
currentBinaryPreviewUrl = data.binary_preview_url;
currentImageMode = 'binary';
updateDisplayedImage();
status.textContent = 'Preview ready';
} catch (error) {
console.error('Binary preview error:', error);
status.textContent = '';
alert('Error previewing binary mask: ' + error.message);
} finally {
isBinaryPreviewUpdating = false;
slider.disabled = false;
updateBinaryMaskControlsUI();
const nextTune = pendingBinaryPreviewTune;
pendingBinaryPreviewTune = null;
if (nextTune !== null && nextTune !== tuneToPreview) {
previewBinaryMask(nextTune);
}
}
}
async function applyBinaryMaskTune() {
if (isApplyingBinaryMask || currentBinaryMaskTune === appliedBinaryMaskTune) {
return;
}
const slider = document.getElementById('binaryMaskSlider');
const applyBtn = document.getElementById('binaryMaskApplyBtn');
const status = document.getElementById('binaryMaskStatus');
isApplyingBinaryMask = true;
slider.disabled = true;
applyBtn.disabled = true;
status.textContent = 'Applying...';
try {
await processImage({ binaryMaskTune: currentBinaryMaskTune, forceReprocess: true });
currentImageMode = 'binary';
updateDisplayedImage();
status.textContent = '';
} catch (_) {
status.textContent = '';
} finally {
isApplyingBinaryMask = false;
slider.disabled = false;
updateBinaryMaskControlsUI();
}
}
function updateMeasurementUnits() {
const unit = currentIsPlaceholderCalibration ? 'px' : 'nm';
document.getElementById('lengthHeaderLabel').textContent = `L (${unit})`;
document.getElementById('widthHeaderLabel').textContent = `W (${unit})`;
const areaLabel = document.getElementById('areaHeaderLabel');
if (areaLabel) areaLabel.textContent = currentIsPlaceholderCalibration ? 'Area (px²)' : 'Area (nm²)';
document.getElementById('meanLengthLabel').textContent = `Mean L (${unit})`;
document.getElementById('meanWidthLabel').textContent = `Mean W (${unit})`;
}
function toggleCalibrationPanel() {
calibrationPanelOpen = !calibrationPanelOpen;
showCalibrationPanel(currentCalibrationInfo);
}
// --- Selection table: sorting & rendering ------------------------------
// currentSort.key === null keeps the original (backend) order. Sorting
// only reorders the visible rows; each checkbox keeps data-index pointing
// at its currentData entry, so IDs and all index-based logic are intact.
let currentSort = { key: null, dir: 'desc' };
function rodAreaValue(rod) {
const px2 = Number(rod.area_px) || 0;
if (currentPixelSize > 0 && !currentIsPlaceholderCalibration) {
return px2 * currentPixelSize * currentPixelSize; // nm^2
}
return px2; // px^2 (uncalibrated)
}
function formatArea(v) {
// Keep the column narrow: exact for normal particles, compact (k/M)
// for large clumps whose nm² runs into the millions.
if (!isFinite(v)) return '-';
if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M';
if (v >= 1e4) return (v / 1e3).toFixed(0) + 'k';
return Math.round(v).toLocaleString();
}
function captureExcludedIds() {
// Prefer the live checkbox state (so re-sorting preserves the current
// selection); fall back to the cached set on first render.
const checkboxes = document.querySelectorAll('.rod-checkbox');
if (checkboxes.length) {
const excluded = [];
checkboxes.forEach(cb => {
const idx = parseInt(cb.dataset.index, 10);
if (!cb.checked && currentData[idx]) excluded.push(currentData[idx].id);
});
return excluded;
}
return JSON.parse(localStorage.getItem('excluded_' + imageId) || '[]');
}
function sortKeyValue(rod) {
switch (currentSort.key) {
case 'area': return Number(rod.area_px) || 0;
case 'length': return Number(rod.length_nm) || 0;
case 'width': return Number(rod.width_nm) || 0;
case 'ar': return Number(rod.aspect_ratio) || 0;
case 'id': return Number(rod.id) || 0;
default: return 0;
}
}
function renderSelectionTable() {
const tbody = document.querySelector('#dataTable tbody');
if (!tbody) return;
const excludedSet = new Set(captureExcludedIds());
// Pair each rod with its ORIGINAL index, then sort the pairs only.
const order = currentData.map((rod, index) => ({ rod, index }));
if (currentSort.key) {
const dir = currentSort.dir === 'asc' ? 1 : -1;
order.sort((a, b) => {
const va = sortKeyValue(a.rod);
const vb = sortKeyValue(b.rod);
if (va < vb) return -dir;
if (va > vb) return dir;
return a.index - b.index; // stable: fall back to original order
});
}
tbody.innerHTML = '';
order.forEach(({ rod, index }) => {
const tr = document.createElement('tr');
const cbTd = document.createElement('td');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'rod-checkbox';
cb.dataset.index = String(index); // original index — never reassigned
cb.checked = !excludedSet.has(rod.id);
cb.addEventListener('change', () => { syncSelectAllState(); triggerParticleFlash(index); });
cbTd.appendChild(cb);
tr.appendChild(cbTd);
[
String(rod.id ?? ''),
Number(rod.length_nm).toFixed(1),
Number(rod.width_nm).toFixed(1),
Number(rod.aspect_ratio).toFixed(1),
formatArea(rodAreaValue(rod)),
].forEach(val => {
const td = document.createElement('td');
td.textContent = val;
tr.appendChild(td);
});
const actTd = document.createElement('td');
actTd.className = 'actions-col';
const menuBtn = document.createElement('button');
menuBtn.type = 'button';
menuBtn.className = 'row-menu-btn';
menuBtn.textContent = '⋮';
menuBtn.title = 'Bulk select/deselect from this row';
menuBtn.dataset.index = String(index);
menuBtn.addEventListener('click', (e) => openRowMenu(e, index));
actTd.appendChild(menuBtn);
tr.appendChild(actTd);
tbody.appendChild(tr);
});
updateSortIndicators();
syncSelectAllState();
}
function sortBy(key) {
if (currentSort.key === key) {
currentSort.dir = currentSort.dir === 'desc' ? 'asc' : 'desc';
} else {
currentSort.key = key;
currentSort.dir = (key === 'id') ? 'asc' : 'desc';
}
renderSelectionTable();
}
function updateSortIndicators() {
['id', 'length', 'width', 'ar', 'area'].forEach(k => {
const el = document.getElementById('ind-' + k);
if (!el) return;
if (currentSort.key === k) {
el.textContent = currentSort.dir === 'asc' ? '▲' : '▼';
el.classList.add('active');
} else {
el.textContent = '↕'; // dim hint that the column is sortable
el.classList.remove('active');
}
});
}
function syncSelectAllState() {
const selectAll = document.getElementById('selectAll');
const all = document.querySelectorAll('.rod-checkbox');
if (!selectAll || !all.length) return;
selectAll.checked = Array.from(all).every(cb => cb.checked);
}
// --- Bulk selection (above / below a row, in current display order) ----
// "above"/"below" follow the visible (sorted) order and include the anchor
// row itself, so e.g. sort by Area ↓ then "Deselect this & above" clears a
// whole block of clumps in one click. IDs are never touched.
function applyBulkSelection(anchorIndex, direction, checked) {
const rows = Array.from(document.querySelectorAll('#dataTable tbody tr'));
const anchorPos = rows.findIndex(tr => {
const cb = tr.querySelector('.rod-checkbox');
return cb && cb.dataset.index === String(anchorIndex);
});
if (anchorPos < 0) return;
rows.forEach((tr, pos) => {
const inRange = direction === 'above' ? pos <= anchorPos : pos >= anchorPos;
if (!inRange) return;
const cb = tr.querySelector('.rod-checkbox');
if (cb) cb.checked = checked;
});
renderParticleOverlay();
syncSelectAllState();
}
let rowMenuAnchorIndex = null;
function openRowMenu(event, index) {
event.stopPropagation();
const menu = document.getElementById('rowMenu');
if (!menu) return;
rowMenuAnchorIndex = index;
menu.classList.remove('hidden');
const btnRect = event.currentTarget.getBoundingClientRect();
const mw = menu.offsetWidth || 190;
const mh = menu.offsetHeight || 140;
let left = btnRect.right - mw;
let top = btnRect.bottom + 4;
if (top + mh > window.innerHeight - 8) top = btnRect.top - mh - 4; // flip up
menu.style.left = `${Math.max(8, left)}px`;
menu.style.top = `${Math.max(8, top)}px`;
}
function closeRowMenu() {
const menu = document.getElementById('rowMenu');
if (menu) menu.classList.add('hidden');
rowMenuAnchorIndex = null;
}
(function initRowMenu() {
const menu = document.getElementById('rowMenu');
if (!menu) return;
menu.querySelectorAll('button[data-act]').forEach(btn => {
btn.addEventListener('click', () => {
if (rowMenuAnchorIndex === null) return;
const act = btn.dataset.act; // e.g. "desel-above"
const checked = act.startsWith('sel-');
const direction = act.endsWith('above') ? 'above' : 'below';
applyBulkSelection(rowMenuAnchorIndex, direction, checked);
closeRowMenu();
});
});
document.addEventListener('click', (e) => {
if (!menu.classList.contains('hidden') &&
!menu.contains(e.target) &&
!e.target.classList.contains('row-menu-btn')) {
closeRowMenu();
}
});
window.addEventListener('resize', closeRowMenu);
const scrollBox = document.querySelector('.scrollable-table');
if (scrollBox) scrollBox.addEventListener('scroll', closeRowMenu);
})();
function toggleAll(source) {
const checkboxes = document.querySelectorAll('.rod-checkbox');
for (let i = 0; i < checkboxes.length; i++) {
checkboxes[i].checked = source.checked;
}
renderParticleOverlay();
syncSelectAllState();
}
// --- Auto-deselect statistical outliers (Tukey 1.5 x IQR) ------------
function _quantile(sorted, q) {
if (sorted.length === 0) return NaN;
const pos = (sorted.length - 1) * q;
const base = Math.floor(pos);
const rest = pos - base;
return sorted[base + 1] !== undefined
? sorted[base] + rest * (sorted[base + 1] - sorted[base])
: sorted[base];
}
function setOutlierStatus(msg) {
const el = document.getElementById('outlierStatus');
if (el) el.textContent = msg;
}
function deselectOutliers() {
// Work on the currently-selected particles so it refines an existing
// selection (and respects prior manual exclusions).
const selected = Array.from(document.querySelectorAll('.rod-checkbox'))
.filter(cb => cb.checked)
.map(cb => ({ cb, rod: currentData[parseInt(cb.dataset.index, 10)] }))
.filter(x => x.rod);
if (selected.length < 8) {
setOutlierStatus('Need ≥ 8 selected particles to detect outliers.');
return;
}
// Tukey fences per metric. A particle is an outlier if length, width
// OR aspect ratio falls outside its fence.
const metrics = ['length_nm', 'width_nm', 'aspect_ratio'];
const fences = {};
metrics.forEach(key => {
const vals = selected.map(x => Number(x.rod[key])).filter(Number.isFinite).sort((a, b) => a - b);
const q1 = _quantile(vals, 0.25), q3 = _quantile(vals, 0.75), iqr = q3 - q1;
fences[key] = [q1 - 1.5 * iqr, q3 + 1.5 * iqr];
});
let removed = 0;
selected.forEach(({ cb, rod }) => {
const isOutlier = metrics.some(key => {
const v = Number(rod[key]);
const [lo, hi] = fences[key];
return Number.isFinite(v) && (v < lo || v > hi);
});
if (isOutlier) { cb.checked = false; removed++; }
});
renderParticleOverlay();
syncSelectAllState();
setOutlierStatus(removed
? `Deselected ${removed} outlier${removed > 1 ? 's' : ''} (L/W/AR, 1.5×IQR).`
: 'No statistical outliers found.');
}
function scrollToSelection() {
document.getElementById('content').scrollIntoView({ behavior: 'smooth' });
}
function calculateAnalysis(isAuto = false) {
const checkboxes = document.querySelectorAll('.rod-checkbox');
const includedData = [];
const excludedIds = [];
checkboxes.forEach(cb => {
const index = parseInt(cb.getAttribute('data-index'));
if (cb.checked) {
includedData.push(currentData[index]);
} else {
if (currentData[index]) {
excludedIds.push(currentData[index].id);
}
}
});
// SCA-CACHING: Save excluded IDs to LocalStorage
localStorage.setItem('excluded_' + imageId, JSON.stringify(excludedIds));
// Show Results Section
document.getElementById('analysisResults').classList.remove('hidden');
// Calculate stats
const count = includedData.length;
if (count > 0) {
const lengths = includedData.map(d => d.length_nm);
const widths = includedData.map(d => d.width_nm);
const ars = includedData.map(d => d.aspect_ratio);
const meanLength = lengths.reduce((a, b) => a + b, 0) / count;
const meanWidth = widths.reduce((a, b) => a + b, 0) / count;
const meanAR = ars.reduce((a, b) => a + b, 0) / count;
// Std Dev
const stdLength = Math.sqrt(lengths.map(x => Math.pow(x - meanLength, 2)).reduce((a, b) => a + b, 0) / count);
const stdWidth = Math.sqrt(widths.map(x => Math.pow(x - meanWidth, 2)).reduce((a, b) => a + b, 0) / count);
const stdAR = Math.sqrt(ars.map(x => Math.pow(x - meanAR, 2)).reduce((a, b) => a + b, 0) / count);
document.getElementById('count').textContent = count;
document.getElementById('meanLength').textContent = meanLength.toFixed(1) + ' ± ' + stdLength.toFixed(1);
document.getElementById('meanWidth').textContent = meanWidth.toFixed(1) + ' ± ' + stdWidth.toFixed(1);
document.getElementById('meanAR').textContent = meanAR.toFixed(1) + ' ± ' + stdAR.toFixed(1);
// Update Charts
const unit = currentIsPlaceholderCalibration ? 'px' : 'nm';
lengthChartInstance = createHistogram('lengthChart', `Length Distribution (${unit})`, lengths, lengthChartInstance);
widthChartInstance = createHistogram('widthChart', `Width Distribution (${unit})`, widths, widthChartInstance);
} else {
document.getElementById('count').textContent = "0";
document.getElementById('meanLength').textContent = "-";
document.getElementById('meanWidth').textContent = "-";
document.getElementById('meanAR').textContent = "-";
if (lengthChartInstance) lengthChartInstance.destroy();
if (widthChartInstance) widthChartInstance.destroy();
}
// Scroll to bottom only if manual click
if (!isAuto) {
document.getElementById('analysisResults').scrollIntoView({ behavior: 'smooth' });
}
}
function createHistogram(canvasId, label, data, existingChart) {
if (existingChart) {
existingChart.destroy();
}
const ctx = document.getElementById(canvasId).getContext('2d');
if (!data || data.length === 0) return null;
// Denser binning - use Sturges' rule or at least 20 bins
const binCount = Math.max(20, Math.ceil(1 + 3.322 * Math.log10(data.length)));
const min = Math.min(...data);
const max = Math.max(...data);
let step = (max - min) / binCount;
if (step === 0) step = 1;
const bins = new Array(binCount).fill(0);
const binCenters = [];
for (let i = 0; i < binCount; i++) {
binCenters.push((min + (i + 0.5) * step).toFixed(1));
}
data.forEach(val => {
const binIndex = Math.min(Math.floor((val - min) / step), binCount - 1);
bins[binIndex]++;
});
// Compute Gaussian fit
const mean = data.reduce((a, b) => a + b, 0) / data.length;
const variance = data.reduce((a, b) => a + (b - mean) ** 2, 0) / data.length;
const stdDev = Math.sqrt(variance);
const amplitude = Math.max(...bins);
const gaussianY = binCenters.map(x => {
const xVal = parseFloat(x);
if (stdDev === 0) return 0;
return amplitude * Math.exp(-0.5 * ((xVal - mean) / stdDev) ** 2);
});
return new Chart(ctx, {
type: 'bar',
data: {
labels: binCenters,
datasets: [{
label: label,
data: bins,
backgroundColor: 'rgba(54, 162, 235, 0.5)',
borderColor: 'rgba(54, 162, 235, 1)',
borderWidth: 1,
barPercentage: 1.0,
categoryPercentage: 1.0,
order: 2
}, {
label: 'Gaussian Fit',
data: gaussianY,
type: 'line',
borderColor: 'rgba(255, 99, 132, 1)',
borderWidth: 2,
pointRadius: 0,
fill: false,
tension: 0.4,
order: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
ticks: {
maxTicksLimit: 10,
maxRotation: 45
}
},
y: { beginAtZero: true }
},
plugins: {
legend: {
labels: { font: { size: 11 } }
}
}
}
});
}
// --- Zoom Logic ---
let isZoomActive = false;
const processedImage = document.getElementById('processedImage');
const zoomWrapper = document.getElementById('zoomWrapper');
const zoomToggle = document.getElementById('zoomToggle');
const particleOverlayCanvas = document.getElementById('particleOverlayCanvas');
const particleOverlayCtx = particleOverlayCanvas.getContext('2d');
function syncParticleOverlayToImage() {
const img = document.getElementById('processedImage');
if (!img || !img.naturalWidth || !img.naturalHeight) return false;
particleOverlayCanvas.width = img.naturalWidth;
particleOverlayCanvas.height = img.naturalHeight;
particleOverlayCanvas.style.width = `${img.clientWidth}px`;
particleOverlayCanvas.style.height = `${img.clientHeight}px`;
particleOverlayCanvas.style.left = `${img.offsetLeft}px`;
particleOverlayCanvas.style.top = `${img.offsetTop}px`;
return true;
}
function getCheckboxStateByIndex(index) {
const checkbox = document.querySelector(`.rod-checkbox[data-index="${index}"]`);
return checkbox ? checkbox.checked : true;
}
function getParticleBoxPoints(rod) {
const widthPx = rod.width_nm / currentPixelSize;
const lengthPx = rod.length_nm / currentPixelSize;
const angleRad = (rod.orientation_deg || 0) * Math.PI / 180;
const cosA = Math.cos(angleRad);
const sinA = Math.sin(angleRad);
const halfW = widthPx / 2;
const halfL = lengthPx / 2;
const localCorners = [
{ x: -halfW, y: -halfL },
{ x: halfW, y: -halfL },
{ x: halfW, y: halfL },
{ x: -halfW, y: halfL }
];
return localCorners.map(pt => ({
x: rod.centroid_x + pt.x * cosA - pt.y * sinA,
y: rod.centroid_y + pt.x * sinA + pt.y * cosA
}));
}
// Show/hide the ID numbers drawn over the image (they can obscure small
// particles). Hidden by default; drawn on the canvas overlay so the
// toggle is instant.
let showLabels = false;
function toggleLabels() {
showLabels = !showLabels;
const btn = document.getElementById('labelToggleBtn');
btn.textContent = showLabels ? 'Hide IDs' : 'Show IDs';
btn.classList.toggle('active', !showLabels);
renderParticleOverlay();
}
// Two-blink flash on toggle, so a just-(de)selected box is easy to spot.
const particleFlashes = new Map(); // currentData index -> start time (ms)
const FLASH_DURATION_MS = 760;
let flashAnimRunning = false;
function triggerParticleFlash(index) {
particleFlashes.set(index, performance.now());
if (!flashAnimRunning) {
flashAnimRunning = true;
requestAnimationFrame(flashTick);
}
}
function flashTick() {
const now = performance.now();
for (const [idx, start] of particleFlashes) {
if (now - start >= FLASH_DURATION_MS) particleFlashes.delete(idx);
}
renderParticleOverlay();
if (particleFlashes.size > 0) {
requestAnimationFrame(flashTick);
} else {
flashAnimRunning = false;
renderParticleOverlay(); // final clean frame
}
}
function pathParticle(ctx, corners) {
ctx.beginPath();
ctx.moveTo(corners[0].x, corners[0].y);
for (let i = 1; i < corners.length; i++) ctx.lineTo(corners[i].x, corners[i].y);
ctx.closePath();
}
function renderParticleOverlay() {
if (!syncParticleOverlayToImage()) return;
const ctx = particleOverlayCtx;
ctx.clearRect(0, 0, particleOverlayCanvas.width, particleOverlayCanvas.height);
if (!currentData || currentData.length === 0) return;
// The canvas is at full image resolution but shown scaled down, so
// size strokes relative to that ratio to keep them visible.
const img = document.getElementById('processedImage');
const scale = (img && img.clientWidth) ? (particleOverlayCanvas.width / img.clientWidth) : 1;
const baseLine = Math.max(1, scale * 0.7); // thin outlines, vivid colour
const now = performance.now();
currentData.forEach((rod, index) => {
const isSelected = getCheckboxStateByIndex(index);
const corners = getParticleBoxPoints(rod);
pathParticle(ctx, corners);
// Outline only (no interior fill). Solid for kept particles,
// dashed for excluded ones.
ctx.setLineDash(isSelected ? [] : [scale * 6, scale * 5]);
ctx.lineWidth = baseLine;
ctx.strokeStyle = isSelected ? '#0a40ff' : '#ff8c00';
ctx.stroke();
ctx.setLineDash([]);
// Flash highlight (two blinks) right after a toggle — a pulsing
// bright outline with a glow, no interior fill.
const start = particleFlashes.get(index);
if (start !== undefined) {
const t = (now - start) / FLASH_DURATION_MS; // 0..1
const intensity = Math.abs(Math.sin(t * Math.PI * 2)); // two humps
ctx.save();
pathParticle(ctx, corners);
ctx.shadowColor = 'rgba(255,255,255,0.95)';
ctx.shadowBlur = scale * 8 * intensity;
ctx.lineWidth = baseLine + scale * 3.5 * intensity;
ctx.strokeStyle = `rgba(255, 255, 255, ${0.55 + 0.45 * intensity})`;
ctx.stroke();
// Coloured inner stroke so the blink keeps its select/exclude hue.
ctx.shadowBlur = 0;
ctx.lineWidth = baseLine + scale * 1.2 * intensity;
ctx.strokeStyle = isSelected
? `rgba(10, 64, 255, ${0.5 + 0.5 * intensity})`
: `rgba(255, 140, 0, ${0.5 + 0.5 * intensity})`;
ctx.stroke();
ctx.restore();
}
});
// ID numbers, drawn last (on top of all boxes) and only when enabled.
// Outlined white text instead of a filled pill so they obscure the
// particle underneath as little as possible.
if (showLabels) {
const fontPx = Math.max(13 * scale, 11);
ctx.font = `bold ${fontPx}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.lineWidth = Math.max(1, scale * 0.9);
ctx.lineJoin = 'round';
currentData.forEach((rod) => {
const label = String(rod.id ?? '');
if (!label) return;
const x = rod.centroid_x, y = rod.centroid_y;
ctx.strokeStyle = 'rgba(0, 0, 0, 0.85)';
ctx.strokeText(label, x, y);
ctx.fillStyle = '#ffffff';
ctx.fillText(label, x, y);
});
ctx.textAlign = 'start';
ctx.textBaseline = 'alphabetic';
}
}
function setImageTransform(origin, scale) {
const targetElements = [processedImage, particleOverlayCanvas, scaleCanvas];
targetElements.forEach(el => {
el.style.transformOrigin = origin;
el.style.transform = scale;
});
}
function toggleZoom() {
isZoomActive = !isZoomActive;
if (isZoomActive) {
zoomToggle.classList.add('active');
zoomWrapper.style.cursor = 'zoom-in';
} else {
zoomToggle.classList.remove('active');
zoomWrapper.style.cursor = 'default';
// Reset
setImageTransform('50% 50%', 'scale(1)');
}
}
zoomWrapper.addEventListener('mousemove', function (e) {
if (!isZoomActive) return;
const rect = zoomWrapper.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Calculate percentage
const xPercent = (x / rect.width) * 100;
const yPercent = (y / rect.height) * 100;
setImageTransform(`${xPercent}% ${yPercent}%`, 'scale(2.5)');
});
zoomWrapper.addEventListener('mouseleave', function () {
if (!isZoomActive) return;
setImageTransform('50% 50%', 'scale(1)');
});
async function downloadFilteredReport() {
const checkboxes = document.querySelectorAll('.rod-checkbox');
const selectedIds = [];
checkboxes.forEach(cb => {
if (cb.checked) {
// ID is in column 2 (index 1), but we have full data in currentData by index
const index = parseInt(cb.getAttribute('data-index'));
if (currentData[index]) {
selectedIds.push(currentData[index].id);
}
}
});
if (selectedIds.length === 0) {
alert("Please select at least one particle to export.");
return;
}
try {
downloadBtn = document.getElementById('downloadExcel');
const originalText = downloadBtn.innerText;
downloadBtn.innerText = "Generating Report...";
const response = await fetch('/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_id: imageId,
selected_ids: selectedIds
})
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// Use filename from response header if possible, or fallback to currentFilename
// But simpler: just use our known filename format
a.download = `${currentFilename || imageId}_detected.xlsx`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} else {
const err = await response.json();
alert("Export failed: " + (err.detail || "Unknown error"));
}
downloadBtn.innerText = originalText;
} catch (e) {
console.error(e);
alert("Export failed");
document.getElementById('downloadExcel').innerText = "Download Excel Report";
}
}
async function saveSelectionForFolder() {
if (!currentFolder) {
alert("No folder context available.");
return;
}
const checkboxes = document.querySelectorAll('.rod-checkbox');
const selectedIds = [];
checkboxes.forEach(cb => {
if (cb.checked) {
selectedIds.push(parseInt(cb.getAttribute('data-index'))); // Index maps to original data order? No, ID is safer
// Wait, data-index is index in currentData. Check logic.
// currentData[index].id is the ID.
}
});
// Re-map to actual IDs
const idsToSave = [];
checkboxes.forEach(cb => {
if (cb.checked) {
const idx = cb.getAttribute('data-index');
idsToSave.push(currentData[idx].id);
}
});
try {
const response = await fetch(`/folders/${encodeURIComponent(currentFolder)}/save_selection`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_id: imageId,
selected_ids: idsToSave
})
});
if (response.ok) {
alert("Selection saved for Folder Analysis!");
} else {
const err = await response.json();
alert("Error saving: " + err.detail);
}
} catch (e) {
console.error(e);
alert("Failed to save selection.");
}
}
// --- Instructions Modal ---
function openInstructions() {
document.getElementById('instrModal').style.display = 'flex';
}
function closeInstructions(event) {
if (event) event.stopPropagation();
document.getElementById('instrModal').style.display = 'none';
}
async function addToFolderAnalysis() {
if (!folderParam) return;
const checkboxes = document.querySelectorAll('.rod-checkbox');
const selectedIds = [];
checkboxes.forEach(cb => {
if (cb.checked) {
const index = parseInt(cb.getAttribute('data-index'));
if (currentData[index]) {
selectedIds.push(currentData[index].id);
}
}
});
if (selectedIds.length === 0) {
alert("Please select at least one particle.");
return;
}
try {
const btn = document.getElementById('addToFolderBtn');
const originalText = btn.innerText;
btn.innerText = "Saving...";
btn.disabled = true;
const response = await fetch(`/folders/${encodeURIComponent(folderParam)}/save_selection`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_id: imageId,
selected_ids: selectedIds
})
});
if (response.ok) {
const res = await response.json();
alert(`Successfully added ${res.count} particles from this image to folder analysis.`);
} else {
const err = await response.json();
alert("Error: " + (err.detail || "Server Error"));
}
btn.innerText = "Add to Folder Analysis";
btn.disabled = false;
} catch (e) {
console.error(e);
alert("Failed to save selection.");
document.getElementById('addToFolderBtn').innerText = "Add to Folder Analysis";
}
}
async function downloadPDF() {
const element = document.getElementById('analysisResults');
// Create a temporary container for PDF generation
const pdfContainer = document.createElement('div');
pdfContainer.style.width = '100%';
pdfContainer.style.background = 'white';
pdfContainer.style.padding = '20px';
// Add Header Info
const title = document.createElement('h2');
title.innerText = `Analysis Report: ${currentFilename}`;
title.style.textAlign = 'center';
title.style.marginBottom = '20px';
pdfContainer.appendChild(title);
// Add the currently displayed image (resized for PDF)
const imgContainer = document.createElement('div');
imgContainer.style.display = 'flex';
imgContainer.style.justifyContent = 'center';
imgContainer.style.marginBottom = '20px';
imgContainer.style.height = '350px';
// Composite the base image with the live overlay (boxes, IDs and the
// current selection state) so the report matches what is on screen.
const img2 = document.createElement('img');
try {
const srcImg = document.getElementById('processedImage');
renderParticleOverlay();
const composite = document.createElement('canvas');
composite.width = srcImg.naturalWidth || particleOverlayCanvas.width;
composite.height = srcImg.naturalHeight || particleOverlayCanvas.height;
const cctx = composite.getContext('2d');
cctx.drawImage(srcImg, 0, 0, composite.width, composite.height);
cctx.drawImage(particleOverlayCanvas, 0, 0, composite.width, composite.height);
img2.src = composite.toDataURL('image/jpeg', 0.9);
} catch (e) {
console.error('Overlay composite failed, using plain image:', e);
img2.src = document.getElementById('processedImage').src;
}
img2.style.maxHeight = '100%';
img2.style.maxWidth = '80%';
img2.style.objectFit = 'contain';
img2.style.transform = 'none';
imgContainer.appendChild(img2);
pdfContainer.appendChild(imgContainer);
// Clone Results Section
const clonedContent = element.cloneNode(true);
// Remove buttons from PDF
const buttons = clonedContent.querySelectorAll('button, a.btn');
buttons.forEach(btn => btn.remove());
// Replace Chart Canvases with Images (Crucial for PDF)
const originalCanvases = element.querySelectorAll('canvas');
const clonedCanvases = clonedContent.querySelectorAll('canvas');
originalCanvases.forEach((origCanvas, index) => {
if (!clonedCanvases[index]) return;
try {
const imgData = origCanvas.toDataURL('image/png');
const img = document.createElement('img');
img.src = imgData;
img.style.width = '100%';
img.style.maxHeight = '200px';
img.style.objectFit = 'contain';
img.style.display = 'block';
const clonedCanvas = clonedCanvases[index];
clonedCanvas.parentNode.replaceChild(img, clonedCanvas);
} catch (e) {
console.error("Chart capture error:", e);
}
});
pdfContainer.appendChild(clonedContent);
const opt = {
margin: 0.5,
filename: `${currentFilename}_report.pdf`,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: 'in', format: 'letter', orientation: 'landscape' }
};
html2pdf().set(opt).from(pdfContainer).save();
}
// --- Click on particle to toggle checkbox ---
document.getElementById('processedImage').addEventListener('click', function(e) {
if (isZoomActive || isDrawingLine) return;
if (!currentData || currentData.length === 0) return;
const img = this;
const rect = img.getBoundingClientRect();
const scaleX = img.naturalWidth / rect.width;
const scaleY = img.naturalHeight / rect.height;
const clickX = (e.clientX - rect.left) * scaleX;
const clickY = (e.clientY - rect.top) * scaleY;
// Primary hit test: is the click INSIDE a particle's box? Among any
// boxes that contain the point, pick the one whose centre is nearest.
// This makes "click inside the box" reliable regardless of box size.
let targetIdx = -1;
let bestDist = Infinity;
currentData.forEach((rod, idx) => {
if (pointInParticle(clickX, clickY, getParticleBoxPoints(rod))) {
const d = Math.hypot(rod.centroid_x - clickX, rod.centroid_y - clickY);
if (d < bestDist) { bestDist = d; targetIdx = idx; }
}
});
// Fallback: nearest centroid within a small tolerance for near-misses.
if (targetIdx < 0) {
const tol = Math.max(40, img.naturalWidth * 0.015);
let minDist = Infinity;
currentData.forEach((rod, idx) => {
const d = Math.hypot(rod.centroid_x - clickX, rod.centroid_y - clickY);
if (d < minDist) { minDist = d; targetIdx = (d <= tol) ? idx : targetIdx; }
});
}
if (targetIdx >= 0) {
// Look up by data-index (not DOM position) so this still hits the
// right particle when the table is sorted.
const checkbox = document.querySelector(`.rod-checkbox[data-index="${targetIdx}"]`);
if (checkbox) {
checkbox.checked = !checkbox.checked;
syncSelectAllState();
triggerParticleFlash(targetIdx); // two-blink flash on the box
// Briefly flash the matching table row WITHOUT scrolling to it,
// so deselecting several while zoomed in doesn't jump the page.
const row = checkbox.closest('tr');
if (row) {
row.style.background = checkbox.checked ? '#d4edda' : '#f8d7da';
setTimeout(() => { row.style.background = ''; }, 600);
}
}
}
});
// Ray-casting point-in-polygon for the 4-corner rotated box.
function pointInParticle(x, y, pts) {
let inside = false;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
const xi = pts[i].x, yi = pts[i].y, xj = pts[j].x, yj = pts[j].y;
if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) {
inside = !inside;
}
}
return inside;
}
// --- Scale Bar Calibration ---
let isDrawingLine = false;
let lineStart = null;
let lineEnd = null;
let drawnLinePx = 0;
const scaleCanvas = document.getElementById('scaleCanvas');
const scaleCtx = scaleCanvas.getContext('2d');
function syncScaleCanvasToImage() {
const img = document.getElementById('processedImage');
if (!img || !img.naturalWidth || !img.naturalHeight) return false;
scaleCanvas.width = img.naturalWidth;
scaleCanvas.height = img.naturalHeight;
scaleCanvas.style.width = `${img.clientWidth}px`;
scaleCanvas.style.height = `${img.clientHeight}px`;
scaleCanvas.style.left = `${img.offsetLeft}px`;
scaleCanvas.style.top = `${img.offsetTop}px`;
return true;
}
function showCalibrationPanel(calibInfo) {
const method = calibInfo ? calibInfo.method : 'default';
const needsCalibration = method === 'default' || method === 'uncalibrated' || (calibInfo && calibInfo.is_placeholder);
const panel = document.getElementById('calibrationPanel');
const toggleBtn = document.getElementById('toggleCalibrationBtn');
const title = document.getElementById('calibrationPanelTitle');
const note = document.getElementById('calibrationPanelNote');
if (needsCalibration) {
calibrationPanelOpen = true;
title.textContent = 'Scale Bar Calibration Required';
note.textContent = 'No reliable scale was found. Please set the scale manually.';
toggleBtn.textContent = 'Hide calibration tools';
} else {
title.textContent = 'Scale Calibration';
note.textContent = 'Current calibration looks usable. You can re-scale this image at any time if needed.';
toggleBtn.textContent = calibrationPanelOpen ? 'Hide re-scale tools' : 'Re-scale';
}
panel.style.display = calibrationPanelOpen ? 'block' : 'none';
}
function startDrawLine() {
if (currentImageMode !== 'processed') {
currentImageMode = 'processed';
updateDisplayedImage();
}
isDrawingLine = true;
lineStart = null;
lineEnd = null;
drawnLinePx = 0;
// Disable zoom while drawing
if (isZoomActive) toggleZoom();
if (!syncScaleCanvasToImage()) {
alert('Image is still loading. Please try again in a moment.');
isDrawingLine = false;
return;
}
scaleCanvas.style.display = 'block';
scaleCtx.clearRect(0, 0, scaleCanvas.width, scaleCanvas.height);
document.getElementById('drawLineBtn').classList.add('cal-active');
document.getElementById('drawLineBtn').textContent = 'Drawing... Click start & end points';
document.getElementById('lineStatus').textContent = '';
}
scaleCanvas.addEventListener('mousedown', function(e) {
if (!isDrawingLine) return;
const rect = scaleCanvas.getBoundingClientRect();
const scaleX = scaleCanvas.width / rect.width;
const scaleY = scaleCanvas.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
if (!lineStart) {
lineStart = { x, y };
// Draw start point
scaleCtx.clearRect(0, 0, scaleCanvas.width, scaleCanvas.height);
scaleCtx.fillStyle = 'lime';
scaleCtx.beginPath();
scaleCtx.arc(x, y, 6, 0, Math.PI * 2);
scaleCtx.fill();
} else {
lineEnd = { x, y };
// Draw line
scaleCtx.clearRect(0, 0, scaleCanvas.width, scaleCanvas.height);
scaleCtx.strokeStyle = 'lime';
scaleCtx.lineWidth = 3;
scaleCtx.beginPath();
scaleCtx.moveTo(lineStart.x, lineStart.y);
scaleCtx.lineTo(lineEnd.x, lineEnd.y);
scaleCtx.stroke();
drawnLinePx = Math.sqrt(
Math.pow(lineEnd.x - lineStart.x, 2) +
Math.pow(lineEnd.y - lineStart.y, 2)
);
isDrawingLine = false;
document.getElementById('drawLineBtn').classList.remove('cal-active');
document.getElementById('drawLineBtn').textContent = 'Draw Line on Image';
document.getElementById('lineStatus').textContent = `Line: ${Math.round(drawnLinePx)} px`;
}
});
scaleCanvas.addEventListener('mousemove', function(e) {
if (!isDrawingLine || !lineStart) return;
const rect = scaleCanvas.getBoundingClientRect();
const scaleX = scaleCanvas.width / rect.width;
const scaleY = scaleCanvas.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
scaleCtx.clearRect(0, 0, scaleCanvas.width, scaleCanvas.height);
scaleCtx.strokeStyle = 'lime';
scaleCtx.lineWidth = 3;
scaleCtx.beginPath();
scaleCtx.moveTo(lineStart.x, lineStart.y);
scaleCtx.lineTo(x, y);
scaleCtx.stroke();
});
async function applyManualScale() {
const nmValue = parseFloat(document.getElementById('scaleNmInput').value);
if (!nmValue || nmValue <= 0) {
alert('Please enter a valid nm value.');
return;
}
if (drawnLinePx <= 0) {
alert('Please draw a line on the scale bar first.');
return;
}
const pixelSize = nmValue / drawnLinePx;
// Hide canvas
scaleCanvas.style.display = 'none';
scaleCtx.clearRect(0, 0, scaleCanvas.width, scaleCanvas.height);
// Reprocess with manual pixel size
document.getElementById('lineStatus').textContent = 'Reprocessing...';
try {
await processImage({
manualPixelSize: pixelSize,
binaryMaskTune: appliedBinaryMaskTune,
forceReprocess: true
});
calibrationPanelOpen = false;
document.getElementById('lineStatus').textContent = '';
} catch (e) {
alert('Error reprocessing: ' + e.message);
}
}
document.getElementById('binaryMaskSlider').addEventListener('input', handleBinaryMaskSliderInput);
document.getElementById('binaryMaskSlider').addEventListener('change', handleBinaryMaskSliderChange);
document.getElementById('binaryMaskApplyBtn').addEventListener('click', applyBinaryMaskTune);
window.addEventListener('resize', function() {
syncParticleOverlayToImage();
renderParticleOverlay();
if (scaleCanvas.style.display === 'block') {
syncScaleCanvasToImage();
}
});
processImage();
</script>
</body>
</html>