@@ -157,9 +157,9 @@ function getSpecialtyRowTone(row, thresholds) {
157157function getSpecialtyChipStyle ( specialty ) {
158158 const colors = getSpecialtyColors ( specialty ) ;
159159 return {
160- backgroundColor : "transparent" ,
161- borderColor : colors . iconFill ,
162- color : colors . iconFill ,
160+ "--chip-fill" : colors . sectorFill ,
161+ "--chip-border" : colors . iconFill ,
162+ "--chip-text" : colors . chipText || colors . iconFill ,
163163 } ;
164164}
165165
@@ -195,9 +195,9 @@ function applyTraconExclusionToProjections(projections, allTraconPolygons) {
195195 return {
196196 ...flight ,
197197 specialty : nowInside ? null : flight . specialty ,
198- proj10Specialty : p10Inside ? null : flight . proj10Specialty ,
199- proj20Specialty : p20Inside ? null : flight . proj20Specialty ,
200- proj30Specialty : p30Inside ? null : flight . proj30Specialty ,
198+ proj10Specialty : ( nowInside || p10Inside ) ? null : flight . proj10Specialty ,
199+ proj20Specialty : ( nowInside || p20Inside ) ? null : flight . proj20Specialty ,
200+ proj30Specialty : ( nowInside || p30Inside ) ? null : flight . proj30Specialty ,
201201 } ;
202202 } ) ;
203203}
@@ -627,7 +627,7 @@ function buildTowerStaffingByAirport(vatsim) {
627627 if ( ! callsign || ! callsign . endsWith ( "_TWR" ) ) {
628628 continue ;
629629 }
630- const match = callsign . match ( / ^ ( [ A - Z 0 - 9 ] { 3 , 4 } ) _ (?: \d { 1 , 3 } _ ) ? T W R $ / ) ;
630+ const match = callsign . match ( / ^ ( [ A - Z 0 - 9 ] { 3 , 4 } ) _ (?: [ A - Z 0 - 9 ] { 1 , 4 } _ ) ? T W R $ / ) ;
631631 if ( ! match ) {
632632 continue ;
633633 }
@@ -729,6 +729,9 @@ export default function TfmsViewerPage() {
729729 const projectedFlightsRef = useRef ( [ ] ) ;
730730 const pilotMotionByCallsignRef = useRef ( { } ) ;
731731 const specialtyTickerTokenRef = useRef ( 0 ) ;
732+ const [ specialtyLogActive , setSpecialtyLogActive ] = useState ( false ) ;
733+ const [ specialtyLogEntries , setSpecialtyLogEntries ] = useState ( [ ] ) ;
734+ const specialtyLogColumnsRef = useRef ( [ ] ) ;
732735 const eventSplitTickerTokenRef = useRef ( 0 ) ;
733736 const airportQueueTrackerRef = useRef ( { } ) ;
734737 const previousAirportQueueByIcaoRef = useRef ( { } ) ;
@@ -854,7 +857,6 @@ export default function TfmsViewerPage() {
854857 if ( ! sector || sector === "zhu" ) {
855858 continue ;
856859 }
857- const floor = Number ( props . floor ) ;
858860 const ceiling = Number ( props . ceiling ) ;
859861 const ring = feature ?. geometry ?. coordinates ?. [ 0 ] || [ ] ;
860862 const points = ring
@@ -947,6 +949,39 @@ export default function TfmsViewerPage() {
947949 ( ) => ( specialtySummary . length > 0 ? specialtySummary : defaultSpecialtySummary ) ,
948950 [ defaultSpecialtySummary , specialtySummary ] ,
949951 ) ;
952+
953+ // Append a log entry whenever data refreshes while logging is active
954+ useEffect ( ( ) => {
955+ if ( ! specialtyLogActive || specialtyDisplay . length === 0 ) return ;
956+ const time = new Date ( ) . toLocaleTimeString ( "en-US" , { hour : "2-digit" , minute : "2-digit" , second : "2-digit" , hour12 : false } ) ;
957+ const counts = Object . fromEntries ( specialtyDisplay . map ( ( row ) => [ row . specialty , row . now ] ) ) ;
958+ setSpecialtyLogEntries ( ( prev ) => [ ...prev , { time, counts } ] ) ;
959+ // eslint-disable-next-line react-hooks/exhaustive-deps
960+ } , [ specialtyLogActive , specialtySummary ] ) ; // intentionally use specialtySummary (not display) so TRACON toggle doesn't add duplicate rows
961+
962+ const handleSpecialtyLogToggle = useCallback ( ( ) => {
963+ if ( ! specialtyLogActive ) {
964+ specialtyLogColumnsRef . current = specialtyDisplay . map ( ( row ) => row . specialty ) ;
965+ setSpecialtyLogEntries ( [ ] ) ;
966+ setSpecialtyLogActive ( true ) ;
967+ } else {
968+ const columns = specialtyLogColumnsRef . current ;
969+ const header = [ "time" , ...columns ] . join ( "," ) ;
970+ const rows = specialtyLogEntries . map ( ( entry ) =>
971+ [ entry . time , ...columns . map ( ( col ) => String ( entry . counts [ col ] ?? 0 ) ) ] . join ( "," ) ,
972+ ) ;
973+ const csv = [ header , ...rows ] . join ( "\n" ) ;
974+ const blob = new Blob ( [ csv ] , { type : "text/csv" } ) ;
975+ const url = URL . createObjectURL ( blob ) ;
976+ const link = document . createElement ( "a" ) ;
977+ link . href = url ;
978+ link . download = `zhu-specialty-log-${ new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) } .csv` ;
979+ link . click ( ) ;
980+ URL . revokeObjectURL ( url ) ;
981+ setSpecialtyLogActive ( false ) ;
982+ }
983+ } , [ specialtyLogActive , specialtyDisplay , specialtyLogEntries ] ) ;
984+
950985 const airportQueueDisplay = useMemo (
951986 ( ) => ( airportQueueSummary . length > 0 ? airportQueueSummary : defaultAirportQueueSummary ) ,
952987 [ airportQueueSummary , defaultAirportQueueSummary ] ,
@@ -1619,6 +1654,17 @@ export default function TfmsViewerPage() {
16191654 < article className = "panel tfms-compact-card" >
16201655 < div className = "flex flex-wrap items-center justify-between gap-2" >
16211656 < h2 className = "font-heading text-main text-2xl" > Specialty Summary</ h2 >
1657+ < div className = "flex items-center gap-2" >
1658+ < button
1659+ onClick = { handleSpecialtyLogToggle }
1660+ className = { `rounded-lg border px-3 py-1 text-xs font-semibold transition-colors ${
1661+ specialtyLogActive
1662+ ? "border-red-500/40 text-red-400 hover:border-red-500/70 hover:bg-red-500/10 hover:text-red-300"
1663+ : "border-emerald-500/40 text-emerald-400 hover:border-emerald-500/70 hover:bg-emerald-500/10 hover:text-emerald-300"
1664+ } `}
1665+ >
1666+ { specialtyLogActive ? `Stop & Export (${ specialtyLogEntries . length } )` : "Start Logging" }
1667+ </ button >
16221668 < label className = "toggle-chip border-default bg-surface-soft text-muted inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs" >
16231669 < input
16241670 className = "sr-only peer"
@@ -1629,6 +1675,7 @@ export default function TfmsViewerPage() {
16291675 < span className = "toggle-chip-dot" aria-hidden = "true" />
16301676 < span > Exclude TRACON Volumes</ span >
16311677 </ label >
1678+ </ div >
16321679 </ div >
16331680 < div className = "mt-3 overflow-x-auto" >
16341681 < table className = "tfms-table tfms-specialty-table tfms-compact-table min-w-full" >
0 commit comments