mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-07-06 01:42:41 +02:00
1216 lines
49 KiB
HTML
1216 lines
49 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>pyMC Repeater - CAD Calibration</title>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<link rel="stylesheet" href="/static/style.css">
|
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
<script src="https://d3js.org/d3-scale-chromatic.v1.min.js"></script>
|
|
<script src="https://d3js.org/d3-hexbin.v0.2.min.js"></script>
|
|
<style>
|
|
.calibration-controls {
|
|
background: var(--bg-secondary);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin: 20px 0;
|
|
border: 1px solid var(--border-color);
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.buttons {
|
|
display: flex;
|
|
gap: 16px;
|
|
}
|
|
|
|
/* Control button styling - matching site design system */
|
|
.control-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 12px 16px;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 6px;
|
|
color: #d4d4d4;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
font-size: 0.875rem;
|
|
font-family: inherit;
|
|
min-width: 160px;
|
|
}
|
|
|
|
.control-btn:hover:not(:disabled) {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-color: rgba(255, 255, 255, 0.2);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.control-btn:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
background: rgba(255, 255, 255, 0.02);
|
|
}
|
|
|
|
.control-btn-start {
|
|
background: rgba(16, 185, 129, 0.1);
|
|
border-color: rgba(16, 185, 129, 0.3);
|
|
color: #10b981;
|
|
}
|
|
|
|
.control-btn-start:hover:not(:disabled) {
|
|
background: rgba(16, 185, 129, 0.2);
|
|
border-color: rgba(16, 185, 129, 0.4);
|
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.15);
|
|
}
|
|
|
|
.control-btn-danger {
|
|
background: rgba(239, 68, 68, 0.1);
|
|
border-color: rgba(239, 68, 68, 0.3);
|
|
color: #ef4444;
|
|
}
|
|
|
|
.control-btn-danger:hover:not(:disabled) {
|
|
background: rgba(239, 68, 68, 0.2);
|
|
border-color: rgba(239, 68, 68, 0.4);
|
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.15);
|
|
}
|
|
|
|
.control-btn .icon {
|
|
width: 20px;
|
|
height: 20px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.control-label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
flex-grow: 1;
|
|
}
|
|
|
|
.control-title {
|
|
font-weight: 500;
|
|
font-size: 0.875rem;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.control-value {
|
|
font-size: 0.75rem;
|
|
opacity: 0.8;
|
|
line-height: 1.1;
|
|
}
|
|
|
|
.control-btn-save {
|
|
background: rgba(59, 130, 246, 0.1);
|
|
border-color: rgba(59, 130, 246, 0.3);
|
|
color: #3b82f6;
|
|
}
|
|
|
|
.control-btn-save:hover:not(:disabled) {
|
|
background: rgba(59, 130, 246, 0.2);
|
|
border-color: rgba(59, 130, 246, 0.4);
|
|
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
|
}
|
|
|
|
.save-controls {
|
|
display: flex;
|
|
justify-content: center;
|
|
}
|
|
|
|
.chart-container {
|
|
background: var(--bg-secondary);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin: 20px 0;
|
|
border: 1px solid var(--border-color);
|
|
height: 600px; /* Increased to match new chart height */
|
|
position: relative;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.heatmap-svg {
|
|
border-radius: 4px;
|
|
background: var(--bg-primary);
|
|
}
|
|
|
|
.heatmap-cell {
|
|
stroke: var(--border-color);
|
|
stroke-width: 1;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.heatmap-cell:hover {
|
|
stroke: var(--primary);
|
|
stroke-width: 2;
|
|
}
|
|
|
|
.axis-label {
|
|
font-size: 12px;
|
|
fill: var(--text-secondary);
|
|
}
|
|
|
|
.axis-title {
|
|
font-size: 14px;
|
|
font-weight: bold;
|
|
fill: var(--text-primary);
|
|
}
|
|
|
|
.legend-title {
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
fill: var(--text-primary);
|
|
}
|
|
|
|
.legend-label {
|
|
font-size: 10px;
|
|
fill: var(--text-secondary);
|
|
}
|
|
|
|
.axis {
|
|
font-size: 12px;
|
|
}
|
|
|
|
.axis path,
|
|
.axis line {
|
|
fill: none;
|
|
stroke: #888;
|
|
shape-rendering: crispEdges;
|
|
}
|
|
|
|
.axis text {
|
|
fill: #fff !important;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.hexagon {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.hexagon:hover {
|
|
opacity: 1 !important;
|
|
}
|
|
|
|
.non-detected {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.chart-background {
|
|
pointer-events: none;
|
|
}
|
|
|
|
.heatmap-tooltip {
|
|
position: absolute;
|
|
background-color: white;
|
|
border: solid 2px #333;
|
|
border-radius: 5px;
|
|
padding: 8px;
|
|
font-size: 12px;
|
|
color: #333;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
z-index: 1000;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
}
|
|
|
|
.heatmap-title {
|
|
fill: #fff !important;
|
|
font-size: 18px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.heatmap-subtitle {
|
|
fill: #ccc !important;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.status-container {
|
|
background: var(--bg-secondary);
|
|
border-radius: 8px;
|
|
padding: 15px;
|
|
margin: 20px 0;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.progress-container {
|
|
margin-top: 15px;
|
|
}
|
|
|
|
.progress-bar {
|
|
width: 100%;
|
|
height: 8px;
|
|
background: var(--bg-tertiary);
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progress-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, var(--accent), var(--primary));
|
|
width: 0%;
|
|
transition: width 0.3s ease;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 15px;
|
|
margin: 20px 0;
|
|
}
|
|
|
|
.stat-card {
|
|
background: var(--bg-tertiary);
|
|
border-radius: 6px;
|
|
padding: 15px;
|
|
text-align: center;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 1.5em;
|
|
font-weight: bold;
|
|
color: var(--primary);
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.stat-label {
|
|
color: var(--text-secondary);
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.results-container {
|
|
background: var(--bg-secondary);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin: 20px 0;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.best-result {
|
|
background: var(--success-bg);
|
|
border: 2px solid var(--success);
|
|
border-radius: 6px;
|
|
padding: 15px;
|
|
margin-top: 15px;
|
|
color: var(--success-text);
|
|
}
|
|
|
|
.hidden {
|
|
display: none;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="layout">
|
|
<!-- Navigation Component -->
|
|
<!-- NAVIGATION_PLACEHOLDER -->
|
|
|
|
<!-- Main Content -->
|
|
<main class="content">
|
|
<header>
|
|
<h1>CAD Calibration Tool</h1>
|
|
<p>Real-time Channel Activity Detection calibration</p>
|
|
</header>
|
|
|
|
<!-- Controls -->
|
|
<div class="calibration-controls">
|
|
<div class="buttons">
|
|
<button id="start-btn" class="control-btn control-btn-start">
|
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polygon points="5 3 19 12 5 21 5 3"></polygon>
|
|
</svg>
|
|
<span class="control-label">
|
|
<span class="control-title">Start Calibration</span>
|
|
|
|
</span>
|
|
</button>
|
|
<button id="stop-btn" class="control-btn control-btn-danger" disabled>
|
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<rect x="6" y="6" width="12" height="12"></rect>
|
|
</svg>
|
|
<span class="control-label">
|
|
<span class="control-title">Stop</span>
|
|
<span class="control-value">Halt calibration</span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status and Progress -->
|
|
<div class="status-container">
|
|
<div id="status-message">Ready to start calibration</div>
|
|
<div id="range-info" style="margin: 10px 0; padding: 10px; background: var(--bg-tertiary); border-radius: 4px; font-size: 14px; display: none;">
|
|
<strong>Configuration:</strong>
|
|
<span id="sf-display">SF?</span> |
|
|
<span id="peak-range">Peak: ? - ?</span> |
|
|
<span id="min-range">Min: ? - ?</span> |
|
|
<span id="total-tests">? tests</span>
|
|
</div>
|
|
<div id="visualization-legend" style="margin: 10px 0; padding: 10px; background: var(--bg-tertiary); border-radius: 4px; font-size: 12px; display: none;">
|
|
<strong>Legend:</strong>
|
|
<div style="display: flex; flex-wrap: wrap; gap: 15px; margin-top: 5px;">
|
|
<div style="display: flex; align-items: center; gap: 5px;">
|
|
<div style="width: 12px; height: 12px; background: linear-gradient(45deg, rgba(0, 150, 150, 0.2), rgba(0, 200, 200, 0.9)); border-radius: 2px;"></div>
|
|
<span>Detection density (light to bright)</span>
|
|
</div>
|
|
<div style="display: flex; align-items: center; gap: 5px;">
|
|
<div style="width: 12px; height: 12px; background: #333333; border-radius: 2px;"></div>
|
|
<span>Tested - No detections</span>
|
|
</div>
|
|
<div style="display: flex; align-items: center; gap: 5px;">
|
|
<div style="width: 12px; height: 12px; background: rgba(68, 1, 84, 0.1); border: 1px solid #555; border-radius: 2px;"></div>
|
|
<span>Not yet tested</span>
|
|
</div>
|
|
<div style="display: flex; align-items: center; gap: 5px;">
|
|
<div style="width: 6px; height: 6px; background: #ffffff; border-radius: 50%;"></div>
|
|
<span>Individual detections</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="progress-container">
|
|
<div class="progress-bar">
|
|
<div id="progress-fill" class="progress-fill"></div>
|
|
</div>
|
|
<div id="progress-text">0 / 0 tests completed</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Statistics -->
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div id="tests-completed" class="stat-value">0</div>
|
|
<div class="stat-label">Tests Completed</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div id="best-rate" class="stat-value">0%</div>
|
|
<div class="stat-label">Best Detection Rate</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div id="avg-rate" class="stat-value">0%</div>
|
|
<div class="stat-label">Average Rate</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div id="elapsed-time" class="stat-value">0s</div>
|
|
<div class="stat-label">Elapsed Time</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chart -->
|
|
<div class="chart-container">
|
|
<div id="heatmap-container"></div>
|
|
</div>
|
|
|
|
<!-- Results -->
|
|
<div id="results-container" class="results-container hidden">
|
|
<h3>Calibration Results</h3>
|
|
<div id="best-result" class="best-result hidden">
|
|
<h4>Optimal Settings Found:</h4>
|
|
<p id="best-settings"></p>
|
|
<div class="save-controls" style="margin-top: 20px;">
|
|
<button id="save-settings-btn" class="control-btn control-btn-save">
|
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
|
|
</svg>
|
|
<span class="control-label">
|
|
<span class="control-title">Save Settings</span>
|
|
<span class="control-value">Apply to configuration</span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="no-results" class="best-result hidden">
|
|
<h4>No Optimal Settings Found</h4>
|
|
<p>All tested combinations showed low detection rates. Consider running calibration again or adjusting test parameters.</p>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<script>
|
|
let eventSource = null;
|
|
let svg = null;
|
|
let isRunning = false;
|
|
let startTime = null;
|
|
let calibrationData = {};
|
|
let individualSamples = []; // Store all individual sample data points for hexbin
|
|
let allTestResults = {}; // Store aggregated test results by peak/min
|
|
|
|
// D3.js hexbin configuration
|
|
const margin = {top: 80, right: 25, bottom: 60, left: 60};
|
|
const width = 700 - margin.left - margin.right;
|
|
const height = 500 - margin.top - margin.bottom;
|
|
|
|
let x, y, colorScale, tooltip, hexbin;
|
|
|
|
// Initialize D3.js hexbin chart
|
|
function initChart() {
|
|
console.log('Initializing hexbin chart...');
|
|
|
|
// Clear any existing chart
|
|
d3.select("#heatmap-container").selectAll("*").remove();
|
|
|
|
// Create main SVG
|
|
svg = d3.select("#heatmap-container")
|
|
.append("svg")
|
|
.attr("width", width + margin.left + margin.right)
|
|
.attr("height", height + margin.top + margin.bottom)
|
|
.append("g")
|
|
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
|
|
|
|
// Add title
|
|
svg.append("text")
|
|
.attr("x", width / 2)
|
|
.attr("y", -50)
|
|
.attr("text-anchor", "middle")
|
|
.attr("class", "heatmap-title")
|
|
.text("CAD Detection Rate");
|
|
|
|
// Add subtitle
|
|
svg.append("text")
|
|
.attr("x", width / 2)
|
|
.attr("y", -25)
|
|
.attr("text-anchor", "middle")
|
|
.attr("class", "heatmap-subtitle")
|
|
.text("detection density visualization");
|
|
|
|
// Create scales for peak and min values - will be updated dynamically
|
|
x = d3.scaleLinear()
|
|
.domain([16, 35]) // Default range, will be updated when calibration starts
|
|
.range([0, width]);
|
|
|
|
y = d3.scaleLinear()
|
|
.domain([6, 20]) // Default range, will be updated when calibration starts
|
|
.range([height, 0]);
|
|
|
|
// Add dark background for the chart area
|
|
svg.append("rect")
|
|
.attr("class", "chart-background")
|
|
.attr("x", 0)
|
|
.attr("y", 0)
|
|
.attr("width", width)
|
|
.attr("height", height)
|
|
.style("fill", "#1a1a1a")
|
|
.style("stroke", "#444")
|
|
.style("stroke-width", "1px");
|
|
|
|
// Add X axis
|
|
svg.append("g")
|
|
.attr("class", "axis x-axis")
|
|
.attr("transform", "translate(0," + height + ")")
|
|
.call(d3.axisBottom(x).tickFormat(d3.format("d")))
|
|
.selectAll("text")
|
|
.style("fill", "#fff");
|
|
|
|
// Add Y axis
|
|
svg.append("g")
|
|
.attr("class", "axis y-axis")
|
|
.call(d3.axisLeft(y).tickFormat(d3.format("d")))
|
|
.selectAll("text")
|
|
.style("fill", "#fff");
|
|
|
|
// Add axis labels
|
|
svg.append("text")
|
|
.attr("class", "axis-label")
|
|
.attr("x", width / 2)
|
|
.attr("y", height + 45)
|
|
.attr("text-anchor", "middle")
|
|
.style("fill", "#fff")
|
|
.style("font-weight", "bold")
|
|
.text("CAD Peak Threshold");
|
|
|
|
svg.append("text")
|
|
.attr("class", "axis-label")
|
|
.attr("transform", "rotate(-90)")
|
|
.attr("x", -height / 2)
|
|
.attr("y", -40)
|
|
.attr("text-anchor", "middle")
|
|
.style("fill", "#fff")
|
|
.style("font-weight", "bold")
|
|
.text("CAD Min Threshold");
|
|
|
|
// Initialize hexbin with fixed radius for consistent shape
|
|
hexbin = d3.hexbin()
|
|
.radius(12) // Fixed radius matching mesh generation
|
|
.extent([[0, 0], [width, height]]);
|
|
|
|
// Color scale for hexbin density - single color fading from light to bright
|
|
colorScale = d3.scaleLinear()
|
|
.range(["rgba(0, 150, 150, 0.1)", "rgba(0, 200, 200, 0.9)"]) // Light teal to bright teal
|
|
.domain([0, 10]); // Will be updated dynamically
|
|
|
|
// Create tooltip
|
|
tooltip = d3.select("#heatmap-container")
|
|
.append("div")
|
|
.attr("class", "heatmap-tooltip");
|
|
|
|
console.log('Hexbin chart initialized');
|
|
}
|
|
|
|
// Update hexbin chart when new data arrives
|
|
function updateChart(peak, min, rate, samples, detections) {
|
|
if (!svg) return;
|
|
|
|
console.log(`Adding data: Peak=${peak}, Min=${min}, Rate=${rate}%, Samples=${samples}, Detections=${detections}`);
|
|
|
|
// Store aggregated result for statistics
|
|
const key = `${peak}_${min}`;
|
|
if (!allTestResults[key]) {
|
|
allTestResults[key] = { peak, min, detections: 0, samples: 0 };
|
|
}
|
|
allTestResults[key].detections += detections;
|
|
allTestResults[key].samples += samples;
|
|
|
|
// Add individual sample points to the data array
|
|
if (samples && detections !== undefined) {
|
|
// Get current domain bounds and add safety margin
|
|
const xDomain = x.domain();
|
|
const yDomain = y.domain();
|
|
|
|
// Only add samples if they're within the actual test range
|
|
if (peak >= xDomain[0] && peak <= xDomain[1] &&
|
|
min >= yDomain[0] && min <= yDomain[1]) {
|
|
|
|
for (let i = 0; i < samples; i++) {
|
|
// Add small random offset but ensure it stays within bounds
|
|
let offsetPeak = peak + (Math.random() - 0.5) * 0.3; // Smaller offset
|
|
let offsetMin = min + (Math.random() - 0.5) * 0.3; // Smaller offset
|
|
|
|
// Strict clamping to actual domain bounds
|
|
offsetPeak = Math.max(xDomain[0] + 0.1, Math.min(xDomain[1] - 0.1, offsetPeak));
|
|
offsetMin = Math.max(yDomain[0] + 0.1, Math.min(yDomain[1] - 0.1, offsetMin));
|
|
|
|
individualSamples.push({
|
|
peak: offsetPeak,
|
|
min: offsetMin,
|
|
detected: i < detections, // First 'detections' samples are detected
|
|
originalPeak: peak,
|
|
originalMin: min,
|
|
sampleIndex: i + 1,
|
|
testRun: Object.keys(calibrationData).length + 1
|
|
});
|
|
}
|
|
console.log(`Added ${samples} individual samples for Peak=${peak}, Min=${min}. Total samples: ${individualSamples.length}`);
|
|
} else {
|
|
console.log(`Skipping out-of-range sample: Peak=${peak}, Min=${min} (outside domain)`);
|
|
}
|
|
}
|
|
|
|
// Update hexbin visualization
|
|
updateHexbin();
|
|
}
|
|
|
|
function updateHexbin() {
|
|
if (individualSamples.length === 0) return;
|
|
|
|
// Prepare data for smooth contour patches - only detected samples for density
|
|
const detectedSamples = individualSamples
|
|
.filter(d => d.detected)
|
|
.map(d => [x(d.peak), y(d.min)]);
|
|
|
|
console.log(`Updating smooth contours with ${detectedSamples.length} detected samples out of ${individualSamples.length} total`);
|
|
|
|
// Create density grid for smooth contour patches
|
|
const gridSize = 20; // Grid resolution for density calculation
|
|
const densityGrid = [];
|
|
|
|
// Calculate density at each grid point
|
|
for (let i = 0; i < gridSize; i++) {
|
|
densityGrid[i] = [];
|
|
for (let j = 0; j < gridSize; j++) {
|
|
const gridX = (i / (gridSize - 1)) * width;
|
|
const gridY = (j / (gridSize - 1)) * height;
|
|
|
|
// Count nearby detected samples using Gaussian kernel
|
|
let density = 0;
|
|
const bandwidth = 30; // Bandwidth for smoothing
|
|
|
|
detectedSamples.forEach(sample => {
|
|
const dx = gridX - sample[0];
|
|
const dy = gridY - sample[1];
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
density += Math.exp(-(distance * distance) / (2 * bandwidth * bandwidth));
|
|
});
|
|
|
|
densityGrid[i][j] = density;
|
|
}
|
|
}
|
|
|
|
// Find maximum density for scaling
|
|
const maxDensity = Math.max(...densityGrid.flat()) || 1;
|
|
colorScale.domain([0, maxDensity * 0.8]); // Scale to 80% for better contrast
|
|
|
|
// Create STRICT clip path to absolutely contain patches within chart area
|
|
svg.select("#clip").remove();
|
|
svg.append("clipPath")
|
|
.attr("id", "clip")
|
|
.append("rect")
|
|
.attr("x", 0)
|
|
.attr("y", 0)
|
|
.attr("width", width)
|
|
.attr("height", height);
|
|
|
|
// Tooltip functions
|
|
const mouseover = function(event, d) {
|
|
tooltip.style("opacity", 1);
|
|
d3.select(this)
|
|
.style("stroke", "white")
|
|
.style("stroke-width", "2px");
|
|
};
|
|
|
|
const mousemove = function(event, d) {
|
|
// Calculate approximate peak/min values for this hexagon
|
|
const peakVal = Math.round(x.invert(d.x));
|
|
const minVal = Math.round(y.invert(d.y));
|
|
|
|
// Find samples in this area
|
|
const samplesInHex = individualSamples.filter(sample => {
|
|
const sampleX = x(sample.peak);
|
|
const sampleY = y(sample.min);
|
|
const distance = Math.sqrt(Math.pow(sampleX - d.x, 2) + Math.pow(sampleY - d.y, 2));
|
|
return distance <= hexbin.radius();
|
|
});
|
|
|
|
const detectedInHex = samplesInHex.filter(s => s.detected).length;
|
|
const totalInHex = samplesInHex.length;
|
|
const detectionRate = totalInHex > 0 ? (detectedInHex / totalInHex * 100) : 0;
|
|
|
|
let tooltipHtml = `<strong>Area: Peak≈${peakVal}, Min≈${minVal}</strong><br>`;
|
|
tooltipHtml += `<strong>Samples in area: ${totalInHex}</strong><br>`;
|
|
tooltipHtml += `<strong>Detected: ${detectedInHex}</strong><br>`;
|
|
tooltipHtml += `<strong>Detection rate: ${detectionRate.toFixed(1)}%</strong>`;
|
|
|
|
tooltip
|
|
.html(tooltipHtml)
|
|
.style("left", (event.pageX + 10) + "px")
|
|
.style("top", (event.pageY - 10) + "px")
|
|
.style("opacity", 1);
|
|
};
|
|
|
|
const mouseleave = function(event, d) {
|
|
tooltip.style("opacity", 0);
|
|
d3.select(this)
|
|
.style("stroke", "#888")
|
|
.style("stroke-width", "1px");
|
|
};
|
|
|
|
// Remove old elements
|
|
svg.selectAll(".contour-patch").remove();
|
|
svg.selectAll(".tested-area").remove();
|
|
|
|
// Create smooth contour patches for detected areas
|
|
const patchSize = width / (gridSize - 1);
|
|
const patches = [];
|
|
|
|
for (let i = 0; i < gridSize - 1; i++) {
|
|
for (let j = 0; j < gridSize - 1; j++) {
|
|
const density = densityGrid[i][j];
|
|
|
|
if (density > 0.1) { // Only create patches where there's meaningful density
|
|
const x1 = i * patchSize;
|
|
const y1 = j * patchSize;
|
|
const x2 = (i + 1) * patchSize;
|
|
const y2 = (j + 1) * patchSize;
|
|
|
|
patches.push({
|
|
x: x1,
|
|
y: y1,
|
|
width: x2 - x1,
|
|
height: y2 - y1,
|
|
density: density
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add tested areas with zero detections as background
|
|
Object.keys(allTestResults).forEach(testKey => {
|
|
const testResult = allTestResults[testKey];
|
|
if (testResult.detections === 0 && testResult.samples > 0) {
|
|
const testX = x(testResult.peak);
|
|
const testY = y(testResult.min);
|
|
|
|
svg.append("circle")
|
|
.attr("class", "tested-area")
|
|
.attr("cx", testX)
|
|
.attr("cy", testY)
|
|
.attr("r", 8)
|
|
.attr("clip-path", "url(#clip)")
|
|
.style("fill", "#333333")
|
|
.style("opacity", 0.3)
|
|
.style("stroke", "none");
|
|
}
|
|
});
|
|
|
|
// Create smooth patches with gradient fills
|
|
svg.selectAll(".contour-patch")
|
|
.data(patches)
|
|
.enter().append("rect")
|
|
.attr("class", "contour-patch")
|
|
.attr("x", d => d.x)
|
|
.attr("y", d => d.y)
|
|
.attr("width", d => d.width)
|
|
.attr("height", d => d.height)
|
|
.attr("clip-path", "url(#clip)")
|
|
.style("fill", d => colorScale(d.density))
|
|
.style("stroke", "none")
|
|
.style("opacity", d => Math.min(0.9, d.density / maxDensity + 0.1))
|
|
.style("rx", "2px") // Slightly rounded corners for smooth appearance
|
|
.style("ry", "2px")
|
|
.on("mouseover", function(event, d) {
|
|
tooltip.style("opacity", 1);
|
|
d3.select(this)
|
|
.style("stroke", "rgba(255, 255, 255, 0.8)")
|
|
.style("stroke-width", "1px");
|
|
})
|
|
.on("mousemove", function(event, d) {
|
|
const peakVal = Math.round(x.invert(d.x + d.width/2));
|
|
const minVal = Math.round(y.invert(d.y + d.height/2));
|
|
|
|
let tooltipHtml = `<strong>Detection Zone</strong><br>`;
|
|
tooltipHtml += `<strong>Area: Peak≈${peakVal}, Min≈${minVal}</strong><br>`;
|
|
tooltipHtml += `<strong>Detection Density: ${(d.density).toFixed(1)}</strong><br>`;
|
|
tooltipHtml += `<strong>Status: Active detection area</strong>`;
|
|
|
|
tooltip
|
|
.html(tooltipHtml)
|
|
.style("left", (event.pageX + 10) + "px")
|
|
.style("top", (event.pageY - 10) + "px")
|
|
.style("opacity", 1);
|
|
})
|
|
.on("mouseleave", function(event, d) {
|
|
tooltip.style("opacity", 0);
|
|
d3.select(this)
|
|
.style("stroke", "none");
|
|
});
|
|
|
|
// Add subtle dots for individual samples (optional overlay)
|
|
svg.selectAll(".sample-dot").remove();
|
|
svg.selectAll(".sample-dot")
|
|
.data(individualSamples.filter(d => d.detected)) // Only show detected samples as tiny dots
|
|
.enter().append("circle")
|
|
.attr("class", "sample-dot")
|
|
.attr("cx", d => {
|
|
const cx = x(d.peak);
|
|
return Math.max(1, Math.min(width - 1, cx)); // Keep dots well within chart bounds
|
|
})
|
|
.attr("cy", d => {
|
|
const cy = y(d.min);
|
|
return Math.max(1, Math.min(height - 1, cy)); // Keep dots well within chart bounds
|
|
})
|
|
.attr("r", 1.0) // Slightly larger for better visibility
|
|
.attr("clip-path", "url(#clip)") // Ensure dots are clipped to chart area
|
|
.style("fill", "#ffffff")
|
|
.style("opacity", 0.6) // More visible
|
|
.style("stroke", "none");
|
|
|
|
console.log(`Total sample dots rendered: ${individualSamples.filter(d => d.detected).length}`);
|
|
|
|
console.log(`Rendered ${patches.length} smooth contour patches`);
|
|
console.log(`Total individual samples stored: ${individualSamples.length}`);
|
|
console.log(`Detected samples for contours: ${detectedSamples.length}`);
|
|
|
|
// Debug: Log sample distribution by peak/min
|
|
const samplesByLocation = {};
|
|
individualSamples.forEach(sample => {
|
|
const key = `${sample.originalPeak}_${sample.originalMin}`;
|
|
if (!samplesByLocation[key]) {
|
|
samplesByLocation[key] = { detected: 0, total: 0 };
|
|
}
|
|
samplesByLocation[key].total++;
|
|
if (sample.detected) samplesByLocation[key].detected++;
|
|
});
|
|
console.log('Samples by location:', samplesByLocation);
|
|
}
|
|
|
|
// Start calibration
|
|
async function startCalibration() {
|
|
const samples = 10; // Fixed samples per test
|
|
const delay = 50; // Fixed delay in ms
|
|
|
|
try {
|
|
const response = await fetch('/api/cad-calibration-start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ samples, delay_ms: delay })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
isRunning = true;
|
|
startTime = Date.now();
|
|
calibrationData = {};
|
|
|
|
// Clear chart and reset data
|
|
individualSamples = [];
|
|
calibrationData = {};
|
|
allTestResults = {}; // Clear all individual test results
|
|
|
|
// Hide range info and legend until new calibration starts
|
|
document.getElementById('range-info').style.display = 'none';
|
|
document.getElementById('visualization-legend').style.display = 'none';
|
|
|
|
if (svg) {
|
|
svg.selectAll(".contour-patch").remove();
|
|
svg.selectAll(".tested-area").remove();
|
|
svg.selectAll(".sample-dot").remove();
|
|
svg.select("#clip").remove();
|
|
console.log('Cleared contour patches and reset data structures');
|
|
}
|
|
|
|
// Update UI
|
|
document.getElementById('start-btn').disabled = true;
|
|
document.getElementById('stop-btn').disabled = false;
|
|
document.getElementById('results-container').classList.add('hidden');
|
|
document.getElementById('best-result').classList.add('hidden');
|
|
|
|
// Reset statistics
|
|
document.getElementById('tests-completed').textContent = '0';
|
|
document.getElementById('best-rate').textContent = '0%';
|
|
document.getElementById('avg-rate').textContent = '0%';
|
|
document.getElementById('elapsed-time').textContent = '0s';
|
|
document.getElementById('progress-fill').style.width = '0%';
|
|
document.getElementById('progress-text').textContent = '0 / 0 tests completed';
|
|
|
|
// Start SSE connection
|
|
connectEventSource();
|
|
} else {
|
|
showError(result.error || 'Failed to start calibration');
|
|
}
|
|
} catch (error) {
|
|
showError('Failed to start calibration: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// Stop calibration
|
|
async function stopCalibration() {
|
|
try {
|
|
const response = await fetch('/api/cad-calibration-stop', {
|
|
method: 'POST'
|
|
});
|
|
|
|
if (response.ok) {
|
|
isRunning = false;
|
|
document.getElementById('start-btn').disabled = false;
|
|
document.getElementById('stop-btn').disabled = true;
|
|
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
eventSource = null;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to stop calibration:', error);
|
|
}
|
|
}
|
|
|
|
// Connect to Server-Sent Events
|
|
function connectEventSource() {
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
}
|
|
|
|
eventSource = new EventSource('/api/cad-calibration-stream');
|
|
|
|
eventSource.onmessage = function(event) {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
handleCalibrationUpdate(data);
|
|
} catch (error) {
|
|
console.error('Failed to parse SSE data:', error);
|
|
}
|
|
};
|
|
|
|
eventSource.onerror = function(event) {
|
|
console.error('SSE connection error:', event);
|
|
if (!isRunning) {
|
|
eventSource.close();
|
|
eventSource = null;
|
|
}
|
|
};
|
|
}
|
|
|
|
// Update chart scales based on test ranges
|
|
function updateChartScales(ranges) {
|
|
if (!ranges || !x || !y || !svg) {
|
|
console.log('Cannot update scales - missing components:', { ranges, x: !!x, y: !!y, svg: !!svg });
|
|
return;
|
|
}
|
|
|
|
console.log('Updating chart scales with ranges:', ranges);
|
|
|
|
// Calculate total tests
|
|
const peakCount = ranges.peak_max - ranges.peak_min + 1;
|
|
const minCount = ranges.min_max - ranges.min_min + 1;
|
|
const totalTests = peakCount * minCount;
|
|
|
|
// Update range info display
|
|
document.getElementById('range-info').style.display = 'block';
|
|
document.getElementById('visualization-legend').style.display = 'block';
|
|
document.getElementById('sf-display').textContent = `SF${ranges.spreading_factor}`;
|
|
document.getElementById('peak-range').textContent = `Peak: ${ranges.peak_min} - ${ranges.peak_max}`;
|
|
document.getElementById('min-range').textContent = `Min: ${ranges.min_min} - ${ranges.min_max}`;
|
|
document.getElementById('total-tests').textContent = `${totalTests} tests`;
|
|
|
|
// Use FIXED domains for consistent hexagon positioning
|
|
// This prevents auto-scaling issues that distort hexagon placement
|
|
const newPeakDomain = [ranges.peak_min - 0.5, ranges.peak_max + 0.5];
|
|
const newMinDomain = [ranges.min_min - 0.5, ranges.min_max + 0.5];
|
|
|
|
console.log('Setting FIXED domains:', {
|
|
peak: newPeakDomain,
|
|
min: newMinDomain
|
|
});
|
|
|
|
x.domain(newPeakDomain);
|
|
y.domain(newMinDomain);
|
|
|
|
// Update hexbin extent to match EXACT scale bounds
|
|
if (hexbin) {
|
|
hexbin.extent([[0, 0], [width, height]]);
|
|
console.log('Updated hexbin extent to chart bounds');
|
|
}
|
|
|
|
// Update axes WITHOUT transitions to prevent positioning issues
|
|
const xAxisSelection = svg.select(".x-axis");
|
|
const yAxisSelection = svg.select(".y-axis");
|
|
|
|
if (!xAxisSelection.empty()) {
|
|
xAxisSelection.call(d3.axisBottom(x).tickFormat(d3.format("d")));
|
|
}
|
|
|
|
if (!yAxisSelection.empty()) {
|
|
yAxisSelection.call(d3.axisLeft(y).tickFormat(d3.format("d")));
|
|
}
|
|
|
|
// Ensure axis text stays white
|
|
svg.selectAll(".axis text").style("fill", "#fff");
|
|
|
|
console.log('Chart scale update completed with fixed positioning');
|
|
}
|
|
|
|
// Handle calibration updates
|
|
function handleCalibrationUpdate(data) {
|
|
console.log('Received calibration update:', data);
|
|
switch (data.type) {
|
|
case 'status':
|
|
document.getElementById('status-message').textContent = data.message;
|
|
|
|
// Update chart scales if test ranges are provided
|
|
console.log('Status message received:', data.message);
|
|
console.log('Test ranges in status:', data.test_ranges);
|
|
if (data.test_ranges) {
|
|
console.log('Calling updateChartScales with ranges:', data.test_ranges);
|
|
updateChartScales(data.test_ranges);
|
|
} else {
|
|
console.log('No test_ranges in status message');
|
|
}
|
|
break;
|
|
|
|
case 'progress':
|
|
const percent = data.total > 0 ? (data.current / data.total) * 100 : 0;
|
|
document.getElementById('progress-fill').style.width = percent + '%';
|
|
document.getElementById('progress-text').textContent = `${data.current} / ${data.total} tests completed`;
|
|
document.getElementById('tests-completed').textContent = data.current;
|
|
break;
|
|
|
|
case 'result':
|
|
// Store result and update chart with individual sample breakdown
|
|
console.log('Processing result:', data);
|
|
const key = `${data.det_peak}_${data.det_min}`;
|
|
calibrationData[key] = data;
|
|
|
|
// Pass samples and detections to create individual sample visualizations
|
|
updateChart(data.det_peak, data.det_min, data.detection_rate, data.samples, data.detections);
|
|
updateStats();
|
|
break;
|
|
|
|
case 'complete':
|
|
case 'completed':
|
|
isRunning = false;
|
|
document.getElementById('start-btn').disabled = false;
|
|
document.getElementById('stop-btn').disabled = true;
|
|
document.getElementById('status-message').textContent = data.message || 'Calibration completed';
|
|
|
|
// Calculate and show results from our collected data
|
|
calculateAndShowResults();
|
|
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
eventSource = null;
|
|
}
|
|
break;
|
|
|
|
case 'error':
|
|
showError(data.message);
|
|
stopCalibration();
|
|
break;
|
|
}
|
|
|
|
// Update elapsed time
|
|
if (startTime) {
|
|
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
document.getElementById('elapsed-time').textContent = elapsed + 's';
|
|
}
|
|
}
|
|
|
|
// Update statistics
|
|
function updateStats() {
|
|
const rates = Object.values(calibrationData).map(d => d.detection_rate);
|
|
if (rates.length === 0) return;
|
|
|
|
const bestRate = Math.max(...rates);
|
|
const avgRate = rates.reduce((a, b) => a + b, 0) / rates.length;
|
|
|
|
document.getElementById('best-rate').textContent = bestRate.toFixed(1) + '%';
|
|
document.getElementById('avg-rate').textContent = avgRate.toFixed(1) + '%';
|
|
}
|
|
|
|
// Calculate and show results from collected data
|
|
function calculateAndShowResults() {
|
|
console.log('Calculating results from collected data...');
|
|
console.log('Calibration data:', calibrationData);
|
|
|
|
document.getElementById('results-container').classList.remove('hidden');
|
|
|
|
// Find best result from our collected data
|
|
let bestResult = null;
|
|
let bestRate = 0;
|
|
|
|
for (const [key, result] of Object.entries(calibrationData)) {
|
|
if (result.detection_rate > bestRate) {
|
|
bestRate = result.detection_rate;
|
|
bestResult = result;
|
|
}
|
|
}
|
|
|
|
console.log('Best result found:', bestResult);
|
|
|
|
// Store globally for save functionality
|
|
bestCalibrationResult = bestResult;
|
|
|
|
if (bestResult && bestRate > 0) {
|
|
document.getElementById('best-result').classList.remove('hidden');
|
|
document.getElementById('no-results').classList.add('hidden');
|
|
document.getElementById('best-settings').innerHTML =
|
|
`Peak: <strong>${bestResult.det_peak}</strong>, Min: <strong>${bestResult.det_min}</strong>, Rate: <strong>${bestResult.detection_rate.toFixed(1)}%</strong>`;
|
|
} else {
|
|
// Show no results message
|
|
document.getElementById('best-result').classList.add('hidden');
|
|
document.getElementById('no-results').classList.remove('hidden');
|
|
bestCalibrationResult = null;
|
|
}
|
|
}
|
|
|
|
// Legacy function for backward compatibility
|
|
function showResults(results) {
|
|
calculateAndShowResults();
|
|
}
|
|
|
|
// Show error message
|
|
function showError(message) {
|
|
document.getElementById('status-message').textContent = 'Error: ' + message;
|
|
document.getElementById('status-message').style.color = 'var(--error)';
|
|
}
|
|
|
|
// Save calibration settings to config
|
|
async function saveSettings() {
|
|
const saveBtn = document.getElementById('save-settings-btn');
|
|
if (!bestCalibrationResult) {
|
|
showError('No calibration results to save');
|
|
return;
|
|
}
|
|
|
|
// Disable save button and show loading
|
|
saveBtn.disabled = true;
|
|
const originalContent = saveBtn.innerHTML;
|
|
saveBtn.innerHTML = `
|
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M21 12a9 9 0 11-6.219-8.56"></path>
|
|
</svg>
|
|
<span class="control-label">
|
|
<span class="control-title">Saving...</span>
|
|
<span class="control-value">Updating configuration</span>
|
|
</span>
|
|
`;
|
|
|
|
try {
|
|
const response = await fetch('/api/save_cad_settings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
peak: bestCalibrationResult.det_peak,
|
|
min_val: bestCalibrationResult.det_min,
|
|
detection_rate: bestCalibrationResult.detection_rate
|
|
})
|
|
});
|
|
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
// Success feedback
|
|
saveBtn.innerHTML = `
|
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="20 6 9 17 4 12"></polyline>
|
|
</svg>
|
|
<span class="control-label">
|
|
<span class="control-title">Saved!</span>
|
|
<span class="control-value">Settings applied</span>
|
|
</span>
|
|
`;
|
|
|
|
// Show success message
|
|
document.getElementById('status-message').textContent =
|
|
`Settings saved! Peak=${bestCalibrationResult.det_peak}, Min=${bestCalibrationResult.det_min} applied to configuration.`;
|
|
document.getElementById('status-message').style.color = 'var(--success)';
|
|
|
|
// Reset button after 3 seconds
|
|
setTimeout(() => {
|
|
saveBtn.innerHTML = originalContent;
|
|
saveBtn.disabled = false;
|
|
}, 3000);
|
|
} else {
|
|
throw new Error(result.error || 'Failed to save settings');
|
|
}
|
|
} catch (error) {
|
|
console.error('Save error:', error);
|
|
showError('Failed to save settings: ' + error.message);
|
|
saveBtn.innerHTML = originalContent;
|
|
saveBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// Store best result globally for saving
|
|
let bestCalibrationResult = null;
|
|
|
|
// Event listeners
|
|
document.getElementById('start-btn').addEventListener('click', startCalibration);
|
|
document.getElementById('stop-btn').addEventListener('click', stopCalibration);
|
|
document.getElementById('save-settings-btn').addEventListener('click', saveSettings);
|
|
|
|
// Initialize chart when page loads
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
console.log('Initializing CAD calibration hexbin chart...');
|
|
initChart();
|
|
// Reset data tracking
|
|
individualSamples = [];
|
|
calibrationData = {};
|
|
allTestResults = {};
|
|
});
|
|
|
|
// Cleanup on page unload
|
|
window.addEventListener('beforeunload', function() {
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html> |