@@ -562,7 +562,33 @@ function normalizeLongitude(lng) {
562562 return ( ( lng + 180 ) % 360 + 360 ) % 360 - 180 ;
563563}
564564
565- // ACE helpers
565+ // utilities: convert lat/lon strings (ex.: "12.3N") to signed decimal degrees
566+ function coordToDecimal ( coord ) {
567+ if ( ! coord || typeof coord !== 'string' ) return NaN ;
568+ const s = coord . trim ( ) ;
569+ if ( ! s . length ) return NaN ;
570+ const last = s . slice ( - 1 ) . toUpperCase ( ) ;
571+ const hasDir = [ 'N' , 'S' , 'E' , 'W' ] . includes ( last ) ;
572+ let val = parseFloat ( hasDir ? s . slice ( 0 , - 1 ) : s ) ;
573+ if ( isNaN ( val ) ) return NaN ;
574+ if ( hasDir ) {
575+ if ( last === 'S' || last === 'W' ) val = - Math . abs ( val ) ;
576+ }
577+ return val ;
578+ }
579+
580+ // haversine formula: distance in kilometers between two lat/lon points
581+ function haversineKm ( lat1 , lon1 , lat2 , lon2 ) {
582+ const toRad = ( deg ) => deg * Math . PI / 180 ;
583+ const R = 6371.0 ; // Earth's radius in km
584+ const dLat = toRad ( lat2 - lat1 ) ;
585+ const dLon = toRad ( lon2 - lon1 ) ;
586+ const a = Math . sin ( dLat / 2 ) ** 2 + Math . cos ( toRad ( lat1 ) ) * Math . cos ( toRad ( lat2 ) ) * Math . sin ( dLon / 2 ) ** 2 ;
587+ const c = 2 * Math . atan2 ( Math . sqrt ( a ) , Math . sqrt ( 1 - a ) ) ;
588+ return R * c ;
589+ }
590+
591+ // ACE + track length calculator
566592function computeACEByStorm ( points ) {
567593 const groups = points . reduce ( ( acc , p ) => {
568594 const k = p . name || "Unnamed" ;
@@ -571,9 +597,14 @@ function computeACEByStorm(points) {
571597 } , { } ) ;
572598 const storms = [ ] ;
573599 let totalRaw = 0 ;
600+ let totalLengthKm = 0 ;
574601
575602 Object . entries ( groups ) . forEach ( ( [ name , arr ] ) => {
576603 let sum = 0 , pts = 0 , tsPts = 0 ;
604+ // for length calc we parse coords to decimals and compute distances between consecutive valid points
605+ let lengthKm = 0 ;
606+ let prevLat = NaN , prevLon = NaN ;
607+
577608 arr . forEach ( p => {
578609 const v = Number ( p . speed ) ;
579610 if ( ! Number . isFinite ( v ) ) return ;
@@ -586,14 +617,40 @@ function computeACEByStorm(points) {
586617 sum += v5 * v5 ;
587618 tsPts ++ ;
588619 }
620+
621+ // length: parse coordinates and compute distance from previous point
622+ const lat = coordToDecimal ( p . latitude ) ;
623+ const lon = coordToDecimal ( p . longitude ) ;
624+ if ( Number . isFinite ( lat ) && Number . isFinite ( lon ) ) {
625+ if ( Number . isFinite ( prevLat ) && Number . isFinite ( prevLon ) ) {
626+ lengthKm += haversineKm ( prevLat , prevLon , lat , lon ) ;
627+ }
628+ prevLat = lat ;
629+ prevLon = lon ;
630+ } else {
631+ // if coordinate invalid, reset prev to avoid incorrect jump
632+ prevLat = NaN ;
633+ prevLon = NaN ;
634+ }
589635 } ) ;
636+
590637 const rawAce = sum / 10000 ;
591638 const ace = + rawAce . toFixed ( 4 ) ;
592- storms . push ( { name, ace, points : pts , tsPoints : tsPts } ) ;
639+ const lengthNm = + ( lengthKm / 1.852 ) . toFixed ( 2 ) ;
640+
641+ storms . push ( {
642+ name,
643+ ace,
644+ points : pts ,
645+ tsPoints : tsPts ,
646+ lengthKm : + lengthKm . toFixed ( 2 ) ,
647+ lengthNm
648+ } ) ;
593649 totalRaw += rawAce ;
650+ totalLengthKm += lengthKm ;
594651 } ) ;
595652
596- return { totalAce : + totalRaw . toFixed ( 4 ) , storms } ;
653+ return { totalAce : + totalRaw . toFixed ( 4 ) , totalLengthKm : + totalLengthKm . toFixed ( 2 ) , totalLengthNm : + ( totalLengthKm / 1.852 ) . toFixed ( 2 ) , storms } ;
597654}
598655
599656function sortStormsByNumber ( storms ) {
@@ -616,37 +673,58 @@ function sortStormsByACE(storms) {
616673function renderACEResults ( ace , sortByNumber = true ) {
617674 const container = document . getElementById ( "ace-results" ) ;
618675 if ( ! container ) return ;
619-
676+
677+ const showLengthPref = JSON . parse ( localStorage . getItem ( "ace_show_length" ) || "false" ) ;
678+
620679 const sortedStorms = sortByNumber ? sortStormsByNumber ( ace . storms ) : sortStormsByACE ( ace . storms ) ;
621680 const sortIcon = sortByNumber ? '🔢' : '📊' ;
622681 const sortLabel = sortByNumber ? 'by number' : 'by ACE' ;
623682 const nextSort = sortByNumber ? 'ACE' : 'number' ;
624-
683+
625684 container . classList . remove ( "hidden-2" ) ;
626685 container . innerHTML = `
627- <h3 style="margin:.25rem 0; display:flex; justify-content:space-between; align-items:center;">
628- <span>ACE</span>
629- <button id="ace-sort-toggle" style="background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); border-radius:.3rem; color:inherit; cursor:pointer; font-size:.75em; padding:.2rem .4rem; transition:background .2s;" title="Sort by ${ nextSort } ">
630- ${ sortIcon } ${ sortLabel }
631- </button>
686+ <h3 class="ace-header">
687+ <span class="ace-title">ACE</span>
688+ <div class="ace-controls">
689+ <label>
690+ <input type="checkbox" id="ace-show-length" ${ showLengthPref ? "checked" : "" } /> <span style="margin-left:.25rem">Show length</span>
691+ </label>
692+ <button id="ace-sort-toggle" class="ace-sort-toggle" title="Sort by ${ nextSort } ">
693+ ${ sortIcon } ${ sortLabel }
694+ </button>
695+ </div>
632696 </h3>
633- <div class="ace-total">Total: ${ ace . totalAce } </div>
697+
698+ <div class="ace-total">Total ACE: ${ ace . totalAce } </div>
699+ ${ showLengthPref ? `<div class="ace-total-length">Total length: ${ ace . totalLengthKm } km (${ ace . totalLengthNm } nm)</div>` : '' }
634700 <ul class="ace-list">
635- ${ sortedStorms . map ( s => `<li>${ s . name || "Unnamed" } : ${ s . ace } <span>(pts: ${ s . tsPoints } /${ s . points } )</span></li>` ) . join ( "" ) }
701+ ${ sortedStorms . map ( s => `
702+ <li class="ace-storm-item">
703+ <div>
704+ <strong>${ s . name || "Unnamed" } </strong>
705+ <span style="margin-left:.5rem;">${ s . ace } </span>
706+ <span style="opacity:.75; margin-left:.5rem;">(pts: ${ s . tsPoints } /${ s . points } )</span>
707+ </div>
708+ ${ showLengthPref ? `<div class="ace-storm-length">Length: ${ s . lengthKm } km (${ s . lengthNm } nm)</div>` : '' }
709+ </li>
710+ ` ) . join ( "" ) }
636711 </ul>
637712 ` ;
638-
639- // click handler for the sort toggle button
713+
714+ // Sort toggle handler
640715 const sortToggle = document . getElementById ( "ace-sort-toggle" ) ;
641716 if ( sortToggle ) {
642717 sortToggle . addEventListener ( "click" , ( ) => {
643718 renderACEResults ( ace , ! sortByNumber ) ;
644719 } ) ;
645- sortToggle . addEventListener ( "mouseenter" , ( e ) => {
646- e . target . style . background = "rgba(255,255,255,0.2)" ;
647- } ) ;
648- sortToggle . addEventListener ( "mouseleave" , ( e ) => {
649- e . target . style . background = "rgba(255,255,255,0.1)" ;
720+ }
721+
722+ // Length toggle handler
723+ const lengthToggle = document . getElementById ( "ace-show-length" ) ;
724+ if ( lengthToggle ) {
725+ lengthToggle . addEventListener ( "change" , ( e ) => {
726+ localStorage . setItem ( "ace_show_length" , JSON . stringify ( e . target . checked ) ) ;
727+ renderACEResults ( ace , sortByNumber ) ;
650728 } ) ;
651729 }
652730}
@@ -1010,8 +1088,7 @@ function createMap(data, accessible) {
10101088 output . classList . remove ( "hidden" ) ;
10111089
10121090 try {
1013- const ace = computeACEByStorm ( data ) ;
1014- renderACEResults ( ace ) ;
1091+ // only compute and render ACE + length if user requested it
10151092 if ( shouldComputeAce ) {
10161093 const ace = computeACEByStorm ( data ) ;
10171094 renderACEResults ( ace ) ;
0 commit comments