RodSizer_web / frontend /folder_analysis.html
Caesium135's picture
Harden session/path/ID validation and fix XSS for public-facing web version
6e77125
Raw History Blame Contribute Delete
21.2 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Folder 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', Arial, sans-serif;
background-color: #f8f9fa;
margin: 0;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
h1,
h2 {
text-align: center;
color: #333;
}
.stats-grid {
display: grid;
/* Adaptive columns for 5 items */
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: #f1f3f5;
padding: 20px;
border-radius: 8px;
text-align: center;
}
.stat-val {
font-size: 24px;
font-weight: bold;
color: #007bff;
}
.stat-label {
color: #666;
font-size: 14px;
}
.charts-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 30px;
}
.chart-wrapper {
height: 300px;
border: 1px solid #eee;
padding: 10px;
border-radius: 8px;
background: white;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th,
td {
border: 1px solid #ddd;
padding: 8px;
text-align: center;
font-size: 14px;
}
th {
background-color: #f2f2f2;
position: sticky;
top: 0;
}
.ledger-container {
max-height: 500px;
overflow-y: auto;
border: 1px solid #eee;
}
.btn {
display: inline-flex;
justify-content: center;
align-items: center;
padding: 12px;
background-color: #007bff;
color: white;
text-decoration: none;
border-radius: 5px;
border: none;
cursor: pointer;
font-size: 14px;
line-height: normal;
}
.btn:hover {
background-color: #0056b3;
}
.back-link {
display: block;
margin-bottom: 20px;
color: #666;
text-decoration: none;
font-size: 16px;
}
.back-link:hover {
color: #333;
}
/* --- 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 {
text-align: left;
margin-top: 0;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.modal-body {
font-size: 14px;
line-height: 1.6;
color: #333;
text-align: left;
}
.modal-body ul {
margin: 10px 0;
padding-left: 20px;
}
.modal-body li {
margin-bottom: 8px;
}
</style>
</head>
<body>
<div class="container">
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<a id="backLink" href="/" class="back-link" style="margin-bottom: 0;">← Back to Dashboard</a>
<button onclick="openInstructions()"
style="background: none; border: 1px solid #007bff; color: #007bff; padding: 5px 15px; border-radius: 4px; cursor: pointer;">Instructions</button>
</div>
<h1 id="folderTitle">Folder Analysis</h1>
<!-- Buttons Container -->
<div style="display: flex; justify-content: center; gap: 20px; margin-bottom: 30px;">
<a id="downloadAggExcel" href="#" class="btn" style="width: 260px;">Download Excel Report</a>
<button onclick="downloadPDF()" class="btn" style="width: 260px; background-color: #6c757d;">Download
PDF
Report</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>Folder Analysis Guide</h3>
<div class="modal-body">
<p>This dashboard presents <strong>Acquired Data</strong> from the specific images you have
analyzed and saved, merged into a single dataset.</p>
<p><strong>Key Features:</strong></p>
<ul>
<li><strong>Batch Statistics:</strong> Calculates Mean Length, Width, and Aspect
Ratio across <em>only</em> the images you processed and saved (not the entire
folder).</li>
<li><strong>Histograms:</strong> Visualizes the distribution of particle dimensions for
this merged dataset.</li>
<li><strong>Reports:</strong> You can download the complete dataset as an <strong>Excel
Report</strong> or a formatted <strong>PDF Report</strong>.</li>
</ul>
</div>
</div>
</div>
</div>
<div id="loading" style="text-align: center;">Loading aggregate data...</div>
<div id="content" style="display: none;">
<!-- Printable Section -->
<div id="report-content" style="padding: 20px; background: white;">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-val" id="fileCount">-</div>
<div class="stat-label">Images Analyzed</div>
</div>
<div class="stat-card">
<div class="stat-val" id="totalParticles">-</div>
<div class="stat-label">Total Particles</div>
</div>
<div class="stat-card">
<div class="stat-val" id="meanLen">-</div>
<div class="stat-label">Mean Length</div>
</div>
<div class="stat-card">
<div class="stat-val" id="meanWidth">-</div>
<div class="stat-label">Mean Width</div>
</div>
<!-- New Aspect Ratio Card -->
<div class="stat-card">
<div class="stat-val" id="meanAR">-</div>
<div class="stat-label">Mean Aspect Ratio</div>
</div>
</div>
<div class="charts-container">
<div class="chart-wrapper"><canvas id="lengthChart"></canvas></div>
<div class="chart-wrapper"><canvas id="widthChart"></canvas></div>
</div>
</div>
<h2>Batch Ledger</h2>
<div class="ledger-container">
<table id="dataTable">
<thead>
<tr>
<th>Source Image</th>
<th>ID</th>
<th>L (nm)</th>
<th>W (nm)</th>
<th>AR</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<script>
// Session isolation
const SESSION_ID = localStorage.getItem('rodsizer_session_id') || (() => {
const id = crypto.randomUUID();
localStorage.setItem('rodsizer_session_id', id);
return id;
})();
// Auto-append session_id to all API fetch calls
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 folderName = urlParams.get('folder');
if (!folderName) {
alert("No folder specified");
window.location.href = "/";
}
document.getElementById('folderTitle').textContent = `Analysis: ${folderName}`;
document.getElementById('backLink').href = `/?folder=${encodeURIComponent(folderName)}`;
document.getElementById('backLink').innerText = `← Back to ${folderName}`;
const dlBtn = document.getElementById('downloadAggExcel');
dlBtn.href = `/folders/${encodeURIComponent(folderName)}/export_aggregate?session_id=${SESSION_ID}`;
let lengthChart, widthChart;
async function loadAggregateData() {
try {
const response = await fetch(`/folders/${encodeURIComponent(folderName)}/aggregate`);
const data = await response.json();
if (data.file_count === 0) {
document.getElementById('loading').textContent = "No saved analysis data found in this folder.";
return;
}
displayData(data);
} catch (e) {
console.error(e);
alert("Error loading aggregate data");
}
}
function displayData(data) {
document.getElementById('loading').style.display = 'none';
document.getElementById('content').style.display = 'block';
// Stats
document.getElementById('fileCount').textContent = data.file_count;
document.getElementById('totalParticles').textContent = data.stats.count || 0;
document.getElementById('meanLen').textContent = data.stats.mean_length || "-";
document.getElementById('meanWidth').textContent = data.stats.mean_width || "-";
document.getElementById('meanAR').textContent = data.stats.mean_ar || "-";
// Charts
const items = data.data;
const lengths = items.map(d => d.length_nm);
const widths = items.map(d => d.width_nm);
lengthChart = createHistogram('lengthChart', 'Length Distribution', lengths);
widthChart = createHistogram('widthChart', 'Width Distribution', widths);
// Table
const tbody = document.querySelector('#dataTable tbody');
tbody.innerHTML = '';
items.forEach(item => {
const tr = document.createElement('tr');
const cells = [
String(item.source_image ?? ''),
String(item.id ?? ''),
Number(item.length_nm).toFixed(1),
Number(item.width_nm).toFixed(1),
Number(item.aspect_ratio).toFixed(1),
];
cells.forEach(val => {
const td = document.createElement('td');
td.textContent = val;
tr.appendChild(td);
});
tbody.appendChild(tr);
});
}
function createHistogram(canvasId, label, data) {
const ctx = document.getElementById(canvasId).getContext('2d');
if (!data.length) return;
const binCount = Math.max(20, Math.ceil(1 + 3.322 * Math.log10(data.length)));
const min = Math.floor(Math.min(...data));
const max = Math.ceil(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(v => {
let idx = Math.floor((v - min) / step);
if (idx >= binCount) idx = binCount - 1;
bins[idx]++;
});
// 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.6)',
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 }
},
animation: false,
layout: { padding: { right: 20 } }
}
});
}
function downloadPDF() {
const element = document.getElementById('report-content');
const pdfContainer = document.createElement('div');
// Strict width for Squeeze to Single Page
pdfContainer.style.width = '900px';
pdfContainer.style.background = 'white';
pdfContainer.style.padding = '20px';
// Add Title (Compact)
const title = document.getElementById('folderTitle').cloneNode(true);
title.style.marginBottom = '10px';
title.style.marginTop = '0';
title.style.textAlign = 'center';
title.style.fontSize = '24px';
pdfContainer.appendChild(title);
// Clone content
const clonedContent = element.cloneNode(true);
// -- SCALE ADJUSTMENTS FOR PDF --
// Force 5-column layout for stats
const statsGrid = clonedContent.querySelector('.stats-grid');
if (statsGrid) {
statsGrid.style.gridTemplateColumns = 'repeat(5, 1fr)';
statsGrid.style.gap = '10px';
statsGrid.style.marginBottom = '20px';
}
// Compact Stat Cards
const cards = clonedContent.querySelectorAll('.stat-card');
cards.forEach(card => {
card.style.padding = '10px';
});
const vals = clonedContent.querySelectorAll('.stat-val');
vals.forEach(val => val.style.fontSize = '18px');
const labels = clonedContent.querySelectorAll('.stat-label');
labels.forEach(lbl => lbl.style.fontSize = '12px');
// Compact Charts - FORCE HEIGHT
const chartWrappers = clonedContent.querySelectorAll('.chart-wrapper');
chartWrappers.forEach(wrapper => {
wrapper.style.height = '220px'; // Forced shorter height
wrapper.style.padding = '5px';
});
const chartsContainer = clonedContent.querySelector('.charts-container');
if (chartsContainer) {
chartsContainer.style.gap = '15px';
}
// Replace Canvases with Images (PDF Fix)
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.height = '100%'; // Match wrapper height
img.style.objectFit = 'contain';
img.style.display = 'block';
const clonedCanvas = clonedCanvases[index];
clonedCanvas.parentNode.replaceChild(img, clonedCanvas);
} catch (e) {
console.error("Error capturing chart for PDF:", e);
}
});
pdfContainer.appendChild(clonedContent);
const opt = {
margin: [0.3, 0.3], // Tight margins
filename: `${folderName}_report.pdf`,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true, logging: false },
jsPDF: { unit: 'in', format: 'letter', orientation: 'landscape' }
};
html2pdf().set(opt).from(pdfContainer).save();
}
// --- Instructions Modal ---
function openInstructions() {
document.getElementById('instrModal').style.display = 'flex';
}
function closeInstructions(event) {
if (event) event.stopPropagation();
document.getElementById('instrModal').style.display = 'none';
}
loadAggregateData();
</script>
</body>
</html>