@@ -183,6 +183,10 @@ function LegStatsDisplay({ stats, formatAltitude, altitudeLabel, showDrift = tru
183183 < div > Time: { stats . timeSec . toFixed ( 1 ) } s</ div >
184184 < div > Heading: { formatDegrees ( stats . heading ) } </ div >
185185 { showBearing && < div > Bearing: { formatDegrees ( stats . bearing ) } </ div > }
186+ { showBearing && ( ( ) => {
187+ const crab = Math . abs ( ( ( stats . heading - stats . bearing + 180 + 360 ) % 360 ) - 180 ) ;
188+ return crab >= 1 ? < div > Crab: { Math . round ( crab ) } °</ div > : null ;
189+ } ) ( ) }
186190 < div > Distance: { formatDistance ( stats . distance , altitudeLabel ) } </ div >
187191 < div > Glide: { stats . glideRatio . toFixed ( 1 ) } </ div >
188192 { showDrift && stats . windDriftDist > 1 && (
@@ -286,6 +290,7 @@ interface InteractivePointProps {
286290 options : google . maps . CircleOptions ;
287291 showTooltip : boolean ;
288292 showDrift : boolean ;
293+ showCrabArrow : boolean ;
289294 isHovered : boolean ;
290295 onHover : ( ) => void ;
291296 onHoverEnd : ( ) => void ;
@@ -308,11 +313,24 @@ const HIGHLIGHT_OPTIONS: google.maps.CircleOptions = {
308313const HOVER_RADIUS = 15 ;
309314const HOVER_RADIUS_POM_ONLY = 30 ;
310315
311- function InteractivePoint ( { point, pointIndex, manoeuvreInitTime, pathStats, options, showTooltip, showDrift, isHovered, onHover, onHoverEnd, formatAltitude, altitudeLabel } : InteractivePointProps ) {
316+ function destPoint ( lat : number , lng : number , heading : number , distM : number ) : google . maps . LatLngLiteral {
317+ const pt = turf . destination ( [ lng , lat ] , distM , heading , { units : 'meters' } ) ;
318+ return { lat : pt . geometry . coordinates [ 1 ] , lng : pt . geometry . coordinates [ 0 ] } ;
319+ }
320+
321+ function InteractivePoint ( { point, pointIndex, manoeuvreInitTime, pathStats, options, showTooltip, showDrift, showCrabArrow, isHovered, onHover, onHoverEnd, formatAltitude, altitudeLabel } : InteractivePointProps ) {
312322 // POMs always have hover/tooltip, non-POMs respect the showTooltip setting
313323 const isPom = Boolean ( point . pom ) ;
314324 const enableHover = isPom || showTooltip ;
315325
326+ // Crab angle arrow: shown on leg POMs when crab > 10°
327+ const segStats = isPom ? getPointSegmentStats ( pointIndex , pathStats ) : null ;
328+ const legStats = segStats ?. type === 'leg' ? segStats . stats : null ;
329+ const crabAngle = legStats
330+ ? Math . abs ( ( ( legStats . heading - legStats . bearing + 180 + 360 ) % 360 ) - 180 )
331+ : 0 ;
332+ const renderCrabArrow = showCrabArrow && crabAngle > 10 && legStats != null ;
333+
316334 // Use larger hover radius when only POMs are hoverable (showTooltip off)
317335 const hoverAreaOptions : google . maps . CircleOptions = {
318336 fillOpacity : 0 ,
@@ -329,6 +347,19 @@ function InteractivePoint({ point, pointIndex, manoeuvreInitTime, pathStats, opt
329347 center = { point }
330348 options = { options }
331349 />
350+ { /* Crab angle heading arrow (40m shaft + arrowhead barbs) */ }
351+ { renderCrabArrow && ( ( ) => {
352+ const h = legStats ! . heading ;
353+ const tip = destPoint ( point . lat , point . lng , h , 40 ) ;
354+ const lineOpts : google . maps . PolylineOptions = { strokeColor : '#ffffff' , strokeOpacity : 0.9 , strokeWeight : 2 , zIndex : 40 , clickable : false } ;
355+ return (
356+ < >
357+ < PolylineF path = { [ { lat : point . lat , lng : point . lng } , tip ] } options = { lineOpts } />
358+ < PolylineF path = { [ tip , destPoint ( tip . lat , tip . lng , ( h + 150 + 360 ) % 360 , 10 ) ] } options = { lineOpts } />
359+ < PolylineF path = { [ tip , destPoint ( tip . lat , tip . lng , ( h - 150 + 360 ) % 360 , 10 ) ] } options = { lineOpts } />
360+ </ >
361+ ) ;
362+ } ) ( ) }
332363 { /* Invisible hover area */ }
333364 { enableHover && (
334365 < CircleF
@@ -511,7 +542,7 @@ function MapComponent({
511542 forecastValidTime,
512543 finalHeading = 0
513544} : MapComponentProps ) {
514- const { showPoms, showPomAltitudes, showPomTooltips, showPreWind, displayWindArrow, highlightCorrespondingPoints, showMeasureTool } = settings ;
545+ const { showPoms, showPomAltitudes, showPomTooltips, showPreWind, displayWindArrow, highlightCorrespondingPoints, showMeasureTool, showCrabArrow } = settings ;
515546 const { formatAltitude, altitudeLabel, formatWindSpeed, windSpeedLabel } = useUnits ( ) ;
516547 const [ hoveredPointIndex , setHoveredPointIndex ] = useState < number | null > ( null ) ;
517548 const [ hoveredPreWindIndex , setHoveredPreWindIndex ] = useState < number | null > ( null ) ;
@@ -660,6 +691,7 @@ function MapComponent({
660691 } }
661692 showTooltip = { showPomTooltips }
662693 showDrift = { false }
694+ showCrabArrow = { false }
663695 isHovered = { hoveredPreWindIndex === i }
664696 onHover = { ( ) => setHoveredPreWindIndex ( i ) }
665697 onHoverEnd = { ( ) => setHoveredPreWindIndex ( null ) }
@@ -697,6 +729,7 @@ function MapComponent({
697729 } }
698730 showTooltip = { showPomTooltips }
699731 showDrift = { true }
732+ showCrabArrow = { showCrabArrow }
700733 isHovered = { hoveredPointIndex === i }
701734 onHover = { ( ) => setHoveredPointIndex ( i ) }
702735 onHoverEnd = { ( ) => setHoveredPointIndex ( null ) }
0 commit comments