Spaces:
Running
Running
Claude Opus 4.8
Sync analysis UI from RodSizer; refresh index docs (keep session + HF notice)
911d85d Download frontend/analysis.html from Caesium135/RodSizer_web: direct link, hf CLI and curl.
- Browser
- Download file 89.6 kB
-
https://huggingface.co/spaces/Caesium135/RodSizer_web/resolve/main/frontend/analysis.html
- Command line
-
hf download hf://spaces/Caesium135/RodSizer_web/frontend/analysis.html
-
curl -L -o analysis.html https://huggingface.co/spaces/Caesium135/RodSizer_web/resolve/main/frontend/analysis.html
89.6 kB
| <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 & 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 & 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 & above</button> | |
| <button type="button" data-act="desel-above">☐ Deselect this & above</button> | |
| <button type="button" data-act="sel-below">☑ Select this & below</button> | |
| <button type="button" data-act="desel-below">☐ Deselect this & 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> | |