ssscoring.notebook

Utility reusable code for notebooks.

  1# See: https://github.com/pr3d4t0r/SSScoring/blob/master/LICENSE.txt
  2
  3"""
  4## Utility reusable code for notebooks.
  5"""
  6
  7from ssscoring.calc import forwardLateralDisplacement
  8from ssscoring.calc import jumpRunBearing
  9from ssscoring.constants import DEFAULT_PLOT_MAX_V_SCALE
 10from ssscoring.constants import DEFAULT_SPEED_ACCURACY_SCALE
 11from ssscoring.constants import MAX_ALTITUDE_FT
 12from ssscoring.constants import MAX_HORIZONTAL_DISTANCE
 13from ssscoring.constants import SAFE_HORIZONTAL_COLOR
 14from ssscoring.constants import SAFE_HORIZONTAL_DISTANCE
 15from ssscoring.constants import SPEED_ACCURACY_THRESHOLD
 16from ssscoring.constants import UNSAFE_HORIZONTAL_COLOR
 17from ssscoring.datatypes import PerformanceWindow
 18from ssscoring.errors import SSScoringError
 19
 20import pandas as pd
 21import plotly.graph_objects as go
 22
 23
 24# *** constants ***
 25
 26DEFAULT_AXIS_COLOR = 'lightsteelblue'
 27"""
 28CSS color name for the axis colors used in notebooks and Streamlit with Plotly.
 29"""
 30
 31
 32# Ref:  https://www.w3schools.com/colors/colors_groups.asp
 33SPEED_COLORS = colors = ('#32cd32', '#0000ff', '#ff6347', '#40e0d0', '#00bfff', '#22b822', '#ff7f50', '#008b8b',)
 34"""
 35Colors used for tracking the lines in a multi-jump plot, so that each track is
 36associated with a different color and easier to visualize.  8 distinct colors,
 37corresponding to each jump in a competition.
 38"""
 39
 40
 41# Map Bokeh-era named ranges to Plotly y-axis IDs.  Preserves the rangeName
 42# kwarg API on graphAltitude/graphAngle/graphAcceleration callers.
 43_Y_AXIS_MAP = {
 44    'speed':         'y',     # default — vKMh, hKMh, score scatter
 45    'altitudeFt':    'y2',
 46    'angle':         'y3',
 47    'vAccelMS2':     'y4',
 48    'speedAccuracy': 'y5',
 49}
 50
 51
 52# *** functions ***
 53
 54def initializePlot(jumpTitle: str,
 55                   height=500,
 56                   width=900,
 57                   xLabel='seconds from exit',
 58                   yLabel='km/h',
 59                   xMax=35.0,
 60                   yMax=DEFAULT_PLOT_MAX_V_SCALE,
 61                   backgroundColorName='#1a1a1a',
 62                   colorName=DEFAULT_AXIS_COLOR):
 63    """
 64    Initialize a Plotly figure for SSScoring jump plots, configured with the
 65    main speed (km/h) Y axis.  Extra Y axes for altitude / angle / acceleration /
 66    speed-accuracy are added by initializeExtraYRanges().
 67
 68    Returns
 69    -------
 70    A `plotly.graph_objects.Figure`.
 71    """
 72    fig = go.Figure()
 73    fig.update_layout(
 74        title=dict(text=jumpTitle, font=dict(color=colorName)),
 75        height=height,
 76        autosize=True,
 77        plot_bgcolor=backgroundColorName,
 78        paper_bgcolor=backgroundColorName,
 79        font=dict(color=colorName),
 80        hovermode='x unified',
 81        showlegend=True,
 82        legend=dict(font=dict(color=colorName)),
 83        xaxis=dict(
 84            title=dict(text=xLabel, font=dict(color=colorName)),
 85            autorange=True,
 86            color=colorName,
 87            tickfont=dict(color=colorName),
 88            showgrid=True,                              # ← changed
 89            gridcolor='rgba(255,255,255,0.08)',         # ← added — subtle grid on dark bg
 90            showline=True,                              # ← added — visible axis line
 91            linecolor=colorName,                        # ← added
 92            zeroline=False,
 93        ),
 94        yaxis=dict(
 95            title=dict(text=yLabel, font=dict(color=colorName)),
 96            autorange=True,
 97            color=colorName,
 98            tickfont=dict(color=colorName),
 99            anchor='x',
100            side='left',
101            showgrid=True,                              # ← changed
102            gridcolor='rgba(255,255,255,0.08)',         # ← added
103            showline=True,                              # ← added
104            linecolor=colorName,                        # ← added
105            zeroline=False,
106        ),
107    )
108    return fig
109
110
111def _graphSegment(fig,
112                  x0=0.0,
113                  y0=0.0,
114                  x1=0.0,
115                  y1=0.0,
116                  lineWidth=1,
117                  color='black'):
118    """
119    Draw a line segment annotation on the plot's main axes.  Plotly equivalent
120    of Bokeh's plot.segment().
121    """
122    fig.add_shape(
123        type='line',
124        x0=x0, y0=y0,
125        x1=x1, y1=y1,
126        line=dict(color=color, width=lineWidth),
127        xref='x', yref='y',
128    )
129
130
131def initializeExtraYRanges(fig,
132                           startY: float = 0.0,
133                           endY: float = MAX_ALTITUDE_FT,
134                           maxSpeedAccuracy: float | None = None):
135    """
136    Configure additional Y axes on the plot for altitude (ft), angle, vertical
137    acceleration, and speed accuracy traces.  Plotly equivalent of Bokeh's
138    extra_y_ranges + LinearAxis layout.
139    """
140    LEFT_MARGIN = 0.24
141    AXIS_SPACING = 0.06
142    POSITIONS = [n*AXIS_SPACING for n in range(4)]  # 0.00, 0.06, 0.12, 0.18
143
144    speedAccuracyEnd = DEFAULT_SPEED_ACCURACY_SCALE
145    if maxSpeedAccuracy is not None and maxSpeedAccuracy >= SPEED_ACCURACY_THRESHOLD:
146        speedAccuracyEnd = maxSpeedAccuracy * 1.1
147
148    color = DEFAULT_AXIS_COLOR
149
150    fig.update_xaxes(domain=(LEFT_MARGIN, 1.0))
151
152    mainYTitle = (fig.layout.yaxis.title.text or 'km/h')
153    fig.update_yaxes(title=None)
154
155    fig.update_layout(
156        yaxis2=dict(
157            autorange=True,
158            anchor='free', overlaying='y', side='left', position=POSITIONS[0],
159            color=color, tickfont=dict(color=color),
160            showline=True, linecolor=color,
161            showgrid=False, zeroline=False,
162        ),
163        yaxis3=dict(
164            autorange=True,
165            anchor='free', overlaying='y', side='left', position=POSITIONS[1],
166            color=color, tickfont=dict(color=color),
167            showline=True, linecolor=color,
168            showgrid=False, zeroline=False,
169        ),
170        yaxis4=dict(
171            autorange=True,
172            anchor='free', overlaying='y', side='left', position=POSITIONS[2],
173            color=color, tickfont=dict(color=color),
174            showline=True, linecolor=color,
175            showgrid=False, zeroline=False,
176        ),
177        yaxis5=dict(
178            range=(0.0, speedAccuracyEnd),
179            anchor='free', overlaying='y', side='left', position=POSITIONS[3],
180            color=color, tickfont=dict(color=color),
181            showline=True, linecolor=color,
182            showgrid=False, zeroline=False,
183        ),
184    )
185
186    titles = [
187        (POSITIONS[0], 'Alt (ft)'),
188        (POSITIONS[1], 'angle'),
189        (POSITIONS[2], 'Vertical acceleration m/s²'),
190        (POSITIONS[3], 'Speed accuracy ISC'),
191        (LEFT_MARGIN,  mainYTitle),
192    ]
193    for pos, text in titles:
194        fig.add_annotation(
195            xref='paper', yref='paper',
196            x=pos + 0.012, y=0.5,
197            text=text,
198            showarrow=False,
199            textangle=-90,
200            font=dict(color=color, size=11),
201            xanchor='center', yanchor='middle',
202        )
203
204    return fig
205
206
207def validationWindowDataFrom(data: pd.DataFrame, window: PerformanceWindow) -> pd.DataFrame:
208    """
209    Generate the validation window dataset for plotting the ISC speed accuracy
210    values.  Subset defined from the end of the scoring window to the
211    validation start.
212
213    NOTE: return type changed from bokeh.models.ColumnDataSource (Bokeh era) to
214    pd.DataFrame as part of the Plotly migration.  Columns: 'x', 'y'.
215    """
216    validationData = data[data.altitudeAGL <= window.validationStart]
217    return pd.DataFrame({
218        'x': validationData.plotTime.values,
219        'y': validationData.speedAccuracyISC.values,
220    })
221
222
223def _plotSpeedAccuracy(fig, data, window):
224    accuracyData = validationWindowDataFrom(data, window)
225    fig.add_trace(go.Scatter(
226        x=accuracyData['x'],
227        y=accuracyData['y'],
228        mode='lines',
229        name='Speed accuracy ISC',
230        line=dict(color='lime', width=5.0),
231        yaxis='y5',
232        hovertemplate='accuracy: %{y:.2f}<extra></extra>',
233    ))
234    validationData = data[data.altitudeAGL <= window.validationStart]
235    fig.add_trace(go.Scatter(
236        x=validationData.plotTime,
237        y=[SPEED_ACCURACY_THRESHOLD] * len(validationData),
238        mode='lines',
239        line=dict(color='lime', width=1.0, dash='dash'),
240        yaxis='y5',
241        showlegend=False,
242        hoverinfo='skip',
243    ))
244
245
246def graphJumpResult(fig,
247                    jumpResult,
248                    lineColor='green',
249                    legend='speed',
250                    showIt=True,
251                    showAccuracy=True):
252    """
253    Graph the jump results onto the initialized Plotly figure.
254
255    Arguments
256    ---------
257        fig
258    A Plotly Figure where to render the plot.
259
260        jumpResult: ssscoring.JumpResults
261    A jump results named tuple with score, max speed, scores, data, etc.
262
263        lineColor: str
264    A valid CSS color name or hex string.  See SPEED_COLORS for the 8 distinct
265    colors used in multi-jump plots.
266
267        legend: str
268    Legend label for the main speed trace.
269
270        showIt: bool
271    If True, render the max speed marker, horizontal speed, and score brackets.
272    Used to discriminate between single-jump plots and aggregate competition
273    overlays.
274
275    Streamlit usage:
276
277```python
278    graphJumpResult(fig, result)
279    st.plotly_chart(fig, width='stretch')
280```
281    """
282    if jumpResult.data is not None:
283        data = jumpResult.data
284        scores = jumpResult.scores
285        score = jumpResult.score
286
287        # Main speed line
288        fig.add_trace(go.Scatter(
289            x=data.plotTime,
290            y=data.vKMh,
291            mode='lines',
292            name=legend,
293            line=dict(color=lineColor, width=2),
294            yaxis='y',
295            hovertemplate='v: %{y:.2f} km/h<extra></extra>',
296        ))
297
298        if showIt:
299            maxSpeed = data.vKMh.max()
300            t = data[data.vKMh == maxSpeed].iloc[0].plotTime
301
302            # Horizontal speed
303            fig.add_trace(go.Scatter(
304                x=data.plotTime,
305                y=data.hKMh,
306                mode='lines',
307                name='H-speed',
308                line=dict(color='red', width=2),
309                yaxis='y',
310                hovertemplate='h: %{y:.2f} km/h<extra></extra>',
311            ))
312
313            _plotSpeedAccuracy(fig, data, jumpResult.window)
314
315            if scores is not None:
316                # Score window brackets
317                _graphSegment(fig, scores[score]+3.0, 0.0, scores[score]+3.0, score, 1, 'darkseagreen')
318                _graphSegment(fig, scores[score],     0.0, scores[score],     score, 1, 'darkseagreen')
319                # Score marker
320                fig.add_trace(go.Scatter(
321                    x=[scores[score]+1.5],
322                    y=[score],
323                    mode='markers',
324                    marker=dict(symbol='circle-cross', size=15,
325                                line=dict(color='limegreen', width=2),
326                                color='darkgreen'),
327                    name='score',
328                    yaxis='y',
329                    hovertemplate='score: %{y:.2f} km/h<extra></extra>',
330                ))
331                # Max-speed marker
332                fig.add_trace(go.Scatter(
333                    x=[t],
334                    y=[maxSpeed],
335                    mode='markers',
336                    marker=dict(symbol='diamond-dot', size=20,
337                                line=dict(color='yellow', width=2),
338                                color='red'),
339                    name='max speed',
340                    yaxis='y',
341                    hovertemplate='max: %{y:.2f} km/h<extra></extra>',
342                ))
343
344
345def graphAltitude(fig,
346                  jumpResult,
347                  label='Alt (ft)',
348                  lineColor='palegoldenrod',
349                  rangeName='altitudeFt'):
350    """
351    Graph altitude trace on the dedicated altitudeFt Y axis.
352    """
353    data = jumpResult.data
354    yaxis = _Y_AXIS_MAP[rangeName]
355    fig.add_trace(go.Scatter(
356        x=data.plotTime,
357        y=data.altitudeAGLFt,
358        mode='lines',
359        name=label,
360        line=dict(color=lineColor, width=2),
361        yaxis=yaxis,
362        hovertemplate='alt: %{y:.0f} ft<extra></extra>',
363    ))
364
365
366def graphAngle(fig,
367               jumpResult,
368               label='angle',
369               lineColor='deepskyblue',
370               rangeName='angle'):
371    """
372    Graph the flight angle trace on the dedicated angle Y axis.
373    """
374    data = jumpResult.data
375    yaxis = _Y_AXIS_MAP[rangeName]
376    fig.add_trace(go.Scatter(
377        x=data.plotTime,
378        y=data.speedAngle,
379        mode='lines',
380        name=label,
381        line=dict(color=lineColor, width=2),
382        yaxis=yaxis,
383        hovertemplate='angle: %{y:.2f}°<extra></extra>',
384    ))
385
386
387def graphAcceleration(fig,
388                      jumpResult,
389                      label='V-accel m/s²',
390                      lineColor='magenta',
391                      rangeName='vAccelMS2'):
392    """
393    Graph the flight vertical acceleration curve and its EMA-smoothed companion
394    on the dedicated vAccelMS2 Y axis.
395    """
396    data = jumpResult.data
397    data['vAccelEMA'] = data.vAccelMS2.ewm(span=20, adjust=False).mean()
398    yaxis = _Y_AXIS_MAP[rangeName]
399    fig.add_trace(go.Scatter(
400        x=data.plotTime,
401        y=data.vAccelMS2,
402        mode='lines',
403        name=label,
404        line=dict(color='dimgrey', width=2),
405        yaxis=yaxis,
406        hovertemplate='a: %{y:.2f} m/s²<extra></extra>',
407    ))
408    fig.add_trace(go.Scatter(
409        x=data.plotTime,
410        y=data.vAccelEMA,
411        mode='lines',
412        name=label + ' (EMA)',
413        line=dict(color=lineColor, width=2),
414        yaxis=yaxis,
415        hovertemplate='a (EMA): %{y:.2f} m/s²<extra></extra>',
416    ))
417
418
419def initializeGroundTrackPlot(jumpTitle: str,
420                              height=450,
421                              backgroundColorName='#1a1a1a',
422                              colorName=DEFAULT_AXIS_COLOR):
423    """
424    Initialize a Plotly figure for the ground-track plot, configured with equal
425    axis scaling so that forward and lateral distances are not distorted.
426
427    X axis: metres forward along the jump run from exit.
428    Y axis: metres lateral (right = positive, left = negative).
429
430    Unlike initializePlot(), no extra Y ranges are added — this canvas is
431    dedicated to spatial displacement only.
432
433    Arguments
434    ---------
435        jumpTitle: str
436    Figure title, usually the jump tag.
437
438        height: int
439    Plot height in pixels.  Default 450 — shorter than the main plot since
440    this is a companion chart.
441
442        backgroundColorName: str
443    CSS colour name or hex string for the plot and paper background.
444
445        colorName: str
446    CSS colour name or hex string for axes, tick labels, and title text.
447
448    Returns
449    -------
450    A `plotly.graph_objects.Figure` ready to receive graphGroundTrack() traces.
451    """
452    figure = go.Figure()
453    figure.update_layout(
454        title=dict(text=jumpTitle, font=dict(color=colorName)),
455        height=height,
456        autosize=True,
457        plot_bgcolor=backgroundColorName,
458        paper_bgcolor=backgroundColorName,
459        font=dict(color=colorName),
460        hovermode='closest',
461        showlegend=True,
462        legend=dict(font=dict(color=colorName)),
463        xaxis=dict(
464            title=dict(text='forward (m)', font=dict(color=colorName)),
465            autorange=True,
466            color=colorName,
467            tickfont=dict(color=colorName),
468            showgrid=True,
469            gridcolor='rgba(255,255,255,0.08)',
470            showline=True,
471            linecolor=colorName,
472            zeroline=True,
473            zerolinecolor='rgba(255,255,255,0.25)',
474            zerolinewidth=1,
475        ),
476        yaxis=dict(
477            title=dict(text='lateral (m)', font=dict(color=colorName)),
478            autorange=True,
479            color=colorName,
480            tickfont=dict(color=colorName),
481            showgrid=True,
482            gridcolor='rgba(255,255,255,0.08)',
483            showline=True,
484            linecolor=colorName,
485            zeroline=True,
486            zerolinecolor='rgba(255,255,255,0.25)',
487            zerolinewidth=1,
488            scaleanchor='x',
489            scaleratio=1,
490        ),
491    )
492    return figure
493
494
495def graphGroundTrack(figure,
496                     jumpResult,
497                     lineColor='deepskyblue'):
498    """
499    Graph the skydiver's ground track during the performance window as forward
500    vs. lateral displacement from exit, with markers coloured by vertical speed.
501
502    X axis = metres forward along the jump run (negative = reversed, i.e. back-
503    fall).  Y axis = metres lateral (positive = right of jump run).  Marker
504    colour encodes vKMh so the speed progression is visible without a separate
505    time axis.
506
507    A clean belly-to-earth run produces a smooth rightward curve staying close
508    to the lateral zero line.  A back-fall reverses toward the origin; the
509    line literally doubles back on itself — unmistakable at a glance.
510
511    Requires a figure initialised by initializeGroundTrackPlot() so that the
512    spatial axes are equal-scaled and the zero lines are present.
513
514    Arguments
515    ---------
516        figure
517    A Plotly Figure initialised by initializeGroundTrackPlot().
518
519        jumpResult: ssscoring.JumpResults
520    A jump results named tuple.  jumpResult.data must contain latitude,
521    longitude, plotTime, and vKMh columns (all present after processJump()).
522
523        lineColor: str
524    A valid CSS colour name or hex string for the connecting track line.
525
526    Streamlit usage:
527
528```python
529    figure = initializeGroundTrackPlot(tag)
530    graphGroundTrack(figure, jumpResult)
531    st.plotly_chart(figure, width='stretch')
532```
533    """
534    data = jumpResult.data
535    exitLat = float(data.latitude.iloc[0])
536    exitLon = float(data.longitude.iloc[0])
537    bearing = jumpRunBearing(data)
538    displacement = forwardLateralDisplacement(data, exitLat, exitLon, bearing)
539
540    figure.add_trace(go.Scatter(
541        x=displacement.forwardM,
542        y=displacement.lateralM,
543        mode='lines',
544        name='track',
545        line=dict(color='rgba(255,255,255,0.15)', width=1),
546        showlegend=False,
547        hoverinfo='skip',
548    ))
549
550    figure.add_trace(go.Scatter(
551        x=displacement.forwardM,
552        y=displacement.lateralM,
553        mode='markers',
554        name='fwd (m)',
555        marker=dict(
556            color=displacement.forwardM.clip(lower=0, upper=MAX_HORIZONTAL_DISTANCE),
557            colorscale=[
558                [0.0, SAFE_HORIZONTAL_COLOR],
559                [SAFE_HORIZONTAL_DISTANCE / MAX_HORIZONTAL_DISTANCE, SAFE_HORIZONTAL_COLOR],
560                [1.0, UNSAFE_HORIZONTAL_COLOR],
561            ],
562            cmin=0,
563            cmax=MAX_HORIZONTAL_DISTANCE,
564            cauto=False,
565            size=5,
566            showscale=True,
567            colorbar=dict(
568                title=dict(text='fwd (m)', font=dict(color=DEFAULT_AXIS_COLOR)),
569                tickfont=dict(color=DEFAULT_AXIS_COLOR),
570                tickvals=[0, SAFE_HORIZONTAL_DISTANCE, MAX_HORIZONTAL_DISTANCE],
571                ticktext=['0', f'{int(SAFE_HORIZONTAL_DISTANCE)}m', f'{int(MAX_HORIZONTAL_DISTANCE)}m'],
572                thickness=12,
573                len=0.75,
574            ),
575        ),
576        hovertemplate='fwd: %{x:.1f} m  lat: %{y:.1f} m<extra></extra>',
577    ))
578
579    figure.add_trace(go.Scatter(
580        x=[displacement.forwardM.iloc[0]],
581        y=[displacement.lateralM.iloc[0]],
582        mode='markers',
583        name='exit',
584        marker=dict(symbol='circle', size=10,
585                    color=SAFE_HORIZONTAL_COLOR,
586                    line=dict(color='white', width=1)),
587        hovertemplate='exit<extra></extra>',
588    ))
589
590    figure.add_trace(go.Scatter(
591        x=[displacement.forwardM.iloc[-1]],
592        y=[displacement.lateralM.iloc[-1]],
593        mode='markers',
594        name='end',
595        marker=dict(symbol='square', size=10,
596                    color=UNSAFE_HORIZONTAL_COLOR,
597                    line=dict(color='white', width=1)),
598        hovertemplate='end: fwd %{x:.1f} m  lat: %{y:.1f} m<extra></extra>',
599    ))
600
601
602def graphForwardDisplacement(figure,
603                             jumpResult):
604    """
605    Graph the forward displacement (metres along the jump run from exit) as a
606    time series on the primary Y axis.
607
608    A skydiver on a clean belly run produces a monotonically rising curve.  A
609    back-fall inflects and drops — the onset time and reversal depth are
610    immediately readable from the shape of the line and the zero reference.
611
612    Markers are coloured by the same green→red gradient used in the ground
613    track: SAFE_HORIZONTAL_COLOR up to SAFE_HORIZONTAL_DISTANCE, linear
614    transition to UNSAFE_HORIZONTAL_COLOR at MAX_HORIZONTAL_DISTANCE, solid
615    red beyond.
616
617    Pair this with graphJumpResult() on a second figure (same plotTime X axis)
618    to show the temporal relationship between displacement reversal and speed
619    loss.
620
621    Arguments
622    ---------
623        figure
624    A Plotly Figure initialised by initializePlot() with xLabel='seconds from
625    exit' and yLabel='forward (m)'.
626
627        jumpResult: ssscoring.JumpResults
628    A jump results named tuple.  jumpResult.data must contain latitude,
629    longitude, and plotTime (all present after processJump()).
630
631    Streamlit usage:
632
633```python
634    figure = initializePlot(tag, yLabel='forward (m)', backgroundColorName='#2c2c2c')
635    graphForwardDisplacement(figure, jumpResult)
636    st.plotly_chart(figure, width='stretch')
637```
638    """
639    data = jumpResult.data
640    exitLat = float(data.latitude.iloc[0])
641    exitLon = float(data.longitude.iloc[0])
642    bearing = jumpRunBearing(data)
643    displacement = forwardLateralDisplacement(data, exitLat, exitLon, bearing)
644
645    figure.add_trace(go.Scatter(
646        x=displacement.plotTime,
647        y=displacement.forwardM,
648        mode='lines',
649        line=dict(color='rgba(255,255,255,0.15)', width=1),
650        showlegend=False,
651        hoverinfo='skip',
652    ))
653
654    figure.add_trace(go.Scatter(
655        x=displacement.plotTime,
656        y=displacement.forwardM,
657        mode='markers',
658        name='fwd (m)',
659        marker=dict(
660            color=displacement.forwardM.clip(lower=0, upper=MAX_HORIZONTAL_DISTANCE),
661            colorscale=[
662                [0.0, SAFE_HORIZONTAL_COLOR],
663                [SAFE_HORIZONTAL_DISTANCE / MAX_HORIZONTAL_DISTANCE, SAFE_HORIZONTAL_COLOR],
664                [1.0, UNSAFE_HORIZONTAL_COLOR],
665            ],
666            cmin=0,
667            cmax=MAX_HORIZONTAL_DISTANCE,
668            cauto=False,
669            size=4,
670            showscale=True,
671            colorbar=dict(
672                title=dict(text='fwd (m)', font=dict(color=DEFAULT_AXIS_COLOR)),
673                tickfont=dict(color=DEFAULT_AXIS_COLOR),
674                tickvals=[0, SAFE_HORIZONTAL_DISTANCE, MAX_HORIZONTAL_DISTANCE],
675                ticktext=['0', f'{int(SAFE_HORIZONTAL_DISTANCE)}m', f'{int(MAX_HORIZONTAL_DISTANCE)}m'],
676                thickness=12,
677                len=0.75,
678            ),
679        ),
680        yaxis='y',
681        hovertemplate='t: %{x:.1f} s  fwd: %{y:.1f} m<extra></extra>',
682    ))
683
684    figure.add_trace(go.Scatter(
685        x=[displacement.plotTime.iloc[0], displacement.plotTime.iloc[-1]],
686        y=[0.0, 0.0],
687        mode='lines',
688        line=dict(color='rgba(255,255,255,0.25)', width=1, dash='dot'),
689        showlegend=False,
690        hoverinfo='skip',
691    ))
692
693
694def convertHexColorToRGB(color: str) -> list:
695    """
696    Converts a color in the format `#a0b1c2` to its RGB equivalent as a list
697    of three values 0-255.
698    """
699    if not isinstance(color, str):
700        raise TypeError('Invalid color type - must be str')
701    color = color.replace('#', '')
702    if len(color) != 6:
703        raise SSScoringError('Invalid hex value length')
704    result = [int(color[x:x+2], 16) for x in range(0, len(color), 2)]
705    return result
DEFAULT_AXIS_COLOR = 'lightsteelblue'

CSS color name for the axis colors used in notebooks and Streamlit with Plotly.

def initializePlot( jumpTitle: str, height=500, width=900, xLabel='seconds from exit', yLabel='km/h', xMax=35.0, yMax=550.0, backgroundColorName='#1a1a1a', colorName='lightsteelblue'):
 55def initializePlot(jumpTitle: str,
 56                   height=500,
 57                   width=900,
 58                   xLabel='seconds from exit',
 59                   yLabel='km/h',
 60                   xMax=35.0,
 61                   yMax=DEFAULT_PLOT_MAX_V_SCALE,
 62                   backgroundColorName='#1a1a1a',
 63                   colorName=DEFAULT_AXIS_COLOR):
 64    """
 65    Initialize a Plotly figure for SSScoring jump plots, configured with the
 66    main speed (km/h) Y axis.  Extra Y axes for altitude / angle / acceleration /
 67    speed-accuracy are added by initializeExtraYRanges().
 68
 69    Returns
 70    -------
 71    A `plotly.graph_objects.Figure`.
 72    """
 73    fig = go.Figure()
 74    fig.update_layout(
 75        title=dict(text=jumpTitle, font=dict(color=colorName)),
 76        height=height,
 77        autosize=True,
 78        plot_bgcolor=backgroundColorName,
 79        paper_bgcolor=backgroundColorName,
 80        font=dict(color=colorName),
 81        hovermode='x unified',
 82        showlegend=True,
 83        legend=dict(font=dict(color=colorName)),
 84        xaxis=dict(
 85            title=dict(text=xLabel, font=dict(color=colorName)),
 86            autorange=True,
 87            color=colorName,
 88            tickfont=dict(color=colorName),
 89            showgrid=True,                              # ← changed
 90            gridcolor='rgba(255,255,255,0.08)',         # ← added — subtle grid on dark bg
 91            showline=True,                              # ← added — visible axis line
 92            linecolor=colorName,                        # ← added
 93            zeroline=False,
 94        ),
 95        yaxis=dict(
 96            title=dict(text=yLabel, font=dict(color=colorName)),
 97            autorange=True,
 98            color=colorName,
 99            tickfont=dict(color=colorName),
100            anchor='x',
101            side='left',
102            showgrid=True,                              # ← changed
103            gridcolor='rgba(255,255,255,0.08)',         # ← added
104            showline=True,                              # ← added
105            linecolor=colorName,                        # ← added
106            zeroline=False,
107        ),
108    )
109    return fig

Initialize a Plotly figure for SSScoring jump plots, configured with the main speed (km/h) Y axis. Extra Y axes for altitude / angle / acceleration / speed-accuracy are added by initializeExtraYRanges().

Returns

A plotly.graph_objects.Figure.

def initializeExtraYRanges( fig, startY: float = 0.0, endY: float = 14000.0, maxSpeedAccuracy: float | None = None):
132def initializeExtraYRanges(fig,
133                           startY: float = 0.0,
134                           endY: float = MAX_ALTITUDE_FT,
135                           maxSpeedAccuracy: float | None = None):
136    """
137    Configure additional Y axes on the plot for altitude (ft), angle, vertical
138    acceleration, and speed accuracy traces.  Plotly equivalent of Bokeh's
139    extra_y_ranges + LinearAxis layout.
140    """
141    LEFT_MARGIN = 0.24
142    AXIS_SPACING = 0.06
143    POSITIONS = [n*AXIS_SPACING for n in range(4)]  # 0.00, 0.06, 0.12, 0.18
144
145    speedAccuracyEnd = DEFAULT_SPEED_ACCURACY_SCALE
146    if maxSpeedAccuracy is not None and maxSpeedAccuracy >= SPEED_ACCURACY_THRESHOLD:
147        speedAccuracyEnd = maxSpeedAccuracy * 1.1
148
149    color = DEFAULT_AXIS_COLOR
150
151    fig.update_xaxes(domain=(LEFT_MARGIN, 1.0))
152
153    mainYTitle = (fig.layout.yaxis.title.text or 'km/h')
154    fig.update_yaxes(title=None)
155
156    fig.update_layout(
157        yaxis2=dict(
158            autorange=True,
159            anchor='free', overlaying='y', side='left', position=POSITIONS[0],
160            color=color, tickfont=dict(color=color),
161            showline=True, linecolor=color,
162            showgrid=False, zeroline=False,
163        ),
164        yaxis3=dict(
165            autorange=True,
166            anchor='free', overlaying='y', side='left', position=POSITIONS[1],
167            color=color, tickfont=dict(color=color),
168            showline=True, linecolor=color,
169            showgrid=False, zeroline=False,
170        ),
171        yaxis4=dict(
172            autorange=True,
173            anchor='free', overlaying='y', side='left', position=POSITIONS[2],
174            color=color, tickfont=dict(color=color),
175            showline=True, linecolor=color,
176            showgrid=False, zeroline=False,
177        ),
178        yaxis5=dict(
179            range=(0.0, speedAccuracyEnd),
180            anchor='free', overlaying='y', side='left', position=POSITIONS[3],
181            color=color, tickfont=dict(color=color),
182            showline=True, linecolor=color,
183            showgrid=False, zeroline=False,
184        ),
185    )
186
187    titles = [
188        (POSITIONS[0], 'Alt (ft)'),
189        (POSITIONS[1], 'angle'),
190        (POSITIONS[2], 'Vertical acceleration m/s²'),
191        (POSITIONS[3], 'Speed accuracy ISC'),
192        (LEFT_MARGIN,  mainYTitle),
193    ]
194    for pos, text in titles:
195        fig.add_annotation(
196            xref='paper', yref='paper',
197            x=pos + 0.012, y=0.5,
198            text=text,
199            showarrow=False,
200            textangle=-90,
201            font=dict(color=color, size=11),
202            xanchor='center', yanchor='middle',
203        )
204
205    return fig

Configure additional Y axes on the plot for altitude (ft), angle, vertical acceleration, and speed accuracy traces. Plotly equivalent of Bokeh's extra_y_ranges + LinearAxis layout.

def validationWindowDataFrom( data: pandas.DataFrame, window: ssscoring.datatypes.PerformanceWindow) -> pandas.DataFrame:
208def validationWindowDataFrom(data: pd.DataFrame, window: PerformanceWindow) -> pd.DataFrame:
209    """
210    Generate the validation window dataset for plotting the ISC speed accuracy
211    values.  Subset defined from the end of the scoring window to the
212    validation start.
213
214    NOTE: return type changed from bokeh.models.ColumnDataSource (Bokeh era) to
215    pd.DataFrame as part of the Plotly migration.  Columns: 'x', 'y'.
216    """
217    validationData = data[data.altitudeAGL <= window.validationStart]
218    return pd.DataFrame({
219        'x': validationData.plotTime.values,
220        'y': validationData.speedAccuracyISC.values,
221    })

Generate the validation window dataset for plotting the ISC speed accuracy values. Subset defined from the end of the scoring window to the validation start.

NOTE: return type changed from bokeh.models.ColumnDataSource (Bokeh era) to pd.DataFrame as part of the Plotly migration. Columns: 'x', 'y'.

def graphJumpResult( fig, jumpResult, lineColor='green', legend='speed', showIt=True, showAccuracy=True):
247def graphJumpResult(fig,
248                    jumpResult,
249                    lineColor='green',
250                    legend='speed',
251                    showIt=True,
252                    showAccuracy=True):
253    """
254    Graph the jump results onto the initialized Plotly figure.
255
256    Arguments
257    ---------
258        fig
259    A Plotly Figure where to render the plot.
260
261        jumpResult: ssscoring.JumpResults
262    A jump results named tuple with score, max speed, scores, data, etc.
263
264        lineColor: str
265    A valid CSS color name or hex string.  See SPEED_COLORS for the 8 distinct
266    colors used in multi-jump plots.
267
268        legend: str
269    Legend label for the main speed trace.
270
271        showIt: bool
272    If True, render the max speed marker, horizontal speed, and score brackets.
273    Used to discriminate between single-jump plots and aggregate competition
274    overlays.
275
276    Streamlit usage:
277
278```python
279    graphJumpResult(fig, result)
280    st.plotly_chart(fig, width='stretch')
281```
282    """
283    if jumpResult.data is not None:
284        data = jumpResult.data
285        scores = jumpResult.scores
286        score = jumpResult.score
287
288        # Main speed line
289        fig.add_trace(go.Scatter(
290            x=data.plotTime,
291            y=data.vKMh,
292            mode='lines',
293            name=legend,
294            line=dict(color=lineColor, width=2),
295            yaxis='y',
296            hovertemplate='v: %{y:.2f} km/h<extra></extra>',
297        ))
298
299        if showIt:
300            maxSpeed = data.vKMh.max()
301            t = data[data.vKMh == maxSpeed].iloc[0].plotTime
302
303            # Horizontal speed
304            fig.add_trace(go.Scatter(
305                x=data.plotTime,
306                y=data.hKMh,
307                mode='lines',
308                name='H-speed',
309                line=dict(color='red', width=2),
310                yaxis='y',
311                hovertemplate='h: %{y:.2f} km/h<extra></extra>',
312            ))
313
314            _plotSpeedAccuracy(fig, data, jumpResult.window)
315
316            if scores is not None:
317                # Score window brackets
318                _graphSegment(fig, scores[score]+3.0, 0.0, scores[score]+3.0, score, 1, 'darkseagreen')
319                _graphSegment(fig, scores[score],     0.0, scores[score],     score, 1, 'darkseagreen')
320                # Score marker
321                fig.add_trace(go.Scatter(
322                    x=[scores[score]+1.5],
323                    y=[score],
324                    mode='markers',
325                    marker=dict(symbol='circle-cross', size=15,
326                                line=dict(color='limegreen', width=2),
327                                color='darkgreen'),
328                    name='score',
329                    yaxis='y',
330                    hovertemplate='score: %{y:.2f} km/h<extra></extra>',
331                ))
332                # Max-speed marker
333                fig.add_trace(go.Scatter(
334                    x=[t],
335                    y=[maxSpeed],
336                    mode='markers',
337                    marker=dict(symbol='diamond-dot', size=20,
338                                line=dict(color='yellow', width=2),
339                                color='red'),
340                    name='max speed',
341                    yaxis='y',
342                    hovertemplate='max: %{y:.2f} km/h<extra></extra>',
343                ))

Graph the jump results onto the initialized Plotly figure.

Arguments
---------
    fig
A Plotly Figure where to render the plot.

    jumpResult: ssscoring.JumpResults
A jump results named tuple with score, max speed, scores, data, etc.

    lineColor: str
A valid CSS color name or hex string.  See SPEED_COLORS for the 8 distinct
colors used in multi-jump plots.

    legend: str
Legend label for the main speed trace.

    showIt: bool
If True, render the max speed marker, horizontal speed, and score brackets.
Used to discriminate between single-jump plots and aggregate competition
overlays.

Streamlit usage:
    graphJumpResult(fig, result)
    st.plotly_chart(fig, width='stretch')
def graphAltitude( fig, jumpResult, label='Alt (ft)', lineColor='palegoldenrod', rangeName='altitudeFt'):
346def graphAltitude(fig,
347                  jumpResult,
348                  label='Alt (ft)',
349                  lineColor='palegoldenrod',
350                  rangeName='altitudeFt'):
351    """
352    Graph altitude trace on the dedicated altitudeFt Y axis.
353    """
354    data = jumpResult.data
355    yaxis = _Y_AXIS_MAP[rangeName]
356    fig.add_trace(go.Scatter(
357        x=data.plotTime,
358        y=data.altitudeAGLFt,
359        mode='lines',
360        name=label,
361        line=dict(color=lineColor, width=2),
362        yaxis=yaxis,
363        hovertemplate='alt: %{y:.0f} ft<extra></extra>',
364    ))

Graph altitude trace on the dedicated altitudeFt Y axis.

def graphAngle( fig, jumpResult, label='angle', lineColor='deepskyblue', rangeName='angle'):
367def graphAngle(fig,
368               jumpResult,
369               label='angle',
370               lineColor='deepskyblue',
371               rangeName='angle'):
372    """
373    Graph the flight angle trace on the dedicated angle Y axis.
374    """
375    data = jumpResult.data
376    yaxis = _Y_AXIS_MAP[rangeName]
377    fig.add_trace(go.Scatter(
378        x=data.plotTime,
379        y=data.speedAngle,
380        mode='lines',
381        name=label,
382        line=dict(color=lineColor, width=2),
383        yaxis=yaxis,
384        hovertemplate='angle: %{y:.2f}°<extra></extra>',
385    ))

Graph the flight angle trace on the dedicated angle Y axis.

def graphAcceleration( fig, jumpResult, label='V-accel m/s²', lineColor='magenta', rangeName='vAccelMS2'):
388def graphAcceleration(fig,
389                      jumpResult,
390                      label='V-accel m/s²',
391                      lineColor='magenta',
392                      rangeName='vAccelMS2'):
393    """
394    Graph the flight vertical acceleration curve and its EMA-smoothed companion
395    on the dedicated vAccelMS2 Y axis.
396    """
397    data = jumpResult.data
398    data['vAccelEMA'] = data.vAccelMS2.ewm(span=20, adjust=False).mean()
399    yaxis = _Y_AXIS_MAP[rangeName]
400    fig.add_trace(go.Scatter(
401        x=data.plotTime,
402        y=data.vAccelMS2,
403        mode='lines',
404        name=label,
405        line=dict(color='dimgrey', width=2),
406        yaxis=yaxis,
407        hovertemplate='a: %{y:.2f} m/s²<extra></extra>',
408    ))
409    fig.add_trace(go.Scatter(
410        x=data.plotTime,
411        y=data.vAccelEMA,
412        mode='lines',
413        name=label + ' (EMA)',
414        line=dict(color=lineColor, width=2),
415        yaxis=yaxis,
416        hovertemplate='a (EMA): %{y:.2f} m/s²<extra></extra>',
417    ))

Graph the flight vertical acceleration curve and its EMA-smoothed companion on the dedicated vAccelMS2 Y axis.

def initializeGroundTrackPlot( jumpTitle: str, height=450, backgroundColorName='#1a1a1a', colorName='lightsteelblue'):
420def initializeGroundTrackPlot(jumpTitle: str,
421                              height=450,
422                              backgroundColorName='#1a1a1a',
423                              colorName=DEFAULT_AXIS_COLOR):
424    """
425    Initialize a Plotly figure for the ground-track plot, configured with equal
426    axis scaling so that forward and lateral distances are not distorted.
427
428    X axis: metres forward along the jump run from exit.
429    Y axis: metres lateral (right = positive, left = negative).
430
431    Unlike initializePlot(), no extra Y ranges are added — this canvas is
432    dedicated to spatial displacement only.
433
434    Arguments
435    ---------
436        jumpTitle: str
437    Figure title, usually the jump tag.
438
439        height: int
440    Plot height in pixels.  Default 450 — shorter than the main plot since
441    this is a companion chart.
442
443        backgroundColorName: str
444    CSS colour name or hex string for the plot and paper background.
445
446        colorName: str
447    CSS colour name or hex string for axes, tick labels, and title text.
448
449    Returns
450    -------
451    A `plotly.graph_objects.Figure` ready to receive graphGroundTrack() traces.
452    """
453    figure = go.Figure()
454    figure.update_layout(
455        title=dict(text=jumpTitle, font=dict(color=colorName)),
456        height=height,
457        autosize=True,
458        plot_bgcolor=backgroundColorName,
459        paper_bgcolor=backgroundColorName,
460        font=dict(color=colorName),
461        hovermode='closest',
462        showlegend=True,
463        legend=dict(font=dict(color=colorName)),
464        xaxis=dict(
465            title=dict(text='forward (m)', font=dict(color=colorName)),
466            autorange=True,
467            color=colorName,
468            tickfont=dict(color=colorName),
469            showgrid=True,
470            gridcolor='rgba(255,255,255,0.08)',
471            showline=True,
472            linecolor=colorName,
473            zeroline=True,
474            zerolinecolor='rgba(255,255,255,0.25)',
475            zerolinewidth=1,
476        ),
477        yaxis=dict(
478            title=dict(text='lateral (m)', font=dict(color=colorName)),
479            autorange=True,
480            color=colorName,
481            tickfont=dict(color=colorName),
482            showgrid=True,
483            gridcolor='rgba(255,255,255,0.08)',
484            showline=True,
485            linecolor=colorName,
486            zeroline=True,
487            zerolinecolor='rgba(255,255,255,0.25)',
488            zerolinewidth=1,
489            scaleanchor='x',
490            scaleratio=1,
491        ),
492    )
493    return figure

Initialize a Plotly figure for the ground-track plot, configured with equal axis scaling so that forward and lateral distances are not distorted.

X axis: metres forward along the jump run from exit. Y axis: metres lateral (right = positive, left = negative).

Unlike initializePlot(), no extra Y ranges are added — this canvas is dedicated to spatial displacement only.

Arguments

jumpTitle: str

Figure title, usually the jump tag.

height: int

Plot height in pixels. Default 450 — shorter than the main plot since this is a companion chart.

backgroundColorName: str

CSS colour name or hex string for the plot and paper background.

colorName: str

CSS colour name or hex string for axes, tick labels, and title text.

Returns

A plotly.graph_objects.Figure ready to receive graphGroundTrack() traces.

def graphGroundTrack(figure, jumpResult, lineColor='deepskyblue'):
496def graphGroundTrack(figure,
497                     jumpResult,
498                     lineColor='deepskyblue'):
499    """
500    Graph the skydiver's ground track during the performance window as forward
501    vs. lateral displacement from exit, with markers coloured by vertical speed.
502
503    X axis = metres forward along the jump run (negative = reversed, i.e. back-
504    fall).  Y axis = metres lateral (positive = right of jump run).  Marker
505    colour encodes vKMh so the speed progression is visible without a separate
506    time axis.
507
508    A clean belly-to-earth run produces a smooth rightward curve staying close
509    to the lateral zero line.  A back-fall reverses toward the origin; the
510    line literally doubles back on itself — unmistakable at a glance.
511
512    Requires a figure initialised by initializeGroundTrackPlot() so that the
513    spatial axes are equal-scaled and the zero lines are present.
514
515    Arguments
516    ---------
517        figure
518    A Plotly Figure initialised by initializeGroundTrackPlot().
519
520        jumpResult: ssscoring.JumpResults
521    A jump results named tuple.  jumpResult.data must contain latitude,
522    longitude, plotTime, and vKMh columns (all present after processJump()).
523
524        lineColor: str
525    A valid CSS colour name or hex string for the connecting track line.
526
527    Streamlit usage:
528
529```python
530    figure = initializeGroundTrackPlot(tag)
531    graphGroundTrack(figure, jumpResult)
532    st.plotly_chart(figure, width='stretch')
533```
534    """
535    data = jumpResult.data
536    exitLat = float(data.latitude.iloc[0])
537    exitLon = float(data.longitude.iloc[0])
538    bearing = jumpRunBearing(data)
539    displacement = forwardLateralDisplacement(data, exitLat, exitLon, bearing)
540
541    figure.add_trace(go.Scatter(
542        x=displacement.forwardM,
543        y=displacement.lateralM,
544        mode='lines',
545        name='track',
546        line=dict(color='rgba(255,255,255,0.15)', width=1),
547        showlegend=False,
548        hoverinfo='skip',
549    ))
550
551    figure.add_trace(go.Scatter(
552        x=displacement.forwardM,
553        y=displacement.lateralM,
554        mode='markers',
555        name='fwd (m)',
556        marker=dict(
557            color=displacement.forwardM.clip(lower=0, upper=MAX_HORIZONTAL_DISTANCE),
558            colorscale=[
559                [0.0, SAFE_HORIZONTAL_COLOR],
560                [SAFE_HORIZONTAL_DISTANCE / MAX_HORIZONTAL_DISTANCE, SAFE_HORIZONTAL_COLOR],
561                [1.0, UNSAFE_HORIZONTAL_COLOR],
562            ],
563            cmin=0,
564            cmax=MAX_HORIZONTAL_DISTANCE,
565            cauto=False,
566            size=5,
567            showscale=True,
568            colorbar=dict(
569                title=dict(text='fwd (m)', font=dict(color=DEFAULT_AXIS_COLOR)),
570                tickfont=dict(color=DEFAULT_AXIS_COLOR),
571                tickvals=[0, SAFE_HORIZONTAL_DISTANCE, MAX_HORIZONTAL_DISTANCE],
572                ticktext=['0', f'{int(SAFE_HORIZONTAL_DISTANCE)}m', f'{int(MAX_HORIZONTAL_DISTANCE)}m'],
573                thickness=12,
574                len=0.75,
575            ),
576        ),
577        hovertemplate='fwd: %{x:.1f} m  lat: %{y:.1f} m<extra></extra>',
578    ))
579
580    figure.add_trace(go.Scatter(
581        x=[displacement.forwardM.iloc[0]],
582        y=[displacement.lateralM.iloc[0]],
583        mode='markers',
584        name='exit',
585        marker=dict(symbol='circle', size=10,
586                    color=SAFE_HORIZONTAL_COLOR,
587                    line=dict(color='white', width=1)),
588        hovertemplate='exit<extra></extra>',
589    ))
590
591    figure.add_trace(go.Scatter(
592        x=[displacement.forwardM.iloc[-1]],
593        y=[displacement.lateralM.iloc[-1]],
594        mode='markers',
595        name='end',
596        marker=dict(symbol='square', size=10,
597                    color=UNSAFE_HORIZONTAL_COLOR,
598                    line=dict(color='white', width=1)),
599        hovertemplate='end: fwd %{x:.1f} m  lat: %{y:.1f} m<extra></extra>',
600    ))

Graph the skydiver's ground track during the performance window as forward vs. lateral displacement from exit, with markers coloured by vertical speed.

X axis = metres forward along the jump run (negative = reversed, i.e. back-
fall).  Y axis = metres lateral (positive = right of jump run).  Marker
colour encodes vKMh so the speed progression is visible without a separate
time axis.

A clean belly-to-earth run produces a smooth rightward curve staying close
to the lateral zero line.  A back-fall reverses toward the origin; the
line literally doubles back on itself — unmistakable at a glance.

Requires a figure initialised by initializeGroundTrackPlot() so that the
spatial axes are equal-scaled and the zero lines are present.

Arguments
---------
    figure
A Plotly Figure initialised by initializeGroundTrackPlot().

    jumpResult: ssscoring.JumpResults
A jump results named tuple.  jumpResult.data must contain latitude,
longitude, plotTime, and vKMh columns (all present after processJump()).

    lineColor: str
A valid CSS colour name or hex string for the connecting track line.

Streamlit usage:
    figure = initializeGroundTrackPlot(tag)
    graphGroundTrack(figure, jumpResult)
    st.plotly_chart(figure, width='stretch')
def graphForwardDisplacement(figure, jumpResult):
603def graphForwardDisplacement(figure,
604                             jumpResult):
605    """
606    Graph the forward displacement (metres along the jump run from exit) as a
607    time series on the primary Y axis.
608
609    A skydiver on a clean belly run produces a monotonically rising curve.  A
610    back-fall inflects and drops — the onset time and reversal depth are
611    immediately readable from the shape of the line and the zero reference.
612
613    Markers are coloured by the same green→red gradient used in the ground
614    track: SAFE_HORIZONTAL_COLOR up to SAFE_HORIZONTAL_DISTANCE, linear
615    transition to UNSAFE_HORIZONTAL_COLOR at MAX_HORIZONTAL_DISTANCE, solid
616    red beyond.
617
618    Pair this with graphJumpResult() on a second figure (same plotTime X axis)
619    to show the temporal relationship between displacement reversal and speed
620    loss.
621
622    Arguments
623    ---------
624        figure
625    A Plotly Figure initialised by initializePlot() with xLabel='seconds from
626    exit' and yLabel='forward (m)'.
627
628        jumpResult: ssscoring.JumpResults
629    A jump results named tuple.  jumpResult.data must contain latitude,
630    longitude, and plotTime (all present after processJump()).
631
632    Streamlit usage:
633
634```python
635    figure = initializePlot(tag, yLabel='forward (m)', backgroundColorName='#2c2c2c')
636    graphForwardDisplacement(figure, jumpResult)
637    st.plotly_chart(figure, width='stretch')
638```
639    """
640    data = jumpResult.data
641    exitLat = float(data.latitude.iloc[0])
642    exitLon = float(data.longitude.iloc[0])
643    bearing = jumpRunBearing(data)
644    displacement = forwardLateralDisplacement(data, exitLat, exitLon, bearing)
645
646    figure.add_trace(go.Scatter(
647        x=displacement.plotTime,
648        y=displacement.forwardM,
649        mode='lines',
650        line=dict(color='rgba(255,255,255,0.15)', width=1),
651        showlegend=False,
652        hoverinfo='skip',
653    ))
654
655    figure.add_trace(go.Scatter(
656        x=displacement.plotTime,
657        y=displacement.forwardM,
658        mode='markers',
659        name='fwd (m)',
660        marker=dict(
661            color=displacement.forwardM.clip(lower=0, upper=MAX_HORIZONTAL_DISTANCE),
662            colorscale=[
663                [0.0, SAFE_HORIZONTAL_COLOR],
664                [SAFE_HORIZONTAL_DISTANCE / MAX_HORIZONTAL_DISTANCE, SAFE_HORIZONTAL_COLOR],
665                [1.0, UNSAFE_HORIZONTAL_COLOR],
666            ],
667            cmin=0,
668            cmax=MAX_HORIZONTAL_DISTANCE,
669            cauto=False,
670            size=4,
671            showscale=True,
672            colorbar=dict(
673                title=dict(text='fwd (m)', font=dict(color=DEFAULT_AXIS_COLOR)),
674                tickfont=dict(color=DEFAULT_AXIS_COLOR),
675                tickvals=[0, SAFE_HORIZONTAL_DISTANCE, MAX_HORIZONTAL_DISTANCE],
676                ticktext=['0', f'{int(SAFE_HORIZONTAL_DISTANCE)}m', f'{int(MAX_HORIZONTAL_DISTANCE)}m'],
677                thickness=12,
678                len=0.75,
679            ),
680        ),
681        yaxis='y',
682        hovertemplate='t: %{x:.1f} s  fwd: %{y:.1f} m<extra></extra>',
683    ))
684
685    figure.add_trace(go.Scatter(
686        x=[displacement.plotTime.iloc[0], displacement.plotTime.iloc[-1]],
687        y=[0.0, 0.0],
688        mode='lines',
689        line=dict(color='rgba(255,255,255,0.25)', width=1, dash='dot'),
690        showlegend=False,
691        hoverinfo='skip',
692    ))

Graph the forward displacement (metres along the jump run from exit) as a time series on the primary Y axis.

A skydiver on a clean belly run produces a monotonically rising curve.  A
back-fall inflects and drops — the onset time and reversal depth are
immediately readable from the shape of the line and the zero reference.

Markers are coloured by the same green→red gradient used in the ground
track: SAFE_HORIZONTAL_COLOR up to SAFE_HORIZONTAL_DISTANCE, linear
transition to UNSAFE_HORIZONTAL_COLOR at MAX_HORIZONTAL_DISTANCE, solid
red beyond.

Pair this with graphJumpResult() on a second figure (same plotTime X axis)
to show the temporal relationship between displacement reversal and speed
loss.

Arguments
---------
    figure
A Plotly Figure initialised by initializePlot() with xLabel='seconds from
exit' and yLabel='forward (m)'.

    jumpResult: ssscoring.JumpResults
A jump results named tuple.  jumpResult.data must contain latitude,
longitude, and plotTime (all present after processJump()).

Streamlit usage:
    figure = initializePlot(tag, yLabel='forward (m)', backgroundColorName='#2c2c2c')
    graphForwardDisplacement(figure, jumpResult)
    st.plotly_chart(figure, width='stretch')
def convertHexColorToRGB(color: str) -> list:
695def convertHexColorToRGB(color: str) -> list:
696    """
697    Converts a color in the format `#a0b1c2` to its RGB equivalent as a list
698    of three values 0-255.
699    """
700    if not isinstance(color, str):
701        raise TypeError('Invalid color type - must be str')
702    color = color.replace('#', '')
703    if len(color) != 6:
704        raise SSScoringError('Invalid hex value length')
705    result = [int(color[x:x+2], 16) for x in range(0, len(color), 2)]
706    return result

Converts a color in the format #a0b1c2 to its RGB equivalent as a list of three values 0-255.