@@ -488,6 +488,12 @@ <h1>Radiation Monitor</h1>
488488 < div class ="stat-value " id ="dataPoints "> --</ div >
489489 < div class ="stat-unit "> in selected range</ div >
490490 </ div >
491+ < div class ="stat-card ">
492+ < span class ="stat-icon "> 📈</ span >
493+ < div class ="stat-label " id ="avgUSVLabel "> Period Average</ div >
494+ < div class ="stat-value " id ="avgUSV "> --</ div >
495+ < div class ="stat-unit "> µSv/h</ div >
496+ </ div >
491497 </ div >
492498
493499 < div class ="scale-container ">
@@ -605,12 +611,23 @@ <h3>☢ <span>Dose Rate</span> — µSv/h</h3>
605611 if ( ! isNaN ( usv ) ) updateScaleMarker ( usv ) ;
606612
607613 // Charts
608- const labels = feeds . map ( f => new Date ( f . created_at ) ) ;
614+ const labels = feeds . map ( f => new Date ( f . created_at ) ) ;
609615 const cpmData = feeds . map ( f => parseFloat ( f . field1 ) || null ) ;
610616 const usvData = feeds . map ( f => parseFloat ( f . field2 ) || null ) ;
611617
612- buildChart ( 'cpmChart' , labels , cpmData , 'CPM' , '#FFD700' , 'rgba(255,215,0,0.08)' ) ;
613- buildChart ( 'usvChart' , labels , usvData , 'µSv/h' , '#FFA500' , 'rgba(255,165,0,0.08)' , 6 ) ;
618+ // Period average stat card
619+ const validUSV = usvData . filter ( v => v !== null && ! isNaN ( v ) ) ;
620+ const avgUSV = validUSV . length > 0 ? validUSV . reduce ( ( a , b ) => a + b , 0 ) / validUSV . length : null ;
621+ document . getElementById ( 'avgUSVLabel' ) . textContent = getAvgLabel ( ) ;
622+ document . getElementById ( 'avgUSV' ) . textContent = avgUSV !== null ? avgUSV . toFixed ( 4 ) : '--' ;
623+
624+ // Moving average datasets for trend lines
625+ const win = getAvgWindow ( ) ;
626+ const cpmAvg = computeMovingAverage ( cpmData , win ) ;
627+ const usvAvg = computeMovingAverage ( usvData , win ) ;
628+
629+ 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 ) ;
614631
615632 // Last update
616633 const ago = timeAgo ( new Date ( latest . created_at ) ) ;
@@ -676,32 +693,58 @@ <h3>☢ <span>Dose Rate</span> — µSv/h</h3>
676693 document . getElementById ( 'scaleMarker' ) . style . left = Math . min ( 98 , Math . max ( 1 , pct ) ) + '%' ;
677694 }
678695
679- function buildChart ( id , labels , data , label , lineColor , fillColor , decimals = 1 ) {
696+ function buildChart ( id , labels , data , avgData , label , lineColor , fillColor , decimals = 1 ) {
680697 if ( charts [ id ] ) { charts [ id ] . destroy ( ) ; }
681698 const ctx = document . getElementById ( id ) . getContext ( '2d' ) ;
682699 charts [ id ] = new Chart ( ctx , {
683700 type : 'line' ,
684701 data : {
685702 labels,
686- datasets : [ {
687- label,
688- data,
689- borderColor : lineColor ,
690- backgroundColor : fillColor ,
691- borderWidth : 2 ,
692- tension : 0.3 ,
693- fill : true ,
694- pointRadius : data . length > 200 ? 0 : 2 ,
695- pointHoverRadius : 5 ,
696- spanGaps : true
697- } ]
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+ ]
698732 } ,
699733 options : {
700734 responsive : true ,
701735 maintainAspectRatio : true ,
702736 interaction : { intersect : false , mode : 'index' } ,
703737 plugins : {
704- legend : { display : false } ,
738+ legend : {
739+ display : true ,
740+ labels : {
741+ color : '#888' ,
742+ boxWidth : 16 ,
743+ font : { size : 11 } ,
744+ usePointStyle : true ,
745+ pointStyle : 'line'
746+ }
747+ } ,
705748 tooltip : {
706749 backgroundColor : '#1a1a1a' ,
707750 borderColor : 'rgba(255,215,0,0.3)' ,
@@ -711,7 +754,11 @@ <h3>☢ <span>Dose Rate</span> — µSv/h</h3>
711754 padding : 12 ,
712755 callbacks : {
713756 title : ctx => new Date ( ctx [ 0 ] . label ) . toLocaleString ( ) ,
714- label : ctx => ` ${ ctx . parsed . y !== null ? ctx . parsed . y . toFixed ( decimals ) : 'N/A' } ${ label } `
757+ label : ctx => {
758+ const v = ctx . parsed . y ;
759+ const suffix = ctx . datasetIndex === 0 ? label : getAvgLabel ( ) ;
760+ return ` ${ v !== null && ! isNaN ( v ) ? v . toFixed ( decimals ) : 'N/A' } ${ suffix } ` ;
761+ }
715762 }
716763 }
717764 } ,
@@ -740,6 +787,27 @@ <h3>☢ <span>Dose Rate</span> — µSv/h</h3>
740787 } ) ;
741788 }
742789
790+ function getAvgLabel ( ) {
791+ const labels = { '1h' : '1hr Avg' , '24h' : '24hr Avg' , '7d' : '7 Day Avg' , '30d' : '30 Day Avg' } ;
792+ return labels [ currentRange ] || 'Average' ;
793+ }
794+
795+ function getAvgWindow ( ) {
796+ // Window size tuned so the smoothing makes visual sense for each range's data density
797+ const windows = { '1h' : 10 , '24h' : 60 , '7d' : 6 , '30d' : 5 } ;
798+ return windows [ currentRange ] || 10 ;
799+ }
800+
801+ function computeMovingAverage ( data , window ) {
802+ const half = Math . floor ( window / 2 ) ;
803+ return data . map ( ( _ , i ) => {
804+ const start = Math . max ( 0 , i - half ) ;
805+ const end = Math . min ( data . length , i + half + 1 ) ;
806+ const slice = data . slice ( start , end ) . filter ( v => v !== null && ! isNaN ( v ) ) ;
807+ return slice . length > 0 ? slice . reduce ( ( a , b ) => a + b , 0 ) / slice . length : null ;
808+ } ) ;
809+ }
810+
743811 function getXFormats ( ) {
744812 switch ( currentRange ) {
745813 case '1h' : return { minute : 'HH:mm' , hour : 'HH:mm' } ;
0 commit comments