Dash web: crosshair, zoom/pan X, scale log/lin, continuum extraction, background resume

- Tooltip entier (intersect:false) + ligne verticale crosshair sur tous les graphes
- Zoom molette/pinch sur l'axe X, pan souris, limites clamped 30-3000 keV
- Toggle échelle log/linéaire onglet Background
- Extraction continuum détecteur (isotope peaks subtracted + Gaussian smoothing)
- Reprise snapshot précédent au démarrage capture_background.py
- Suppression refs "Théorique" et "Bruit capteur" de l'interface
- Plugin chartjs-plugin-zoom + hammerjs via CDN
- Fix Chart constructor spread operator

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jacquin Antoine
2026-05-19 23:26:28 +02:00
parent 0f2417bf88
commit c764a5c264
15 changed files with 975 additions and 221 deletions

View File

@ -78,6 +78,8 @@ main { padding: 16px; }
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
height: 450px;
position: relative;
}
.controls {
@ -108,6 +110,63 @@ main { padding: 16px; }
.controls button:hover { background: var(--accent-bright); color: #000; }
.btn-small {
background: var(--accent);
color: var(--text);
border: 1px solid #444;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 0.8em;
margin-left: auto;
}
.btn-small:hover { background: var(--accent-bright); color: #000; }
.chart-container.fullscreen {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 1000;
background: var(--bg);
padding: 20px;
margin: 0;
border-radius: 0;
display: flex;
flex-direction: column;
}
.chart-container.fullscreen canvas {
flex: 1;
}
.exit-fullscreen-btn {
display: none;
position: absolute;
top: 10px;
right: 14px;
z-index: 1001;
background: rgba(255,255,255,0.15);
color: #fff;
border: none;
border-radius: 50%;
width: 36px;
height: 36px;
font-size: 1.2em;
cursor: pointer;
line-height: 1;
}
.chart-container.fullscreen .exit-fullscreen-btn {
display: flex;
align-items: center;
justify-content: center;
}
.exit-fullscreen-btn:hover {
background: rgba(255,255,255,0.3);
}
.chart-header {
display: flex;
justify-content: flex-end;
margin-bottom: 4px;
}
#isotopes-table, #peaks-table {
background: var(--bg-card);
border-radius: 8px;

View File

@ -7,6 +7,8 @@
<link rel="stylesheet" href="/static/css/style.css?v=2">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3.0.1/dist/chartjs-plugin-annotation.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hammerjs@2.0.8/hammer.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.min.js"></script>
</head>
<body>
<header>
@ -38,6 +40,7 @@
<label id="lines-detected-label" style="display:none"><input type="checkbox" id="lines-detected-only" checked> Détectés uniquement</label>
<label><input type="checkbox" id="show-bg-overlay"> Overlay background</label>
<button id="download-csv" class="btn-small">CSV</button>
<button id="reset-zoom-spectrum" class="btn-small" style="display:none" title="Réinitialiser le zoom"></button>
<button id="fullscreen-btn" class="btn-small" title="Plein écran">&#x26F6;</button>
</div>
<div id="isotopes-table"></div>
@ -51,9 +54,10 @@
<div class="bg-stats" id="bg-stats"></div>
<div class="chart-header">
<label style="font-size:0.85em;color:#888;display:flex;align-items:center;gap:4px"><input type="checkbox" id="show-bg-smooth" checked> Lissé</label>
<label style="font-size:0.85em;color:#888;display:flex;align-items:center;gap:4px"><input type="checkbox" id="show-bg-theoretical"> Théorique</label>
<label style="font-size:0.85em;color:#888;display:flex;align-items:center;gap:4px"><input type="checkbox" id="show-bg-continuum"> Continuum CsI</label>
<label style="font-size:0.85em;color:#888;display:flex;align-items:center;gap:4px"><input type="checkbox" id="show-bg-continuum"> Continuum</label>
<label style="display:none;font-size:0.85em;color:#888"><input type="checkbox" id="show-bg-reference"> Ref 24h</label>
<label style="font-size:0.85em;color:#888;display:flex;align-items:center;gap:4px"><input type="checkbox" id="bg-scale-log" checked> Log</label>
<button id="reset-zoom-bg" class="btn-small" style="display:none" title="Réinitialiser le zoom"></button>
<button class="btn-small fullscreen-btn" title="Plein écran">&#x26F6;</button>
</div>
<div class="chart-container">
@ -69,6 +73,7 @@
<button onclick="loadCps(6)">6h</button>
<button onclick="loadCps(24)">24h</button>
<button onclick="loadCps(168)">7j</button>
<button id="reset-zoom-cps" class="btn-small" style="display:none" title="Réinitialiser le zoom"></button>
<button class="btn-small fullscreen-btn" title="Plein écran">&#x26F6;</button>
</div>
<div class="chart-container">
@ -78,11 +83,11 @@
</section>
</main>
<script src="/static/js/isotope_lines.js?v=2"></script>
<script src="/static/js/spectrum.js?v=2"></script>
<script src="/static/js/isotope_lines.js?v=3"></script>
<script src="/static/js/spectrum.js?v=6"></script>
<script src="/static/js/history.js?v=2"></script>
<script src="/static/js/background.js?v=3"></script>
<script src="/static/js/cps.js?v=2"></script>
<script src="/static/js/app.js?v=2"></script>
<script src="/static/js/background.js?v=11"></script>
<script src="/static/js/cps.js?v=5"></script>
<script src="/static/js/app.js?v=3"></script>
</body>
</html>

View File

@ -28,9 +28,13 @@ async function refreshStatus() {
}
const data = await resp.json();
const dot = document.getElementById('status-connected');
dot.className = data.connected && !data.stale ? 'status-dot connected' : 'status-dot';
const ok = data.connected && !data.stale;
dot.className = ok ? 'status-dot connected' : 'status-dot';
document.getElementById('status-cps').textContent = `${data.cps.toFixed(1)} CPS`;
document.getElementById('status-live-time').textContent = `${data.cumulated_live_time_h.toFixed(1)} h`;
document.title = ok
? `${data.cps.toFixed(1)} CPS · ${data.cumulated_live_time_h.toFixed(1)}h`
: 'Hors ligne';
} catch {
document.getElementById('status-connected').className = 'status-dot';
}
@ -47,5 +51,41 @@ function startRefresh() {
}, REFRESH_MS);
}
// Initialize
startRefresh();
// Isotope lines toggle — show/hide "detected only" checkbox
document.getElementById('show-isotope-lines').addEventListener('change', (e) => {
document.getElementById('lines-detected-label').style.display = e.target.checked ? 'flex' : 'none';
if (!e.target.checked) refreshSpectrum();
});
// Fullscreen toggle for charts
function exitFullscreen() {
document.querySelectorAll('.chart-container.fullscreen').forEach(c => c.classList.remove('fullscreen'));
document.querySelectorAll('.fullscreen-btn, #fullscreen-btn').forEach(btn => btn.innerHTML = '&#x26F6;');
setTimeout(() => window.dispatchEvent(new Event('resize')), 100);
}
document.querySelectorAll('.fullscreen-btn, #fullscreen-btn').forEach(btn => {
btn.addEventListener('click', () => {
const container = btn.closest('section').querySelector('.chart-container');
if (container.classList.contains('fullscreen')) {
exitFullscreen();
} else {
container.classList.add('fullscreen');
btn.innerHTML = '&#x2715;';
setTimeout(() => window.dispatchEvent(new Event('resize')), 100);
}
});
});
// Exit fullscreen buttons inside chart containers
document.querySelectorAll('.exit-fullscreen-btn').forEach(btn => {
btn.addEventListener('click', exitFullscreen);
});
// ESC to exit fullscreen
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') exitFullscreen();
});
// Initialize — called after all scripts are loaded
window.addEventListener('load', startRefresh);

View File

@ -1,6 +1,5 @@
let bgChart = null;
let bgReferenceData = null;
let bgTheoreticalData = null;
let bgContinuumData = null;
async function loadBgReference() {
@ -11,14 +10,6 @@ async function loadBgReference() {
} catch {}
}
async function loadBgTheoretical(cps, liveTime) {
try {
const resp = await fetch(`${API_BASE}/api/background/theoretical?cps=${cps}&live_time_s=${liveTime}`);
if (!resp.ok) return;
bgTheoreticalData = await resp.json();
} catch {}
}
async function loadBgContinuum(cps, liveTime) {
try {
const resp = await fetch(`${API_BASE}/api/background/continuum?cps=${cps}&live_time_s=${liveTime}`);
@ -30,7 +21,7 @@ async function loadBgContinuum(cps, liveTime) {
/**
* Gaussian kernel smoothing.
* Convolves the data with a Gaussian kernel of given sigma (in channels).
* Preserves peak shapes while removing statistical noise.
* Preserves peak shapes while reducing statistical variation.
*/
function smoothGaussian(data, sigma) {
if (!data || data.length === 0) return data;
@ -79,12 +70,7 @@ async function refreshBackground() {
<div class="bg-stat"><div class="bg-stat-value">${info.cps.toFixed(2)}</div><div class="bg-stat-label">CPS</div></div>
`;
// Load theoretical curve on first load
if (!bgTheoreticalData && spec.live_time_s > 0) {
await loadBgTheoretical(info.cps || 6.0, spec.live_time_s);
}
// Load CsI(Tl) continuum on first load
// Load continuum on first load
if (!bgContinuumData && spec.live_time_s > 0) {
await loadBgContinuum(info.cps || 6.0, spec.live_time_s);
}
@ -102,9 +88,9 @@ async function refreshBackground() {
}
function updateBackgroundChart(spec) {
const showLog = document.getElementById('bg-scale-log')?.checked;
const ctx = document.getElementById('background-chart').getContext('2d');
const showRef = document.getElementById('show-bg-reference')?.checked && bgReferenceData;
const showTheory = document.getElementById('show-bg-theoretical')?.checked && bgTheoreticalData;
const showSmooth = document.getElementById('show-bg-smooth')?.checked;
const showContinuum = document.getElementById('show-bg-continuum')?.checked && bgContinuumData;
@ -119,8 +105,6 @@ function updateBackgroundChart(spec) {
}];
if (showSmooth) {
// Smoothed version of live data — sigma=8 channels (~24 keV)
// Wide enough to remove noise, narrow enough to preserve the 100 keV peak
const smoothed = smoothGaussian(spec.counts, 8);
datasets.push({
label: 'Lissé',
@ -133,22 +117,9 @@ function updateBackgroundChart(spec) {
});
}
if (showTheory) {
datasets.push({
label: 'Théorique',
data: bgTheoreticalData.counts,
borderColor: 'rgba(76, 175, 80, 0.7)',
backgroundColor: 'rgba(76, 175, 80, 0.05)',
borderWidth: 1.5,
pointRadius: 0,
fill: true,
borderDash: [6, 3],
});
}
if (showContinuum) {
datasets.push({
label: 'Continuum CsI(Tl)',
label: 'Continuum',
data: bgContinuumData.counts,
borderColor: 'rgba(156, 39, 176, 0.8)',
backgroundColor: 'rgba(156, 39, 176, 0.05)',
@ -183,13 +154,33 @@ function updateBackgroundChart(spec) {
const options = {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { labels: { color: '#e0e0e0' } },
tooltip: {
enabled: true,
mode: 'index',
intersect: false,
filter: (item) => item.raw != null,
callbacks: {
title: (items) => `${spec.energy_kev[items[0].dataIndex]} keV`,
label: (item) => `${item.dataset.label}: ${item.raw.toFixed(1)} counts`
}
},
zoom: {
pan: {
enabled: true,
mode: 'x',
modifierKey: null,
},
zoom: {
wheel: { enabled: true },
pinch: { enabled: true },
drag: { enabled: false },
mode: 'x',
limits: { x: { min: 30, max: 3000 } },
onZoom: () => { document.getElementById('reset-zoom-bg').style.display = 'inline-block'; }
}
}
},
scales: {
@ -200,21 +191,21 @@ function updateBackgroundChart(spec) {
grid: { color: '#333' },
},
y: {
type: 'logarithmic',
title: { display: true, text: 'Comptages (log)', color: '#888' },
min: 0.9,
type: showLog ? 'logarithmic' : 'linear',
title: { display: true, text: `Comptages (${showLog ? 'log' : 'lin'})`, color: '#888' },
...(showLog ? { min: 0.9 } : {}),
ticks: { color: '#888' },
grid: { color: '#333' },
}
}
};
if (bgChart) {
if (bgChart) {
bgChart.data = chartData;
bgChart.options = options;
bgChart.update();
} else {
bgChart = new Chart(ctx, { type: 'line', data: chartData, options });
bgChart = new Chart(ctx, { type: 'line', data: chartData, ...options });
}
}
@ -242,19 +233,23 @@ document.querySelector('[data-tab="background"]').addEventListener('click', () =
// Toggle handlers
document.getElementById('show-bg-reference')?.addEventListener('change', () => refreshBackground());
document.getElementById('show-bg-theoretical')?.addEventListener('change', () => {
if (document.getElementById('show-bg-theoretical').checked && !bgTheoreticalData) {
loadBgTheoretical(6.0, 3600).then(() => refreshBackground());
} else {
refreshBackground();
}
});
document.getElementById('show-bg-continuum')?.addEventListener('change', () => {
if (document.getElementById('show-bg-continuum').checked && !bgContinuumData) {
const info = document.getElementById('bg-stats');
loadBgContinuum(6.0, 3600).then(() => refreshBackground());
} else {
refreshBackground();
}
});
document.getElementById('show-bg-smooth')?.addEventListener('change', () => refreshBackground());
document.getElementById('show-bg-smooth')?.addEventListener('change', () => refreshBackground());
document.getElementById('bg-scale-log')?.addEventListener('change', () => refreshBackground());
// Reset zoom button
document.getElementById('reset-zoom-bg')?.addEventListener('click', () => {
if (bgChart) {
bgChart.resetZoom();
document.getElementById('reset-zoom-bg').style.display = 'none';
}
});
// Show/hide reset button based on zoom state — wrapped via options plugin
const _origBgOptions = options => options;

View File

@ -44,8 +44,36 @@ function updateCpsChart(labels, values) {
const options = {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { labels: { color: '#e0e0e0' } },
tooltip: {
enabled: true,
mode: 'index',
intersect: false,
filter: (item) => item.raw != null,
callbacks: {
title: (items) => {
const d = new Date(items[0].parsed.x / 1000);
return d.toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
},
label: (item) => `${item.dataset.label}: ${item.raw.toFixed(2)}`
}
},
zoom: {
pan: {
enabled: true,
mode: 'x',
modifierKey: null,
},
zoom: {
wheel: { enabled: true },
pinch: { enabled: true },
drag: { enabled: false },
mode: 'x',
onZoom: () => { document.getElementById('reset-zoom-cps').style.display = 'inline-block'; }
}
}
},
scales: {
x: {
@ -76,8 +104,16 @@ function updateCpsChart(labels, values) {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js';
script.onload = () => {
cpsChart = new Chart(ctx, { type: 'line', data: chartData, options });
cpsChart = new Chart(ctx, { type: 'line', data: chartData, ...options });
};
document.head.appendChild(script);
}
}
}
// Reset zoom
document.getElementById('reset-zoom-cps')?.addEventListener('click', () => {
if (cpsChart) {
cpsChart.resetZoom();
document.getElementById('reset-zoom-cps').style.display = 'none';
}
});

View File

@ -0,0 +1,122 @@
// Raies gamma principales pour les isotopes les plus courants
// Format : { isotope, energy_keV, intensity } (intensity = % de désintégration)
const ISOTOPE_LINES = [
// Chaîne Uranium-238 (présent naturellement)
{ isotope: "Bi-214", energy_keV: 609.3, intensity: 45.5 },
{ isotope: "Bi-214", energy_keV: 1120.3, intensity: 15.0 },
{ isotope: "Bi-214", energy_keV: 1764.5, intensity: 15.4 },
{ isotope: "Pb-214", energy_keV: 295.2, intensity: 19.2 },
{ isotope: "Pb-214", energy_keV: 351.9, intensity: 37.6 },
{ isotope: "Ra-226", energy_keV: 186.2, intensity: 3.6 },
// Chaîne Thorium-232 (présent naturellement)
{ isotope: "Ac-228", energy_keV: 911.2, intensity: 27.7 },
{ isotope: "Ac-228", energy_keV: 338.3, intensity: 11.3 },
{ isotope: "Tl-208", energy_keV: 583.2, intensity: 84.5 },
{ isotope: "Tl-208", energy_keV: 2614.5, intensity: 99.0 },
{ isotope: "Pb-212", energy_keV: 238.6, intensity: 43.6 },
// Potassium-40 (naturel, ubiquitaire)
{ isotope: "K-40", energy_keV: 1460.8, intensity: 10.7 },
// Isotopes artificiels courants
{ isotope: "Cs-137", energy_keV: 661.7, intensity: 85.1 },
{ isotope: "Cs-134", energy_keV: 604.7, intensity: 97.6 },
{ isotope: "Cs-134", energy_keV: 795.8, intensity: 85.5 },
{ isotope: "Co-60", energy_keV: 1173.2, intensity: 99.9 },
{ isotope: "Co-60", energy_keV: 1332.5, intensity: 100.0 },
{ isotope: "Co-58", energy_keV: 810.8, intensity: 99.4 },
{ isotope: "I-131", energy_keV: 364.5, intensity: 81.7 },
{ isotope: "I-131", energy_keV: 637.0, intensity: 7.3 },
{ isotope: "I-131", energy_keV: 284.3, intensity: 6.1 },
{ isotope: "Ba-133", energy_keV: 356.0, intensity: 62.0 },
{ isotope: "Ir-192", energy_keV: 316.5, intensity: 82.8 },
{ isotope: "Ir-192", energy_keV: 468.1, intensity: 47.8 },
{ isotope: "Ir-192", energy_keV: 604.4, intensity: 8.2 },
{ isotope: "Am-241", energy_keV: 59.5, intensity: 35.9 },
// Autres courants
{ isotope: "Na-22", energy_keV: 511.0, intensity: 90.0 },
{ isotope: "Na-22", energy_keV: 1274.5, intensity: 99.9 },
{ isotope: "Eu-152", energy_keV: 121.8, intensity: 28.6 },
{ isotope: "Eu-152", energy_keV: 344.3, intensity: 26.6 },
{ isotope: "Eu-152", energy_keV: 1408.0, intensity: 20.9 },
{ isotope: "Mn-54", energy_keV: 834.8, intensity: 99.9 },
{ isotope: "Zn-65", energy_keV: 1115.5, intensity: 50.0 },
{ isotope: "Zr-95/Nb-95", energy_keV: 765.8, intensity: 99.8 },
{ isotope: "Ru-106/Rh-106", energy_keV: 512.0, intensity: 20.5 },
];
// Filtrer les lignes dans la plage visible du détecteur (30-3050 keV pour Radiacode 103)
const VISIBLE_LINES = ISOTOPE_LINES.filter(l => l.energy_keV >= 30 && l.energy_keV <= 3050);
// Global crosshair plugin — vertical dashed line on hover for all charts
const CrosshairPlugin = {
id: 'crosshair',
afterDraw(chart) {
const tooltip = chart.tooltip;
if (!tooltip || !tooltip._active || tooltip._active.length === 0) return;
const x = tooltip._active[0].element.x;
const { top, bottom } = chart.chartArea;
const ctx = chart.ctx;
ctx.save();
ctx.beginPath();
ctx.moveTo(x, top);
ctx.lineTo(x, bottom);
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
ctx.setLineDash([4, 3]);
ctx.stroke();
ctx.restore();
},
};
Chart.register(CrosshairPlugin);
// Couleurs par catégorie d'isotope
function isotopeLineColor(isotope) {
if (["K-40", "Bi-214", "Pb-214", "Ra-226"].includes(isotope)) return "rgba(255,152,0,0.5)"; // Uranium chain - orange
if (["Ac-228", "Tl-208", "Pb-212"].includes(isotope)) return "rgba(156,39,176,0.5)"; // Thorium chain - purple
if (isotope === "K-40") return "rgba(255,193,7,0.5)"; // K-40 - yellow
if (isotope.startsWith("Cs") || isotope.startsWith("I-131")) return "rgba(244,67,54,0.5)"; // Fission products - red
if (isotope.startsWith("Co")) return "rgba(76,175,80,0.5)"; // Activation - green
return "rgba(100,181,246,0.35)"; // Others - light blue
}
function isotopeLabelColor(isotope) {
if (["K-40", "Bi-214", "Pb-214", "Ra-226"].includes(isotope)) return "#ff9800";
if (["Ac-228", "Tl-208", "Pb-212"].includes(isotope)) return "#9c27b0";
if (isotope === "K-40") return "#ffc107";
if (isotope.startsWith("Cs") || isotope.startsWith("I-131")) return "#f44336";
if (isotope.startsWith("Co")) return "#4caf50";
return "#64b5f6";
}
// Créer les annotations Chart.js pour les raies isotopiques
function buildIsotopeAnnotations(showDetectedOnly, detectedIsotopes) {
const annotations = {};
const detected = new Set(detectedIsotopes || []);
VISIBLE_LINES.forEach((line, i) => {
if (showDetectedOnly && !detected.has(line.isotope)) return;
// Regrouper les labels pour éviter les chevauchements
annotations[`line_${i}`] = {
type: 'line',
xMin: line.energy_keV,
xMax: line.energy_keV,
borderColor: isotopeLineColor(line.isotope),
borderWidth: 1,
label: {
display: true,
content: `${line.isotope} ${line.energy_keV}`,
position: 'start',
backgroundColor: 'rgba(26,26,46,0.85)',
color: isotopeLabelColor(line.isotope),
font: { size: 9 },
padding: 2,
rotation: -90,
}
};
});
return annotations;
}

View File

@ -17,34 +17,83 @@ async function refreshSpectrum() {
function updateSpectrumChart(data) {
const logScale = document.getElementById('log-scale').checked;
const showLines = document.getElementById('show-isotope-lines').checked;
const detectedOnly = document.getElementById('lines-detected-only').checked;
const showBgOverlay = document.getElementById('show-bg-overlay').checked;
const ctx = document.getElementById('spectrum-chart').getContext('2d');
const chartData = {
labels: data.energy_kev,
datasets: [{
label: data.background_subtracted ? 'Spectre (background soustrait)' : 'Spectre cumulé',
data: data.counts,
borderColor: '#4fc3f7',
backgroundColor: 'rgba(79, 195, 247, 0.1)',
const datasets = [{
label: data.background_subtracted ? 'Spectre (background soustrait)' : 'Spectre cumulé',
data: data.counts,
borderColor: '#4fc3f7',
backgroundColor: 'rgba(79, 195, 247, 0.1)',
borderWidth: 1,
pointRadius: 0,
fill: true,
tension: 0.1,
}];
// Overlay background if requested and available
if (showBgOverlay && bgOverlayData) {
datasets.push({
label: 'Background',
data: bgOverlayData.counts,
borderColor: 'rgba(255, 152, 0, 0.6)',
backgroundColor: 'rgba(255, 152, 0, 0.05)',
borderWidth: 1,
pointRadius: 0,
fill: true,
}]
tension: 0.1,
});
}
const chartData = {
labels: data.energy_kev,
datasets: datasets,
};
// Annotations
let annotations = {};
if (showLines) {
annotations = buildIsotopeAnnotations(detectedOnly, (data.isotopes_detected || []).map(i => i.isotope));
}
const options = {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 300 },
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { labels: { color: '#e0e0e0' } },
tooltip: {
enabled: true,
mode: 'index',
intersect: false,
filter: (item) => item.raw != null,
callbacks: {
title: (items) => {
const idx = items[0].dataIndex;
return `${data.energy_kev[idx]} keV`;
},
label: (item) => `${item.raw.toFixed(1)} counts`
label: (item) => `${item.dataset.label}: ${item.raw.toFixed(1)} counts`
}
},
annotation: {
annotations: annotations
},
zoom: {
pan: {
enabled: true,
mode: 'x',
modifierKey: null,
},
zoom: {
wheel: { enabled: true },
pinch: { enabled: true },
drag: { enabled: false },
mode: 'x',
limits: { x: { min: 30, max: 3000 } },
onZoom: () => { document.getElementById('reset-zoom-spectrum').style.display = 'inline-block'; }
}
}
},
@ -57,7 +106,8 @@ function updateSpectrumChart(data) {
},
y: {
type: logScale ? 'logarithmic' : 'linear',
title: { display: true, text: 'Comptages', color: '#888' },
title: { display: true, text: logScale ? 'Comptages (log)' : 'Comptages', color: '#888' },
min: logScale ? 0.9 : undefined,
ticks: { color: '#888' },
grid: { color: '#333' },
}
@ -69,7 +119,7 @@ function updateSpectrumChart(data) {
spectrumChart.options = options;
spectrumChart.update();
} else {
spectrumChart = new Chart(ctx, { type: 'line', data: chartData, options });
spectrumChart = new Chart(ctx, { type: 'line', data: chartData, ...options });
}
}
@ -92,6 +142,48 @@ function updateIsotopesTable(isotopes) {
container.innerHTML = html;
}
let bgOverlayData = null;
async function loadBgOverlay() {
if (bgOverlayData) return;
try {
const resp = await fetch(`${API_BASE}/api/background/spectrum`);
if (!resp.ok) return;
bgOverlayData = await resp.json();
} catch {}
}
// Event listeners
document.getElementById('show-difference').addEventListener('change', refreshSpectrum);
document.getElementById('log-scale').addEventListener('change', refreshSpectrum);
document.getElementById('log-scale').addEventListener('change', refreshSpectrum);
document.getElementById('show-isotope-lines').addEventListener('change', refreshSpectrum);
document.getElementById('lines-detected-only').addEventListener('change', refreshSpectrum);
document.getElementById('show-bg-overlay').addEventListener('change', async (e) => {
if (e.target.checked) await loadBgOverlay();
refreshSpectrum();
});
// Reset zoom
document.getElementById('reset-zoom-spectrum')?.addEventListener('click', () => {
if (spectrumChart) {
spectrumChart.resetZoom();
document.getElementById('reset-zoom-spectrum').style.display = 'none';
}
});
// Download CSV
document.getElementById('download-csv').addEventListener('click', () => {
if (!currentSpectrumData) return;
const header = 'energy_keV,counts';
const rows = currentSpectrumData.energy_kev.map((e, i) =>
`${e},${currentSpectrumData.counts[i]}`
);
const csv = [header, ...rows].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `spectrum_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')}.csv`;
a.click();
URL.revokeObjectURL(url);
});