Skip to content

Commit 486d4ec

Browse files
i6modsclaude
andcommitted
Add min/max range card, auto-refresh countdown, and threshold annotation
- Period Range stat card: single card with Low ▼ / High ▲ µSv/h split layout, label updates dynamically with selected time range - Auto-refresh countdown: refresh button counts down ↻ 30s → 1s then auto-reloads; manual click resets the countdown cleanly - Threshold annotation: green dashed line at 0.30 µSv/h on dose chart marking the normal background ceiling; hidden from hover tooltips Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent fe10d8a commit 486d4ec

1 file changed

Lines changed: 125 additions & 37 deletions

File tree

index.html

Lines changed: 125 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,38 @@
284284
50% { opacity: 0.3; }
285285
}
286286

287+
/* ── MIN / MAX SPLIT CARD ── */
288+
.stat-minmax-row {
289+
display: flex;
290+
align-items: center;
291+
justify-content: center;
292+
gap: 12px;
293+
margin-bottom: 6px;
294+
}
295+
296+
.stat-minmax-col { text-align: center; }
297+
298+
.stat-minmax-val {
299+
font-size: 1.55em;
300+
font-weight: 700;
301+
line-height: 1;
302+
text-shadow: 0 0 12px currentColor;
303+
}
304+
305+
.stat-minmax-lbl {
306+
font-size: 0.7em;
307+
color: var(--text-dim);
308+
text-transform: uppercase;
309+
letter-spacing: 1px;
310+
margin-top: 4px;
311+
}
312+
313+
.stat-minmax-divider {
314+
width: 1px;
315+
height: 36px;
316+
background: var(--border);
317+
}
318+
287319
/* ── RADIATION SCALE ── */
288320
.scale-container {
289321
background: var(--bg-card);
@@ -494,6 +526,22 @@ <h1>Radiation Monitor</h1>
494526
<div class="stat-value" id="avgUSV">--</div>
495527
<div class="stat-unit">µSv/h</div>
496528
</div>
529+
<div class="stat-card">
530+
<span class="stat-icon"></span>
531+
<div class="stat-label" id="minMaxLabel">Period Range</div>
532+
<div class="stat-minmax-row">
533+
<div class="stat-minmax-col">
534+
<div class="stat-minmax-val" id="periodLow" style="color:#4CAF50">--</div>
535+
<div class="stat-minmax-lbl">▼ Low</div>
536+
</div>
537+
<div class="stat-minmax-divider"></div>
538+
<div class="stat-minmax-col">
539+
<div class="stat-minmax-val" id="periodHigh" style="color:#FF8C00">--</div>
540+
<div class="stat-minmax-lbl">▲ High</div>
541+
</div>
542+
</div>
543+
<div class="stat-unit">µSv/h</div>
544+
</div>
497545
</div>
498546

499547
<div class="scale-container">
@@ -549,6 +597,7 @@ <h3>☢ <span>Dose Rate</span> — µSv/h</h3>
549597

550598
let currentRange = '1h';
551599
let charts = {};
600+
let countdownTimer = null;
552601

553602
const timeRanges = {
554603
// minutes=60 → true 60-min window (results=120 was count-based;
@@ -574,10 +623,11 @@ <h3>☢ <span>Dose Rate</span> — µSv/h</h3>
574623
});
575624
document.getElementById('refreshBtn').addEventListener('click', loadData);
576625
loadData();
577-
setInterval(loadData, 30000);
578626
});
579627

580628
async function loadData() {
629+
clearInterval(countdownTimer);
630+
document.getElementById('refreshBtn').textContent = '↻ Loading…';
581631
document.getElementById('lastUpdate').textContent = 'Loading...';
582632
const { param } = timeRanges[currentRange];
583633
const url = `https://api.thingspeak.com/channels/${CHANNEL_ID}/feeds.json?api_key=${READ_API_KEY}&${param}`;
@@ -591,6 +641,22 @@ <h3>☢ <span>Dose Rate</span> — µSv/h</h3>
591641
document.getElementById('lastUpdate').textContent = 'Error loading data';
592642
console.error(err);
593643
}
644+
startCountdown();
645+
}
646+
647+
function startCountdown() {
648+
let secs = 30;
649+
const btn = document.getElementById('refreshBtn');
650+
btn.textContent = `↻ ${secs}s`;
651+
countdownTimer = setInterval(() => {
652+
secs--;
653+
if (secs <= 0) {
654+
clearInterval(countdownTimer);
655+
loadData();
656+
} else {
657+
btn.textContent = `↻ ${secs}s`;
658+
}
659+
}, 1000);
594660
}
595661

596662
function processData(feeds) {
@@ -615,19 +681,24 @@ <h3>☢ <span>Dose Rate</span> — µSv/h</h3>
615681
const cpmData = feeds.map(f => parseFloat(f.field1) || null);
616682
const usvData = feeds.map(f => parseFloat(f.field2) || null);
617683

618-
// Period average stat card
684+
// Period average / min / max stat cards
619685
const validUSV = usvData.filter(v => v !== null && !isNaN(v));
620686
const avgUSV = validUSV.length > 0 ? validUSV.reduce((a, b) => a + b, 0) / validUSV.length : null;
687+
const minUSV = validUSV.length > 0 ? Math.min(...validUSV) : null;
688+
const maxUSV = validUSV.length > 0 ? Math.max(...validUSV) : null;
621689
document.getElementById('avgUSVLabel').textContent = getAvgLabel();
622-
document.getElementById('avgUSV').textContent = avgUSV !== null ? avgUSV.toFixed(4) : '--';
690+
document.getElementById('avgUSV').textContent = avgUSV !== null ? avgUSV.toFixed(4) : '--';
691+
document.getElementById('minMaxLabel').textContent = getAvgLabel().replace('Avg', 'Range');
692+
document.getElementById('periodLow').textContent = minUSV !== null ? minUSV.toFixed(4) : '--';
693+
document.getElementById('periodHigh').textContent = maxUSV !== null ? maxUSV.toFixed(4) : '--';
623694

624695
// Moving average datasets for trend lines
625696
const win = getAvgWindow();
626697
const cpmAvg = computeMovingAverage(cpmData, win);
627698
const usvAvg = computeMovingAverage(usvData, win);
628699

629700
buildChart('cpmChart', labels, cpmData, cpmAvg, 'CPM', '#FFD700', 'rgba(255,215,0,0.08)');
630-
buildChart('usvChart', labels, usvData, usvAvg, 'µSv/h', '#FFA500', 'rgba(255,165,0,0.08)', 6);
701+
buildChart('usvChart', labels, usvData, usvAvg, 'µSv/h', '#FFA500', 'rgba(255,165,0,0.08)', 6, 0.30);
631702

632703
// Last update
633704
const ago = timeAgo(new Date(latest.created_at));
@@ -693,43 +764,59 @@ <h3>☢ <span>Dose Rate</span> — µSv/h</h3>
693764
document.getElementById('scaleMarker').style.left = Math.min(98, Math.max(1, pct)) + '%';
694765
}
695766

696-
function buildChart(id, labels, data, avgData, label, lineColor, fillColor, decimals = 1) {
767+
function buildChart(id, labels, data, avgData, label, lineColor, fillColor, decimals = 1, thresholdVal = null) {
697768
if (charts[id]) { charts[id].destroy(); }
698769
const ctx = document.getElementById(id).getContext('2d');
770+
771+
const datasets = [
772+
{
773+
label,
774+
data,
775+
borderColor: lineColor,
776+
backgroundColor: fillColor,
777+
borderWidth: 2,
778+
tension: 0.3,
779+
fill: true,
780+
pointRadius: data.length > 200 ? 0 : 2,
781+
pointHoverRadius: 5,
782+
spanGaps: true,
783+
order: 2
784+
},
785+
{
786+
label: getAvgLabel(),
787+
data: avgData,
788+
borderColor: 'rgba(255,255,255,0.55)',
789+
backgroundColor: 'transparent',
790+
borderWidth: 1.5,
791+
borderDash: [6, 4],
792+
tension: 0.4,
793+
fill: false,
794+
pointRadius: 0,
795+
pointHoverRadius: 4,
796+
spanGaps: true,
797+
order: 1
798+
}
799+
];
800+
801+
if (thresholdVal !== null) {
802+
datasets.push({
803+
label: `Normal limit (${thresholdVal} µSv/h)`,
804+
data: labels.map(() => thresholdVal),
805+
borderColor: 'rgba(76,175,80,0.7)',
806+
backgroundColor: 'transparent',
807+
borderWidth: 1.5,
808+
borderDash: [4, 4],
809+
fill: false,
810+
pointRadius: 0,
811+
pointHoverRadius: 0,
812+
spanGaps: true,
813+
order: 3
814+
});
815+
}
816+
699817
charts[id] = new Chart(ctx, {
700818
type: 'line',
701-
data: {
702-
labels,
703-
datasets: [
704-
{
705-
label,
706-
data,
707-
borderColor: lineColor,
708-
backgroundColor: fillColor,
709-
borderWidth: 2,
710-
tension: 0.3,
711-
fill: true,
712-
pointRadius: data.length > 200 ? 0 : 2,
713-
pointHoverRadius: 5,
714-
spanGaps: true,
715-
order: 2
716-
},
717-
{
718-
label: getAvgLabel(),
719-
data: avgData,
720-
borderColor: 'rgba(255,255,255,0.55)',
721-
backgroundColor: 'transparent',
722-
borderWidth: 1.5,
723-
borderDash: [6, 4],
724-
tension: 0.4,
725-
fill: false,
726-
pointRadius: 0,
727-
pointHoverRadius: 4,
728-
spanGaps: true,
729-
order: 1
730-
}
731-
]
732-
},
819+
data: { labels, datasets },
733820
options: {
734821
responsive: true,
735822
maintainAspectRatio: true,
@@ -755,6 +842,7 @@ <h3>☢ <span>Dose Rate</span> — µSv/h</h3>
755842
callbacks: {
756843
title: ctx => new Date(ctx[0].label).toLocaleString(),
757844
label: ctx => {
845+
if (thresholdVal !== null && ctx.datasetIndex === 2) return null;
758846
const v = ctx.parsed.y;
759847
const suffix = ctx.datasetIndex === 0 ? label : getAvgLabel();
760848
return ` ${v !== null && !isNaN(v) ? v.toFixed(decimals) : 'N/A'} ${suffix}`;

0 commit comments

Comments
 (0)