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